Радикальная асинхронщина
Асинхронность... Как много в слове этом! Асинхронность считается одной из сложных тем программирования вообще. Не все просто в этом вопросе. В интернете периодически появляются различные статьи, туториалы по тем или иным вопросам асинхронности. Из последнего дельной статьей я считаю вот этот материал. Собственно, эта статья и послужила последней каплей, переполнившей моё терпение. Мною сделана попытка взглянуть на асинхронность, проблемы и сложности, связанные с ней, совершенно под другим углом. Выводы, я бы сказал, напрашиваются весьма радикальные. И так, поехали!
Источником нашей головной боли являются операции ввода-вывода. Это, как правило, медленные операции, протекающие намного медленнее скорости работы наших программ. Наши быстрые процессоры вынуждены ждать завершения медленных операций ввода-вывода, вынуждены простаивать, вместо того, чтобы производить полезные вычисления. Как результат, производительность наших программ намного ниже, чем это могло бы быть.
Суть недостатка синхронного подхода
В синхронном подходе программирования ввода-вывода мы всегда дожидаемся завершения текущей операции. Да, современный процессор простаивает, но зато мы имеем простой и ясный код программ. Мы используем простые и интуитивно понятные функции ввода и вывода, такие как read() или write(). Выполнение программы (потока) блокируется до тех пор, пока функция read() не прочтет данные из сетевого сокета и возвратит нам управление когда данные будут уже прочитаны. После возврата из функции мы можем приступить к обработке поступившей информации.
Рассмотрим классический пример, обычно рассматриваемый в таких случаях. Мы пишем некий сетевой сервис, который принимает запросы от клиентов, обрабатывает запрос, скажем, ищет информацию в базе данных и отправляет ответ клиенту. В синхронном стиле такой код мог быть таким (псевдокод):
auto request = socket.read(...);
auto result = db_client.do_query(prepare_request(request));
socket.write(prepare_result(result));
socket.close();
Это очень хороший, легко читаемый и наглядный код! Что еще надо?
Обычно в таких случаях пишут про производительность. И это так. Что будет если клиентов будет несколько десятков, сотен, тысяч? В таком коде клиентам придётся ждать своей "очереди", пока будут обработаны запросы предыдущих клиентов. Но сейчас так никто уже не делает. Обычно для каждого нового клиента (входящего соединения) создают по отдельному потоку. И каждого клиента "ведут" в отдельном потоке. Этот отдельный поток может быть заблокирован на какой-то операции ввода-вывода. Но ничего страшного, на первый взгляд, в этом нет. Но что будет если клиентов будет по-настоящему много? Тысячи и десятки тысяч? Будет создано столько же отдельных потоков.
Обычно авторы статей про "асинхронность" и тут пишут про проблемы с производительностью. Мол, у наших процессоров число ядер ограничено, они будут захлебываться в переключении контекста между тысячами и десятками тысяч потоков. Операция переключения контекста это весьма дорогостоящая операция, требующая, как правило, перехода в защищенный режим ядра операционной системы, включающая работу планировщика потоков операционной системы и т.д. Это все правильно.
Как правило, ставят в недостаток то, что мы имеем тысячи потоков, из которых большинство простаивают, ожидая завершения текущей операции ввода-вывода. Лично я тут не вижу никакой проблемы. Ну и что что у нас тысячи потоков, из которых 90% простаивают? Ведь оставшиеся 10% потоков с лихвой загружают наш процессор, все его ядра, выполняя полезную работу. В чем проблема?
Проблема именно в дорогостоящей операции переключения контекстов при переключении потоков. Уж слишком много процессорного времени тратится не на полезную работу выполняемую внутри потока, а на переключение между потоками.
Также проблему может составить и особенности планировщика потоков операционной системы и особенности организации вытесняющей многозадачности. Например, в случае, если потоку дается минимальный квант времени, скажем, вычисляемый динамически, в диапазоне от 1 до 10 миллисекунд, как в ОС Linux, но поток после 30 микросекунд активной работы инициировал операцию ввода-вывода в блокирующем синхронном режиме, то остаток данного кванта времени будет потрачен в холостую, в случае, если операция ввода-вывода у нас продлится более 10 миллисекунд. И тут на сцену выходит асинхронщина.
Асинхронность
В асинхронном режиме мы не дожидается завершения операций ввода-вывода внутри функций read() и write(), вместо этого они возвращают управление практически моментально. Мы имеем возможность осуществлять некую полезную вычислительную работу, в то же самое время происходит наш ввод-вывод. После происшествии некоторого времени мы возвращаемся к нашему незавершенному вводу-выводу и обрабатываем его результаты. Нам достаточно иметь один или несколько потоков для обработки тысяч входящих соединений! Мы всегда выполняем полезную работу, повышая общую производительность, не тратя драгоценные такты процессора на переключение между потоками.
Но главной проблемой становится читаемость и понятность кода. Читаем из сокета в одном месте, обрабатываем данные из него в другом. При использовании коллбэков очень легко запутаться, ведь код становится похожим на спагетти из них. Эту ситуацию описывает термин "callback hell". В С++ существует несколько вариантов. И ни один из них не является хорошим.
Один из них - это подход, взятый из языка С, когда каждый коллбэк является указателем на функцию, и мы устанавливаем коллбэки для обработки каждого наступившего события. Существует достаточно много библиотек на языке С, исповедающих данную идеологию, и этот подход работает. Недостатком является неудобство программирования и многословность такого подхода.
Второй подход это использование интерфейсов языка С++, классов с абстракными виртуальными методами, с именами наподобие OnData(). Программист должен создать свой класс, реализующий такой интерфейс, а указатель на интерфейс передать в асинхронную функцию. Кажется, такой подход менее распространен, чем первый, но он работает. Его недостатки такие же как и у первого - многословность и неудобство программирования.
Третий подход похож на второй, за тем исключением, что вместо абстракных интерфейсов используется новая возможность языка С++, это лямбды. Фактически лямбды - это синтаксический сахар, однако, позволяющий сократить многословность.
Главной проблемой всех трех подходов в реализации асинхронности - это логическая сложность кода, программист должен в уме проследить за последовательностью вызовов коллбэков, временем жизни всех переменных нужных для обработки результатов ввода-вывода, временем жизни захваченных переменных в лямбды.
Принципиальным моментом является то, что, как правило, невозможно использовать локальные переменные на стеке для обработки результатов текущей операции ввода-вывода. Например, мы запустили операцию чтения из сокета, и передали ей буфер для хранения результатов чтения. Однако, так как асинхронная функция read() вернет управление немедлено, а результат операции будет доступен где-то в другом месте, в котором у нас нет доступа к локальным переменным текущей области видимости, нам нужно думать головой и что-то соображать. Использование лямбд не позволяет забыть про время жизни захваченных объектов, и требует задействования серого вещества в голове программиста, а мозговая деятельность, как известно, является наиболее энергозатратной в жизнедеятельности человека.
Все перечисленное выше заставляет асинхронному программированию быть на порядок сложнее синхронного, требовать более высокой квалификации программиста. Производительность программирования при этом также падает, если выполнять оценку по параметру - количество реализованного функционала за единицу рабочего времени.
Спасение в корутинах
Самым новым подходом в реализации асинхронного программирования является использование так называемых корутин. Авторы утверждают, что корутины, это почти что серебряная пуля, решающая задачи асинхронного программирования. Они сочетают преимущества простоты программирования синхронного подхода и высокой производительности асинхронного подхода.
Но для этого необходимо иметь поддержку корутин в языке программирования. С++ начиная с версии стандарта С++20 имеет базовую поддержку корутин. А также нужно уметь пользоваться корутинами и использовать их в своем коде.
Для корутин в языке С++ введены новые ключевые слова: co_return, co_yeild, co_await. Не буду вдаваться в подробности синтаксиса корутин, вы можете это сделать при помощи сторонней литературы. Вкратце, корутиной является любая функция (ну, почти), которая использует в своем теле одно из ключевых слов из co_return, co_yeild, co_await. Код, для сетевого сервиса выше, написанный при помощи корутин может быть таким (псевдокод):
auto request = await socket.read(...);
auto result = await db_client.do_query(prepare_request(request));
await socket.write(prepare_result(result));
socket.close();
Компилятор сделает так, чтобы эта функция могла запоминать свое состояние, прерываясь в выполнении на другую полезную функцию, а затем, после наступления события, например, завершения операции чтения из сокета восстанавливать свое состояние.
Компилятор сгенерирует машину состояний (конечный автомат) внутри нашей функции, для того, чтобы повторный вход в нашу функцию, выполнял переход сразу на нужную строку с которой необходимо возобновить работу, например, данные из сокета уже прочитаны и нам нужно продолжить выполнение функции с места подготовки запроса для базы данных. Исторически такой конечный автомат называется Duff's Device.
Таким образом мы имеем столько точек восстановления состояния, столько раз мы использовали ключевое слово await. После каждого await мы запоминаем текущее состояние функции, выходим из нее, выполняем некоторую полезную работу до наступления готовности данных от асинхронной операции, затем заново входим в функцию и переходим в то самое состояние в котором мы покинули нашу функцию, для продолжения ее работы.
Таким образом, в одном текущем потоке мы выполняем как бы сразу несколько функций, экономя на переключениях между потоками. Такие функции, выполняемые сразу несколько на одном потоке, называются корутинами. Обращаю внимание, корутины выполняется не параллельно, а последовательно в рамках одного потока. Сначала выполняется одна корутина, до ключевого слова co_await, затем выполняется другая корутина до ключевого слова co_await и так далее. Последовательно.
Основная проблема корутин кроится в переменных на стеке. Ведь каждый раз мы выходим из функции, при этом стек чистится и переменные на стеке удаляются! А при входе в функцию второй и последующие разы нам необходимо восстановить переменные на стеке. Как это реализовано технически и определяет тип корутин.
Существует несколько реализаций корутин. Это так называемые stackfull-корутины и stackless. Стек это область памяти на которую указывает регистр процессора, обычно называемый SP (stack pointer). И стек обычно весьма ограничен по своим размерам, например, размер стека в ОС Linux, принятый по умолчанию равняется 2 мегабайтам на поток. Stackfull-корутины просто запоминают текущее значение регистра SP, устанавливают его на новую область памяти, и передают управление другой корутине, при этом гарантируют, что область памяти старого стека остается нетронутой. А при восстановлении работы старой корутины попросту происходит восстановление значения регистра указателя стека. Такой вот достаточно низкоуровневый подход, требующий знания специфики текущей архитектуры.
Stackless-корутины фактически не используют стек для локальных переменных. Компилятор имеет возможность проанализировать тело корутины, определить размер памяти для всех локальных переменных корутины, и аллоцировать блок памяти требуемого размера в куче. А затем сохранять или восстанавливать указатель на текущий блок локальных переменных, именуемый фреймом корутины.
Для создания своих собственных объектов, которые могут использоваться справа от ключевого слова co_await в языке С++ существуют определенные правила. Так называемые Awaitable объекты должны иметь три следующих метода: await_ready, await_suspend, await_resume. Существуют определенные нюансы реализации этих методов. Автор не будет углубляться в детали реализации данных методов. Однако, хочу заметить, даже использование ключевых слов co_await, co_return, создание своих Awaitable объектов и реализация методов await_ready, await_suspend, await_resume требует некоторых, но все же усилий от программиста.
Суть корутин
В синхронном случае мы имеем множество потоков, операционная система посредством механизма вытесняющей многозадачности выполняет переключение контекста, по сути переключает выполнение потоков на ядрах процессора. Да, операция переключения контекста - это дорогостоящая операция, так как требует перехода в режим ядра операционной системы. Используется некий алгоритм, вычисляющий динамически минимальный квант процессорного времени, выделяемый для потока, в зависимости от его приоритета, и часть операционной системой, называемая диспетчер многозадачности, или диспетчер потоков, реализует такой алгоритм. На многоядерных процессорах часть потоков будет действительно выполняться параллельно, часть последовательно, путем последовательного переключения контекста.
В случае корутин на одном потоке операционной системы может выполняться несколько корутин. При этом переключение между корутинами выполняется достаточно быстро, так как нам не требуется переключаться в защищенный режим ядра операционной системы, а выполняется все в user space. И выполняются корутины последовательно, с использованием так называемой кооперативной многозадачности, когда текущая выполняемая корутина сама определяет, когда она может быть приостановлена и управление передается на следующую корутину. Вам это ничего не напоминает?
Фактически корутины - это попытка перенести в user space часть операционной системы - планировщик потоков. Технически вместо потоков у нас корутины, которые выполняются в режиме "многозадачности". В режиме вытесняющей или кооперативной многозадачности - это не так важна сейчас. Также существует некий планировщик потоков корутин. Все эти манипуляции с регистром SP в stackfull-корутинах, это, по сути, реализация механизма переключение контекста процессора "на-минималках". Реализация машины состояний Duff's Device - это фактически эмуляция сохранения и восстановления регистра IP (instruction pointer), которое используется в переключениях контекста процессора.
Отказ от использования стековой памяти в stackless-корутинах приводит к лишней фрагментации памяти в куче и более медленного выделения такой памяти, так как выделение памяти в настоящем стеке - это очень быстрая операция. И все ради облегчения процесса переключения корутин, для облегчения реализации диспетчера "многозадачности".
Философский вопрос
Почему при попытке перенести планировщик многозадачности из kernel space в user space мы вынуждены прибегать к различным извращениям и ухищрениям, вводя различные новые языковые средства, ключевые слова, меняя сам подход к программированию и, даже, саму философию программирования?
При такой постановке вопроса, почему какая-то низкоуровневая деталь, навроде планировщика или диспетчера потоков ОС, заставляет менять нашу систему программирования и подход вообще? Может тут что-то не так?
Доводя мысль до абсурда, например, почему при переносе нашей программы с ОС Windows на ОС Linux, у которых, допустим, разные реализации диспетчера потоков, мы практически не меняем исходный код программы? А ведь мы должны были бы поменять сам подход к программированию, скажем, пересев с объектно-ориентированного стиля на функциональное программирование и полностью переписав программу. Ну, это же, конечно, сарказм.
После корутин
Сделаю смелое предложение. Каким может быть асинхронное программирование следующего этапа, после корутин?
Синхронным! Выкинем все Awaitable, co_await, лямбды, коллбэк-интерфейсы и указатели на функции для коллбэков. Это все лишние вещи, усложняющие программирование. Мы должны концентрироваться на прикладных вещах, а не заниматься такими задачами, как организация какой-то там многозадачности. Это операционная система или язык должны это обеспечивать.
Есть, скажем, у нас готовая и хорошо отлаженная программа, написанная в синхронном стиле в многопоточном режиме. Мы можем просто перекомпилируем нашу программу для работы с диспетчером многозадачности в user space и наша программа становиться "асинхронной" под капотом.
Реализация библиотеки POSIX Threads может создавать так называемые "корутины" вместо настоящих потоков ОС, и исполнять нашу программу на фиксированном числе потоков по числе доступных ядер в процессоре. Вся инфраструктура межпоточной синхронизации должна быть обновлена для работы в режиме "корутин" под капотом. Не зря же некоторые компании уже создают свою мьютексы, заточенные под корутины. Например, Яндекс в его userver. И вообще, получается, что необходимо заново реализовывать все примитивы межпоточной синхронизации, включая условные переменные, мьютексы, семафоры для корутин. У меня стойкое ощущение что мы идем куда-то не туда. Почему же не использовать те же самые примитивы синхронизации из пространства имен std, но с другой реализацией под капотом, а не пилить свои?
Особым вопросом является генерация и расстановка co_await под капотом такой целевой платформы компиляции. В режиме вытесняющей многозадачности мы не думаем и не беспокоимся, когда же наша программа может быть приостановлена в исполнении и процессор будет передан другой программе. Почему же должно быть иначе в режиме кооперативной многозадачности, реализованной при поддержке компилятора?
Пускай компилятор сам и расставляет co_await. Возникают вопросы, какой должен быть использован алгоритм для расстановки co_await и какие эвристики должны быть использованы. Надеюсь, что и для этого технического вопроса может быть найдено решение. Также точки co_await могут быть и точками отмены (cancellation point) для отмены выполнения текущего "виртуального потока" (который реализован на самом деле при помощи корутины). Но прикладной программист об этом и не будет знать, что его текущий поток вовсе не настоящий, а основан на корутине.
Пишите синхронный код, он прост и понятен! А под капотом в будущем там может быть оказаться кооперативная многозадачность с асинхронщиной. Это и есть радикальное решение проблемы асинхронного программирования.