Всем привет! Меня зовут Сергей и последнее время я занимаюсь backend-разработкой на Scala. Вообще, мой опыт асинхронного программирования на Scala и C# составляет более десяти лет, и за это время сложилось вполне достаточное понимание этой темы. Во всяком случае, тогда мне так казалось…

Но недавно в беседе с коллегами обнаружились большие пробелы в моём «понимании», что мотивировало детально разобраться в этом вопросе. В интернете можно найти немало материалов по этой теме, но обычно они сфокусированы на конкретном инструменте или языке программирования. Приведу лишь некоторые из них:

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

Содержание

Многозадачнеость (multitasking)

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

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

В большинстве операционных систем для управления переключением задачи используется абстракция под названием

Нити (threads)

Современные операционные системы позволяют не только запускать несколько процессов одновременно, но и дают возможность каждому из них инициировать несколько нитей вычисления. В русскоязычной литературе чаще используют термин «потоки» вместо «нити», а про саму возможность приложения создавать свои нити называют многопоточностью (multithreading). Операционная система хранит очередь нитей и с периодически прокручивает её, сменяя выполняющиеся на процессоре нити. Если активная нить не успела завершиться сама за выделенный ей квант времени, её исполнение жёстко приостанавливается, она возвращается в очередь, согласно приоритету, а на процессор передаётся следующая нить. Такая стратегия называется вытесняющей многозадачностью (preemptive multitasking). Она пришла на смену кооперативной многозадачности, когда переключение происходило не по решению операционной системы, а лишь когда процессы сами оповещали о такой возможности.

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

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

Нити являются ценным ограниченным ресурсом. Для каждой запрошенной процессом нити сразу резервируется фиксированная область памяти под стек, обычно с надёжным запасом порядка 1Мб. Максимальное количество запущенных нитей определяется не только объёмом оперативной памяти компьютера, но и «административными запретами», защищающими всю систему от обрушения даже единственным опасным процессом. Попытка процесса запросить у операционной системы нити сверх лимита приведёт к его остановке, но это сохранит работоспособность всей системы.

Пул нитей (thread pool)

Ответственность за бережное отношение к этому ресурсу возлагается на само приложение. Когда программа запускается, она резервирует у операционной системы определённое количество нитей, ориентируясь на свои цели и количество ядер процессора. Такой шаблон проектирования называется пулом потоков (пул нитей, thread pool). Поступающие задачи (например, запросы к веб-серверу) выстраиваются в очередь, из которой они распределяются по свободным нитям пула. Как только нить завершает работу, она не уничтожается, а возвращается в пул в ожидании следующей задачи. Очередь задач уровня приложения при фиксированном пуле дополнительно защищает систему — в случае пиковых нагрузок (например, DDoS-атака) упадёт лишь само приложение.

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

При обработке запроса к web-серверу полезная работа процессора занимает меньше 5% от суммарного времени. Всё остальное — это какие-либо «ожидания», когда операционная система может попробовать переключить исполнение на другую нить. Это означает, что технически система может параллельно выполнять сотни нитей на каждом ядре процессора. Учитывая, что в среднем обработка запроса занимает десятки миллисекунд, наш «сферический сервер в вакууме» способен обрабатывать десятки тысяч запросов в секунду (RPS).

Помимо управления нитями напрямую, многие языки программирования позволяют настраивать и целые пулы, обычно с помощью одноимённого класса ThreadPool.

Пробуксовка (thrashing)

Оказывается, что что при росте RPS, производительность системы в определённый момент резко деградирует. И связано это не с тем, что система становится физически не способна выполнить все вычисления, а с особенностями самого механизма переключения нитей. Давайте разберём проблему подробнее.

Операционная система не знает об устройстве нитей, и должна переключить их выполнение максимально надёжно. Это обеспечивается большим количеством действий, но всё же обычно речь идёт о микросекундах:

Операция

ОЧЕНЬ примерная оценка

Переключение процессора, в частности:
- переход в Kernel Mode и обратно,
- копирование/восстановление регистров,
- прогрев конвейера и «предсказателя»,
- актуализация TLB,
- обновление процессорных регистров.

~4 мкс

Работа планировщика операционной системы

~2 мкс

Прогрев кэша процессора

~10 мкс

В спокойном режиме работы такие задержки почти не ощущаются. Под высокой нагрузкой эти цифры возрастают в десятки раз, что уже становится заметно, хотя по прежнему выглядит не критично. Основная проблема заключается не в росте времени на одно переключение, а в том, что планировщик буквально «захлёбывается» их количеством, причём большинство итераций проходят вхолостую. Нагруженная система обрабатывает лавину событий, требующих немедленной реакции; процессорные кванты времени, отведённые нитям, становятся всё короче, а количество логических блокировок (доступ к БД и т. д.) — всё больше. Планировщик активирует следующую нить, которая тут же натыкается на ожидание ресурса и мгновенно возвращается в самый конец очереди. И так далее по кругу.

Когда очередь переполнена, на каждую нить может приходиться больше сотни бесполезных переключений контекста. Процессор начинает тратить на само управление нитями больше ресурсов, чем на выполнение бизнес-логики. В итоге отзывчивость такого веб-сервера под нагрузкой падает в 3–5 раз, а в худших случаях время обработки запроса вырастает настолько, что сервер кажется полностью «зависшим».

Столь нерациональная трата ресурсов — критическая проблема для высоконагруженных систем. В литературе она известна под названиями динамической взаимоблокировки (live-lock), пробуксовка (thrashing), или «проблема 10 тысяч соединений» (C10k).

Циклы событий (event loop)

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

Идея такова: есть очередь событий (events), список обработчиков (callbacks) и цикл (loop), который бесконечно крутится на единственной нити. На каждом шаге цикла:

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

  • из очереди достаётся очередная задача и выполняется;

  • задача буквально завершается в момент, когда требуется ожидание внешнего ресурса, но при этом регистрируется «обратный вызов» (callback), который продолжит вычисления позже, когда операционная система оповестит о возможности продолжения.

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

В том или ином виде циклы событий реализованы в самых разных языках программирования и библиотеках. Существуют вариации с пулами нитей и несколькими очередями, может отличаться терминология, но суть остается прежней: приложение само в синхронном цикле разбирает очередь задач. Но независимо от реализации, использование цикла событий даёт прирост максимального RPS порядка 50 раз по сравнению с использованием нитей! Если на нитях типичный веб-сервер обрабатывает максимум 2000 запросов в секунду, то реализация той же логики через цикл событий позволяет достигать показателей порядка 100 000 RPS на том же оборудовании.

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

Другая связанная проблема — сложность передачи контекста вычислений. Значения локальных переменных, которые потребуются на будущих шагах, приходится вручную «протаскивать» через цепочку вызовов. Частично это решается использованием замыканий (closures), позволяющих сохранить переменные из внешнего контекста. Однако зачастую это лишь сильнее запутывает структуру программы и усложняет управление памятью.

Сопрограммы (coroutines)

Этот термин изначально появился в переводах классических работ по программированию, но в русскоязычном сообществе закрепилась транслитерация «корутина». Мелвин Конвей в своей классической статье 1963 года «Design of a Separable Transition-Diagram Compiler» определил сопрограммы как подпрограммы, которые взаимодействуют на равных. Он описывал техники, позволяющие приостанавливать исполнение одних подпрограмм, чтобы дать возможность поработать другим, запущенным еще до завершения первой.

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

В разных языках эта идея реализована по-разному. В Scala корутины строятся вокруг контейнера Future[_]:

def step1(a: A): Future[B] = ??? // реализация не важна
def step2(b: B): Future[C] = ???
def step3(b: B, c: C): Future[D] = ???

def program(a: A): Future[D] =
  step1(a)                              // Future[B]
    .flatMap(b => step2(b).map(b -> _)) // Future[(B, C)]
    .flatMap(step3.tupled)              // Future[D]

Здесь логика программы состоит из трёх последовательных шагов. Монадный метод flatMap реализован так, что он регистрирует переданную ему функцию-п��одолжение в очереди задач. Эта регистрация происходит динамически, непосредственно во время исполнения.

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

В данном примере видно: чтобы передать дальше уже «использованное» значение b: B, то мы обязаны это сделать явно, например, посредством метода map. В этой «явности» заключается огромная ФП-шная мощь, защищающая от целой кучи багов, присущих императивному «стековому» программированию. К сожалению, не многие программисты это понимают, и большинство, следуя сложившимся привычкам, предпочитают использовать для монадической композиции for-выражения:

def program(a: A): Future[D] = for {
  b <- step1(a)
  c <- step2(b)
  d <- step3(b, c)
} yield d

Синтаксический сахар for-выражений разворачивается на этапе предкомпиляции в цепочку вложенных вызовов flatMap. Его сомнительным преимуществом является сквозной проброс контекста вычислений (здесь — переменных b и c).

В языке C# аналогом скаловского Future[] является контейнер Task<>. Асинхронные вычисления с его помощью рекомендуется организовывать посредством ключевых слов async/await:

Task<B> Step1(A a);
Task<C> Step2(B b);
Task<D> Step3(B b, C c);

async Task<D> Program(A a)
{
	var b = await Step1(a);
	var c = await Step2(b);
	var d = await Step3(b, c);

	return d;
}

В отличие от for-выражений в Scala техника await допускает больше возможностей для использования управляющих конструкций (циклы и т.п.), делая код «максимально привычным». Но самое важное отличие — код декомпозируется на цепочку обратных вызовов ещё на этапе компиляции. На каждом await код разбивается на «до» и «после», затем в каждом «после» снова ищутся await и разбиения продолжаются дальше. Выстраивается конечный автомат, в состояниях которого будут храниться значения локальных переменных. Таким образом контекст распространяется через все шаги вычислений.

Да, в C# есть возможность и монадической композиции вычислений, основанной на функториальном (ну, типа) преобразовании ContinueWith и монадном «разматрёшивании» Unwrap:

Task<D> Program(A a) =>
    Step1(a)
        .ContinueWith(taskB  => Step2(taskB.Result)
            .ContinueWith(taskC => (taskB.Result, taskC.Result))
        ).Unwrap
        .ContinueWith(taskBc => Step3(taskBc.Result.Item1, taskBc.Result.Item2)).Unwrap;

Формально мы получаем ту же логику, что и раньше, но реализация корутин будет уже другой, динамической, как это было с Future в Scala. Однако такая техника не является рекомендуемой даже не столько из-за громоздкости (при желании можно было бы предоставить более удобные методы), а потому что вся асинхронность в C# завязана именно на async/await. В этом случае компилятор, имея перед глазами весь код, может применить наиболее мощные оптимизации.

При монадической композиции исключения на любом шаге можно будет обработать в самом конце, методами контейнера Future/Task. Постоянные вызовы flatMap (ContinueWith/Unwrap в C#) немного засоряют стек вызовов, но по факту это не сильно усложняет отладку. Компилятор и отладчик C# дают для async/await «визуально чистый» стек вызовов и позволяют отлавливать исключения привычной связкой try/catch/finally.

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

Неявная асинхронность (goroutines, loom)

Асинхронное программирование на корутинах обычно предполагает, что «ожидающие» функции должны быть типа A => Something[B], то есть они должны возвращать значение внутри некого обобщённого контейнера. Многие приверженцы императивного стиля видят в этом «страшную проблему» цветных функций. Она заключается в том, что решение сделать какой-то метод асинхронным вынуждает сделать асинхронными все вызывающие методы, что вроде как противоречит принципу открытости/закрытости.

В языке Go предложили способ избежать этой проблемы, полностью отказавшись от обобщённых контейнеров для управления асинхронностью. Даже без явных кооперативных пометок вроде await среда исполнения сама находит блокирующие операции и приостанавливает выполнение задачи, отправляя её продолжение (вместе с её небольшим стеком) в очередь. Нити операционной системы и ядра процессора при этом не простаивают, а программисты могут писать код в привычном стиле, не задумываясь о «цвете» функций. Такой механизм языка Go получил название горутины (goroutines).

Идея неявной асинхронности привлекла внимание и разработчиков языка Java. Свой ответ горутинам они воплотили в рамках проекта Loom (ткацкий станок). Начиная с версии Java 21, разработчикам стали доступны виртуальные нити (virtual threads). В отличие от «молодого» Go, где горутины были заложены в фундамент изначально, Loom — это адаптация огромной существующий экосистемы. Простое переключение на последнюю версию JVM магическим образом превращает старый блокирующий код в легковесные задачи, которые при ожидании ввода-вывода временно «паркуются» в памяти, освобождая ресурсы процессора.

Утечка задач (tasks leak)

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

Во многих языках эту проблему пытаются решить с помощью техники кооперативной отмены. Её суть заключается в том, что вызывающая корутина может в любой момент установить «флаг отмены» вызываемой корутины, при этом последняя обязана сама регулярно проверять его состояние. В C# приходится прокидывать в каждый метод CancellationToken, в Go такой флаг передаётся внутри Context, в Java используются статус прерывания (interrupted status). Но проблема остаётся — нет никаких гарантий, что вызываемая корутина обработает этот флаг!

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

Попытка спрятать от разработчика «сложности» механизмов ввода-вывода, свести всё к «привычной императивщине», лишь усиливает фундаментальные изъяны этой парадигмы — низкие гарантии предсказуемости поведения и хрупкость относительно дальнейших правок. Подобные гарантии может предоставить лишь декларативное функциональное программирование, флагманом которого выступает язык Scala с его библиотеками Cats Effect или ZIO. В них представлены механизмы управления асинхронностью, защищённые от описанных выше проблем.

Волокна (fibers)

Вообще-то уже горутины с виртуальными нитями Loom принято называть «волокнами» (fibers). Но в Scala в экосистемах Cats Effect (CE) и ZIO представлены одноимённые сущности, которые представляют гораздо больше возможностей, чем обычные корутины. Поэтому закрепим термин «волокна» именно за этими сущностями.

Для обслуживания операций ввода-вывода в CE и ZIO используются обобщённые типы IO[_] и ZIO[R, E, _] соответственно. В обеих библиотеках волокна реализованы схожим образом, поэтому для демонстрации выберем лишь одну из них — CE.

В CE асинхронные функции имеют вид A => IO[B], что внешне похоже на рассмотренные ранее корутины, основанные на контейнере Future[_]. Но у контейнера IO[_] есть существенное отличие, дающее максимальный контроль над операциями ввода-вывода. В соответствии с парадигмой функционального программирования операции по «запаковке» в вычислений в IO[_] и их преобразования являются чистыми, не вызывающими побочных эффектов. В контейнере хранятся лишь данные — план вычислений, который выполняется библиотечным интерпретатором лишь при «распаковке» контейнера в самом конце программы. ФП-шная чистота функций, ссылочная прозрачность позволяет вносить любые коррективы в план вычислений до его исполнения — добавить финализаторы, которые интерпретатор гарантированно запустит по завершении задачи, настроить повторения, связать с параллельными вычислениями и т.п.

В противоположность этому Future «жадно» запускает вычисления в момент создания. Мы не сможем запустить их повторно или по желанию приостановить, так как нет гарантий, что в логике корректно реализованы механизмы кооперативной отмены. Волокна CE/ZIO не полагаются на кооперативность: интерпретатор может прервать волокно при любом flatMap (и не только) на основании флага отмены, но предварительно выполнив пользовательскую финализацию. Кроме того, подготовленный план вычислений при необходимости можно дополнить любой стратегией перезапуска.

Вот простой пример безопасной отмены волокна:

import cats.effect.*  
import scala.concurrent.duration.*  
  
object fibers extends IOApp.Simple:  
  
  def longTask: IO[Unit] =  
    IO.println("Работа началась...")  
      .productR(IO.sleep(10.seconds))                  // flatMap  
      .productR(IO.println("Работа завершена!"))       // flatMap  
      .guarantee(IO.println("Освобождаем ресурсы...")) // финализатор!  
  
  def run: IO[Unit] = for  
    fiber <- longTask.start            // Запускаем в отдельном волокне  
    _     <- IO.sleep(1.second)        // Ждем немного  
    _     <- IO.println("Решили отменить...")  
    _     <- fiber.cancel              // Принудительная отмена  
  yield ()

fibers.main(Array.empty)
// Работа началась...
// Решили отменить...
// Освобождаем ресурсы...

«Ресурсы освобождаются» в любом случае, даже если «работа не завершена». Тип переменой fiber будет Fiber[IO, Unit] — это и есть то самое волокно, которое можно прервать или дождаться (join).

Механизм безопасной отмены позволяет, например, организовать гонку (race) волокон:

def fetchFromSource1: IO[String] =  
  IO.println("Запрос к источнику 1") *>  
  IO.sleep(2.seconds)  
    .productR(IO.pure("Результат 1"))  
    .onCancel(IO.println("Запрос к 1 отменили!"))  
  
def fetchFromSource2: IO[String] =  
  IO.println("Запрос к источнику 2") *>  
  IO.sleep(1.seconds)  
    .productR(IO.pure("Результат 2"))  
    .onCancel(IO.println("Запрос к 2 отменили!")) // это вряд ли  
  
def raceExample: IO[Unit] =  
  IO.race(fetchFromSource1, fetchFromSource2) // IO[Either[String, String]]
    .map(_.merge) // здесь нам важен не победитель, а лишь его результат
    .flatMap(IO.println)
    
// Запрос к источнику 1
// Запрос к источнику 2
// Запрос к 1 отменили!
// Результат 2

Метод IO.race запускает задачи в параллельных волокнах, и как только одна из задач завершается, другое волокно обрывается автоматически.

Но, пожалуй, самое важное — это автоматическая отмена дочерних волокон:

val child =  
  IO.println("Дочернее волокно работает...")  
    .productR(IO.never) // никогда не остановится  
    .guarantee(IO.println("Дочернее волокно безопасно остановлено!"))  
  
val tasks = List.fill(2)(child) // список нескольких задач  
  
def structuralParallelism =  
  for  
    tasksFiber <- tasks.parSequence.start // порождаем родительское волокно  
    _ <- IO.sleep(1.second)  
    _ <- IO.println("Отменяем родителя...")  
    _ <- tasksFiber.cancel // Это ГАРАНТИРОВАННО отменит всех "детей"!
  yield ()

// Дочернее волокно работает...
// Дочернее волокно работает...
// Отменяем родителя...
// Дочернее волокно безопасно остановлено!
// Дочернее волокно безопасно остановлено!

Метод parSequence запускает все задачи из списка в параллельных волокнах и возвращает одну задачу, вычисляющую список значений (в данном случае IO[List[Nothing]]). При отмене волокна этой большой задачи автоматически оборвутся все дочерние волокна.

Такая техника называется «структурированный параллелизм». Она даёт максимальную защиту от утечки волокон. Конечно, для каких-то целей можно организовать такую «утечку» вручную, но получить её случайно практически невозможно.

Волокна CE/ZIO реализованы с использованием лучших практик по работе с циклами событий. Например, там есть механизм «кражи задач» (work stealing), перекидывающий волокна между очередями, что позволяет сбалансировать нагрузку на ядра процессора. Также есть разделение пулов нитей на вычислительный (compute) и блокирующий (blocking). В первом работают волокна с обычными легковесными задачками, тогда как во второй выносятся «тяжеловесные» операции, которые могли бы парализовать работу всей системы, окажись они в вычислительном пуле. Программисты могут вручную переключать (shift) выполнение волокна между этими контекстами (пулами), а также разрешать или запрещать прерывание задачи (например, помечая критические секции как uncancelable).

Императивный стиль (direct style)

Стремление к привычному стилю программирования эффектов ввода-вывода, реализованное в таких языках, как Go или C#, не обошло стороной и Scala. Автор языка Мартин Одерски активно продвигает идею «императивного стиля» (direct style) в функциональном программировании. Да, в таком стиле функции по-прежнему остаются «раскрашенными», что сохраняет прозрачный контроль над эффектами, но при этом композиция обычных и эффективных (effectful) шагов становится максимально привычной для большинства программистов.

Есть несколько реализаций такого подхода. Например, в экосистеме CE можно подключить библиотеку cats-effect-cps и писать такой код:

import cats.effect.cps.*

def fetchData(id: Int): IO[String] =  
  IO.sleep(1.second) *> IO.pure(s"Данные для $id")  
  
def directProgram: IO[Unit] = async[IO]:  
  val data = fetchData(42).await // подождите... déjà vu?  
  
  // Мы можем писать обычный императивный код внутри async-блока!
  if data.isEmpty then  
    println("Пусто")  
  else  
    println(s"Получено: $data")

Да, это знакомый синтаксический сахар async/await, существующий в C# и других языках. На этапе компиляции такой код декомпозируется на фрагменты, которые снова соединяются в цепочку flatMap.

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

Терминология (вокабуляр)))

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

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

  • Многопоточность — способ реализации многозадачности внутри одного процесса, при котором выполнение разделяется на несколько потоков (нитей).

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

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

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

Некраткие тезисы (TL;DR)

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

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

  • Чаще всего для переключения задач применяется абстракция под названием «нить» (thread). Операционные системы позволяют приложениям создавать дополнительные нити для параллельного решения своих задач.

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

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

  • Альтернативным решением является отказ от избыточных нитей в приложениях. Алгоритмы разбиваются на фрагменты, разделённые этапами «ожиданий»; эти фрагменты помещаются в очередь задач, которая обрабатывается в цикле событий в единственной нити (или в небольшом пуле, соразмерном количеству ядер). Это многократно поднимает максимальный RPS веб-сервера в сравнении со стратегией на базе нитей ОС.

  • Наивная реализация техники цикла событий приводит к дроблению алгоритмов на слабо связанные блоки-продолжения. Это порождает ад обратных вызовов (callback hell), радикально усложняющий отладку и обработку исключений.

  • Собрать фрагменты асинхронного алгоритма в целостную последовательность позволяет техника корутин. Она реализуется через синтаксический сахар или монадическую композицию. Вызовы flatMap или конструкции await выступают маркерами кооперативной многозадачности. Это места, где компилятор или среда исполнения сами декомпозируют алгоритм на части, между которыми можно приостановить выполнение одной задачи и переключиться на другую. Корутины упрощают отладку и обработку исключений.

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

  • Однако императивные техники программирования концептуально не способны предоставить программистам достаточно гибкие инструменты контроля над процессом вычислений. В частности, актуальной остаётся проблема безопасной отмены задач и связанные с ней потенциальные утечки ресурсов.

  • Эти проблемы решаются декларативными техниками функционального программирования. В Scala-библиотеках CE и ZIO используются «ленивые контейнеры», которые являются не надстройкой над уже исполняемой задачей, а накапливают план вычислений, позволяя детально сконфигурировать его ещё до начала исполнения. Это позволило реализовать безопасный механизм отмены задач в точках монадической композиции, с гарантированным запуском пользовательских финализаторов. Такие корутины называются волокнами (fibers). Они открывают широкие возможности по управлению параллелизмом, одной из которых является автоматическая отмена дочерних волокон при отмене родительского. Такой структурированный параллелизм обеспечивает максимальную защиту от утечек ресурсов и предсказуемость поведения системы под нагрузкой.