コルーチンライブラリ coru の使い方
コルーチンライブラリ coru
の使い方(環境構築と主なユースケース)について紹介する:
補足情報
「コルーチン」という言葉について
コルーチンという言葉は,下記リンクにもあるように
- 対称・非対称コルーチン: 大域的な脱出・復帰のための制御構造
- async / await 構文: 非同期(ノンブロッキング)と同期(ブロッキング)の結合
という 2
(3
) つの歴史的な文脈で使われるらしい:
上記の定義に照らし合わせれば,coru
が実現しているのは非対称コルーチンだと思われる.とはいいつつも,「コールバックパターンを用いずに非同期・並列プログラミングを行う」という目的を実現する手段としては全て同じだというのが筆者の見解である.
コルーチン以外に機能について
姉妹ライブラリに,同じく C 言語用スケジューラライブラリ equeue
がある:
これはどちらかというと RTOS の存在を前提としたタスクキューなので,ベアメタル環境での動作には工夫が必要.
ただ,coru
と equeue
は姉妹(作者は同じ)なので,相性も良い.
環境構築
大まかに下記手順に従う:
- ホスト環境(macOS)でリポジトリを clone
- gcc の Docker コンテナを起動してマウント・アタッチ
- Docker コンテナ内で作業
事前にホスト側で下記コマンドを使用可能な状態にしておく:
git
docker
リポジトリの用意
- 適当な作業ディレクトリを用意し移動.
-
https://github.com/geky/coru を clone or pull.
mkdir -p ~/tmp && cd ~/tmp git clone https://github.com/geky/coru.git
Docker コンテナの起動
- GCC の公式イメージ https://hub.docker.com/_/gcc を pull.
- 用意した作業ディレクトリをマウントした状態でコンテナを起動
# ホストの ~/tmp に居る想定 docker images | grep 'gcc' docker pull gcc docker run -it --mount type=bind,source="$(pwd)",target=/tmp gcc # コンテナ内 root の / に着陸
Docker コンテナ内での作業
- Python の用意
-
make test
が Python 依存なため
-
- 各種ビルド・テストの確認
# コンテナ内の / に居る想定 apt update && apt -y install python cd tmp/coru/ make clean # => rm -f coru.a # => rm -f coru.o coru_platform.o # => rm -f coru.d coru_platform.d # => rm -f coru.s coru_platform.s make # => cc -c -MMD -Os -I. -std=c99 -Wall -pedantic -Wextra -Wshadow -Wjump-misses-init -Wno-missing-field-initializers coru.c -o coru.o # => cc -c -MMD -Os -I. -std=c99 -Wall -pedantic -Wextra -Wshadow -Wjump-misses-init -Wno-missing-field-initializers coru_platform.c -o coru_platform.o # => ar rcs coru.a coru.o coru_platform.o make clean # 略 make test # => === Simple tests === # => --- Single test --- # => === Parameter tests === # => --- Simple create test --- # => --- Create with arg test --- # => --- Simple create inplace test --- # => --- Create inplace with arg test --- # => === Corner case tests === # => --- Resume after exit test --- # => --- Yield outside coru test --- # => --- Resume self test --- # => === Stack overflow tests === # => --- Stack overflow test --- # => === Parallel coru tests === # => --- Parallel test, 2 coroutines --- # => --- Parallel test, 3 coroutines --- # => --- Parallel test, 10 coroutines --- # => --- Parallel test, 100 coroutines --- # => === Nested coru tests === # => --- Nested test, 2 coroutines --- # => --- Nested test, 3 coroutines --- # => --- Nested test, 10 coroutines --- # => --- Nested test, 100 coroutines ---
coru が実現する機能
基礎: yield と resume のペアを用いた文脈の保存・復帰
公式の README.md にもあるように下記のようなプログラミングパターンをサポートする(コード例は一部改変):
void func(void *) {
for (int i = 0; i < 10; i++) {
printf("hi %d!\n", i);
coru_yield();
}
}
int main(void) {
coru_t co;
coru_create(&co, func, NULL, 4096); // returns 0
coru_resume(&co); // returns CORU_ERR_AGAIN, prints hi 0!
return 0;
}
すなわち,下記の 2 軸の機能が coru
のほぼ全てである:
-
coru_resume
(関数main
側) とcoru_yield
(関数func
側)の相互連携- 交互に制御を渡し合うことができる
- 連携し合う 2 つの関数を紐付ける
coru_t
型(構造体)-
coru_create
/coru_create_inplace
でアクティベート -
coru_destroy
でディアクティベート
-
coru_*
の関数シグネチャや細かい仕様は coru.c
/ coru.h
に全て記載されており,プラットフォーム依存となる部分は全て coru_platform.c
/ coru_platform.h
に払い出されているのが特徴である.
コードベースは下記の 5 つのファイル群で尽きている(括弧内は依存関係を示す):
coru.c
(→coru.h
)coru.h
(→coru_util.h
)coru_util.h
coru_platform.c
(→coru.h
, →coru_platform.h
)coru_platform.h
(→coru_platform.h
, → coru.h`)
yield
と resume
を用いた並列プログラミング
応用: 例えば,組み込みデバイスでは SPI 経由でフラッシュ領域を読み書きするために DMA コントローラ等が独立して動くことがある.
このようなケースでは,SW / HW 境界に位置するドライバ層は
SW 起因の信号を HW に投げるや否や,即座にエラーコードを返却する
HW 側での処理が完了するとエラーコードとは別のフラグ変数(通常グローバル)を更新
という動きをするのが理想的である.つまり,MCU の最低レイヤはノンブロッキングになっていると考えて差し支えない.
したがって,yield
と resume
のペアを上手く使えば最低レイヤのノンブロッキングな制御構造を(その気になれば)最上レイヤのアプリケーションレイヤまでチェインさせられる.ただ,下から上まで全部ノンブロッキングだと綺麗に書きたいコードも綺麗にならないし,実現したいスケジューリングポリシーとかち合うケースもある.でも,その場合は(例えば)ライブラリのレイヤまでに限定してしまって,そこから上はシーケンシャルな書き方を貫くということも全然できる.
ちょうど https://github.com/geky/coru で紹介されている spif_read
というファイルシステムの実装例はこうしたアイデアの実現になっている.
主な異常系
ユースケースの限界キワキワもしくは例外的な挙動を観察しておくのは重要である.
coru_resume(...)
による念押し
関数 return 後の 覚えておいた方が良いのは
-
coru_resume
および(対応する)coru_yield
はcoru_t
型への参照がある限り何らかの戻り値を返す
という仕様.まずは正常系の話から見ていく.
正常系で期待されること
例えば,下記のような関数があるとする:
int func(void *) {
// entry_1
coru_yield(); // exit_1 (entry_2)
coru_yield(); // exit_2 (entry_3)
coru_yield(); // exit_3 (entry_4)
return // exit_4
}
このとき,関数 main
内部での coru_resume(&co)
で期待される挙動は,概ね
- 1 回目の
coru_resume
:entry_1
からfunc
に入りexit_1
からfunc
を出る - 2 回目の
coru_resume
:entry_2
からfunc
に入りexit_2
からfunc
を出る - 3 回目の
coru_resume
:entry_3
からfunc
に入りexit_3
からfunc
を出る
である.ここで,下記を仮定した:
-
co
はfunc
と紐付く(初期化済みの)cotu_t
型である -
exit_X
(X
=1
,2
,3
) はコメント文のシグネチャと対応する
異常系で期待されること
それでは,4
回目ないしは 5
回目以降はどうなるのかというと
-
coru_resume(&co)
の戻り値が0
になる
という格好で特に fault
などは引き起こさない.
逆に 1
回目から 3
回目では 0
以外のマシン依存値が返されるので,0
かどうかで区別ができる.
4
回目の coru_resume
と 5
回目以降の coru_resume
はそれぞれ
-
4
回目のcoru_resume
:exit_4
からfunc
を出る(return
) -
5
回目以降のcoru_resume
:func
からのreturn
なのでよく分からない
という違いがあるが,いずれの場合も戻り値は 0
であり,区別には手間を要する.
例えば coru_resume(&co)
が 0
を返した段階で coru_destroy(&co)
(後述)を呼ぶという方法もあるが,それが可能かどうかはユースケースに依存するだろう.
検証手順
- 検証用ソースコード
main.c~
(後述)をcoru
のトップディレクトリに配置 -
make
で静的ライブラリcoru.a
を生成 -
main.c~
をコンパイルしつつcoru.a
とリンク - 生成物を実行
検証手順・コマンド例
# コンテナ内の /tmp/coru/ に居る想定
# main.c を作成・編集
make
mv main.c~ main.c; mv coru.a libcoru.a
gcc -o main main.c -L. -lcoru
mv libcoru.a coru.a; mv main.c main.c~
./main
# => func: 1
# => -11
# => func: 2
# => -11
# => func: 3
# => -11
# => func: 4
# => 0
# => 0
main.c~
の中身
検証手順・makefile
からの検索避けのためファイル名を main.c~
としておく.
// main.c~
#include <stdio.h>
#include "coru.h"
void func(void *) {
printf("func: 1\n");
coru_yield();
printf("func: 2\n");
coru_yield();
printf("func: 3\n");
coru_yield();
printf("func: 4\n");
return;
}
int main(void) {
coru_t co;
coru_create(&co, func, NULL, 4096);
int err_code;
err_code = coru_resume(&co);
printf("%d\n", err_code);
err_code = coru_resume(&co);
printf("%d\n", err_code);
err_code = coru_resume(&co);
printf("%d\n", err_code);
err_code = coru_resume(&co);
printf("%d\n", err_code);
err_code = coru_resume(&co);
printf("%d\n", err_code);
return 0;
}
coru_t
型への参照を外した後の coru_resume(...)
ここまでのコード例から,coru_resume
は
- 後段の
coru_yield
への関数継続をトライする
ということが分かっている.
では,関数継続ができそうにも関わらず関数 main
側で対応する coru_t
型への参照を外してから coru_resume(...)
を実行するとどうなるか?
coru_t
型への参照を外す方法は幾つかあるが,標準で coru_destroy
という API 関数が提供されているので,これを使うことを前提にする.
結論から述べると,coru_resume(...)
自体は問題なく呼べる.しかし,対応する関数内部における coru_yield();
直前まで処理が継続された後に segmentation fault が呼ばれる.
つまり,coru_yield()
が coru_t
型を参照しており,coru_resume(&co);
自体は coru_t
型へのポインタを引数に持つだけで即座に segmentation fault を投げたりはしないのである.また,segmentation fault が投げられるのは件の coru_resume
が return する前である.
これを見るには,実際にサンプルコードを実行するのが早い.
検証手順
- 検証用ソースコード
main.c~
(後述)をcoru
のトップディレクトリに配置 -
make
で静的ライブラリcoru.a
を生成 -
main.c~
をコンパイルしつつcoru.a
とリンク - 生成物を実行
検証手順・コマンド例
# コンテナ内の /tmp/coru/ に居る想定
# main.c を作成・編集
make
mv main.c~ main.c; mv coru.a libcoru.a
gcc -o main main.c -L. -lcoru
mv libcoru.a coru.a; mv main.c main.c~
./main
# => func: 1
# => -11
# => func: 2
# => -11
# => func: 3
# => Segmentation fault
main.c~
の中身
検証手順・makefile
からの検索避けのためファイル名を main.c~
としておく.
// main.c~
#include <stdio.h>
#include "coru.h"
void func(void *) {
printf("func: 1\n");
coru_yield();
printf("func: 2\n");
coru_yield();
printf("func: 3\n");
coru_yield();
printf("func: 4\n");
return;
}
int main(void) {
coru_t co;
coru_create(&co, func, NULL, 4096);
int err_code;
err_code = coru_resume(&co);
printf("%d\n", err_code);
err_code = coru_resume(&co);
printf("%d\n", err_code);
coru_destroy(&co);
err_code = coru_resume(&co);
printf("%d\n", err_code);
err_code = coru_resume(&co);
printf("%d\n", err_code);
err_code = coru_resume(&co);
printf("%d\n", err_code);
return 0;
}
まとめ
ベアメタル組み込み C 環境で動作するコルーチンライブラリ coru
を使えば,main
とサブルーチン関数の間を行ったり来たりする制御構造を実現できる.
これを使えば,ノンブロッキングでイベント駆動なデバイスの挙動を SW スタックの任意のレイヤで吸収し,ブロッキングな制御構造に閉じ込めつつ本来やりたい処理はきっちり並列で処理させるといったことも可能だ.
こうしたコンポーネントは,RTOS の自作や OS や HW よりの開発に関わる人以外にはビビッと来る代物ではないかも知れないが,軽量であるお陰で読もうという気になれるのが素晴らしい.
別言語で実装し直したり,新規アーキテクチャ向けに改良したり,設計に着想を得て既存の RTOS に組み込むといった取り組みは面白いかも知れない.
(もっとも,作者の Haster 氏は Rust 版を準備中とのこと)
Discussion