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


(Тем временем, вы и я используем только самый просветленный язык и отточенные инструменты, разработанные для ловких рук таких мастеров, как мы).


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


Чтобы защититься от праведного огня и не оскорбить ваши (вероятно деликатные) чувства, я буду рассказывать о языке...


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


Я знаю, что это звучит глупо, но поверьте, в конце мы увидим, чье лицо (или лица) были нарисованы на соломенной башке.


Новый язык


Будет перегибом учить совершенно новый (и отстойный) язык только для статьи в блоге, поэтому допустим, что он очень похож на язык, который мы уже знаем. Например Javascript. Фигурные скобки и точки с запятой. if, while и т.д. — Lingua franca нашей толпы.


Я выбрал JS не потому, что эта статья о нем. Просто это язык, в который среднестатистический читатель скорее всего врубается. Вуаля:


function thisIsAFunction(){
  return "Круто!";
}

Так как наше чучело — это крутой (читай — хреновый) язык, то он имеет функции первого класса. Так что вы можете написать что-то такое:


// вернуть список, содержащий все элементы из коллекции,
// которые соответствуют условию
function filter(collection, predicate) {
  var result = [];
  for (var i = 0; i < collection.length; i++) {
    if (predicate(collection[i])){
      result.push(collection[i]);
    }
  }
  return result;
}

Это одна из тех самых функций первого класса, и, как следует из названия, они классные и супер-полезные. Вы наверное привыкли преобразовывать коллекции данных с их помощью, но, как только ухватываешь концепцию, начинаешь использовать их везде, блин.


Может в тестах:


describe("Яблоко", function(){
  it("не апельсин", function(){
    expect("Яблоко").not.toBe("Апельсин");
  });
};

Или когда надо разобрать (распарсить) данные:


tokens.match(Token.LEFT_BRACKET, function(token){
  // Parse a list literal...
  tokens.consume(Token.RIGHT_BRACKET);
});

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


переводчик: в оригинале "Functapalooza". Приставка-слово -a-palooza такое классное, что хочется им поделится для всех.

Какого цвета ваша функция?


И тут начинаются странности. Наш язык имеет одну своеобразную особенность:


1. Каждая функция имеет цвет.


Каждая функция — анонимный callback или обычная функция с именем — является или красной или синей. Так как подсветка кода в нашем блоге не выделяет разный цвет функций, давайте договоримся что синтакс такой:


blue*function doSomethingAzure(){
  // это синяя функция...
}

red*function doSomethingCarnelian(){
  // а это красная функция...
}

У нашего языка нет бесцветных функций. Хотите сделать функцию? — должны выбрать цвет. Такие вот правила. И есть еще несколько правил, которым вы должны следовать:


2. Цвет влияет на способ вызова функции


Представьте, что есть два синтаксиса вызова функций — "синий" и "красный". Что-нибудь типа:


doSomethingAzure(...)*blue;
doSomethingCarnelian()*red;

Когда вызываете функцию — вы должны использовать вызов, который соответствует её цвету. Если не угадали — вызвали красную функцию с *blue после скобок (или наоборот) — произойдет что-то очень плохое. Давно забытый детский кошмар, типа клоуна со змеями вместо рук, который прятался под вашей кроватью. Он выпрыгнет из монитора и высосет ваши глаза.


Дурацкое правило, правда? Ой, но вот еще одно:


3. Только красная функция может вызвать красную функцию.


Вы можете вызвать синюю функцию из красной. Это кошерно:


red*function doSomethingCarnelian(){
  doSomethingAzure()*blue;
}

Но не наоборот. Если вы попробуете:


blue*function doSomethingAzure(){
  doSomethingCarnelian()*red;
}

— вас посетит старый Клоун Паучья Пасть.


Это делает сложнее написание высших функций, таких как filter() из примера. Мы должны выбрать цвет для каждой новой функции и это влияет на цвет функций, которые мы можем ей передать. Очевидное решение — сделать filter() красной. Тогда мы можем вызывать хоть красные, хоть синие функции. Но тогда мы поранимся о следующую колючку в терновом венце, которым является данный язык:


4. Красные функции больнее вызывать


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


Неважно что это, но если вы решили сделать функцию красной, каждый, кто использует ваш API, захочет плюнуть в ваш кофе или сделать чего похуже.


Очевидное решение в таком случае — никогда не использовать красные функции. Просто сделать все синим, и вы снова в нормальном мире, где все функции одного цвета, что равно тому, что у них нет цвета и что наш язык не совсем тупой.


Увы, но садисты, которые разработали данный язык (все знают, что авторы языков программирования являются садистами, правда?), втыкают в нас последний шип:


5. Некоторые функции ядра языка — красные.


Некоторые функции, встроенные в платформу, функции, которые нам нужно использовать, которые невозможно написать самим — доступны только в красном цвете. В этот момент разумный человек может начать подозревать, что этот язык ненавидит нас.


Это все вина функциональных языков!


Вы можете подумать, что проблема в том, что мы пытаемся использовать функции высшего порядка. Если мы просто перестанем валять дурака со всей этой функциональной ерундой, и начнем писать нормальные синие функции первого порядка (функции, которые не оперируют другими функциями — прим. переводчика), как и планировалось Богом — мы избавимся от всей этой боли.


Если мы вызываем только синие функции — делаем все наши функции синими. Иначе — делаем красными. Пока мы не создаем функции, которые принимают функции, нам не нужно беспокоиться о "полиморфности к цвету функции" (полихроматичность?) или других глупостях.


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


Например, у нас есть приятный маленький кусочек кода, который, ну я не знаю, реализует алгоритм Дейкстры над графом, представляющим как сильно ваши социальные связи давят друг на друга. (я потратил кучу времени, пытаясь решить, что бы означал результат. Транзитивная нежелательность?)


Позже вам понадобилось использовать этот алгоритм где-то еще. Естественно, вы оборачиваете код в отдельную функцию. Вызываете ее из старого места и из нового. Но какого цвета должна быть функция? Вероятно вы постараетесь сделать ее синей, но что, если она использует одну из этих противных "только красных" функций из библиотеки ядра?


Допустим, что новое место, из которого вы хотите вызывать функцию — синее? Но теперь вам нужно переписать вызывающий код в красный. И потом переделать функцию, которая вызывает этот код. Уф. Вам придется постоянно помнить про цвет в любом случае. Это будет песком в ваших плавках на пляжном отдыхе программирования.


Цветная аллегория


На самом деле я тут не про цвет говорю. Это аллегория, литературный прием. Сничи — это не про звезды на животиках, это про расу. Вы уже наверное подозреваете ...


Красные функции — асинхронные


Если вы программируете на JavaScript или Node.js, каждый раз, определяя функцию, которая вызывает функцию обратного вызова (коллбэк), чтобы "вернуть" результат — вы пишете красную функцию. Посмотрите на этот список правил и заметьте, как они укладываются в мою метафору:


  1. Синхронные функции возвращают результат, асинхронные — нет, взамен они вызывают коллбэк.
  2. Синхронные функции выдают результат как возвращаемое значение, асинхронные выдают его, вызывая коллбек, который вы им передали.
  3. Вы не можете вызвать асинхронную функцию из синхронной, потому, что вы не сможете узнать результат, пока асинхронная функция не выполнится позже.
  4. Асинхронные функции не составляются в выражения из-за коллбэков, требуют иначе обрабатывать их ошибки и не могут быть использованы в try/catch блоке или в ряде других выражений, управляющих программой.
  5. вся фишка Node.js в том, что библиотека ядра вся асинхронная. (Хотя они сдают назад и начали добавлять _Sync() версии множеству вещей.)

Когда люди рассказывают про "ад обратных вызовов" — они говорят о том, как досадно иметь "красные" функции в их языке. Когда они создают 4089 библиотек для асинхронного программирования (в 2019-м уже 11217 — прим. Переводчика), они пытаются на уровне библиотеки совладать с проблемой, которую им всучили вместе с языком.


I promise the future is better


в переводе: "Я обещаю, что будущее лучше" теряется игра слов из названия и содержимого раздела

Люди в обществе Node.js уже давно осознали, что коллбэки это больно, и искали решения. Одна из техник, которая воодушевила многих людей, это promises, которые вы также можете знать по кличке futures.


в русском IT вместо перевода "promises" как "обещания", установилась калька с английского — "промисы". Слово "Futures" же используется, как есть, вероятно потому что "фьючерсы" уже заняты финансовым сленгом.

Промис это обертка для коллбэка и обработчика ошибок. Если вы думаете о передаче коллбэка для результата и другого коллбэка для ошибки, то future является воплощением этой идеи. Это базовый объект, который представляет собой асинхронную операцию.


Я только что разродился кучей причудливых формулировок и это может звучать как отличное решение, но в основном это змеиное масло. Промисы и правда позволяют писать асинхронный код чуть проще. Их проще составлять в выражения, так что правило №4 немного менее жесткое.


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


Вы все еще не можете использовать промисы с обработкой исключений или другими
управляющими операторами. Вы не можете вызывать функцию, которая возвращает future, из синхронного кода. (вы таки можете, но тогда следующий майнтейнер вашего кода изобретет машину времени, вернется в момент, когда вы это сделали, и воткнет вам в лицо карандаш по причине №2.)


Промисы все еще делят ваш мир на асинхронную и синхронную половинки со всем вытекающим из этого страданием. Так что, если даже ваш язык поддерживает promises или futures, он все еще очень похож на мое чучело.


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


проект по ссылке официально заброшен

I'm awaiting a solution


Программисты С#, вероятно, чувствуют себя самодовольно (причина, по которой они все более становятся жертвами, это то, что Хейлсберг и компания все посыпают и посыпают язык синтаксическим сахаром). В C# вы можете использовать ключевое слово await, чтобы вызвать асинхронную функцию.


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


Async-await приятный, поэтому мы добавляем его в Dart. С ним гораздо легче писать асинхронный код. Но, как всегда, есть одно "Но". Вот оно. Но... вы все еще делите мир пополам. Асинхронные функции теперь легче писать, но они все еще асинхронные функции.


У вас все еще два цвета. Async-await решают досадную проблему №4 — они делают вызов красных функций не труднее вызова синих. Но остальные правила все еще здесь:


  1. Синхронные функции возвращают значения, асинхронные возвращают обертку (Task<T> в С# или Future<T> в Dart) вокруг значения.
  2. Синхронные просто вызываются, асинхронным нужен await.
  3. Вызывая асинхронную функцию, вы получаете объект-обертку, когда на самом деле вы хотите значение. Вы не можете развернуть значение, пока вы не сделаете вашу функцию асинхронной и не вызовете ее с await (но см. следующий пункт).
  4. Помимо небольшого украшения await'ом, по крайней мере эту проблему мы решили.
  5. Библиотека ядра C# старше, чем асинхронность, так что я думаю, они никогда не имели этой проблемы.

Async действительно лучше. Я предпочту async-await голым коллбэкам в любой день недели. Но мы лжем себе, если думаем, что все проблемы решены. Как только вы начинаете писать функции высшего порядка, или переиспользовать код — вы снова понимаете, что цвет все еще там, кровоточит через весь ваш исходный код.


Какой язык не цветной?


Итак JS, Dart, C# и Python имеют эту проблему. CoffeeScript и большинство других языков, компилирующихся в JS — тоже (и Dart унаследовал). Я думаю, даже у ClojureScript есть эта загвоздка, несмотря на их активные старания с core.async


Хотите знать, какой не имеет? Java. Я прав? Как часто вы говорите — "да уж, Java единственная делает это правильно"? И вот это случилось. В их защиту, они активно пытаются исправить свою оплошность, продвигая futures и async IO. Это как гонка "кто хуже".


в Java уже все есть

C#, на самом деле, тоже может обойти эту проблему. Они выбрали иметь цвет. До того, как они добавили async-await и все это Task<T> барахло, вы могли просто использовать обычные синхронные вызовы API. Три других языка, которые не имеют "цветной" проблемы: Go, Lua и Ruby.


Догадываетесь, что у них общего?


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


(Вот почему для C# есть эта маленькая оговорка — вы можете избежать асинхронной боли в C#, используя потоки.)


Память о прошлых операциях


Фундаментальная проблема это "как продолжить с того же места, когда (асинхронная) операция завершится"? Вы погрузились в пучину стека вызовов и потом вызвали какую-то операцию ввода-вывода. Ради ускорения, эта операция использует нижележащий асинхронный API вашей ОС. Вы не можете ждать, пока она завершится. Вы должны вернуться к циклу событий вашего языка и дать ОС время, чтобы выполнить операцию.


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


Но, чтобы выполнить асинхронный ввод-вывод, вы должны размотать, отбросить весь стек вызовов в языке С. Типа Уловка-22. У вас супер быстрый ввод-вывод, но вы не можете использовать результат! Все языки с асинхронным вводом-выводом под капотом — или, в случае JS, циклом событий браузера — вынуждены как-то справлятся с этим.


Node, с его "вечно-марширующими-вправо" коллбэками, запихивает все эти вызовы в замыкания. Когда вы пишете:


function makeSundae(callback) {
  scoopIceCream(function (iceCream) {
    warmUpCaramel(function (caramel) {
      callback(pourOnIceCream(iceCream, caramel));
    });
  });
}

Каждое из этих функциональных выражений замыкает весь свой окружающий контекст. Это переносит параметры, такие как iceCream и caramel, из стека вызовов в кучу. Когда внешняя функция возвращает результат и стек вызовов уничтожен, это круто. Данные всё ещё гдето в куче.


Проблема в том, что вы должны снова воскресить каждый из этих чертовых вызовов. Есть даже специальное название для этого преобразования: continuation-passing style


по ссылке лютая функциональщина

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


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


Заметьте, что промисы и futures мало чем помогают на самом деле. Если вы используете их, вы знаете, что по прежнему нагромождаете гигантские пласты функциональных выражений. Вы просто передаете их в .then() вместо самой асинхронной функции.


Awaiting a generated solution


Async-await действительно помогает. Если заглянуть под капот компилятору, когда он встречает await, вы увидите, что он фактически выполняет CPS-преобразование. Вот почему вам нужно использовать await в C# — это подсказка компилятору — "остановите функцию здесь посередине". Все, что после await, становится новой функцией, которую компилятор синтезирует от вашего имени.


Вот почему async-await не нуждается в поддержке среды выполнения внутри .NET фреймворка. Компилятор компилирует это в цепочку связанных замыканий, которые он уже умеет обрабатывать. (Интересно, что замыканиям тоже не требуется поддержка среды выполнения. Они компилируются в анонимные классы. В C# замыкания — это просто объекты.)


Вам наверное интересно, когда я упомяну генераторы. В вашем языке есть yield? Тогда он может делать чтото очень похожее.


(я считаю, что генераторы и async-await изоморфны на самом деле. Где-то в пыльных закоулках моего жесткого диска валяется кусок кода, в котором реализован игровой цикл на генераторах с использованием только async-await.)


Так, где это я? Ах да. Так что с коллбэками, промисами, async-await и генераторами, вы кончаете тем, что берете свою асинхронную функцию и разбиваете ее в пачку замыканий, которые живут в куче.


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


Вот откуда берется правило "вызвать красную функцию можно только из красной функции". Вы должны сохранить в замыканиях весь стек вызовов до самого main() или обработчика событий.


Реализация стека вызовов


Но используя треды (зеленые или уровня ОС), вам не нужно это делать. Вы можете просто приостановить весь тред и прыгнуть к ОС или циклу событий без необходимости возвращаться из всех этих функций.


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


Если посмотреть на операции ввода-вывода в стандартной библиотеке Golang, они кажутся синхронными. Другими словами, они просто работают и потом возвращают результат, когда готовы. Но эта синхронность не означает то же, что в Javascript. Другой Go-код может работать, пока мы ждем IO операцию. Так Go устранил различие между синхронным и асинхронным кодом.


Параллелизм в Go — это как вы выбираете моделировать вашу программу, а не цвет каждой функции в стандартной библиотеке. Это значит что вся боль пяти правил, которые я упомянул, полностью и абсолютно устранена.


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




От переводчика


Язык автора, его саркастичные метафоры и мастерская игра слов показались мне настолько хороши, что было бы упущением не познакомить с ним русскоязычного читателя. Зная английский на уровне чуть выше среднего, вы получите огромное удовольствие от чтения в оригинале. Надеюсь, мне удалось передать хотя бы 50% авторского юмора и перевод не стал при этом слишком тяжеловесным.


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


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


Но вот в моем любимом Питоне, сколько бы я ни брался (на самом деле всего пару раз) за асинхронность — вместо удовольствия, которое я получаю от программирования, я получал непонимание, где нужен и где не нужен этот async и как это все втиснуть в существующие синхронные программы. Обычно я заканчивал с import threading и переставал забивать себе голову (а может быть, мне просто еще не попался в работе проект на AsyncIO, Twisted или Tornado, чтобы въехать в тему).


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


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


Уж точно, что я не один такой программист средней руки, которому кажется, что (вероятно из-за врожденного асинхронного порока мозга) он один не может легко и просто начать применять асинхронность, несмотря на толпу гуру со всеми их статьями про "async-await для чайников". Для них и сделан этот перевод.


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


Надеюсь, что другие статьи Боба так же хороши, и мне захочется переводить еще.

Комментарии (34)


  1. hd_keeper
    05.09.2019 16:46

    Я уже где-то читал иной перевод этой же статьи, про цветные функции.


    1. igormich88
      05.09.2019 17:26

      1. hd_keeper
        05.09.2019 17:28

        Возможно!


      1. AcckiyGerman Автор
        05.09.2019 17:42

        Это основано на том же первоисточнике, но автор не упоминается, половина текста отброшена, другая переработана, и добавлена лекция об асинхронности в JS (более двух третей объема). Так что я бы сказал, что это не перевод, а обучающий доклад с использованием оригинальной статьи, как вдохновления.


  1. 0xd34df00d
    05.09.2019 16:57

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


    1. youROCK
      06.09.2019 00:06

      Мне кажется, автор не утверждал, что Go это всё изобрел :). Библиотеки для «зеленых тредов» существовали ещё для старых UNIX-систем, однако всегда были нюансы. В Go просто это всё реализовано довольно хорошо, ну и язык компилируемый, поэтому в целом может соревноваться по скорости с C. Erlang, появившийся очень давно, и где тоже всё хорошо в плане асинхронщины, это скриптовый язык, да ещё и функциональный, что сразу делает его намного менее привлекательным для многих разработчиков (включая меня). Мне кажется, Go получился таким, какой он есть, потому что он расчитан на массовую аудиторию, и вроде как вполне неплохо справляется со своими обязанностями.


      1. 0xd34df00d
        06.09.2019 00:28

        Erlang, появившийся очень давно, и где тоже всё хорошо в плане асинхронщины, это скриптовый язык, да ещё и функциональный, что сразу делает его намного менее привлекательным для многих разработчиков (включая меня).

        А вас скриптовая часть или функциональная больше смущает? А то есть ведь, например, тот же хаскель, где с асинхронностью тоже всё отлично, и при этом он компилируется в нативный код.


        Ну и, например, коли разговор зашёл, пусть у меня есть дерево, где в узлах лежат ID комментариев, и есть функция, которая (асинхронно) идёт в БД и по ID коммента вытягивает его содержимое. Как мне минимальной писаниной преобразовать дерево ID-шников в дерево самих комментов, загрузив все их асинхронно?


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


        1. youROCK
          06.09.2019 01:21

          Что подразумевается под «с минимальной писаниной… загрузив все их асинхронно»? Каждый комментарий асинхронно по отдельности? Если да, то примерно такой код должен сделать то, что вы хотите (хотя лучше ограничивать конкурентность, что тоже несложно в Go):

          type comment struct {
              id int // задано
              contents string // заполняем сами
              children []*comment // задано
          }
          
          func loadComments(root *comment) {
              var wg sync.WaitGroup
              loadCommentsInner(&wg, root)
              wg.Wait()
          }
          
          func loadCommentsInner(wg *sync.WaitGroup, node *comment) {
              wg.Add(1)
              go func() {
                  // не факт, что можно просто так заполнять поля произвольной структуры
                  // из любой горутины, но вроде можно
                  node.contents = loadCommentContentsByID(node.id)
                  wg.Done()
              }()
              for _, c := range node.children {
                  loadCommentsInner(w, c)
              }
          }
          


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

          По факту, весь код в Go в любом случае синхронный, и «асинхронная» часть здесь только в go func() {… }, которая осуществляет загрузку в отдельной горутине, не блокируя текущую.


          1. 0xd34df00d
            06.09.2019 02:27

            Ну вот х-ле я могу написать просто mapM loadCommentContentsByID commentsTree. Тут прямо так и написано: примени монадическое действие к каждому элементу списка. Даже думать не надо.


            Почему ручное написание императивного кода для обхода дерева и выполнения действия над каждым узлом считается за благо, мне непонятно.
            Равно как и непонятно, почему комментарий содержит в себе что ID, что содержимое (это, блин, разные сущности, я хочу отличать ID комментария от полностью загруженного комментария на уровне типов).


            Или непонятно, почему комментарий отвечает за хранение своих детей (это не его задача, содержимое комментария — одна сущность, иерархия комментариев — другая). А, ну да, нет дженериков же. Дерево не сделаешь. Да кому нужны эти деревья, можно прям так фигачить, и считатЬ, что такой код типобезопасен (Go же называют типобезопасным?).


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


            Впрочем, хаскель тоже отстой

            потому что очевидно, что загрузка одного комментария не влияет на содержимое другого, поэтому здесь можно ограничиться аппликативными функторами, а не монадами, и взять mapA вместо mapM, но mapA в стандартной библиотеке нет, придётся написать вместо этого sequenceA $ fmap. Но не суть.


            1. youROCK
              06.09.2019 03:09

              Скажу честно, не очень хорошо знаком с функциональщиной, поэтому аргументированно вряд ли могу рассуждать на тему того, какой подход лучше :). Реальное приложение на Go будет выглядеть совершенно по-другому, и логика асинхронной загрузки будет хорошо если 10% кода составлять. Остальное это управление конкуретностью (чтобы не загружать в слишком много потоков), обработка ошибок асинхронной загрузки (в каждом конкретном случае мы должны решать, нормально ли отдавать пользователю неполный результат или нет), логирование, трассировка, и т.д. Плюс вряд ли у вас будет где-то существовать дерево комментариев с их id, так что структуры данных тоже будут использоваться другие, хотя и наверняка похожие.

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

              > Равно как и непонятно, почему комментарий содержит в себе что ID, что содержимое (это, блин, разные сущности, я хочу отличать ID комментария от полностью загруженного комментария на уровне типов).
              Вы можете это сделать и в Go, при желании. Но да, в Go нет «иммутабельных» структур данных, кроме строк.

              > Или непонятно, почему комментарий отвечает за хранение своих детей
              Да, ибо нет (пока что :)) дженериков.

              > Да кому нужны эти деревья, можно прям так фигачить, и считатЬ, что такой код типобезопасен (Go же называют типобезопасным?).
              Приведенный мной код строго типизирован. Можно было бы использовать интерфейсы или reflection, тогда, конечно, про типобезопасность можно забыть.

              > Извините, я просто не ожидал, что разговор об асинхронности выльется в разговоры о моей больной теме.
              Как я уже упоминал выше, в реальных приложениях кода, относящегося непосредственно к бизнес-логике не так много, так что оверхед от того, что приходится такое писать, относительно невелик. К тому же, как я сказал, поток исполнения как на ладони, и, в отличие от приведенного Вами фрагмента на Хаскеле (это библиотечный вызов же просто?), этот кусок кода можно относительно легко улучшить, добавив туда ограничение конкурентности, например.


              1. 0xd34df00d
                06.09.2019 05:56

                Реальное приложение на Go будет выглядеть совершенно по-другому, и логика асинхронной загрузки будет хорошо если 10% кода составлять.

                Тут была шутка про выразительность языка, но она избитая, и я её стёр.


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

                Так это совсем просто, те три токенна, что я написал, даже менять не придётся. Просто функция загрузки теперь возвращает не CommentBody, а Either Error CommentBody, и соответственно дерево будет уже не из комментариев, а из вот таких вот возможных ошибок. Дальше можно тем же traverse по нему пройтись и собрать ошибки (если они есть) и дальше делать выводы.


                Кстати, я так понимаю, Go-шник для этого в структуру, определяющую коммент, добавит поле с возможной ошибкой загрузки? :)


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

                Почему? Это почти реальный кейс.


                Но императивный код легко понять большинству программистов.

                Это вопрос начальных инвестиций против последующей отдачи. Да, вам придётся один раз понять монады и mapM, но после этого это знание с вами на всю жизнь, и вам не нужно вчитываться в портянку кода: «так, тут… тут пробегаем по всем потомкам, для каждого вызываем эту функцию рекурсивно… наверное, да, мы тут дерево строим из этого вот дерева, скорее всего».


                Вы можете это сделать и в Go, при желании. Но да, в Go нет «иммутабельных» структур данных, кроме строк.

                Дело не в иммутабельности. Дело в желании отличать только-ID-коммента и тело-коммента-из-БД.


                Приведенный мной код строго типизирован.

                Но он не типобезопасен. Если передо мной коммент с пустым телом, то я не знаю, это он из базы не материализован ещё, или там на самом деле пустое тело.


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

                Ну фиг знает, по моему опыту с хаскелем доля бизнес-логики по сравнению со всяким шумом там весьма высокая.


                К тому же, как я сказал, поток исполнения как на ладони, и, в отличие от приведенного Вами фрагмента на Хаскеле (это библиотечный вызов же просто?)

                Да, mapM — библиотечная функция.


                этот кусок кода можно относительно легко улучшить, добавив туда ограничение конкурентности, например.

                А вот тут прелести полиморфизма и прочих дженериков. Никто не мешает вам взять любую библиотеку с пулом, и этот код почти не поменяется.


                Или вместо mapM взять mapTasks из первой попавшейся либы плюс fmap.


  1. Sly_tom_cat
    05.09.2019 17:32

    Я когда на Go попробовал поиграть в асинхронность, так сразу влюбился в этот язык.

    Ранее на Python я тоже баловался и с threading и с asyncio… но только в Go создалось впечатление что ты в этом паришь и летаешь, а не на костылях ползешь на карачках как в питоне.


    1. AcckiyGerman Автор
      05.09.2019 17:45

      Ну вот, а я после Питона и JS уже боялся взяться за Go, чтобы не обжечься об асинхронность в очередной раз.


      1. youROCK
        06.09.2019 00:00

        В Go вам не нужно думать о том, асинхронный код у вас «под капотом» или нет. Вы всегда пишете синхронный код. Единственный случай, когда это становится важно, это когда вы вызываете из Go кода библиотеки на Си, которые сами ходят в сеть (блокирующим образом), но даже в этом случае просто автоматически создаются новые треды (по количеству заблокированных на I/O вызовов), и всё более-менее хорошо.


  1. MooNDeaR
    05.09.2019 18:58

    Но…
    Я могу из красной всегда вызывать синие. Значит я пишу синюю функцию и пишу для неё красную примитивную обертку для всех красных мест?


    1. Evir
      06.09.2019 00:31

      Судя по всему, также важно, какие функции передаются через аргументы. То есть, красная функция не может вызвать синию, передавая красную функцию одним из аргументов.


      1. Cerberuser
        06.09.2019 04:59

        Ну как сказать: вызвать-то может, только синяя не вправе вызывать этот аргумент.


    1. AcckiyGerman Автор
      06.09.2019 11:26
      -1

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


      Например — у меня есть проект на синхронном фреймвоке Django, и, в функции обработки данных, которые прислал пользователь, мне нужно выполнить отложенную задачу (например, удалить какие-то данные через какое-то время).


      Как вариант можно использовать модуль threading.Timer(interval, function), который выполнит задачу позже, но мне кажется слишком жирным запускать отдельный поток (а значит форкать в памяти весь Python-интерпретатор — слава богу, ОС хотя использует copy-on-write для такого).


      Хотя многие люди прикручивают Selery сразу, который сам по себе тот еще огромный монстр (по сравнению с одной функцией), который хранит будущие задачи в базе мать ее данных, и переодически проверяет, не пора ли чего-либо запустить.


      В JS я просто делаю


      setTimeout(...)

      просто потому, что он изначально построен на асинхронном events-loop, что дает кучу плюшек сразу из коробки.


  1. PsyHaSTe
    05.09.2019 19:54
    +1

    Надо понимать, что это движение в сторону от функциональных языков, то есть в целом в направлении от того, которое хотелось бы видеть. Почему? Функциональщики наоборот, обожают выносить все в сигнатуры методов. Может вернуть null? Одна сигнатура. Может закончится с ошибкой? Никаких эксешнов, другая сигнатура. Нужно сходить в базу? Не беда, третья сигнатура. Является асинхронным чтением с диска? Четвертая сигнатура. Причем если она ходит в базу и может вернуть null то результирующий тип будет комбинацией этих двух, аналогично с чтением с диска и возвратом результата. Тип функции всегда говорит обо всем, что происходит внутри.


    Это что касается ФП. Что касается мейнстрим-языков, то они придерживаются middleground. Они в сигнатуру пихают, является ли операция синхронной или нет (с помощью Task/Promise/Future/...), и в последнее время, вернется null или нет (с помощью Option). Функции могут проводить флоу, которые никак не указаны в типе (бросать исключения/ходить в базу/..), но все же это сравнительно юзабельно (хотя и не так круто как в ФП языках).


    И вот гринтреды. Завершается функция синхронно? Асинхронно? Да хрен его знает, в типе никак это не отметить. Каждый раз, когда вы отказываетесь от типизации, вы теряете часть контроля. Черт, да даже в динамичиски типизированнос жс есть промисы, и функции явно говорят о том, что они асинхронные (пусть и в рантайме), а мы в статике от этого откажемся.


    У этого подхода полно минусов, но я хочу отметить один. Мне на ревью отдали JS-код который выглядел примерно так:


    let result = [];
    for (int i = 0; i < n; i++) {
       result.push(await someOp(i))
    } 

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


    let promises = Array(n).keys().map(i => someOp(i));
    let result = Promise.all(promises);

    После чего время загрузи страницы с секунды упало до 200мс. Вопрос — была ли информация о том, что данная функция асинхронная полезной? Повлияло это на решение как написать вызывающий код? Имело ли это знание существенное влияние на итоговое качество продукта? Да, да и да.


    Чтобы было понятно, someOp было операцией, по которой нельзя было судить, синхронная она или нет, это не было методов sendHttpRequest(), а какой-то бизнесовый метод. И покуда вы не предлагаете кодировать все асинхронные операции венгерской нотацией и забивать на подобный вызов в цикле, гринтредовые корутины никак не показывающие себя в сигнатуре — зло.


    1. 0xd34df00d
      06.09.2019 00:50

      Каждый раз, когда вы отказываетесь от типизации, вы теряете часть контроля. Черт, да даже в динамичиски типизированнос жс есть промисы, и функции явно говорят о том, что они асинхронные (пусть и в рантайме), а мы в статике от этого откажемся.

      Самое характерное (или ироничное, или грустное) — у автора статьи даже, кажется, где-то мимо проплыла мысль, что проблема в отсутствии полиморфизма по асинхронности. Но в роли решения почему-то предлагается от фиксирования асинхронности в типах вместо того, чтобы просто добавить этот несчастный полиморфизм.


  1. rboots
    05.09.2019 20:55

    Основная проблема в этом пункте:

    Красные функции больнее вызывать

    Тестировал функции с async и без в js — разница в несколько раз. По хорошему не сложно добавить оптимизацию, даже на уровне синтаксиса, что если внутри функции есть await — добавлять к определению функции async, если нет — убирать. Думаю async добавили как раз для того, чтобы ассинхронные функции выделялись явно, и чтобы вы думали когда пишете код. У меня на практике проблем описанных автором не возникет, так как в момент создания функции задаю себе вопрос: является ли действие, которое выполняет функция, ассинхронным по своей природе, и если да — добавляю async даже если пока нет await, если нет — не переживаю об этом, так как вся функциональность с ассинхронной природой (включая отправку логов, ожидание пользовательского ввода и т.п.) должна быть вынесена из этой функции по определению, так как single responsibility.
    Подход с потоками давно известный и более старый, чем async-await. Такой же давно известный минус — блокировка данных, когда разные потоки обращаются к одним и тем же переменным. Основной плюс — потоки позволяют выполнять программу на разных ядрах и шарить память, это даёт больше возможностей для оптимизации производительности, но не нужно говорить, что это проще. Когда в продуктах топовых IT-компаний, от Apple до Google, перестанут появляться детские баги с блокировкой UI при долгих операциях — я соглашусь, что потоки стали просты. Но пока с многопоточностью почти все косячат, а с async-await даже специалисты со средней квалификацией пишут вполне работающий, без больших недостаткой, пусть и режущий old-school разработчикам глаза, код.


  1. SirEdvin
    05.09.2019 22:20

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

    А вот это не правда. Параллелизм в го, это когда цвет уже выбрали за вас, и цвет — красный. То есть вы сталкиваетесь с проблемой "красные функции больнее вызывать".


    1. youROCK
      05.09.2019 23:56

      Ну как больнее… Когда используются настоящие треды ОС, нет никакого дополнительного оверхеда на вызов «красной» функции по сравнению с «синей», т.к. блокировку треда делает ОС и оверхед есть только от системных вызовов, но это не простые вызовы функций, в общем-то.

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


      1. mayorovp
        06.09.2019 09:05

        На самом деле сегментированный (а не просто динамически растущий) стек — это именно что обязательное требование. Без него вы никаким чудом не уложитесь в разумный объём памяти на миллионе соединений, что в свою очередь означает, что рано или поздно в языке появится асинхронная библиотека с промизами...


      1. movl
        06.09.2019 15:48

        Автор не связывал понятие боли с накладными расходами, а связал с трудом, который необходимо будет совершить программисту (прыжок через обруч). Если в случае асинхронных функций болью являются обратные вызовы. То в случае многопоточного программирования очевидная боль проявляется в синхронизации потоков и раздельном доступе к памяти, о чем автор видимо забыл упомянуть.


  1. Cerberuser
    06.09.2019 05:03

    Будет интересно посмотреть, к чему в итоге придёт Rust — потоки в нём были изначально (с отдельными библиотеками для большей эргономичности), а теперь вот async/await в язык понемногу вносят...


  1. wng
    06.09.2019 10:51

    Синхронные функции возвращают результат, асинхронные — нет, взамен они вызывают коллбэк.

    Возмутило то как автор приплетает, возможно для пущей убедительности, такие слова как "функция", "функция высшего порядка", "функциональщина", в то время как описывает банальный callback hell и все сопутствующие неприятности.
    Функция в с е г д а возвращает результат (если отбросить возможность исключения/зависания), иначе это не функция, а процедура. А если написать кучу процедур, да ещё и сдобрить callback-ами как следует, то конечно жди проблем.


    1. AcckiyGerman Автор
      06.09.2019 11:00

      Технически, вы правы (в плане терминологии), но практически он делает ударение на том, что в синхронном коде ничего нельзя поделать с "результатом", который возвращает асинхронная функция, так как это не результат а


      console.log( fetch("http://ya.ru", {method: 'GET'}) )
      > Promise {<pending>}


      1. unel
        06.09.2019 11:12
        -1

        Как это ничего нельзя поделать с «результатом» (обещанием)?
        А подписаться на него — это не «поделать»?


        1. AcckiyGerman Автор
          06.09.2019 11:39
          +1

          Я имею в виду нельзя ничего поделать здесь и сейчас в синхронном смысле. А подписаться конечно, можно (+callback) и заодно передать в параметрах функцию обработки результата (+callback) и т.п. — здравствуй Callback Hell или .then().then().then()


          1. unel
            06.09.2019 12:08
            -1

            Если немного задумываться над потоками выполнения, то можно избегать и callbackHell (очень в этом плане спасала либа async) и длинной цепочки then-ов.

            Это не та проблема, для которой нет решения =)


  1. unel
    06.09.2019 11:24

    Читая про «красные» и «синие» функции, я сперва подумал, что автор ведёт речь про «чистые» и с «сайд-эффектами», а никак не про синхронные и асинхронные =)

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


  1. potan
    06.09.2019 13:40
    +1

    Чего только не придумаю, лишь бы программист про монады не догадался…


  1. movl
    06.09.2019 17:43

    Автор рассказал лишь половину и поспешил закончить, объявив, что все озвученные проблемы решены. Но опыт подсказывает, что на фоне решения одних проблем, обычно возникают другие. Закономерный вопрос. Не является ли, в свою очередь, концепция событий и асинхронных вызовов, решением проблем многопоточного программирования? Например такой как синхронизация потоков. Там порой для решения требуется не то что через обруч прыгать, а через горящее кольцо.