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

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

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

Естественно, в проекте не допускалось использование 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_— да, фактическое количество строк в исходном коде оказывается счётчиком команд конечного автомата. Это смелый хак, который придаёт нашему изощрённому финту с макросами практически целостный вид. Подробное описание разворачивания макросов можно найти здесь.

Комментарии (3)


  1. mapnik
    20.07.2025 10:17

    порочный альянс макросов и конечных автоматов под управлением непреодолимой воли

    Любая разработка на Си в 2025 году с точки зрения вебских фронтендеров.


  1. Ilya_JOATMON
    20.07.2025 10:17

    Рассчитывал увидеть переключение контекста вручную с сохранением и восстановлением содержимого стека под каждую "параллельную" функцию. Но это конечно потребовало бы ассемблера.


  1. x89377
    20.07.2025 10:17

    :-) Проснулся чувачок через 20 лет после Protothreads