
Недавно работал в команде, занимавшейся разработкой встроенного ПО. Это ПО в значительной степени основывалось на конечных автоматах, которые десятками были разбросаны по множеству функций. И хотя такая архитектура весьма распространена в разработке встраиваемых систем, в особенности систем без ОС, я задался вопросом: неужели нет способа выразить поток управления более чисто?
Конечные автоматы в нашем коде работают прекрасно, но их понимание и обслуживание зачастую вызывало головную боль. В их работе отсутствовал линейный поток, плюс они требовали мысленного жонглирования флагами, состояниями и переходами, происходящими в функциях опроса.
Меня не покидала мысль: «А не будет ли проще написать логику в виде последовательной программы, ожидающей события и возобновляющей выполнение с места остановки?»
Естественно, в проекте не допускалось использование RTOS, посему традиционный подход применения потоков или систем блокирования для управления конкурентностью не рассматривался. Но я знал, что должна быть некая золотая середина.
Примерно в то же время я как раз работал с корутинами в Python, JavaScript, Dart и Rust. Они позволяют приостанавливать и возобновлять выполнение, не опираясь на потоки — то есть реализуют эдакую кооперативную многозадачность.
И тут меня осенило: «Корутины могут идеально подойти для нашей задачи, обеспечив конкурентность в условиях отсутствия ОС».
Прежде чем углубиться в это решение на основе корутин, предлагаю рассмотреть упрощённый пример, иллюстрирующий нашу задачу.
Мы хотим реализовать механизм мигания светодиода, обозначенный как p, в котором пользователь сможет устанавливать период мигания. Изначально светодиод мигает через фиксированный интервал в 2 секунды. Тем не менее пользователь должен иметь возможность в любой момент изменять этот интервал через нажатие и удержание кнопки. При отпускании кнопки светодиод должен перезапускать свой цикл мигания уже с новым интервалом, вдвое короче того времени, которое была нажата кнопка (p/2).
Для моделирования этого поведения можно использовать два простых конечных автомата, как показано на рисунке ниже:

Первый, led_blinker, состоит из двух состояний: LED_ON и LED_OFF. Система переходит из LED_ON в LED_OFF после задержки в p/2, и обратно из LED_OFF в LED_ON после очередной такой же задержки. Кроме того, если от второго конечного автомата получается событие resetLed, led_blinker тут же переходит в состояние LED_OFF, в каком бы состоянии он на тот момент ни находился. Запускается этот конечный автомат в состоянии LED_OFF.
У второго конечного автомата, button_record, тоже есть два состояния: WAIT_BUTTON_PRESSED и WAIT_BUTTON_UNPRESSED. В изначальном состоянии он ожидает, когда пользователь нажмёт кнопку. После её нажатия механизм записывает текущее время t_s и переходит в состояние WAIT_BUTTON_UNPRESSED. В этом состоянии он ожидает, пока пользователь не отпустит кнопку. Когда кнопка отпущена, он фиксирует текущее время t_e, вычисляет новый полупериод по формуле p/2 = t_e - t_s, отправляет событие resetLed и возвращается к состоянию WAIT_BUTTON_PRESSED.
Реализация механизма опроса для Arduino выглядит так:
Показать код
#define BUTTON_PIN 2 enum led_blink_state { STATE_LED_OFF = 0, STATE_LED_ON }; uint64_t led_blink_duration_ms = 1000; uint64_t led_blink_toggle_time = 0; uint8_t reset_led_requested = 0; enum button_record_state { STATE_WAIT_BUTTON_PRESSED = 0, STATE_WAIT_BUTTON_UNPRESSED }; void setup() { led_blink_state = STATE_LED_OFF; button_record_state = STATE_WAIT_BUTTON_PRESSED; led_blink_toggle_time = millis() + led_blink_duration_ms; pinMode(LED_BUILTIN, OUTPUT); pinMode(BUTTON_PIN, INPUT_PULLUP); } void poll_led_blink() { static enum led_blink_state = STATE_LED_OFF; if (led_blink_state == STATE_LED_OFF) { digitalWrite(LED_BUILTIN, LOW); } else if (led_blink_state == STATE_LED_ON) { digitalWrite(LED_BUILTIN, HIGH); } if (reset_led_requested) { reset_led_requested = 0; led_blink_state = STATE_LED_OFF; led_blink_toggle_time = millis() + led_blink_duration_ms; } else if (millis() >= led_blink_toggle_time) { if (led_blink_state == STATE_LED_OFF) { led_blink_state = STATE_LED_ON; } else if (led_blink_state == STATE_LED_ON) { led_blink_state = STATE_LED_OFF; } led_blink_toggle_time = millis() + led_blink_duration_ms; } } void poll_button_record() { static enum button_record_state = STATE_WAIT_BUTTON_PRESSED; static int button_pressed_start_time = 0; if (button_record_state == STATE_WAIT_BUTTON_PRESSED) { if (digitalRead(BUTTON_PIN) == LOW) { button_record_state = STATE_WAIT_BUTTON_UNPRESSED; button_pressed_start_time = millis(); } } else if (button_record_state == STATE_WAIT_BUTTON_UNPRESSED) { if (digitalRead(BUTTON_PIN) == HIGH) { button_record_state = STATE_WAIT_BUTTON_PRESSED; int button_pressed_end_time = millis(); led_blink_duration_ms = button_pressed_end_time - button_pressed_start_time; reset_led_requested = 1; } } } void loop() { poll_led_blink(); poll_button_record(); }
Эта реализация практически один в один представляет перевод наших конечных автоматов на C. Отобразить показанную схему в код относительно просто. Тем не менее, если смотреть только в код, то трудно понять, что он конкретно делает. Дело в том, что в функциях poll_led_blink и poll_button_record functions отсутствует линейное управление потоком выполнения. Вместо этого они вызываются циклически, проверяя текущее состояние и соответствующим образом реагируя, а это фрагментирует логику и затрудняет понимание кода.
Не будет ли проще, если функция каждого конечного автомата сможет просто приостанавливаться в ожидании некоего события, например, нажатия/отпускания кнопки, истечения таймера или операции resetLed, а затем возобновлять своё выполнение с того же момента? Такая структура позволит писать код, следующий чистому, последовательному потоку. Реализовать подобное поведение довольно просто при использовании FreeRTOS — нужно отобразить каждый конечный автомат с отдельной задачей, которая сможет блокироваться на время ожидания неких событий.
Показать код
#include <Arduino_FreeRTOS.h> #define BUTTON_PIN 2 TickType_t led_blink_duration_ticks = pdMS_TO_TICKS(1000); TaskHandle_t led_blink_task_handle; #define NOTIFYBIT_RESET_LED 0x80 void led_blink() { while (true) { digitalWrite(LED_BUILTIN, LOW); // Ожидает события resetLed или отсчётов led_blink_duration_ticks. if (xTaskNotifyWait(0, NOTIFYBIT_RESET_LED, NULL, led_blink_duration_ticks) == pdTRUE) { // Событие resetLed получено -> перезапуск. continue; } digitalWrite(LED_BUILTIN, HIGH); // Ожидает события resetLed или отсчётов led_blink_duration_ticks. xTaskNotifyWait(0, NOTIFYBIT_RESET_LED, NULL, led_blink_duration_ticks); } } void wait_pin(int pin, int level) { while (digitalRead(pin) == level) ; } void button_record() { while (true) { wait_pin(BUTTON_PIN, HIGH); TickType_t start_time_ticks = xTaskGetTickCount(); wait_pin(BUTTON_PIN, LOW); TickType_t end_time_ticks = xTaskGetTickCount(); led_blink_duration_ticks = end_time_ticks - start_time_ticks; xTaskNotify(led_blink_task_handle, NOTIFYBIT_RESET_LED, eSetBits); // Отправляет событие resetLed в задачу led_blink. } } void setup() { pinMode(LED_BUILTIN, OUTPUT); pinMode(BUTTON_PIN, INPUT_PULLUP); xTaskCreate(led_blink, "led_blink", 512, NULL, 1, &led_blink_task_handle); xTaskCreate(button_record, "button_record", 512, NULL, 1, NULL); } void loop() {}
На мой взгляд, такой подход делает код куда более читаемым и понятным. Он также устраняет потребность предварительного определения дискретного конечного автомата — можно просто выразить логику напрямую в виде последовательного кода.
Тем не менее здесь замешан важный нюанс: это решение требует наличия операционной системы. В частности, оно опирается на (предварительное) распланирование переключения между задачами. То есть в ваш проект потребуется включить операционную систему вроде FreeRTOS.
В итоге при использовании корутин на основе макросов структура реализации этой задачи в своей грубой форме очень похожа на подход с FreeRTOS. Начнём с реализации button_recorder:
CORO(button_recorder_fn, CORO_NO_ARGS, CORO_LOCALS(uint64_t button_pressed_start_time;), CORO_CALLS( CORO_CALL(wait_pin_low, wait_pin), CORO_CALL(wait_pin_high, wait_pin)), { coro_res_t res; while (true) { CALL(res, wait_pin_low, wait_pin, BUTTON_PIN, LOW); LOCAL(button_pressed_start_time) = millis(); CALL(res, wait_pin_high, wait_pin, BUTTON_PIN, HIGH); uint64_t button_pressed_end_time = millis(); led_blink_duration_ms = button_pressed_end_time - LOCAL(button_pressed_start_time); coro_cond_var_notify(&reset_led_signal); } return CORO_RES_DONE; })
На первый взгляд это может выглядеть как сплошная стена макросов. Но в реальности здесь происходит вот что. Эти макросы, по сути, на этапе компиляции транспилируют корутину в конечный автомат. Чтобы лучше понять этот процесс, нужно коротко пробежаться по механизму работы вызовов функций, и посмотреть, что мы здесь делаем иначе.
В стандартной функции C для манипулирования потоком управления и сохранения локальных переменных используется стек вызовов. Каждый вызов функции ведёт к передаче фрейма стека в стек вызовов, который содержит адрес возврата, параметры и локальные переменные. После завершения функции этот фрейм из стека извлекается, и выполнение продолжается с места, где оно было прервано.
Тем не менее в нашей системе с корутинами мы не можем опираться на этот встроенный механизм стека, так как приостанавливаем выполнение в произвольных точках с последующим возобновлением, возможно, из другой части программного цикла. Вместо этого мы манипулируем собственным потоком управления. Все локальные переменные, которые нужно сохранять в ходе обработки ожидающих вызовов, должны сохраняться вне стандартного стека — в структуре, привязанной к контексту корутины. За её создание отвечает макрос CORO_LOCALS.
Аналогичным образом, поскольку нет стека, который бы внутренне фиксировал точку, где произошла остановка (например, в виде адреса возврата), мы используем для выражения текущей точки выполнения корутины явную переменную состояния. Каждый возможный CALL суб-корутины определяет состояние, объявляемое через CORO_CALLS, и каждая из этих суб-корутин также имеет свой контекст. Это очень близко к реализации вручную того, что компилятор C обычно делает за вас при компиляции вызовов стандартных функций.
Этот паттерн напоминает классический приём C, известный как «метод Даффа» — техника, использующая инструкцию switch попутно с разворачиванием цикла для реализации некой формы корутины или кооперативной многозадачности. И здесь мы применяем подобный же механизм: таблицу переходов на основе switch, которая возобновляет выполнение ровно в той точке, где была приостановлена. Для пущей наглядности покажу, как выглядит корутина button_recorder после разворачивания макроса:
Показать код
enum button_recorder_fn_fct_state { button_recorder_fn_state_initial = 0, coro_state_wait_pin_low, coro_state_wait_pin_high }; struct button_recorder_fn_fct_ctx { enum button_recorder_fn_fct_state state; uint64_t button_pressed_start_time; union { uint8_t placeholder; struct wait_pin_fct_ctx wait_pin_low; struct wait_pin_fct_ctx wait_pin_high; } calls; }; coro_res_t button_recorder_fn_fct(struct button_task_fn_fct_ctx *ctx) { switch (ctx->state) { case button_recorder_fn_state_initial: { coro_res_t res; while (1) { // CALL(res, wait_pin_low, wait_pin, BUTTON_PIN, LOW); ctx->state = coro_state_wait_pin_low; ctx->calls.wait_pin_low.state = (enum wait_pin_fct_state)0; case coro_state_wait_pin_low: res = wait_pin_fct(&ctx->calls.wait_pin_low, 2, LOW); if (res & CORO_RES_PENDING) return res; (ctx->button_pressed_start_time) = millis(); // CALL(res, wait_pin_low, wait_pin, BUTTON_PIN, HIGH); ctx->state = coro_state_wait_pin_high; ctx->calls.wait_pin_high.state = (enum wait_pin_fct_state)0; case coro_state_wait_pin_high: res = wait_pin_fct(&ctx->calls.wait_pin_high, 2, HIGH); if (res & CORO_RES_PENDING) return res; uint64_t button_pressed_end_time = millis(); led_blink_duration_ms = button_pressed_end_time - (ctx->button_pressed_start_time); coro_cond_var_notify(&reset_led_signal); } return CORO_RES_DONE; } } }
В таком виде уже понятно, как система макросов превращает корутину в конечный автомат. Каждый CALL, по сути, становится пунктом case в switch, а ctx->state отслеживает, какая часть функции должна выполняться при следующем её возобновлении. Контексты всех локальных состояний и суб-корутин постоянно сохраняются в структуре в динамической памяти (или статически), а не на стеке вызовов.
Аналогичная идея рассматривается в этой статье Саймона Тэтхама, где автор достаточно грамотно описывает этот ловкий приём:
Естественно, эта уловка нарушает все стандарты программирования из учебника. Рискните сделать так в коде вашей компании и получите либо строгий выговор, либо дисциплинарное взыскание. Здесь у вас в макросах используются непарные скобки, операторы case в подблоках, и это ещё не все проблемы. Ай-ай-ай! Удивительно, что вас вообще с ходу не уволили за столь безответственное написание кода. Как вам не стыдно.
А теперь разберём корутину wait_ms, которая откладывает выполнение на указанное количество миллисекунд:
CORO(wait_ms, CORO_ARGS(uint64_t delay), CORO_LOCALS(uint64_t end_time;), CORO_CALLS(CORO_CALL(wait_ms_yield, coro_yield)), { coro_res_t res; LOCAL(end_time) = millis() + delay; while (LOCAL(end_time) >= millis()) { CALL(res, wait_ms_yield, coro_yield); } return CORO_RES_DONE; })
Эта корутина ожидает, пока текущее время превысит вычисленное конечное время. В течение этого периода она циклически выдаёт значения (yield) через другую корутину под названием coro_yield, которая обычно представляет одну итерацию в схеме кооперативного планирования, позволяя выполняться другим корутинам или логике основного цикла.
Посмотрим, во что этот код развернётся после обработки макросов:
enum wait_ms_fct_state { wait_ms_state_initial = 0, coro_state_wait_ms_yield }; struct wait_ms_fct_ctx { enum wait_ms_fct_state state; uint64_t end_time; union { uint8_t placeholder; struct coro_yield_fct_ctx wait_ms_yield; } calls; }; coro_res_t wait_ms_fct(struct wait_ms_fct_ctx *ctx, uint64_t delay) { switch (ctx->state) { case wait_ms_state_initial: { coro_res_t res; (ctx->end_time) = millis() + delay; while ((ctx->end_time) >= millis()) { ctx->state = coro_state_wait_ms_yield; ctx->calls.wait_ms_yield.state = (enum coro_yield_fct_state)0; case coro_state_wait_ms_yield: res = coro_yield_fct(&ctx->calls.wait_ms_yield); if (res & CORO_RES_PENDING) return res; } return CORO_RES_DONE; } } }
Здесь у нас структура, практически идентичная предыдущей корутине. Основная идея в том, что корутина с помощью перечисления state «запоминает», где она была приостановлена, и все переменные, которые должны сохраняться в течение нескольких вызовов (например, end_time) находятся в контексте корутины.
Цикл while частично разворачивается с помощью этого ручного конечного автомата, и вызов coro_yield преобразуется в собственный возобновляемый блок кода. Это позволяет корутине wait_ms находиться в состоянии ожидания без блокирования других корутин. Таким образом она реализует неблокирующую задержку в полностью однопоточной среде.
Вот здесь-то и проявляется настоящая сила этой техники. Несмотря на то, что мы не используем операционную систему или реальные потоки, мы можем симулировать кооперативную многозадачность просто за счёт грамотной структуры кода и разворачивания макроса. Связывая в цепочку корутины — каждую с собственным сохраняемым состоянием и локальным контекстом — мы можем выстраивать сложное поведение, сохраняя поток управления читаемым и последовательным.
По существу, мы размениваем фреймы стека и превентивное планирование на сохраняемое состояние и ручное переключение. В результате получается система, которая интуитивна по своей структуре, несмотря на полную отрешённость её реализации от традиционных парадигм, а по мнению некоторых, даже кощунственна в отношении общепринятых стандартов C.
Итак, мы увидели, как отдельные функции корутин вроде wait_ms или button_recorder_fn компилируются в конечные автоматы с помощью макросов. Теперь же давайте посмотрим, как эти корутины фактически выполняются — то есть как они планируются, приостанавливаются, возобновляются и в конечном итоге завершаются. Вот код, который определяет основную систему среды выполнения корутин:
Показать код
enum coro_task_state { coro_task_state_not_started = 0, coro_task_state_running = 1, coro_task_state_waiting_for_execution = 2, coro_task_state_parked = 3, coro_task_state_finished = 4, coro_task_state_failed = 5, }; struct coro_task; struct coro_task *current_task = NULL; struct coro_executor { struct coro_task *task_queue_head; struct coro_task *task_queue_tail; }; typedef coro_res_t (*coro_task_root_fct)(void *ctx); struct coro_task { enum coro_task_state state; struct coro_executor *executor; struct coro_task *next; coro_task_root_fct root_fct; uint8_t canceled; void *context; }; void coro_executor_enqueue_task(struct coro_executor *executor, struct coro_task *task) { assert(task->state == coro_task_state_waiting_for_execution); assert(task->executor == executor); struct coro_task **task_queue_tail = &executor->task_queue_tail; if (*task_queue_tail) // Если очередь не пуста. (*task_queue_tail)->next = task; else // if(!executor->task_queue_head) executor->task_queue_head = task; *task_queue_tail = task; } void coro_executor_start_task(struct coro_executor *executor, struct coro_task *task) { assert(task->state == coro_task_state_not_started); assert(task->executor == NULL); task->state = coro_task_state_waiting_for_execution; task->executor = executor; coro_executor_enqueue_task(executor, task); } void coro_executor_process(struct coro_executor *executor) { struct coro_task **task_queue_head = &executor->task_queue_head; while (*task_queue_head != NULL) { struct coro_task task = task_queue_head; if (task->state == coro_task_state_waiting_for_execution) { task->state = coro_task_state_running; current_task = task; coro_res_t res = task->root_fct(task->context); if (res == CORO_RES_DONE) { task->state = coro_task_state_finished; } else if (res == CORO_RES_CANCELED) { task->state = coro_task_state_failed; } else if (res == CORO_RES_PENDING) { task->state = coro_task_state_parked; } else if (res == CORO_RES_PENDING_NON_PARKING) { task->state = coro_task_state_waiting_for_execution; coro_executor_enqueue_task(executor, task); } else { assert(0); } } *task_queue_head = task->next; task->next = NULL; } executor->task_queue_tail = NULL; current_task = NULL; } enum coro_yield_fct_state { yield_state_init = 0, yield_state_after_yield = 1, }; struct coro_yield_fct_ctx { enum coro_yield_fct_state state; }; coro_res_t coro_yield_fct(struct coro_yield_fct_ctx *ctx) { if (current_task->canceled) return CORO_RES_CANCELED; if (ctx->state == yield_state_init) { ctx->state = yield_state_after_yield; return CORO_RES_PENDING_NON_PARKING; } else { return CORO_RES_DONE; } }
Здесь coro_executor — это минимальный планировщик. Он поддерживает очередь задач корутин готовой к выполнению.
Когда вызывается coro_executor_process, он:
-
Выбирает первую задачу в очереди.
-
Вызывает её функцию корутины.
-
Проверяет возвращаемое значение, чтобы определиться с дальнейшими действиями:
-
CORO_RES_DONE: корутина завершена. -
CORO_RES_CANCELED: корутина была отменена в процессе — рассмотрим её поведение позднее. -
CORO_RES_PENDING: корутина ожидает и остаётся заблокированной (PARKING), пока не будет выполнено необходимое действие. -
CORO_RES_PENDING_NON_PARKING: корутина произвела выдачу добровольно и должна быть возобновлена как можно скорее (например, в следующем цикле). -
Если задача выдаёт
CORO_RES_PENDING_NON_PARKING, она сразу же возвращается в очередь.
-
Теперь разберём led_task, которая несколько сложнее предыдущих корутин. Её задача — мигать светодиодом, включая и выключая его на основе установленного пользователем интервала. Но тут есть подвох: она должна мгновенно реагировать на событие resetLed, которое может быть отправлено в любой момент через условную переменную. Это означает, что во время каждой фазы on/off светодиода задача должна ожидать одновременно двух событий:
-
Истечения таймаута
(wait_ms). -
Сигнала
(coro_cond_var_wait), указывающего, что период мигания изменился.
И здесь есть один важный момент — нас не волнует, что из этого произойдёт первым, но как только это случится, нужно отменить второе. Например, если будет получено событие resetLed, отсчёт таймера потеряет актуальность, и наоборот. Вот здесь и подключаются макрос ANY_CALL с механизмом отмены корутин.
Вот как выглядит эта корутина:
CORO(led_task_fn, CORO_NO_ARGS, CORO_NO_LOCALS, CORO_CALLS( CORO_ANY_CALL(wait_a, wait_ms, coro_cond_var_wait), CORO_ANY_CALL(wait_b, wait_ms, coro_cond_var_wait)), { coro_res_t res_wait_ms; coro_res_t res_reset_led; while (true) { digitalWrite(LED_BUILTIN, LOW); ANY_CALL(res_wait_ms, res_reset_led, wait_a, wait_ms, (led_blink_duration_ms), coro_cond_var_wait, (&reset_led_signal) ); if (res_reset_led == CORO_RES_DONE) continue; digitalWrite(LED_BUILTIN, HIGH); ANY_CALL(res_wait_ms, res_reset_led, wait_b, wait_ms, (led_blink_duration_ms), coro_cond_var_wait, (&reset_led_signal) ); if (res_reset_led == CORO_RES_DONE) continue; } return CORO_RES_DONE; })
Тот же код в развёрнутом виде:
Показать код
enum led_task_fn_fct_state { led_task_fn_state_initial = 0, coro_state_wait_a, coro_state_wait_b }; struct led_task_fn_fct_ctx { enum led_task_fn_fct_state state; union { uint8_t placeholder; struct { struct wait_ms_fct_ctx a; struct coro_cond_var_wait_fct_ctx b; } wait_a; struct { struct wait_ms_fct_ctx a; struct coro_cond_var_wait_fct_ctx b; } wait_b; } calls; }; coro_res_t led_task_fn_fct(struct led_task_fn_fct_ctx *ctx) { switch (ctx->state) { case led_task_fn_state_initial: { coro_res_t res_wait_ms; coro_res_t res_reset_led; while (1) { digitalWrite(LED_BUILTIN, LOW); // ANY_CALL(res_a, res_b, wait_a, // wait_ms, (led_blink_duration_ms), // coro_cond_var_wait, (&reset_led_signal) // ); ctx->state = coro_state_wait_a; ctx->calls.wait_a.a.state = (enum wait_ms_fct_state)0; ctx->calls.wait_a.b.state = (enum coro_cond_var_wait_fct_state)0; case coro_state_wait_a: res_wait_ms = wait_ms_fct(&ctx->calls.wait_a.a, led_blink_duration_ms); res_reset_led = coro_cond_var_wait_fct(&ctx->calls.wait_a.b, &reset_led_signal); if (res_wait_ms & CORO_RES_DONE && !(res_reset_led & CORO_RES_DONE)) { current_task->canceled++; res_reset_led = coro_cond_var_wait_fct(&ctx->calls.wait_a.b, &reset_led_signal); current_task->canceled--; } else if (res_reset_led & CORO_RES_DONE && !(res_wait_ms & CORO_RES_DONE)) { current_task->canceled++; res_wait_ms = wait_ms_fct(&ctx->calls.wait_a.a, led_blink_duration_ms); current_task->canceled--; } if ((res_wait_ms | res_reset_led) & CORO_RES_PENDING) return (res_wait_ms | res_reset_led); if (res_reset_led == CORO_RES_DONE) continue; digitalWrite(LED_BUILTIN, HIGH); // ANY_CALL(res_a, res_b, wait_a, // wait_ms, (led_blink_duration_ms), // coro_cond_var_wait, (&reset_led_signal) // ); // ... } return CORO_RES_DONE; } } }
Макрос ANY_CALL запускает две суб-корутины параллельно и ожидает завершения любой из них. Когда это происходит, макрос инкрементирует флаг canceled в current_task, прежде чем повторять незавершённую корутину. Это активирует отмену на уровне корутин.
Отмена в этой системе является кооперативной и применяется по желанию. Каждая корутина может проверять, не была ли она отменена, инспектируя флаг current_task->canceled. Если флаг установлен, корутина должна завершиться рано (обычно вернув CORO_RES_CANCELED). Такая схема позволяет безопасно «отменять» выполняющиеся корутины, не требуя их фактического вытеснения и без риска получить несогласованное состояние.
Один из практических и важнейших случаев использования этой системы отмены корутин проявляется в реализации условных переменных, в частности в корутине coro_cond_var_wait.
coro_cond_var поддерживает связанный список ожидающих (waiter), в котором каждый такой ожидающий — это структура coro_cond_var_waiter, встроенная непосредственно в локальный контекст корутины. Это эффективный способ избежать аллокаций в куче, но он вносит ощутимый подвох: если корутина будет отменена или завершится до получения сигнала и не подчистит за собой память, в списке появится зависший указатель — ссылка на контекст, которого уже не существует. То есть здесь мы получаем затаившееся неопределённое поведение.
Чтобы это предотвратить, coro_cond_var_wait старательно удаляет своего ожидающего из списка, если корутина была отменена до получения сигнала.
Как это работает:
Показать код
void coro_unpark_task(struct coro_task *task) { assert(task->state != coro_task_state_not_started); assert(task->executor != NULL); if (task->state == coro_task_state_parked) { task->state = coro_task_state_waiting_for_execution; coro_executor_enqueue_task(task->executor, task); } } enum coro_cond_var_waiter_state { coro_cond_var_waiter_idle = 0, coro_cond_var_waiter_waiting = 1, coro_cond_var_waiter_signaled = 2, }; struct coro_cond_var_waiter { enum coro_cond_var_waiter_state state; struct coro_cond_var_waiter *next; struct coro_task *parked_task; }; struct coro_cond_var { struct coro_cond_var_waiter *waiter_head; }; void coro_cond_var_notify(struct coro_cond_var *cond_var) { struct coro_cond_var_waiter **waiter_head = &cond_var->waiter_head; while (*waiter_head != NULL) { if ((*waiter_head)->state == coro_cond_var_waiter_waiting) { (*waiter_head)->state = coro_cond_var_waiter_signaled; coro_unpark_task((*waiter_head)->parked_task); } waiter_head = (waiter_head)->next; } } static void coro_cond_var_add_waiter(struct coro_cond_var *cond_var, struct coro_cond_var_waiter *waiter) { struct coro_cond_var_waiter **waiter_head = &cond_var->waiter_head; waiter->next = *waiter_head; *waiter_head = waiter; } static void coro_cond_var_remove_waiter(struct coro_cond_var *cond_var, struct coro_cond_var_waiter *waiter) { for (struct coro_cond_var_waiter **waiter_head = &cond_var->waiter_head; waiter_head != NULL; waiter_head = &(waiter_head)->next) { if (*waiter_head == waiter) { waiter_head = (waiter_head)->next; break; } } } CORO(coro_cond_var_wait, CORO_ARGS(struct coro_cond_var *cond_var), CORO_LOCALS(struct coro_cond_var_waiter waiter;), CORO_CALLS(CORO_CALL(coro_cond_var_wait_park, coro_park)), { coro_res_t res; coro_cond_var_add_waiter(cond_var, &LOCAL(waiter)); do { LOCAL(waiter).state = coro_cond_var_waiter_waiting; LOCAL(waiter).parked_task = current_task; CALL(res, coro_cond_var_wait_park, coro_park); if (res == CORO_RES_CANCELED && ctx->waiter.state != coro_cond_var_waiter_signaled) { coro_cond_var_remove_waiter(cond_var, &LOCAL(waiter)); return CORO_RES_CANCELED; } } while (ctx->waiter.state != coro_cond_var_waiter_signaled); return CORO_RES_DONE; })
Этот механизм подчёркивает более широкий аспект подобной системы корутин: каждая корутина отвечает за очистку последствий своих действий — особенно в свете возможной отмены. Поскольку мы встраиваем объекты waiter непосредственно во фреймы стека корутин (которые могут находиться как в динамической, так и в статической памяти), очень важно снять регистрацию этих объектов до того, как их стек исчезнет.
Теперь можно всё гладко объединить. Фрагмент ниже показывает, как система запускается и выполняется в среде вроде Arduino:
DECLARE_TASK(led_task, led_task_fn); DECLARE_TASK(button_task, button_task_fn); DECLARE_EXECUTOR(exe); void setup() { pinMode(LED_BUILTIN, OUTPUT); pinMode(BUTTON_PIN, INPUT_PULLUP); coro_executor_start_task(&exe, &led_task); coro_executor_start_task(&exe, &button_task); } void loop() { coro_executor_process(&exe); }
Каждая задача корутины объявляется и инициализируется заблаговременно. При выполнении setup задачи регистрируются исполнителем (executor) и отмечаются как готовые к выполнению. В функции loop, которая циклически вызывается средой выполнения Arduino, исполнитель возобновляет каждую корутину, которая готова продолжать выполнение. Если вам интересно ознакомиться с недостающими частями этой реализации, цельный исходный код лежит здесь.
Заключение
Вся эта хитрая конфигурация представляет порочный альянс макросов C и конечных автоматов под управлением непреодолимой воли. Да, она грамотная, поучительна и весьма занятна. Но будем честны — в 2025 году здравомыслящие люди не станут писать ПО таким образом.
Если вы подумали что-то вроде: «Ничего себе, столько бойлерплейта, просто чтобы асинхронно помигать светодиодом», то абсолютно правы. Здесь мы, по сути, создали асинхронную среду выполнения для неимущих. Очень поучительную, очень смелую и сильно упёртую в макросы среду для неимущих.
Так что позволю себе лёгкую иронию:
Если вас интересуют корутины, асинхронный ввод/вывод и конкурентность на основе задач — переходите на Rust.
Rust даёт всё, что мы здесь наскребли и состряпали, только нативно. Механизм async/await в нём работает без стека и не накладывает лишних затрат, зато подкрепляется реальными гарантиями со стороны компилятора, безопасностью памяти, семантикой отмены и лаконично комбинируемыми промисами — никакого грязного ада макросов с небрежно прикрученной ручной логикой отмены.
То, что мы проделали на C, прекрасно выглядит изнутри. Но если вы будете заниматься какой-то серьёзной работой или решите создать решение, из-за которого не придётся подскакивать в 3 утра по случаю загадочного жёсткого сбоя, то лучше избавьте себя от этой боли.
Дополнение
Когда я уже написал статью, то случайно наткнулся на альтернативный подход: Адам Данкельс превзошёл меня в своём гениальном проекте Protothreads, и его подход определённо изящней.
Вместо того, чтобы генерировать явные перечисления state, как сделали мы, в Protothreads для представления состояния продуманным образом используется макрос _LINE_— да, фактическое количество строк в исходном коде оказывается счётчиком команд конечного автомата. Это смелый хак, который придаёт нашему изощрённому финту с макросами практически целостный вид. Подробное описание разворачивания макросов можно найти здесь.
ссылка на оригинал статьи https://habr.com/ru/articles/928860/
Добавить комментарий