Pull to refresh

Comments 43

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


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

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

UFO landed and left these words here

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

UFO landed and left these words here

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

UFO landed and left these words here

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

UFO landed and left these words here

Горутины:


  • выполняются параллельно (напр. если они заняты тяжёлыми вычислительными задачами без какого-либо I/O, т.е. без точек, где можно перехватить выполнение при cooperative multitasking, то всё-равно будет параллельно выполняться примерно столько горутин, сколько на компе ядер CPU) — что позволяет не беспокоится о том, что обработчик какого-то события окажется слишком медленным и приостановит работу всей системы
  • позволяют использовать блокирующий I/O — что позволяет писать код более просто и ясно, чем асинхронный на callbacks/futures/promises
  • очень быстро создаются и уничтожаются миллионами — что позволяет строить на этом архитектуру приложения, в результате чего оно обычно упрощается
  • миллионы горутин используют достаточно мало памяти (обычно у горутины стек 2KB, т.е. миллион сожрёт всего 2GB RAM — иными словами даже на ноуте с 4-8GB RAM вполне реально погонять клиент/сервер на миллион соединений (на практике надо ещё ядро потюнить, иначе ядро на каждый сокет ещё около 8KB сожрёт)

Иными словами, это даёт возможность писать приложения совершенно иначе — в целом, намного проще, и при этом очень эффективно в плане производительности и памяти. Если убрать любой из этих пунктов — в таком стиле нагруженные приложения писать станет невозможно. Как с этими пунктами у тасков C#/Java?

Не буду говорить про C# (не знаю). И еще не знаю что имеется в виду «таски Java». Поэтому отвечу про вариант «ректор + промисы» безотносительно к языку:
  • выполняются параллельно, но не более, чем ваш размер тредпула (который обычно больше, нежели количество ядер)
  • точно также позволяют использовать блокирующее io… через делегацию работы в отдельный резиновый тредпул (а вы как думали, в Go это при помощи магии делается?); но это надо делать явно, да
  • очень быстро создаются и уничтожаются десятками миллионов
  • миллионы промисов/тасок/тп использует мало памяти, стек на целых 0KB. т.е. миллион сожрет всего 0GB RAM на стек
UFO landed and left these words here
Именно обходится. В Go есть ровно один поток, который занимается обработкой асинхронного IO. По сути, глобальный select, который по событиям разблокирует горутины. Это особое поведение только для IO, чтобы снаружи все было блокирующим, а на самом деле было полностью под контролем разработчика — в любой момент из любой горутины можно закрыть соединение, для каждой операции есть таймауты, нет никаких непонятных зависаний блокирующего вызова, отчего виснет все остальное.

Для всех остальных системных вызовов используется другой алгоритм — для каждого из них создается ОС поток для ожидания ответа, а горутина кладется в очередь ожидания.
UFO landed and left these words here
Таски тоже не бесплатны таки, там нужно переключать кучу колбэков. И таки это довольно не быстро получается, судя по моим наблюдениям.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

А еще когда ругают исключения, мол как они не к месту в Go, всегда ругают все типы исключения, но говорят только про обычные. Как будто checked исключений и не существует вовсе.
Кстати насколько мне помнится отказ в Go от патерна try/catch/finally в частности обосновывался тем, что это не прикрутить нормально к языку с асинхронной мультизадачностью на сопрограммах. Но! Это вполне нормально реализовали в Python, да и в других языках реализующих асинхронность тоже как-то решают эту проблему.
UFO landed and left these words here
Аварийность заключается в том, что у Go стандартная политика «чуть что не так — падаем всем процессом и ждем когда нас перезапутсят». В контексте компании, из которой Go пришел, это кстати вполне норм — инфраструктура, уровень репликации и автоматизация перезапусков сервисов ну очень хороши.

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

А «ручная обработка» лучше тем, что создает иллюзию безопасности. Заметил, что некоторые гоферы считают вот это
if err != nil { 
    return err
} 
обработкой ошибки :) А раз «обработал» — значит защищен :)
Интересно, как же все таки программисты не любят обрабатывать ошибки…
Ведь не дураки сидят в команде языка Go. И не просто так приняли такое решение при обработке ошибок (Уж точно не из-за лени там или незнания того как исключения реализовать).

Давайте просто всегда делать:
file, _ := os.Open(filename)

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

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

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

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

Sign up to leave a comment.

Articles