Comments 43
А как в этом другом языке с лёгкими нитями, каналами, скоростью сборки и портабельностью?
Возможно я немного отстал, я на этих языках не пишу, но, насколько я помню, в C# нельзя было плодить миллионы корутин, и в чистой Java тоже (но вроде можно было на каких-то других языках на базе JVM). Расскажите плз, как с этим делом сейчас — реально можно написать на C# и Java лёгкий сервер, который будет держать миллионы одновременных соединений используя по две корутины на соединение (в которых блокирующие чтение и запись), общающиеся между собой через каналы, и он будет иметь производительность и потребление памяти сравнимое с Go?
Изобрёл, безусловно, не Go. Проблема обычно в том, что каждой нити, лёгкая она или нет, нужен свой стек. И вот размер этого стека, умноженный на миллионы нитей, создаёт одну из основных проблем при реализации этого подхода. Языки, в которых изначально не заложили поддержку маленького, динамически растущего стека, обычно не в состоянии эффективно добавить поддержку этой фичи. Вот, например, бенчмарк (правда, тут задача сильно проще, чем делать I/O): https://github.com/atemerev/skynet — .Net (C# вроде на нём, если не путаю) там упоминается только в контексте Futures/promises, а это, насколько я понимаю, не совсем то же самое, что лёгкие нити в го, это вроде про асинхронный код, как в NodeJS, а он не выполняется параллельно как горутины.
Горутины:
- выполняются параллельно (напр. если они заняты тяжёлыми вычислительными задачами без какого-либо I/O, т.е. без точек, где можно перехватить выполнение при cooperative multitasking, то всё-равно будет параллельно выполняться примерно столько горутин, сколько на компе ядер CPU) — что позволяет не беспокоится о том, что обработчик какого-то события окажется слишком медленным и приостановит работу всей системы
- позволяют использовать блокирующий I/O — что позволяет писать код более просто и ясно, чем асинхронный на callbacks/futures/promises
- очень быстро создаются и уничтожаются миллионами — что позволяет строить на этом архитектуру приложения, в результате чего оно обычно упрощается
- миллионы горутин используют достаточно мало памяти (обычно у горутины стек 2KB, т.е. миллион сожрёт всего 2GB RAM — иными словами даже на ноуте с 4-8GB RAM вполне реально погонять клиент/сервер на миллион соединений (на практике надо ещё ядро потюнить, иначе ядро на каждый сокет ещё около 8KB сожрёт)
Иными словами, это даёт возможность писать приложения совершенно иначе — в целом, намного проще, и при этом очень эффективно в плане производительности и памяти. Если убрать любой из этих пунктов — в таком стиле нагруженные приложения писать станет невозможно. Как с этими пунктами у тасков C#/Java?
- выполняются параллельно, но не более, чем ваш размер тредпула (который обычно больше, нежели количество ядер)
- точно также позволяют использовать блокирующее io… через делегацию работы в отдельный резиновый тредпул (а вы как думали, в Go это при помощи магии делается?); но это надо делать явно, да
- очень быстро создаются и уничтожаются десятками миллионов
- миллионы промисов/тасок/тп использует мало памяти, стек на целых 0KB. т.е. миллион сожрет всего 0GB RAM на стек
Для всех остальных системных вызовов используется другой алгоритм — для каждого из них создается ОС поток для ожидания ответа, а горутина кладется в очередь ожидания.
Здесь все же не годится «you can always throw more hardware». Горутины эффективно используют все ядра по-умолчанию, чего не скажешь о тасках, которые, вообще, для этого даже не задумывались. За это вполне можно разменять немного памяти.
Таски предназначены для асинхронности — выполнить что-то быстренько где-то и вернуться в свой поток, эмулируя линейное выполнение кода. Параллельное выполнение и тем более мультиплексирование с ними несовместимо от слова совсем. Это ортогональные парадигмы. Любые попытки междпотокового взаимодействия вкупе с асинками превращаются в жуткое насилие как над языком, так и над самим собой. А так же сложным в отладке багам вроде дэдлоков. Ведь что такое асинки в своей базовой реализации — очередь колбэков (синхронизационный контекст), которая формируется нарезанием кода ключевым словом await. Самостоятельно реализуется как два пальца, чтобы в любом потоке async await работало так же как в главном, т.е. управление всегда возвращалось на твой поток. Ну или берется готовый Nito.AsyncEx.AsyncContext
И точно так же как один и тот же асинхронный метод не может выполняться сразу в двух потоках, так и горутина выполняется в один момент времени только в одном потоке.
Вообще никакой разница нет между тасками и горутинами по части параллельности.
В моем понимании, данные либо еще нужны, либо уже нет. Если они еще нужны, то отменять операцию чтения нет смысла. Если они уже не нужны — можно закрыть сокет…
Таймауты нужны затем, что данные нужны, но не позднее чем через X. Потому что, например, где-то сидит юзер, отправивший запрос, и ему нужно оперативно вернуть ответ. И если оперативно не получается — нужно прервать операцию и вернуть ошибку — это лучше, чем подвиснуть на несколько минут. Если нативной поддержки таймаутов на соединения нет, то их приходится эмулировать создавая отдельные горутины/callback-и, которые будут вызываться через заданное время, проверять что операция ещё не завершилась, и закрывать соединение чтобы прервать эту операцию. Просто кучка лишней ручной работы плюс лишний источник багов (потому что тестированием таймаутов зачастую пренебрегают, ибо долго и неудобно).
это вроде про асинхронный код, как в NodeJS, а он не выполняется параллельно как горутины.Может выполняться и параллельно, если «исполнятель» (реактор и т.п.) умеет это делать.
Ведь как работает рантайм го. Есть некий тред-пул, который попеременно выполняет то одну гороутину, то другую. Он же производит асинхронный ввод-вывод через специальный апи. При этом если гороутина читает/пишет сокет, то может ее приостановить, пока данные не придут. Плюс в последних версиях go он может гороутину прервать посерединке, при выполнении cpu-работы.
С промисами ситуация немного иная. Тоже есть тредпул, он тоже умеет делать асинхронный ввод-вывод, зовут его реактором. И он тоже передает управление в тот промис, для которого есть данные. По сути примерно тоже самое, но есть пара моментов: а) промисы нельзя приостановить посерединке, только там, где явно указал программист; б) промисам не нужен стек, ни маленький, ни динамический…
Так что все не так однозначно. Плюс гороутин — вроде как легче писать код, он выглядит «синхронным». Ну… тут достаточно субъективно как по мне, хотя писать в таком стиле научиться попроще (а го позиционируется как легкий в изучении).
Минус — они все же тяжелее, иногда сложнее делать синхронизацию, ведь вы не знаете когда гороутина может быть приостановлена… Надо использовать сложные примитивы, каналы и т.п.
Из личного опыта — я асинхронный код писал лет 15. В основном на Perl, но не только. Для перла я даже делал свою реализацию event loop на epoll, когда в перле поддержки epoll ещё не было (потом со своей реализации перешёл на восхитительную библиотечку EV/libev). С другой стороны, синхронный код на горутинах и каналах я тоже писал ещё до появления Go — на Limbo (одном из прародителей Go). Я знаю, что у многих программистов проблема с пониманием и написанием асинхронного кода, но я к ним не отношусь — мне асинхронный код всегда давался достаточно легко. Тем не менее, имея много опыта в обоих подходах, я честно скажу: писать на горутинах синхронный код реально в 2-3 раза проще и быстрее. А читать его проще раз в 10.
Может выполняться и параллельно, если «исполнятель» (реактор и т.п.) умеет это делать.
Может. Только вот делать это достаточно быстро может уже далеко не каждый реактор, потому что начинается синхронизация между реальными потоками OS, появляется глобальная блокировка, etc.
иногда сложнее делать синхронизацию, ведь вы не знаете когда гороутина может быть приостановлена
Непонятно, что тут имеется ввиду. Никогда даже мысли такой не было, чтобы думать, когда там что будет приостановлено. Go чрезвычайно предсказуем в этом плане.
Что до субъективности, оно может и так, но писать на Go во многие разы проще. Просто несравнимо проще, что в сравнении с обычными асинхронными API на колбэках, что новомодными таск эвэйтами. Код прост как пробка и эффективен, что при возвращении через несколько лет к своему проекту нет никакой проблемы воссоздать логику все этих асинхронных взаимодействий. Благо опыт уже был такой и не раз. Go может быть не может все и вся, но под этим конкретные паттерны заточен как никто другой.
По поводу коллбеков полностью согласен, это зло. А вот с await'ами как по мне все сложнее. С ними код более явный, четко видно где происходит асинхронная операция, а где мы «внутри программы». Вижу как плюсы, так и минусы…
Ничего подобного, Вы меня просто не поняли. Имелось в виду, что при переходе на другой "лучший" язык в котором исключения есть из коробки, не хотелось бы потерять то, что ценно в Go.
А вот давайте по теме статьи, вот такой код не «скомпилируется» (в смысле jex кинет ошибку):
go func() {
badFunction_()
}()
А вот такой код уже ок:
go func() {
if TRY() {
badFunction_()
} else {
log.Error(EX())
}
}()
Для вас это достаточно явно и предсказуемо? Хорошо ли это вписывается в конкурентный код?
А еще вы немного передергиваете :)
Исключения работают плохо с тасками? Да, они асинхронны, но ведь в Go гороутины. Гороутины как раз куда лучше дружат с исключениями.
Кучи горутин, каждая из которых может аварийно свалиться от любой ошибки, это плохо? Да, несомненно! Но это именно то, что происходит прямо сейчас, без исключений. Банальный nil dereference или index out of range — весь процесс упал. Покажите мне Java-сервер, который падает при любом NPE. Или Erlang, где тоже зеленые потоки, все асинхронное, есть исключения… и упор делается на неубиваемость по.
А еще когда ругают исключения, мол как они не к месту в Go, всегда ругают все типы исключения, но говорят только про обычные. Как будто checked исключений и не существует вовсе.
Ну а тут уже начинает работать инертность мышления — если для Go несловненная паника авариайна, то, наверное, и для всех языков также… что, может быть иначе, есть варианты… да ну, вы все врете :)
А «ручная обработка» лучше тем, что создает иллюзию безопасности. Заметил, что некоторые гоферы считают вот это
if err != nil {
return err
}
обработкой ошибки :) А раз «обработал» — значит защищен :)Ведь не дураки сидят в команде языка Go. И не просто так приняли такое решение при обработке ошибок (Уж точно не из-за лени там или незнания того как исключения реализовать).
Давайте просто всегда делать:
file, _ := os.Open(filename)
И будет идеально красивый хорошенький код, а главное ни какого бойлерплэйта :D
А да, еще хотелось бы статью на тему того как с помощью кодогенерации выпилить go fmt, а то он гадина форматирует как то странно, не так как мне хочется код форматировать, я вот хочу скобочку например на следующей строке. Ну вы меня поняли… ;)
Честно, я спецом не искал такое, заметил уже в процессе переписывания. Вся соль в том, что с исключениями и меньше бойлерплэйта, и сложнее ошибку проигнорировать.
По поводу почему авторы приняли такое решение… Ну они очень много неоднозначных решений приняли, сильно заточив язык под свои корпоративные нужды. Но там вообще своя инженерная атмосфера и специфика ;)
PS: По поводу `go fmt`. Это вы так пытаетесь иронизировать и троллить?
Такой исключительный Go