понедельник, 13 ноября 2023 г.

[prog.c++] Несколько слов про реализацию цепочек асинхронных обработчиков в RESTinio-0.7.0

Этот пост можно рассматривать и как логическое продолжение поста "Наглядная иллюстрация на тему "ушел рисовать каракули на бумаге"..." и как дополнение к релизу RESTinio-0.7.0, где появились цепочки асинхронных обработчиков. Здесь я попытаюсь рассказать почему эти самые цепочки получились именно такими.

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

С синхронными обработчиками все было просто:

  • RESTinio на какой-то своей рабочей нити завершает разбор очередного входящего запроса;
  • на этой же рабочей нити происходит вызов первого синхронного обработчика в цепочке;
  • на этой же рабочей нити происходит вызов следующего синхронного обработчика в цепочке и т.д.;
  • на этой же рабочей нити RESTinio получает от пользователя четкий ответ о судьбе запроса: запрос либо был принят к обработке (какой-то из обработчиков возвратил значение accepted), либо не был принят (какой-то из обработчиков возвратил rejected или же все обработчики возвращали not_handled);
  • в итоге RESTinio знает, что нужно либо оставить запрос на откуп пользователю (случай accepted), либо нужно сформировать и отправить отрицательный ответ клиенту (случай rejected/not_handled);
  • все это происходит на одной и той же рабочей нити RESTinio, которая оказывается "заблокированной" до момента окончания обработки запроса.

И все это происходит, что называется, "не отходя от кассы" -- здесь и сейчас.

При этом завершение очередного обработчика в цепочке служит автоматическим толчком для запуска следующего обработчика. Именно так, буквально: как только i-ый обработчик возвращает not_handled, как сразу же, здесь и сейчас, запускается (i+1)-й обработчик.

В случае же с асинхронной обработкой ситуация несколько сложнее.


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

Увидев значение accepted RESTinio поймет, что пользователь взял запрос в обработку. Что позволяет RESTinio продолжить обслуживание других запросов на этой рабочей нити.

Т.е. для RESTinio значение accepted означает, что запрос будет обработан прикладным кодом.

Но фокус в том, что прикладной код пока еще не знает, что будет с запросом дальше, фактическая обработка могла еще и не начаться!

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

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


Во-вторых, как понять что очередной асинхронный обработчик завершил свою работу и пришло время запустить следующий обработчик? Да и что вообще означает "запустить следующий обработчик"?

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

Но в асинхронном режиме ситуация другая. Мы вызвали на контексте нити RESTinio функцию и она должна вернуть управление как можно быстрее, чтобы RESTinio мог продолжить свою работу. Значит, вызванная функция не должна делать обработку запроса, она должна эту самую работу кому-то делегировать. Получается, что вызываемая на контексте RESTinio функция уже и не обработчик, а планировщик (scheduler) -- ее задача как-то запланировать реальную обработку запроса на каком-то другом контексте.

Что значит "запланировать"?

Самое простое -- это просто отослать запрос куда-то через очередь сообщений.

Где-то будет специальная рабочая нить, которая извлекает запрос из очереди и производит его прикладную обработку.

Но сама функция-планировщик, которая вызывается на контексте RESTinio, она обработку не делает. Она только делегирует обработку запроса кому-то.

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

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

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

Но здесь есть два вопроса:

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

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

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

    Соответственно, если на какой-то стадии обработка запроса фактически завершается (т.е. клиенту отсылается либо положительный, либо отрицательный ответ), то просто прекращается вызов функций-планировщиков.

    Отсюда и появляется функция next() которую нужно вызвать дабы передать ответственность за обработку запроса следующему обработчику запроса. И, по большому счету, единственное, что делает next(), -- это вызывает следующую функцию-планировщик. Соответственно, next() возвращает управление когда вызванная функция-планировщик завершилась.

  2. Кто, где и как будет считать, какой по счету обработчик работает и какую именно функцию-планировщик следует вызывать внутри next()?

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

    А вот в асинхронном сценарии первая функция-планировщик отработала и что делать дальше? Когда отработает следующая?

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

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

    Фокус в том, что объект-контроллер -- это, если можно так выразиться, unique object. Т.е. объект, у которого в текущий момент может быть всего один владелец.

    Сперва владельцем является async_chain (т.е. экземпляр асинхронной цепочки, которому RESTinio отдает запрос). Затем владельцем становится первая функция-планировщик, которая отдает владение первому актуальному обработчику. Первый актуальный обработчик отдает владение функции next(). А функция next() отдает владение второй функции-планировщику и т.д.

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

Сочетание двух описанных выше факторов и привело к тому, что async_chain работает с функциями другого формата, нежели sync_chain: в async_chain передаются функции-планировщики. Каждый планировщик получает не исходный HTTP-запрос, а экземпляр объекта-контроллера. И возвращает планировщик не accepted/rejected/not_handled, а признак того, было ли делегирование дальнейшей обработки успешным или нет.


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

Очень надеюсь, что данная информация для кого-то оказалась полезной. По крайней мере я постарался связно объяснить почему в RESTinio асинхронные цепочки выглядят именно так. Хотя, вероятно, более честным объяснением было бы такое: ничего лучше придумать не смог 😒


Не буду скромным и прорекламирую самого себя: (пере)изобретаю велосипеды для себя, могу (пере)изобрести и для вас.

Комментариев нет: