Недавно были опубликованы черновики дизайна новой обработки ошибок в 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 идет в отрыв, для средних и больших исключения уже равняются с ручным пробросом.


Короче, если ошибки возникают крайне редко — исключения могут код даже немного ускорить. Если как у всех, то будет примерно так-на-так. А вот если очень часто… то медленные исключения — далеко не самая важная проблема, из-за которой стоит переживать.


В качестве теста пробно мигрировал реальную гошную библиотеку на исключения.


К моему глубокому прискорбию, не вышло переписать 1-в-1

Точнее оно бы и получилось, но это надо заморачиваться.


Так, например, функция 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)


  1. FluffyMan
    08.10.2018 17:55
    +1

    и ни слова о «finally»)


    1. anjensan Автор
      08.10.2018 22:25

      Поначалу планировал сделать для комплекта, но потом понял, что смысла особого и нет :)
      defer не без недостатков конечно, но роль finally на себя взять может.


      А вообще ведь finally не является неотъемлемой частью исключений как таковых, его может и не быть.


  1. Alesh
    08.10.2018 18:08

    Сейчас вас порвут в клочья)


    1. powerman
      08.10.2018 21:44
      +4

      С чего бы? Я вот не фанат исключений в Go и использовать это не планирую, но — почитать было интересно, работа проделана достойная, причин наезжать на автора просто нет.


  1. lostmsu
    08.10.2018 18:19
    +2

    Этим можно воспользоваться прямо сейчас, без странноватого синтаксиса. Достаточно перейти на другой язык, который не выбрал особый путь!


    1. powerman
      08.10.2018 21:45
      +1

      А как в этом другом языке с лёгкими нитями, каналами, скоростью сборки и портабельностью?


      1. lostmsu
        08.10.2018 21:59
        +1

        C#, Java, всё отлично, многое лучше. Для каналов нет специального синтаксиса, правда.


        1. powerman
          08.10.2018 23:06

          Возможно я немного отстал, я на этих языках не пишу, но, насколько я помню, в C# нельзя было плодить миллионы корутин, и в чистой Java тоже (но вроде можно было на каких-то других языках на базе JVM). Расскажите плз, как с этим делом сейчас — реально можно написать на C# и Java лёгкий сервер, который будет держать миллионы одновременных соединений используя по две корутины на соединение (в которых блокирующие чтение и запись), общающиеся между собой через каналы, и он будет иметь производительность и потребление памяти сравнимое с Go?


          1. lostmsu
            09.10.2018 00:01

            Мне навскидку не очевидно, откуда вы взяли, что в C# нельзя было плодить миллионы тасков. За конкретные примеры ничего сказать не могу — надо гуглить. Но планировщик тасков там устроен примерно также. Не Go же изобрёл per-thread work stealing queues. Они откуда-то из середины 90-ых.


            1. powerman
              09.10.2018 00:15

              Изобрёл, безусловно, не Go. Проблема обычно в том, что каждой нити, лёгкая она или нет, нужен свой стек. И вот размер этого стека, умноженный на миллионы нитей, создаёт одну из основных проблем при реализации этого подхода. Языки, в которых изначально не заложили поддержку маленького, динамически растущего стека, обычно не в состоянии эффективно добавить поддержку этой фичи. Вот, например, бенчмарк (правда, тут задача сильно проще, чем делать I/O): https://github.com/atemerev/skynet — .Net (C# вроде на нём, если не путаю) там упоминается только в контексте Futures/promises, а это, насколько я понимаю, не совсем то же самое, что лёгкие нити в го, это вроде про асинхронный код, как в NodeJS, а он не выполняется параллельно как горутины.


              1. lostmsu
                09.10.2018 00:22
                -1

                Мне навскидку не очевидно, что реалзизация горутин чем-то отличается от тасков C#/Java. Всё, что я нашёл — это полумаркетинговый материал про то, как gorutines они примерно как потоки, но куда легче. Но зачем их сравнивают с потоками, а не с тасками — не понятно.

                Я бы сказал, что они идентичны, пока не будет показано иное. И по сути просто per-thread work stealing queues.


                1. 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?


                  1. anjensan Автор
                    09.10.2018 01:04

                    Не буду говорить про C# (не знаю). И еще не знаю что имеется в виду «таски Java». Поэтому отвечу про вариант «ректор + промисы» безотносительно к языку:

                    • выполняются параллельно, но не более, чем ваш размер тредпула (который обычно больше, нежели количество ядер)
                    • точно также позволяют использовать блокирующее io… через делегацию работы в отдельный резиновый тредпул (а вы как думали, в Go это при помощи магии делается?); но это надо делать явно, да
                    • очень быстро создаются и уничтожаются десятками миллионов
                    • миллионы промисов/тасок/тп использует мало памяти, стек на целых 0KB. т.е. миллион сожрет всего 0GB RAM на стек


                    1. lostmsu
                      09.10.2018 01:20

                      Я так понял Go обходится или по крайней мере может обходиться без резинового тредпула. Просто то, что выглядит как блокирующий вызов, может на самом деле yieldить текущую нить а внутри вызывать асинхронный системный примитив.


                      1. creker
                        09.10.2018 01:29

                        Именно обходится. В Go есть ровно один поток, который занимается обработкой асинхронного IO. По сути, глобальный select, который по событиям разблокирует горутины. Это особое поведение только для IO, чтобы снаружи все было блокирующим, а на самом деле было полностью под контролем разработчика — в любой момент из любой горутины можно закрыть соединение, для каждой операции есть таймауты, нет никаких непонятных зависаний блокирующего вызова, отчего виснет все остальное.

                        Для всех остальных системных вызовов используется другой алгоритм — для каждого из них создается ОС поток для ожидания ответа, а горутина кладется в очередь ожидания.


                  1. lostmsu
                    09.10.2018 01:14

                    Похоже Go тут имеет преимущество в виде отсутствующих async-await, но потенциально более низкую производительность из-за необходимости переключать мини-стеки (таски по идее потребляют куда меньше 2кб).

                    Честно говоря, с учётом трендов, первое в целом выглядит лучше, т.к. «you can always throw more hardware», а разработчики дороги.


                    1. creker
                      09.10.2018 01:20

                      Таски тоже не бесплатны таки, там нужно переключать кучу колбэков. И таки это довольно не быстро получается, судя по моим наблюдениям.

                      Здесь все же не годится «you can always throw more hardware». Горутины эффективно используют все ядра по-умолчанию, чего не скажешь о тасках, которые, вообще, для этого даже не задумывались. За это вполне можно разменять немного памяти.


                      1. lostmsu
                        09.10.2018 01:44

                        > эффективно используют все ядра по-умолчанию, чего не скажешь о тасках

                        в каком смысле?


                        1. creker
                          09.10.2018 01:53

                          В смысле, что горутины по-умолчанию мультиплексятся на реальные потоки. Паралелльное выполнение мы получаем из коробки автоматически.

                          Таски предназначены для асинхронности — выполнить что-то быстренько где-то и вернуться в свой поток, эмулируя линейное выполнение кода. Параллельное выполнение и тем более мультиплексирование с ними несовместимо от слова совсем. Это ортогональные парадигмы. Любые попытки междпотокового взаимодействия вкупе с асинками превращаются в жуткое насилие как над языком, так и над самим собой. А так же сложным в отладке багам вроде дэдлоков. Ведь что такое асинки в своей базовой реализации — очередь колбэков (синхронизационный контекст), которая формируется нарезанием кода ключевым словом await. Самостоятельно реализуется как два пальца, чтобы в любом потоке async await работало так же как в главном, т.е. управление всегда возвращалось на твой поток. Ну или берется готовый Nito.AsyncEx.AsyncContext


                          1. lostmsu
                            09.10.2018 02:03

                            > Параллельное выполнение и тем более мультиплексирование с ними несовместимо от слова совсем. Это ортогональные парадигмы.

                            var fileDatas = await Task.WhenAll(fileNames.Select(File.ReadAllAsync)).ConfigureAwait(false);


                            На месте ReadAllAsync может быть любое вычисление.

                            Или о чём вы?


                          1. mayorovp
                            09.10.2018 08:55

                            Два разных таска без проблем выполняются в двух разных потоках пула одновременно, точно так же как и две разные горутины.

                            И точно так же как один и тот же асинхронный метод не может выполняться сразу в двух потоках, так и горутина выполняется в один момент времени только в одном потоке.

                            Вообще никакой разница нет между тасками и горутинами по части параллельности.


                  1. creker
                    09.10.2018 01:17

                    Таски C# дают в некоторой степени эффективность и простоту, т.к. блокирующее ИО эмулируется, по сути, а за кулисами находятся асинхронные примитивы ОС. Проблема начинается в другом. Таски прикручены в язык слишком поздно и все в нем так и противится написать красиво и модно. Даже банальный TCP сервер требует довольно хитрых манипуляций, чтобы достичь всех необходимых качеств. И, в конечном итоге, все равно все приходит к отдельному реальному потоку на каждое соединение, где внутри уже все делается на тасках, чтобы красиво и просто было. Отчасти это проблема API, который до конца не адаптирован на таски. Позор МС, что они до сих пор не могут прикрутить CancellationToken в NetworkStream, что делает невозможным нормальное TCP с управляемыми таймаутами.


                    1. mayorovp
                      09.10.2018 09:01

                      А что такого хитрого вы собрались делать с тайм-аутами в TCP?

                      В моем понимании, данные либо еще нужны, либо уже нет. Если они еще нужны, то отменять операцию чтения нет смысла. Если они уже не нужны — можно закрыть сокет…


                      1. powerman
                        09.10.2018 14:11

                        Таймауты нужны затем, что данные нужны, но не позднее чем через X. Потому что, например, где-то сидит юзер, отправивший запрос, и ему нужно оперативно вернуть ответ. И если оперативно не получается — нужно прервать операцию и вернуть ошибку — это лучше, чем подвиснуть на несколько минут. Если нативной поддержки таймаутов на соединения нет, то их приходится эмулировать создавая отдельные горутины/callback-и, которые будут вызываться через заданное время, проверять что операция ещё не завершилась, и закрывать соединение чтобы прервать эту операцию. Просто кучка лишней ручной работы плюс лишний источник багов (потому что тестированием таймаутов зачастую пренебрегают, ибо долго и неудобно).


                  1. mayorovp
                    09.10.2018 08:51

                    async/await дает все то же самое что есть в горутинах, только с чуть-чуть более шумным синтаксисом. К тому же, таски кушают еще меньше памяти по сравнению с горутинами. А еще есть Kotlin, где даже await писать не требуется.


              1. anjensan Автор
                09.10.2018 00:43

                это вроде про асинхронный код, как в NodeJS, а он не выполняется параллельно как горутины.
                Может выполняться и параллельно, если «исполнятель» (реактор и т.п.) умеет это делать.

                Ведь как работает рантайм го. Есть некий тред-пул, который попеременно выполняет то одну гороутину, то другую. Он же производит асинхронный ввод-вывод через специальный апи. При этом если гороутина читает/пишет сокет, то может ее приостановить, пока данные не придут. Плюс в последних версиях go он может гороутину прервать посерединке, при выполнении cpu-работы.

                С промисами ситуация немного иная. Тоже есть тредпул, он тоже умеет делать асинхронный ввод-вывод, зовут его реактором. И он тоже передает управление в тот промис, для которого есть данные. По сути примерно тоже самое, но есть пара моментов: а) промисы нельзя приостановить посерединке, только там, где явно указал программист; б) промисам не нужен стек, ни маленький, ни динамический…

                Так что все не так однозначно. Плюс гороутин — вроде как легче писать код, он выглядит «синхронным». Ну… тут достаточно субъективно как по мне, хотя писать в таком стиле научиться попроще (а го позиционируется как легкий в изучении).
                Минус — они все же тяжелее, иногда сложнее делать синхронизацию, ведь вы не знаете когда гороутина может быть приостановлена… Надо использовать сложные примитивы, каналы и т.п.


                1. powerman
                  09.10.2018 00:58

                  Из личного опыта — я асинхронный код писал лет 15. В основном на Perl, но не только. Для перла я даже делал свою реализацию event loop на epoll, когда в перле поддержки epoll ещё не было (потом со своей реализации перешёл на восхитительную библиотечку EV/libev). С другой стороны, синхронный код на горутинах и каналах я тоже писал ещё до появления Go — на Limbo (одном из прародителей Go). Я знаю, что у многих программистов проблема с пониманием и написанием асинхронного кода, но я к ним не отношусь — мне асинхронный код всегда давался достаточно легко. Тем не менее, имея много опыта в обоих подходах, я честно скажу: писать на горутинах синхронный код реально в 2-3 раза проще и быстрее. А читать его проще раз в 10.


                1. powerman
                  09.10.2018 01:07

                  Может выполняться и параллельно, если «исполнятель» (реактор и т.п.) умеет это делать.

                  Может. Только вот делать это достаточно быстро может уже далеко не каждый реактор, потому что начинается синхронизация между реальными потоками OS, появляется глобальная блокировка, etc.


                1. creker
                  09.10.2018 01:10

                  иногда сложнее делать синхронизацию, ведь вы не знаете когда гороутина может быть приостановлена

                  Непонятно, что тут имеется ввиду. Никогда даже мысли такой не было, чтобы думать, когда там что будет приостановлено. Go чрезвычайно предсказуем в этом плане.

                  Что до субъективности, оно может и так, но писать на Go во многие разы проще. Просто несравнимо проще, что в сравнении с обычными асинхронными API на колбэках, что новомодными таск эвэйтами. Код прост как пробка и эффективен, что при возвращении через несколько лет к своему проекту нет никакой проблемы воссоздать логику все этих асинхронных взаимодействий. Благо опыт уже был такой и не раз. Go может быть не может все и вся, но под этим конкретные паттерны заточен как никто другой.


                  1. anjensan Автор
                    09.10.2018 01:27

                    Ну, на самом деле довод про синхронизации действительно притянут за уши, так что я его забираю, был не прав :)

                    По поводу коллбеков полностью согласен, это зло. А вот с await'ами как по мне все сложнее. С ними код более явный, четко видно где происходит асинхронная операция, а где мы «внутри программы». Вижу как плюсы, так и минусы…


                    1. creker
                      09.10.2018 01:35
                      +1

                      Я был такого мнения об эвэйтах на заре их рождения. Все круто, все просто. Сейчас эта технология оказывается во многие разы сложнее, чем горутины и даже колбэки, когда ты выходишь хоть немного за пределы примитивных паттернов — http запрос ответа на нажатие кнопки юзером, как во всех и каждом примерах делают. Конкретно в C# реализации столько подводных камней, неочевидного поведения, что я уже не уверен, что стало реально лучше. Вот это «прикручено сбоку» постоянно порождает уродливые конструкции, чтобы всунуть асинки туда, где о них никто не думал с самого начала. Все таки это огромное преимущество, что в Go все примитивы были с самого начала и все и вся построено на них. Ничего из этого не ново и известно с бородатых годов, но реализация сделала свое дело.


        1. 0xd34df00d
          09.10.2018 17:06

          А я что-то ghc вспомнил. Гринтреды офигенные, всякие ништяки вроде STM, и так далее. Ну и типобезопасность-выразительность.

          Скорость сборки так себе, правда.


      1. anjensan Автор
        09.10.2018 00:26

        Вы так говорите, будто легкие нити с каналами, скорость сборки и портабельность всенепременно конфликтуют с исключениями.


        1. powerman
          09.10.2018 00:30

          Ничего подобного, Вы меня просто не поняли. Имелось в виду, что при переходе на другой "лучший" язык в котором исключения есть из коробки, не хотелось бы потерять то, что ценно в Go.


        1. creker
          09.10.2018 01:02

          Несколько конфликтуют или, по крайней мере, усложняют жизнь. Именно поэтому таски C# очень специфично работают с исключениями, по сути, выламывая их так, чтобы казалось, будто все синхронное. Кучи горутин, каждая из которых может аварийно завершиться от выброшенного неудачно исключения, это не очень приятная картина. Это отчасти одна из причин, почему в Go не вписывались исключениями с самого начала. Ручная обработка ошибок в этом случае дает более предсказуемое поведение, что для Go важно, т.к., в отличие от C# и Java, его код обычно очень конкурентный.


          1. anjensan Автор
            09.10.2018 01:14

            Так таски специфично работают с исключениями или гороутины?..

            А вот давайте по теме статьи, вот такой код не «скомпилируется» (в смысле jex кинет ошибку):

            go func() {
               badFunction_()
            }()
            

            А вот такой код уже ок:
            go func() {
               if TRY() {
                   badFunction_()
               } else {
                   log.Error(EX())
               }
            }()
            

            Для вас это достаточно явно и предсказуемо? Хорошо ли это вписывается в конкурентный код?


            1. creker
              09.10.2018 01:23
              +1

              Да, это правильно и это то, что я предлагал неоднократно для Go в профильной теме их репозитория. Несколько походит на Swift с его как бы исключениями. Правда без всех этих catch, т.к. видеть возвращение этого паттерна в Go совершенно не хочется.


              1. anjensan Автор
                09.10.2018 10:06

                Вы считаете что исключения в Go не вписываются, но при этом предлагали подобное решение с исключениями? Мне это кажется чуть нелогичным.

                А еще вы немного передергиваете :)

                Исключения работают плохо с тасками? Да, они асинхронны, но ведь в Go гороутины. Гороутины как раз куда лучше дружат с исключениями.

                Кучи горутин, каждая из которых может аварийно свалиться от любой ошибки, это плохо? Да, несомненно! Но это именно то, что происходит прямо сейчас, без исключений. Банальный nil dereference или index out of range — весь процесс упал. Покажите мне Java-сервер, который падает при любом NPE. Или Erlang, где тоже зеленые потоки, все асинхронное, есть исключения… и упор делается на неубиваемость по.

                А еще когда ругают исключения, мол как они не к месту в Go, всегда ругают все типы исключения, но говорят только про обычные. Как будто checked исключений и не существует вовсе.


                1. Alesh
                  09.10.2018 11:40

                  Кстати насколько мне помнится отказ в Go от патерна try/catch/finally в частности обосновывался тем, что это не прикрутить нормально к языку с асинхронной мультизадачностью на сопрограммах. Но! Это вполне нормально реализовали в Python, да и в других языках реализующих асинхронность тоже как-то решают эту проблему.


          1. 0xd34df00d
            09.10.2018 17:10

            Кучи горутин, каждая из которых может аварийно завершиться от выброшенного неудачно исключения, это не очень приятная картина.

            Я не очень понял вот это вот. Чем ручная обработка ошибок лучше и в чем вообще заключается аварийность завершения?


            1. anjensan Автор
              10.10.2018 11:57

              Аварийность заключается в том, что у Go стандартная политика «чуть что не так — падаем всем процессом и ждем когда нас перезапутсят». В контексте компании, из которой Go пришел, это кстати вполне норм — инфраструктура, уровень репликации и автоматизация перезапусков сервисов ну очень хороши.

              Ну а тут уже начинает работать инертность мышления — если для Go несловненная паника авариайна, то, наверное, и для всех языков также… что, может быть иначе, есть варианты… да ну, вы все врете :)

              А «ручная обработка» лучше тем, что создает иллюзию безопасности. Заметил, что некоторые гоферы считают вот это

              if err != nil { 
                  return err
              } 
              обработкой ошибки :) А раз «обработал» — значит защищен :)


  1. DexterHD
    09.10.2018 12:46

    Интересно, как же все таки программисты не любят обрабатывать ошибки…
    Ведь не дураки сидят в команде языка Go. И не просто так приняли такое решение при обработке ошибок (Уж точно не из-за лени там или незнания того как исключения реализовать).

    Давайте просто всегда делать:

    file, _ := os.Open(filename)
    

    И будет идеально красивый хорошенький код, а главное ни какого бойлерплэйта :D

    А да, еще хотелось бы статью на тему того как с помощью кодогенерации выпилить go fmt, а то он гадина форматирует как то странно, не так как мне хочется код форматировать, я вот хочу скобочку например на следующей строке. Ну вы меня поняли… ;)


    1. anjensan Автор
      09.10.2018 13:05

      Ну вот в моем примере автор библиотеки именно так и сделал в двух местах :)
      Честно, я спецом не искал такое, заметил уже в процессе переписывания. Вся соль в том, что с исключениями и меньше бойлерплэйта, и сложнее ошибку проигнорировать.

      По поводу почему авторы приняли такое решение… Ну они очень много неоднозначных решений приняли, сильно заточив язык под свои корпоративные нужды. Но там вообще своя инженерная атмосфера и специфика ;)

      PS: По поводу `go fmt`. Это вы так пытаетесь иронизировать и троллить?