Привет всем!

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

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

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

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

Различие между синхронностью и асинхронностью в языке и создании систем — как раз такой аспект проектирования, у которого есть глубокие физические основания. Большинство программистов сразу начинают работать с такими программами и языками, где подразумевается синхронное выполнение. На самом деле, это настолько естественно, что никто об этом напрямую даже не упоминает и не рассказывает. Термин «синхронный» в данном контексте означает, что вычисление происходит сразу, как серия последовательных шагов, и до его завершения больше ничего не происходит. Я выполняю “c = a + b” или “x = f(y)” – и больше ничего не произойдет, пока не завершится выполнение этой инструкции.

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

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

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

Например, процессор и система памяти обеспечиваются изрядной инфраструктурой, отвечающей за считывание и запись данных в памяти с учетом ее иерархии. На уровне 1 (L1) ссылка на кэш-память может занять несколько наносекунд, тогда как сама ссылка на память должна пройти весь путь через L2, L3 и главную память, на что могут потребоваться сотни наносекунд. Если просто ждать, пока ссылка на память разрешится, то процессор будет простаивать в течение значительного процента времени.

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

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

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

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

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

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

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

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

Естественно, локальное пакетирование можно реализовать и на синхронном интерфейсе, но для этого придется либо в значительной степени «скрыть истину», либо запрограммировать объединение в пакеты как специальную фичу интерфейса, что может значительно усложнить весь клиент. Классический пример «сокрытия истины» — буферизованный ввод/вывод. Приложение вызывает “write(byte)”, и интерфейс возвращает success, но, фактически, сама запись (а также информация о том, успешно ли она прошла) не состоится, пока буфер не будет явно заполнен или опустошен, а это происходит при закрытии файла. Многие приложения могут игнорировать такие детали – беспорядок возникает лишь тогда, когда в приложении требуется гарантировать некоторые взаимодействующие последовательности выполнения операций, а также истинное представление о том, что происходит на нижележащих уровнях.

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

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

В синхронных моделях обычно тесно связываются компоненты и модели их обработки.
Асинхронные взаимодействия – это механизм, зачастую применяемый для ослабления связывания.

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

Интересный пример – история веб-серверов. Ранние веб-серверы (создававшиеся на основе Unix) для управления входящим запросом обычно ответвляли отдельный процесс. Затем этот процесс мог считывать это соединение и записывать в него, происходило это, в сущности, синхронно. Такой дизайн развивался, и издержки удалось снизить, когда вместо процессов стали использоваться потоки, но общая синхронная модель выполнения сохранялась. В современных вариантах дизайна признается, что основное внимание следует уделять не модели вычислений, а, прежде всего, сопутствующему вводу/выводу, связанному со считыванием и записью при обмене информацией с базой данных, файловой системой или при передаче информации по сети, формулируя при этом отклик. Обычно для этого используются рабочие очереди, в которых допускается некое предельное количество потоков – и в таком случае удается более явно выстраивать управление ресурсами.

Успех NodeJS в бэкенд-разработке объясняется не только поддержкой этого движка со стороны многочисленных JavaScript-разработчиков, выросших на создании клиентских веб-интерфейсов. В NodeJS, как и в браузерном скриптинге, огромное внимание уделяется проектированию в асинхронном ключе, что хорошо сочетается с типичными вариантами серверной нагрузки: управление ресурсами сервера зависит в первую очередь от ввода/вывода, а не от обработки.

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

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

Другие темы

Отмена

Отмена – сложная тема. Исторически синхронно-ориентированные системы плохо справлялись с обработкой отмены, а некоторые даже вообще не поддерживали отмену. Отмена по сути своей должна была проектироваться «вне полосы», для такой операции требовалось вызывать отдельный поток выполнения. В качестве альтернативы подойдут асинхронные модели, где поддержка отмены организована более естественно, в частности, применяется такой тривиальный подход: попросту игнорируется, какой отклик в итоге возвращается (и возвращается ли вообще). Отмена становится все важнее, когда увеличивается вариативность задержек, а также на практике возрастает частота ошибок – что дает вполне хороший исторический срез, демонстрирующий, как развивались наши сетевые окружения.

Дросселирование / Управление ресурсами

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

Мне пришлось с этим столкнуться в самом начале карьеры, когда мы портировали текстовый редактор с синхронного графического API Sun на X Windows. При применении API Sun операция отрисовки происходила синхронно, так что клиент не получал управление обратно, пока она не завершалась. В X Windows графический запрос асинхронно диспетчеризовался по сетевому соединению, а потом выполнялся сервером отображения (который мог находиться на той же самой или на другой машине).

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

Эта проблема не теряет актуальности и сегодня, более чем 30 лет спустя (по-видимому, приложение Facebook для iPhone в определенных сценариях страдает в точности от такой же проблемы). Альтернативная схема – написать специальный код для обновления экрана (в котором учтено, как часто способен обновляться экран), сделать его ведущим и явно направлять на клиент обратный вызов для отрисовки области, а не оставлять клиенту ведущую роль в этом взаимодействии. В таком случае на клиенте нужно соблюдать жесткий хронометраж, чтобы подобный код работал эффективно, а это не всегда практично.

Сложность

Вся эта тема в конечном счете вращается вокруг относительной сложности создания приложений, построенных по асинхронным схемам. На одной из моих первых лекций, прочитанных на высокоуровневом внутреннем техническом семинаре компании Microsoft, я доказывал, что асинхронные API – ключевая составляющая для создания приложений, которые не будут подвисать так часто, как первые программы для ПК. На галерке сидел Батлер Лэмпсон, лауреат премии Тьюринга, один из основателей сегмента ПК – и он воскликнул: «Да, но и работать они не будут!» В последующие годы я много и предметно беседовал с Батлером, но он так и оставался глубоко обеспокоен тем, как же управлять асинхронностью в крупных масштабах.

Существует две ключевые проблемы, возникающие при асинхронном дизайне. Первая – как описать, что вычисление необходимо перезапустить по прибытии асинхронного отклика. В частности, есть такая проблема: возможно ли найти способ компоновки, поддерживающий скрывание информации, необходимое для построения сложных приложений, состоящих из независимых компонентов. Распространены машины состояний, работающие в явном событийно-ориентированном ключе. Для практического решения таких проблем применимы языковые конструкции, например, async/await или промисы. Более «грубые» подходы, скажем, использование обратных вызовов, очень распространены в коде JavaScript. В данном случае возникает такая проблема: долгосрочное состояние программы погребается под непроницаемыми обратными вызовами, которые зачастую остаются неуправляемыми и вообще не поддаются управлению. Async/await позволяет описать асинхронное вычисление, как если бы оно было последовательным, запрограммированным в синхронном коде. Под капотом все это превращается в цепочки замыканий и функций продолжения. Такой подход также обеспечивает стандартное обертывание асинхронных вычислений, не требуя прибегать к спонтанным и непоследовательным приемам, которые наблюдаются в сыром коде, основанном на обратных вызовах.

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

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

Как и в целом при проектировании, когда приходится управлять сложностью, на первый план выходит стратегия изоляции, в особенности – изоляция данных. Один из вызовов при работе с графическими приложениями заключается в том, что в программе вполне может быть нужно информировать пользователя об этих промежуточных состояниях. Обычное требование – демонстрировать прогресс того или иного вычисления, либо прерывать, а затем перезапускать вычисление, на которое повлияли те или иные действия пользователя (классические примеры, вписывающиеся в обе эти категории – разметка страницы в Word или пересчет результатов в Excel). Во многих случаях, с которыми я сталкивался за многолетнюю практику, масса сложности добавляется из-за попыток определить, как эти промежуточные состояния программы должны подаваться пользователю, а не просто поставить курсор, оформленный в режиме ожидания.
Определенно, для особенно длительных операций либо для таких, где велик риск отказа, пользователь очень хочет понимать, что же именно происходит в программе.
Какую бы стратегию мы ни выбрали, всегда полезно действовать дисциплинированно и единообразно – со временем такая стратегия обязательно окупается. Импровизированные подходы быстро скатываются к невероятной сложности.

Выводы

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

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


  1. rzerda
    17.09.2018 19:27
    +1

    Не миф, а абстракция, и местами очень удобная, несмотря на указанные течи.


  1. gBear
    18.09.2018 18:37

    Ужас какой-то… сам себе придумал «терминологию», которую потом же и объявил мифом :-)

    «Синхронный» — это не потому, что

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


    А потому, что в control flow — так или иначе — наличествует синхронизация с «вычислителем» (например, с io).

    В отличие от «асинхронного», когда control flow спокойно может быть утилизирован.


  1. saipr
    19.09.2018 08:46

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

    А в программировании бывает по-другому?