
Недавно работал в команде, занимавшейся разработкой встроенного ПО. Это ПО в значительной степени основывалось на конечных автоматах, которые десятками были разбросаны по множеству функций. И хотя такая архитектура весьма распространена в разработке встраиваемых систем, в особенности систем без ОС, я задался вопросом: неужели нет способа выразить поток управления более чисто?
Конечные автоматы в нашем коде работают прекрасно, но их понимание и обслуживание зачастую вызывало головную боль. В их работе отсутствовал линейный поток, плюс они требовали мысленного жонглирования флагами, состояниями и переходами, происходящими в функциях опроса.
Меня не покидала мысль: «А не будет ли проще написать логику в виде последовательной программы, ожидающей события и возобновляющей выполнение с места остановки?»
Естественно, в проекте не допускалось использование 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_
— да, фактическое количество строк в исходном коде оказывается счётчиком команд конечного автомата. Это смелый хак, который придаёт нашему изощрённому финту с макросами практически целостный вид. Подробное описание разворачивания макросов можно найти здесь.
Комментарии (3)
Ilya_JOATMON
20.07.2025 10:17Рассчитывал увидеть переключение контекста вручную с сохранением и восстановлением содержимого стека под каждую "параллельную" функцию. Но это конечно потребовало бы ассемблера.
mapnik
Любая разработка на Си в 2025 году с точки зрения вебских фронтендеров.