Недавно были опубликованы черновики дизайна новой обработки ошибок в Go 2. Очень радует, что язык не стоит на одном месте — он развивается и c каждым годом хорошеет как на дрожжах.
Только вот пока Go 2 лишь виднеется на горизонте, а ждать уж очень тягостно и грустно. Посему берем дело в свои руки. Немножко кодогенерации, чуть работы с ast, и легким движением руки паники превращаются, превращаются паники… в элегантные исключения!
И сразу же хочу сделать очень важное и абсолютно серьезное заявление.
Данное решение носит исключительно развлекательный и педагогический характер.
То бишь just 4 fun. Это вообще proof-of-concept, по правде говоря. Я предупредил :)
Так что же вышло
Получилась небольшенькая такая библиотека-кодогенератор. А кодогенераторы, как всем хорошо известно, несут в себе добро и благодать. На самом деле нет, но в мире Go они довольно популярны.
Натравливаем такой кодогенератор на go-сырец. Он его парсит за помощью стандартного модуля go/ast
, делает там некие нехитрые трансформации, результат пишет рядышком в файл, добавляя суффикс _jex.go
. Полученные файлы для работы хотят малюсенький рантайм.
Вот таким вот незамысловатым образом мы и добавляем исключения в Go.
Пользуем
Подключаем генератор к файлу, в шапку (до package
) пишем
//+build jex
//go:generate jex
Если теперь запустить команду go generate -tags jex
, то будет выполнена утилитка jex
. Она берет имя файла из os.Getenv("GOFILE")
, кушает его, переваривает и пишет {file}_jex.go
. У новорожденного файла в шапке уже //+build !jex
(тег инвертирован), так что go build
, а в купе с ним и остальные команды, навроде go test
или go install
, учитывают только новые, правильные файлы. Лепота...
Теперь дот-импортируем github.com/anjensan/jex
.
Да-да, пока импорт через точку обязателен. В будущем планируется оставить точно также.
import . "github.com/anjensan/jex"
Отлично, теперь в код можно вставлять вызовы функций-заглушек TRY
, THROW
, EX
. Код при всем этом остается синтаксически валидным, и даже компилируется в необработанном виде (только не работает), поэтому доступны автодополнения и линтеры не особо ругаются. Редакторы показали бы и документацию к этим функциям, если бы только она у них была.
Бросаем исключение
THROW(errors.New("error name"))
Ловим исключение
if TRY() {
// некий код
} else {
fmt.Println(EX())
}
Под капотом сгенерируется анонимная функция. А в ней defer
. А в нем еще одна функция. А в ней recover
… Ну там еще немного ast-магии для обработки return
и defer
.
И да, кстати, они поддерживаются!
Вдобавок есть особая макро-переменная ERR
. Если присвоить в нее ошибку, то выкидывается исключение. Так легче вызывать функции, которые по старинке все еще возвращают error
file, ERR := os.Open(filename)
Дополнительно имеется парочка небольших утилитных пакетика ex
и must
, но там не о чем особо рассказывать.
Примеры
Вот пример корректного, идиоматичного кода на Go
func CopyFile(src, dst string) error {
r, err := os.Open(src)
if err != nil {
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
defer r.Close()
w, err := os.Create(dst)
if err != nil {
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
if _, err := io.Copy(w, r); err != nil {
w.Close()
os.Remove(dst)
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
if err := w.Close(); err != nil {
os.Remove(dst)
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
}
Этот код не так уж приятен и элегантен. Между прочим, это не только мое мнение!
Но jex
поможет нам его улучшить
func CopyFile_(src, dst string) {
defer ex.Logf("copy %s %s", src, dst)
r, ERR := os.Open(src)
defer r.Close()
w, ERR := os.Create(dst)
if TRY() {
ERR := io.Copy(w, r)
ERR := w.Close()
} else {
w.Close()
os.Remove(dst)
THROW()
}
}
А вот например следующая программа
func main() {
hex, err := ioutil.ReadAll(os.Stdin)
if err != nil {
log.Fatal(err)
}
data, err := parseHexdump(string(hex))
if err != nil {
log.Fatal(err)
}
os.Stdout.Write(data)
}
может быть переписана как
func main() {
if TRY() {
hex, ERR := ioutil.ReadAll(os.Stdin)
data, ERR := parseHexdump(string(hex))
os.Stdout.Write(data)
} else {
log.Fatal(EX())
}
}
Вот ещё пример, дабы прочувствовать предложенную идею получше. Оригинальный код
func printSum(a, b string) error {
x, err := strconv.Atoi(a)
if err != nil {
return err
}
y, err := strconv.Atoi(b)
if err != nil {
return err
}
fmt.Println("result:", x + y)
return nil
}
может быть переписан как
func printSum_(a, b string) {
x, ERR := strconv.Atoi(a)
y, ERR := strconv.Atoi(b)
fmt.Println("result:", x + y)
}
или вот даже так
func printSum_(a, b string) {
fmt.Println("result:", must.Int_(strconv.Atoi(a)) + must.Int_(strconv.Atoi(b)))
}
Исключение
Суть простенькая структурка-обертка над экземпляром error
type exception struct {
// оригинальная ошибка, без комментариев
err error
// всякий мусор^Wотладочная информация, переменные, логи там какие
log []interface{}
// вдруг мы уже обрабатывали другую ошибку, когда бросили исключение
suppress []*exception
}
Важный момент — обычные паники не воспринимаются как исключения. Так, не являются исключениями все стандартные ошибки, вроде runtime.TypeAssertionError
. Это соответствует принятым бест-практикам в Go — если у нас, скажем, nil-dereference, то мы весело и бодренько роняем весь процесс. Надежно и предсказуемо. Хотя не уверен, быть может стоит пересмотреть данный момент и таки ловить подобные ошибки. Может опционально?
А вот пример цепочки исключений
func one_() {
THROW(errors.New("one"))
}
func two_() {
THROW(errors.New("two")
}
func three() {
if TRY() {
one_()
} else {
two_()
}
}
Тут мы спокойно обрабатываем исключение one
, как внезапно бац… и выбрасывается исключение two
. Так вот к нему в поле suppress
автомагически прикрепится исходное one
. Ничего не пропадет, все пойдет в логи. А посему и нету особой надобности запихивать всю цепочку ошибок прямо в текст сообщения при помощи весьма популярного паттерна fmt.Errorf("blabla: %v", err)
. Хотя никто, конечно, не запрещает его использовать и здесь, если уж очень хочется.
Когда забыли отловить
Ах, еще один шибко важный момент. В целях повышения читаемости имеется дополнительная проверка: если функция может выкинуть исключение, то ее имя должно оканчиваться на _
. Сознательно кривое имя, которое подскажет программисту "многоуважаемый сударь, вот тут в вашей программе что-то может пойти не так, извольте проявить внимательность и усердие!"
Проверка автоматом запускается для трансформируемых файлов, плюс еще может быть запущена вручную в проекте при помощи команды jex-check
. Пожалуй имеет смысл запускать ее как часть билд процесса наравне с прочими линтерами.
Отключается проверка комментарием //jex:nocheck
. Это, к слову, пока единственный способ выбрасывать исключения из анонимной функции.
Конечно это не панацея от всех проблем. Чекер пропустит вот такое
func bad_() {
THROW(errors.New("ups"))
}
func worse() {
f := bad_
f()
}
С другой стороны, это не сильно хуже стандартной проверки на err declared and not used
, которую ну очень легко обойти
func worse() {
a, err := foo()
if err != nil {
return err
}
b, err := bar()
// забыли проверку, а все типо ok... go vet, доколе?
}
В общем, сей вопрос скорее философский, что же лучше делать, когда забыли обработать ошибку — втихую ее проигнорировать, или выкинуть панику… Кстати, лучших результатов проверки можно было бы достигнуть, внедряя поддержку исключений в компилятор, но это сильно выходит за рамки данной статьи.
Некоторые могут сказать, что, хоть это и замечательное решение, но уже исключениями не является, поскольку сейчас исключения означают вполне конкретную реализацию. Ну там потому, что к исключениям не прикрепляются стектрейсы, или есть отдельный линтер для проверки имен функций, или что функция может заканчиваться на _
но при этом не выбрасывать исключений, или нету прямой поддержки в синтаксисе, или что это на самом деле паники, а паники не исключения вовсе, потому что гладиолус… Споры могут быть столь же жаркими, сколь бесполезными и бесцельными. Посему оставлю их за бортом статьи, а описанное решение продолжу невозбранно обзывать "исключениями".
По поводу стектрейсов
Часто разработчики в целях упрощения отладки приклепляют стектрейс к кастомным имплементациям error
. Есть даже несколько популярных библиотек для этого. Но, к счастью, с исключениями для этого не нужно никаких дополнительных действий благодаря одной интересной особенности Go — при панике блоки defer
выполняются в стековом контектсе того кода, который панику выбросил. Поэтому тут
func foo_() {
THROW(errors.New("ups"))
}
func bar() {
if TRY() {
foo_()
} else {
debug.PrintStack()
}
}
распечатается полноценный стектрейс, пускай и чуть многословный (имена файлов вырезал)
runtime/debug.Stack
runtime/debug.PrintStack
main.bar.func2
github.com/anjensan/jex/runtime.TryCatch.func1
panic
main.foo_
main.bar.func1
github.com/anjensan/jex/runtime.TryCatch
main.bar
main.main
Не помешает еще сделать свой хелпер для форматирования/печати стектрейса с учетом суррогатных функций, скрывая их для читаемости. Думаю неплохая идея, записал в .
А можно захватить стек и прикрепить его к исключению при помощи ex.Log()
. Потом такое исключение дозволено передавать в другую гороутину — стректрейсы не теряются.
func foobar_() {
e := make(chan error, 1)
go func() {
defer close(e)
if TRY() {
checkZero_()
} else {
EX().Log(debug.Stack()) // прикрепляем стектрейс
e <- EX().Wrap() // оборачиваем исключение в ошибку
}
}()
ex.Must_(<-e) // разворачиваем и, быть может, перевыбрасываем
}
К сожалению
Эх… конечно, куда лучше выглядело бы что-то такое
try {
throw io.EOF, "some comment"
} catch e {
fmt.Printf("exception: %v", e)
}
Но увы и ах, синтаксис у Go нерасширяемый.
[задумчиво] Хотя, наверное, это все же к лучшему...
В любом случае, приходится извращаться. Одной из альтернативных идей было сделать
TRY; {
THROW(io.EOF, "some comment")
}; CATCH; {
fmt.Printf("exception: %v", EX)
}
Но такой код выглядит стремновато после go fmt
. А еще компилятор ругается, когда видит return
в обоих ветках. С if-TRY
такой проблемы нет.
Было бы еще круто заменить макрос ERR
на функцию MUST
(лучше просто must
). Дабы писать
return MUST(strconv.Atoi(a)) + MUST(strconv.Atoi(b))
В принципе это таки реализуемо, можно при анализе ast выводить тип выражений, для всех вариантов типов сгенерировать простую функцию-обертку, вроде тех, что объявлены в пакете must
, а потом подменять MUST
на имя соответствующей суррогатной функции. Это не совсем тривиально, но совершенно возможно… Только вот редакторы/иде не смогут понимать такой код. Ведь сигнатура функции-заглушки MUST
не выражаема в рамках системы типов Go. А поэтому никакого автокомплита.
Под капотом
Во все обработанные файлы добавляется новый импорт
import _jex "github.com/anjensan/jex/runtime"
Вызов THROW
заменяется на panic(_jex.NewException(...))
. Также происходит замена EX()
на имя локальной переменной, в которой лежит выловленное исключение.
А вот if TRY() {..} else {..}
обрабатывается чуть посложнее. Сначала происходит специальная обработка для всех return
и defer
. Потом обработанные ветки if-а помещаются в анонимные функции. И потом эти функции передаются в _jex.TryCatch(..)
. Вот такое
func test(a int) (int, string) {
fmt.Println("before")
if TRY() {
if a == 0 {
THROW(errors.New("a == 0"))
}
defer fmt.Printf("a = %d\n", a)
return a + 1, "ok"
} else {
fmt.Println("fail")
}
return 0, "hmm"
}
превращается примерно в такое (я убрал комментарии //line
):
func test(a int) (_jex_r0 int, _jex_r1 string) {
var _jex_ret bool
fmt.Println("before")
var _jex_md2502 _jex.MultiDefer
defer _jex_md2502.Run()
_jex.TryCatch(func() {
if a == 0 {
panic(_jex.NewException(errors.New("a == 0")))
}
{
_f, _p0, _p1 := fmt.Printf, "a = %d\n", a
_jex_md2502.Defer(func() { _f(_p0, _p1) })
}
_jex_ret, _jex_r0, _jex_r1 = true, a+1, "ok"
return
}, func(_jex_ex _jex.Exception) {
defer _jex.Suppress(_jex_ex)
fmt.Println("fail")
})
if _jex_ret {
return
}
return 0, "hmm"
}
Много, не красиво, но работает. Ладно, не все и не всегда. Например, не получится сделать defer-recover
внутри TRY, поскольку вызов функции оборачивается в дополнительную лямбду.
Также при выводе ast дерева указана опция "сохранить комментарии". Так что, по идее, go/printer
должен их распечатать… Что он честно и делает, правда очень и очень криво =) Примеры приводить не буду, просто криво. В принципе, такая проблемка вполне решаема, если тщательно указать позиции для всех ast-узлов (сейчас они пустые), но это точно не входит в список необходимых вещей для прототипа.
Пробуем
Из любопытства написал небольшой бенчмарк.
Имеем деревянную реализацию qsort'а, которая в нагрузку проверяет наличие дубликатов. Нашли — ошибка. Одна версия просто пробрасывает через return err
, другая уточняет ошибку вызовом fmt.Errorf
. И еще одна использует исключения. Сортируем слайсы разного размера, либо вовсе без дубликатов (ошибки нет, слайс сортируется полностью), либо с одним повтором (сортировка обрывается примерно на полпути, видно по таймингам).
~ > cat /proc/cpuinfo | grep 'model name' | head -1
model name : Intel(R) Core(TM) i7-6700K CPU @ 4.00GHz
~ > go version
go version go1.11 linux/amd64
~ > go test -bench=. github.com/anjensan/jex/demo
goos: linux
goarch: amd64
pkg: github.com/anjensan/jex/demo
BenchmarkNoErrors/_____10/exception-8 10000000 236 ns/op
BenchmarkNoErrors/_____10/return_err-8 5000000 255 ns/op
BenchmarkNoErrors/_____10/fmt.errorf-8 5000000 287 ns/op
BenchmarkNoErrors/____100/exception-8 500000 3119 ns/op
BenchmarkNoErrors/____100/return_err-8 500000 3194 ns/op
BenchmarkNoErrors/____100/fmt.errorf-8 500000 3533 ns/op
BenchmarkNoErrors/___1000/exception-8 30000 42356 ns/op
BenchmarkNoErrors/___1000/return_err-8 30000 42204 ns/op
BenchmarkNoErrors/___1000/fmt.errorf-8 30000 44465 ns/op
BenchmarkNoErrors/__10000/exception-8 3000 525864 ns/op
BenchmarkNoErrors/__10000/return_err-8 3000 524781 ns/op
BenchmarkNoErrors/__10000/fmt.errorf-8 3000 561256 ns/op
BenchmarkNoErrors/_100000/exception-8 200 6309181 ns/op
BenchmarkNoErrors/_100000/return_err-8 200 6335135 ns/op
BenchmarkNoErrors/_100000/fmt.errorf-8 200 6687197 ns/op
BenchmarkNoErrors/1000000/exception-8 20 76274341 ns/op
BenchmarkNoErrors/1000000/return_err-8 20 77806506 ns/op
BenchmarkNoErrors/1000000/fmt.errorf-8 20 78019041 ns/op
BenchmarkOneError/_____10/exception-8 2000000 712 ns/op
BenchmarkOneError/_____10/return_err-8 5000000 268 ns/op
BenchmarkOneError/_____10/fmt.errorf-8 2000000 799 ns/op
BenchmarkOneError/____100/exception-8 500000 2296 ns/op
BenchmarkOneError/____100/return_err-8 1000000 1809 ns/op
BenchmarkOneError/____100/fmt.errorf-8 500000 3529 ns/op
BenchmarkOneError/___1000/exception-8 100000 21168 ns/op
BenchmarkOneError/___1000/return_err-8 100000 20747 ns/op
BenchmarkOneError/___1000/fmt.errorf-8 50000 24560 ns/op
BenchmarkOneError/__10000/exception-8 10000 242077 ns/op
BenchmarkOneError/__10000/return_err-8 5000 242376 ns/op
BenchmarkOneError/__10000/fmt.errorf-8 5000 251043 ns/op
BenchmarkOneError/_100000/exception-8 500 2753692 ns/op
BenchmarkOneError/_100000/return_err-8 500 2824116 ns/op
BenchmarkOneError/_100000/fmt.errorf-8 500 2845701 ns/op
BenchmarkOneError/1000000/exception-8 50 33452819 ns/op
BenchmarkOneError/1000000/return_err-8 50 33374000 ns/op
BenchmarkOneError/1000000/fmt.errorf-8 50 33705994 ns/op
PASS
ok github.com/anjensan/jex/demo 64.008s
Если ошибка так и не брошена (код стабилен и железобетонен), то варант с пробросом исключения примерно сопоставим с return err
и fmt.Errorf
. Иногда чуточку быстрее. А вот ежели ошибку выбросили, то исключения уходят на второе место. Но все сильно зависит от соотношения "полезная работа / ошибки" и глубины стека. Для малых слайсов return err
идет в отрыв, для средних и больших исключения уже равняются с ручным пробросом.
Короче, если ошибки возникают крайне редко — исключения могут код даже немного ускорить. Если как у всех, то будет примерно так-на-так. А вот если очень часто… то медленные исключения — далеко не самая важная проблема, из-за которой стоит переживать.
В качестве теста пробно мигрировал реальную гошную библиотеку на исключения.
Точнее оно бы и получилось, но это надо заморачиваться.
Так, например, функция rpc2XML
вроде как возвращает error
… да вот только никогда его не возвращает. Если попытаться сериализовать неподдерживаемый тип данных — никакой ошибки, просто пустой вывод. Может так и задумано?.. Нет, совесть не позволяет так оставлять. Добавил
default:
THROW(fmt.Errorf("unsupported type %T", value))
Но оказалось, что эта фукнция используется особым образом
func rpcParams2XML(rpc interface{}) (string, error) {
var err error
buffer := "<params>"
for i := 0; i < reflect.ValueOf(rpc).Elem().NumField(); i++ {
var xml string
buffer += "<param>"
xml, err = rpc2XML(reflect.ValueOf(rpc).Elem().Field(i).Interface())
buffer += xml
buffer += "</param>"
}
buffer += "</params>"
return buffer, err
}
Тут бежим по списку параметров, сериализуем их все, но возвращаем ошибку только для последнего. Остальные ошибки игнорируются. Странное поведение, сделал проще
func rpcParams2XML_(rpc interface{}) string {
buffer := "<params>"
for i := 0; i < reflect.ValueOf(rpc).Elem().NumField(); i++ {
buffer += "<param>"
buffer += rpc2XML_(reflect.ValueOf(rpc).Elem().Field(i).Interface())
buffer += "</param>"
}
buffer += "</params>"
return buffer
}
Если хоть один филд не вышло сериализовать — ошибка. Ну, так-то получше. Но оказалось, что и эта функция используется особым образом
xmlstr, _ = rpcResponse2XML(response)
опять же, для исходного кода это не так уж и принципиально, ведь там ошибки и так игнорируются. Я походу начинаю догадываться, почему же некоторые программисты так любят явную обработку ошибок через if err != nil
… Но с исключениями все же проще пробросить или обработать, нежели проигнорировать
xmlstr = rpcResponse2XML_(response)
А еще я не стал убирать "цепочки ошибок". Вот оригинальный код
func DecodeClientResponse(r io.Reader, reply interface{}) error {
rawxml, err := ioutil.ReadAll(r)
if err != nil {
return FaultSystemError
}
return xml2RPC(string(rawxml), reply)
}
вот переписанный
func DecodeClientResponse_(r io.Reader, reply interface{}) {
var rawxml []byte
if TRY() {
rawxml, ERR = ioutil.ReadAll(r)
} else {
THROW(FaultSystemError)
}
xml2RPC_(string(rawxml), reply)
}
Тут оригинальая ошибка (которую ioutil.ReadAll
вернул) не потеряется, будет прикреплена к исключению в поле suppress
. Опять же, можно сделать и как в оригинале, но это надо специально заморочиться...
Переписал тесты, заменив if err != nil { log.Error(..) }
на простой проброс исключения. Есть негативный момент — тесты валятся на первой же ошибке, не продолжая работать "ну хоть как-то". По уму надо бы разделить их на под-тесты… Что, в общем то, стоит делать в любом случае. Но зато очень легко вывести правильный стектрейс
func errorReporter(t testing.TB) func(error) {
return func(e error) {
t.Log(string(debug.Stack()))
t.Fatal(e)
}
}
func TestRPC2XMLConverter_(t *testing.T) {
defer ex.Catch(errorReporter(t))
// ...
xml := rpcRequest2XML_("Some.Method", req)
}
Вообще ошибки очень уж легко игнорировать. В оригинальном коде
func fault2XML(fault Fault) string {
buffer := "<methodResponse><fault>"
xml, _ := rpc2XML(fault)
buffer += xml
buffer += "</fault></methodResponse>"
return buffer
}
тут ошибка из rpc2XML
снова тихонько игнорируется. Стало вот так
func fault2XML(fault Fault) string {
buffer := "<methodResponse><fault>"
if TRY() {
buffer += rpc2XML_(fault)
} else {
fmt.Printf("ERR: %v", EX())
buffer += "<nil/>"
}
buffer += "</fault></methodResponse>"
return buffer
}
По моим личным ощущениям, с ошибками легче вернуть "полуготовый" результат.
Например, наполовину сконструированный респонс. С исключениями посложнее, поскольку функция либо возвращает успешный результат, либо вообще ничего не возвращает. Эдакая атомарность. С другой стороны, исключения труднее проигнорировать или потерять первопричину при цепочке исключений. Ведь нужно еще специально постараться это сделать. С ошибками же такое происходит легко и непринужденно.
Вместо заключения
При написании данной статьи ни один гофер не пострадал.
За фотографию гофера-алкоголика спасибо http://migranov.ru
Не смог выбрать между хабами "Программирование" и "Ненормальное программирование".
Весьма сложный выбор, добавил в оба.
Комментарии (43)
lostmsu
08.10.2018 18:19+2Этим можно воспользоваться прямо сейчас, без странноватого синтаксиса. Достаточно перейти на другой язык, который не выбрал особый путь!
powerman
08.10.2018 21:45+1А как в этом другом языке с лёгкими нитями, каналами, скоростью сборки и портабельностью?
lostmsu
08.10.2018 21:59+1C#, Java, всё отлично, многое лучше. Для каналов нет специального синтаксиса, правда.
powerman
08.10.2018 23:06Возможно я немного отстал, я на этих языках не пишу, но, насколько я помню, в C# нельзя было плодить миллионы корутин, и в чистой Java тоже (но вроде можно было на каких-то других языках на базе JVM). Расскажите плз, как с этим делом сейчас — реально можно написать на C# и Java лёгкий сервер, который будет держать миллионы одновременных соединений используя по две корутины на соединение (в которых блокирующие чтение и запись), общающиеся между собой через каналы, и он будет иметь производительность и потребление памяти сравнимое с Go?
lostmsu
09.10.2018 00:01Мне навскидку не очевидно, откуда вы взяли, что в C# нельзя было плодить миллионы тасков. За конкретные примеры ничего сказать не могу — надо гуглить. Но планировщик тасков там устроен примерно также. Не Go же изобрёл per-thread work stealing queues. Они откуда-то из середины 90-ых.
powerman
09.10.2018 00:15Изобрёл, безусловно, не Go. Проблема обычно в том, что каждой нити, лёгкая она или нет, нужен свой стек. И вот размер этого стека, умноженный на миллионы нитей, создаёт одну из основных проблем при реализации этого подхода. Языки, в которых изначально не заложили поддержку маленького, динамически растущего стека, обычно не в состоянии эффективно добавить поддержку этой фичи. Вот, например, бенчмарк (правда, тут задача сильно проще, чем делать I/O): https://github.com/atemerev/skynet — .Net (C# вроде на нём, если не путаю) там упоминается только в контексте Futures/promises, а это, насколько я понимаю, не совсем то же самое, что лёгкие нити в го, это вроде про асинхронный код, как в NodeJS, а он не выполняется параллельно как горутины.
lostmsu
09.10.2018 00:22-1Мне навскидку не очевидно, что реалзизация горутин чем-то отличается от тасков C#/Java. Всё, что я нашёл — это полумаркетинговый материал про то, как gorutines они примерно как потоки, но куда легче. Но зачем их сравнивают с потоками, а не с тасками — не понятно.
Я бы сказал, что они идентичны, пока не будет показано иное. И по сути просто per-thread work stealing queues.powerman
09.10.2018 00:51Горутины:
- выполняются параллельно (напр. если они заняты тяжёлыми вычислительными задачами без какого-либо I/O, т.е. без точек, где можно перехватить выполнение при cooperative multitasking, то всё-равно будет параллельно выполняться примерно столько горутин, сколько на компе ядер CPU) — что позволяет не беспокоится о том, что обработчик какого-то события окажется слишком медленным и приостановит работу всей системы
- позволяют использовать блокирующий I/O — что позволяет писать код более просто и ясно, чем асинхронный на callbacks/futures/promises
- очень быстро создаются и уничтожаются миллионами — что позволяет строить на этом архитектуру приложения, в результате чего оно обычно упрощается
- миллионы горутин используют достаточно мало памяти (обычно у горутины стек 2KB, т.е. миллион сожрёт всего 2GB RAM — иными словами даже на ноуте с 4-8GB RAM вполне реально погонять клиент/сервер на миллион соединений (на практике надо ещё ядро потюнить, иначе ядро на каждый сокет ещё около 8KB сожрёт)
Иными словами, это даёт возможность писать приложения совершенно иначе — в целом, намного проще, и при этом очень эффективно в плане производительности и памяти. Если убрать любой из этих пунктов — в таком стиле нагруженные приложения писать станет невозможно. Как с этими пунктами у тасков C#/Java?
anjensan Автор
09.10.2018 01:04Не буду говорить про C# (не знаю). И еще не знаю что имеется в виду «таски Java». Поэтому отвечу про вариант «ректор + промисы» безотносительно к языку:
- выполняются параллельно, но не более, чем ваш размер тредпула (который обычно больше, нежели количество ядер)
- точно также позволяют использовать блокирующее io… через делегацию работы в отдельный резиновый тредпул (а вы как думали, в Go это при помощи магии делается?); но это надо делать явно, да
- очень быстро создаются и уничтожаются десятками миллионов
- миллионы промисов/тасок/тп использует мало памяти, стек на целых 0KB. т.е. миллион сожрет всего 0GB RAM на стек
lostmsu
09.10.2018 01:20Я так понял Go обходится или по крайней мере может обходиться без резинового тредпула. Просто то, что выглядит как блокирующий вызов, может на самом деле yieldить текущую нить а внутри вызывать асинхронный системный примитив.
creker
09.10.2018 01:29Именно обходится. В Go есть ровно один поток, который занимается обработкой асинхронного IO. По сути, глобальный select, который по событиям разблокирует горутины. Это особое поведение только для IO, чтобы снаружи все было блокирующим, а на самом деле было полностью под контролем разработчика — в любой момент из любой горутины можно закрыть соединение, для каждой операции есть таймауты, нет никаких непонятных зависаний блокирующего вызова, отчего виснет все остальное.
Для всех остальных системных вызовов используется другой алгоритм — для каждого из них создается ОС поток для ожидания ответа, а горутина кладется в очередь ожидания.
lostmsu
09.10.2018 01:14Похоже Go тут имеет преимущество в виде отсутствующих async-await, но потенциально более низкую производительность из-за необходимости переключать мини-стеки (таски по идее потребляют куда меньше 2кб).
Честно говоря, с учётом трендов, первое в целом выглядит лучше, т.к. «you can always throw more hardware», а разработчики дороги.creker
09.10.2018 01:20Таски тоже не бесплатны таки, там нужно переключать кучу колбэков. И таки это довольно не быстро получается, судя по моим наблюдениям.
Здесь все же не годится «you can always throw more hardware». Горутины эффективно используют все ядра по-умолчанию, чего не скажешь о тасках, которые, вообще, для этого даже не задумывались. За это вполне можно разменять немного памяти.lostmsu
09.10.2018 01:44> эффективно используют все ядра по-умолчанию, чего не скажешь о тасках
в каком смысле?creker
09.10.2018 01:53В смысле, что горутины по-умолчанию мультиплексятся на реальные потоки. Паралелльное выполнение мы получаем из коробки автоматически.
Таски предназначены для асинхронности — выполнить что-то быстренько где-то и вернуться в свой поток, эмулируя линейное выполнение кода. Параллельное выполнение и тем более мультиплексирование с ними несовместимо от слова совсем. Это ортогональные парадигмы. Любые попытки междпотокового взаимодействия вкупе с асинками превращаются в жуткое насилие как над языком, так и над самим собой. А так же сложным в отладке багам вроде дэдлоков. Ведь что такое асинки в своей базовой реализации — очередь колбэков (синхронизационный контекст), которая формируется нарезанием кода ключевым словом await. Самостоятельно реализуется как два пальца, чтобы в любом потоке async await работало так же как в главном, т.е. управление всегда возвращалось на твой поток. Ну или берется готовый Nito.AsyncEx.AsyncContextlostmsu
09.10.2018 02:03> Параллельное выполнение и тем более мультиплексирование с ними несовместимо от слова совсем. Это ортогональные парадигмы.
var fileDatas = await Task.WhenAll(fileNames.Select(File.ReadAllAsync)).ConfigureAwait(false);
На месте ReadAllAsync может быть любое вычисление.
Или о чём вы?
mayorovp
09.10.2018 08:55Два разных таска без проблем выполняются в двух разных потоках пула одновременно, точно так же как и две разные горутины.
И точно так же как один и тот же асинхронный метод не может выполняться сразу в двух потоках, так и горутина выполняется в один момент времени только в одном потоке.
Вообще никакой разница нет между тасками и горутинами по части параллельности.
creker
09.10.2018 01:17Таски C# дают в некоторой степени эффективность и простоту, т.к. блокирующее ИО эмулируется, по сути, а за кулисами находятся асинхронные примитивы ОС. Проблема начинается в другом. Таски прикручены в язык слишком поздно и все в нем так и противится написать красиво и модно. Даже банальный TCP сервер требует довольно хитрых манипуляций, чтобы достичь всех необходимых качеств. И, в конечном итоге, все равно все приходит к отдельному реальному потоку на каждое соединение, где внутри уже все делается на тасках, чтобы красиво и просто было. Отчасти это проблема API, который до конца не адаптирован на таски. Позор МС, что они до сих пор не могут прикрутить CancellationToken в NetworkStream, что делает невозможным нормальное TCP с управляемыми таймаутами.
mayorovp
09.10.2018 09:01А что такого хитрого вы собрались делать с тайм-аутами в TCP?
В моем понимании, данные либо еще нужны, либо уже нет. Если они еще нужны, то отменять операцию чтения нет смысла. Если они уже не нужны — можно закрыть сокет…powerman
09.10.2018 14:11Таймауты нужны затем, что данные нужны, но не позднее чем через X. Потому что, например, где-то сидит юзер, отправивший запрос, и ему нужно оперативно вернуть ответ. И если оперативно не получается — нужно прервать операцию и вернуть ошибку — это лучше, чем подвиснуть на несколько минут. Если нативной поддержки таймаутов на соединения нет, то их приходится эмулировать создавая отдельные горутины/callback-и, которые будут вызываться через заданное время, проверять что операция ещё не завершилась, и закрывать соединение чтобы прервать эту операцию. Просто кучка лишней ручной работы плюс лишний источник багов (потому что тестированием таймаутов зачастую пренебрегают, ибо долго и неудобно).
mayorovp
09.10.2018 08:51async/await дает все то же самое что есть в горутинах, только с чуть-чуть более шумным синтаксисом. К тому же, таски кушают еще меньше памяти по сравнению с горутинами. А еще есть Kotlin, где даже await писать не требуется.
anjensan Автор
09.10.2018 00:43это вроде про асинхронный код, как в NodeJS, а он не выполняется параллельно как горутины.
Может выполняться и параллельно, если «исполнятель» (реактор и т.п.) умеет это делать.
Ведь как работает рантайм го. Есть некий тред-пул, который попеременно выполняет то одну гороутину, то другую. Он же производит асинхронный ввод-вывод через специальный апи. При этом если гороутина читает/пишет сокет, то может ее приостановить, пока данные не придут. Плюс в последних версиях go он может гороутину прервать посерединке, при выполнении cpu-работы.
С промисами ситуация немного иная. Тоже есть тредпул, он тоже умеет делать асинхронный ввод-вывод, зовут его реактором. И он тоже передает управление в тот промис, для которого есть данные. По сути примерно тоже самое, но есть пара моментов: а) промисы нельзя приостановить посерединке, только там, где явно указал программист; б) промисам не нужен стек, ни маленький, ни динамический…
Так что все не так однозначно. Плюс гороутин — вроде как легче писать код, он выглядит «синхронным». Ну… тут достаточно субъективно как по мне, хотя писать в таком стиле научиться попроще (а го позиционируется как легкий в изучении).
Минус — они все же тяжелее, иногда сложнее делать синхронизацию, ведь вы не знаете когда гороутина может быть приостановлена… Надо использовать сложные примитивы, каналы и т.п.powerman
09.10.2018 00:58Из личного опыта — я асинхронный код писал лет 15. В основном на Perl, но не только. Для перла я даже делал свою реализацию event loop на epoll, когда в перле поддержки epoll ещё не было (потом со своей реализации перешёл на восхитительную библиотечку EV/libev). С другой стороны, синхронный код на горутинах и каналах я тоже писал ещё до появления Go — на Limbo (одном из прародителей Go). Я знаю, что у многих программистов проблема с пониманием и написанием асинхронного кода, но я к ним не отношусь — мне асинхронный код всегда давался достаточно легко. Тем не менее, имея много опыта в обоих подходах, я честно скажу: писать на горутинах синхронный код реально в 2-3 раза проще и быстрее. А читать его проще раз в 10.
powerman
09.10.2018 01:07Может выполняться и параллельно, если «исполнятель» (реактор и т.п.) умеет это делать.
Может. Только вот делать это достаточно быстро может уже далеко не каждый реактор, потому что начинается синхронизация между реальными потоками OS, появляется глобальная блокировка, etc.
creker
09.10.2018 01:10иногда сложнее делать синхронизацию, ведь вы не знаете когда гороутина может быть приостановлена
Непонятно, что тут имеется ввиду. Никогда даже мысли такой не было, чтобы думать, когда там что будет приостановлено. Go чрезвычайно предсказуем в этом плане.
Что до субъективности, оно может и так, но писать на Go во многие разы проще. Просто несравнимо проще, что в сравнении с обычными асинхронными API на колбэках, что новомодными таск эвэйтами. Код прост как пробка и эффективен, что при возвращении через несколько лет к своему проекту нет никакой проблемы воссоздать логику все этих асинхронных взаимодействий. Благо опыт уже был такой и не раз. Go может быть не может все и вся, но под этим конкретные паттерны заточен как никто другой.anjensan Автор
09.10.2018 01:27Ну, на самом деле довод про синхронизации действительно притянут за уши, так что я его забираю, был не прав :)
По поводу коллбеков полностью согласен, это зло. А вот с await'ами как по мне все сложнее. С ними код более явный, четко видно где происходит асинхронная операция, а где мы «внутри программы». Вижу как плюсы, так и минусы…
creker
09.10.2018 01:35+1Я был такого мнения об эвэйтах на заре их рождения. Все круто, все просто. Сейчас эта технология оказывается во многие разы сложнее, чем горутины и даже колбэки, когда ты выходишь хоть немного за пределы примитивных паттернов — http запрос ответа на нажатие кнопки юзером, как во всех и каждом примерах делают. Конкретно в C# реализации столько подводных камней, неочевидного поведения, что я уже не уверен, что стало реально лучше. Вот это «прикручено сбоку» постоянно порождает уродливые конструкции, чтобы всунуть асинки туда, где о них никто не думал с самого начала. Все таки это огромное преимущество, что в Go все примитивы были с самого начала и все и вся построено на них. Ничего из этого не ново и известно с бородатых годов, но реализация сделала свое дело.
0xd34df00d
09.10.2018 17:06А я что-то ghc вспомнил. Гринтреды офигенные, всякие ништяки вроде STM, и так далее. Ну и типобезопасность-выразительность.
Скорость сборки так себе, правда.
anjensan Автор
09.10.2018 00:26Вы так говорите, будто легкие нити с каналами, скорость сборки и портабельность всенепременно конфликтуют с исключениями.
powerman
09.10.2018 00:30Ничего подобного, Вы меня просто не поняли. Имелось в виду, что при переходе на другой "лучший" язык в котором исключения есть из коробки, не хотелось бы потерять то, что ценно в Go.
creker
09.10.2018 01:02Несколько конфликтуют или, по крайней мере, усложняют жизнь. Именно поэтому таски C# очень специфично работают с исключениями, по сути, выламывая их так, чтобы казалось, будто все синхронное. Кучи горутин, каждая из которых может аварийно завершиться от выброшенного неудачно исключения, это не очень приятная картина. Это отчасти одна из причин, почему в Go не вписывались исключениями с самого начала. Ручная обработка ошибок в этом случае дает более предсказуемое поведение, что для Go важно, т.к., в отличие от C# и Java, его код обычно очень конкурентный.
anjensan Автор
09.10.2018 01:14Так таски специфично работают с исключениями или гороутины?..
А вот давайте по теме статьи, вот такой код не «скомпилируется» (в смысле jex кинет ошибку):go func() { badFunction_() }()
А вот такой код уже ок:
go func() { if TRY() { badFunction_() } else { log.Error(EX()) } }()
Для вас это достаточно явно и предсказуемо? Хорошо ли это вписывается в конкурентный код?creker
09.10.2018 01:23+1Да, это правильно и это то, что я предлагал неоднократно для Go в профильной теме их репозитория. Несколько походит на Swift с его как бы исключениями. Правда без всех этих catch, т.к. видеть возвращение этого паттерна в Go совершенно не хочется.
anjensan Автор
09.10.2018 10:06Вы считаете что исключения в Go не вписываются, но при этом предлагали подобное решение с исключениями? Мне это кажется чуть нелогичным.
А еще вы немного передергиваете :)
Исключения работают плохо с тасками? Да, они асинхронны, но ведь в Go гороутины. Гороутины как раз куда лучше дружат с исключениями.
Кучи горутин, каждая из которых может аварийно свалиться от любой ошибки, это плохо? Да, несомненно! Но это именно то, что происходит прямо сейчас, без исключений. Банальный nil dereference или index out of range — весь процесс упал. Покажите мне Java-сервер, который падает при любом NPE. Или Erlang, где тоже зеленые потоки, все асинхронное, есть исключения… и упор делается на неубиваемость по.
А еще когда ругают исключения, мол как они не к месту в Go, всегда ругают все типы исключения, но говорят только про обычные. Как будто checked исключений и не существует вовсе.Alesh
09.10.2018 11:40Кстати насколько мне помнится отказ в Go от патерна try/catch/finally в частности обосновывался тем, что это не прикрутить нормально к языку с асинхронной мультизадачностью на сопрограммах. Но! Это вполне нормально реализовали в Python, да и в других языках реализующих асинхронность тоже как-то решают эту проблему.
0xd34df00d
09.10.2018 17:10Кучи горутин, каждая из которых может аварийно завершиться от выброшенного неудачно исключения, это не очень приятная картина.
Я не очень понял вот это вот. Чем ручная обработка ошибок лучше и в чем вообще заключается аварийность завершения?
anjensan Автор
10.10.2018 11:57Аварийность заключается в том, что у Go стандартная политика «чуть что не так — падаем всем процессом и ждем когда нас перезапутсят». В контексте компании, из которой Go пришел, это кстати вполне норм — инфраструктура, уровень репликации и автоматизация перезапусков сервисов ну очень хороши.
Ну а тут уже начинает работать инертность мышления — если для Go несловненная паника авариайна, то, наверное, и для всех языков также… что, может быть иначе, есть варианты… да ну, вы все врете :)
А «ручная обработка» лучше тем, что создает иллюзию безопасности. Заметил, что некоторые гоферы считают вот это
обработкой ошибки :) А раз «обработал» — значит защищен :)if err != nil { return err }
DexterHD
09.10.2018 12:46Интересно, как же все таки программисты не любят обрабатывать ошибки…
Ведь не дураки сидят в команде языка Go. И не просто так приняли такое решение при обработке ошибок (Уж точно не из-за лени там или незнания того как исключения реализовать).
Давайте просто всегда делать:
file, _ := os.Open(filename)
И будет идеально красивый хорошенький код, а главное ни какого бойлерплэйта :D
А да, еще хотелось бы статью на тему того как с помощью кодогенерации выпилить go fmt, а то он гадина форматирует как то странно, не так как мне хочется код форматировать, я вот хочу скобочку например на следующей строке. Ну вы меня поняли… ;)anjensan Автор
09.10.2018 13:05Ну вот в моем примере автор библиотеки именно так и сделал в двух местах :)
Честно, я спецом не искал такое, заметил уже в процессе переписывания. Вся соль в том, что с исключениями и меньше бойлерплэйта, и сложнее ошибку проигнорировать.
По поводу почему авторы приняли такое решение… Ну они очень много неоднозначных решений приняли, сильно заточив язык под свои корпоративные нужды. Но там вообще своя инженерная атмосфера и специфика ;)
PS: По поводу `go fmt`. Это вы так пытаетесь иронизировать и троллить?
FluffyMan
и ни слова о «finally»)
anjensan Автор
Поначалу планировал сделать для комплекта, но потом понял, что смысла особого и нет :)
defer
не без недостатков конечно, но рольfinally
на себя взять может.А вообще ведь
finally
не является неотъемлемой частью исключений как таковых, его может и не быть.