Хакаем корутины в C

от автора

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

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

Меня не покидала мысль: «А не будет ли проще написать логику в виде последовательной программы, ожидающей события и возобновляющей выполнение с места остановки?»

Естественно, в проекте не допускалось использование 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 после очередной такой же задержки. Кроме того, если от второго конечного автомата получается событие resetLedled_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, он:

  1. Выбирает первую задачу в очереди.

  2. Вызывает её функцию корутины.

  3. Проверяет возвращаемое значение, чтобы определиться с дальнейшими действиями:

    • 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 светодиода задача должна ожидать одновременно двух событий:

  1. Истечения таймаута (wait_ms).

  2. Сигнала (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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *