Этот мир — асинхронный, и что вы ему сделаете

e41afc0d3d0caa3b11f44e1b5da7de2c.jpg

Все современные средства разработки — практически без исключения — наделены двумя родовыми травмами. Они не дают доступа к чуть более низкому софтверному уровню (синтаксическому дереву) без помощи сторонних хаков и ориентированы на синхронное исполнение.

Прежде, чем продолжить, я сразу оговорюсь: я не имею в виду узкоспециализированные задачи, типа написания драйверов, программирования контроллеров и прочей околожелезной разработки; там другие правила. Я говорю про мир приложений: от инди-игр до энтерпрайза. Языки высокого уровня, на которых сегодня ведется более (оценка навскидку) 98% всей разработки продуктов для конечного пользователя, лишены примитивов представления AST и параллельного (не путать с асинхронным) исполнения.

Из известных мне более-менее распространенных языков, AST на уровне стандартной библиотеки (что означает — в прямом доступе компилятора) реализован в лиспах, эрланге и эликсире (а также наполовину в julia и на 200% в brainfuck). Примитивы параллельного исполнения — прерогатива BEAM-языков (эрланг, эликсир, gleam, lfe) и отчасти go. Отчасти — потому, что до версии 1.12, пока язык матерел, в него была вмурована кооперативная многозадачность (приводящая в общем случае к превращению любой программы в тыкву рано или поздно), что сделало внедрение вытесняющей многозадачности — походом на костылях по граблям.

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

Потому что языки, используемые для её решения, вообще не предназначены для асинхронного программирования. Великий мастер, наверное, способен изваять Давида и из глины и палок, но даже Агостино ди Дуччо предпочел приволочь из Каррары гигантскую мраморную глыбу, Антонио Росселлини честно старался, но не совладал с трещиной и сдался, Симоне да Фьезоле чуть всё не испортил, а приглашенный по наводке да Винчи никому не известный мальчишка из Капрезе — всё-таки довершил начатое. Никто их них не пытался опротестовать сам выбор материала: для такого грандиозного замысла требовался именно мрамор. Из глины хорошо делать кувшинчики и пепельницы.

Что предоставляют нам традиционные, широко распространенные языки программирования?

  • Методы объектов, если речь про ООП. Без танца с бубном вокруг, любой метод вызывается синхронно, а значит, — надо оценивать асимптотическую сложность каждого метода каждого класса, а если внутри — что-нибудь тяжелое, то… ну да, городить вокруг строительные леса из потоков, тредов, мьютексов и прочего низкоуровневого инстументария, который сразу превращает код в практически нечитаемый. Еще и дедлоки «вдоль дороги с косами стоя́т».

  • Функции, если речь про классическое ФП. Но функция лишена времени жизни. Значит, нужен какой-то оркестратор, или типа того, а это нарушает чистоту. Для API веб-сервера — хаскель практически идеальный кандидат, потому что реакцию на запрос не описать лучше, понятнее и более ёмко, чем функцией. Но вебсокет — уже проблема (решаемая, хотя хранить стейт и придётся костылями).

  • Корутины (и горутины), если речь про go или julia, или даже современный ruby. Но горутины — это всегда что-то с коротким временем жизни, скорее — одноразовая задача, чем сущность языка, живущая вместе с программой от начала до конца, как инстанс класса, например. Тысяча горутин, с продолжительным временем жизни, обменивающихся данными — это неподдерживаемый кошмар. Хотя как было бы заманчиво запустить на каждую сущность из реального мира свою горутину, и забыть о ней, пока не потребуется.

А мир точно асинхронный?

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

Наши, годами выработанные паттерны, шаблоны и навыки — к правильному переводу всего этого на магинные языки — не приспособлены, потому что мы все привыкли к одноядерным процессорам. Хоть триста абстракций вокруг одного ядра построй — две задачи одновременно оно выполнять не научится. Отсюда все эти заблуждения молодежи, будто нода — умеет исполнять код параллельно, или что асинхронность уменьшает общее время исполнения (костыли наподобие вызова посторонних процессов для IO-bound задач — это про другое, я так и из ассемблера умею). Просто асинхронность ничего к производительности не добавляет. При этом приносит неочевидную, но крайне сложную проблему (спасибо @apevzner за кристаллизацию этой мысли, приведшую к написанию данного текста): мы не можем гарантировать eventual consistency нашей модели, если она асинхронна. В воздухе явственно запахло CAP-теоремой, но на практике всё гораздо проще.

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

Синхронное решение задачи распродажи колбасы

Каждый покупатель через хорошо построенную модель абстракций в результате вызывает метод инстанса класса Inventory, в котором содержится остаток. Это может быть база, синглтон, чёрта в ступе — не суть важно. При помощи мьютексов, локов на уровне базы, или других примитивов синхронизации мы добиваемся того, что пять пользователей будут обслужены последовательно, чтобы гарантировать наличие товара для каждого (последнего) из них. Если товар заканчивается — мы возвращаем ошибку. Если покупателей на этой распродаже много (тысячи) — последний будет ждать N×t, где N — количество покупателей, а t — время на обработку одного заказа.

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

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

Распродажа в асинхронном мире

Асинхронный код строится на гринтредах (горутинах, или, в терминологии эрланга, — на процессах). Каждый пользователь — это процесс, прилавок (Inventory) — это тоже процесс. Когда пользователь хочет осуществить покупку — происходит единственное синхронизированное действие — уменьшение счетчика в процессе Inventory на единицу (это тоже не идеальная абстракция, в идеальной — можно было бы уменьшить его сразу на m ≥ 1, в один квант времени — два человека абсолютно одновременно хватают товар с разных сторон прилавка, но во-первых в реальном мире они все-таки хватают его последовательно, когда счет идет на наносекунды, во-вторых, можно построить и такую абстракцию, но тогда придется разруливать ситуацию «три человека ухватили последюю колбасу за разные концы, но нам ничего не мешает ввести в задачу «супер-быстрого служителя магазина», который все-таки не разрешает покупателям самим тянуть свои чахлые ручонки к товару и раздает его).

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

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

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

Во всех этих примерах разные раздаватели колбасы и разные кассиры работают не только асинхронно, но и параллельно. Без явного согласования! Без мьютексов и локов. На чистых очередях. Что вам лично более понятно в качестве абстракции, взятой из реального мира — очередь, или мьютекс?

А как же все-таки быть с eventual consistency?

Пока покупатель ходит по залу с колбасой в корзине, нам надо научиться жить в условиях неконсистентности всей системы. Всего колбас у нас 100, оплачены 50, осталось 10. Остальные 40 бродят неучтенными (по залу, хотелось бы верить). Этому мозг любого программиста противится всеми силами. Так быть не должно.

Но в этом нет ничего страшного. Достаточно простой привычки. Очереди рассосутся (особенно, если разработчик слышал такой термин, как back pressure, и умеет с этим феноменом работать). Можно по камерам отследить все неоплаченные колбасы в зале (медленно и неэффективно, но надо — значит надо). Можно договориться считать баланс после закрытия магазина. Можно, в конце концов, вспомнить о понятии «страховой случай» и просто забить на потерявшиеся колбасы (этот вариант неприменим, если магазин торгует золотыми слитками, но там редко случается такой ажиотаж).

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

Пример из жизни

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

Пример с большим количеством кода выйдет на днях, в качестве Приложения №1 к этому тексту.

Удачной рассинхронизации!

Habrahabr.ru прочитано 4602 раза