🐱

C言語向けコルーチン非同期 I/O ライブラリ neco を使ってみた。

2024/04/11に公開

はじめに

C言語でコルーチンを扱う方法は色々ありますが、専用の命令を専用の記述方法で実装しなければならなかったりなど、あまりとっつきやすいものではありませんでした。

今日 X/Twitter のタイムラインで見付けた neco はまさにそんな悩みを解消できる物でした。

https://github.com/tidwall/neco

neco とは

neco はコルーチンを使った非同期 I/O ライブラリです。

  • コルーチン: 開始、スリープ、一時停止、再開、移譲、および結合。
  • 同期: チャネル、ジェネレータ、ミューテックス、条件変数、および待機グループ。
  • デッドラインとキャンセルのサポート。
  • ファイルディスクリプタを使った Posix フレンドリーなインターフェース。
  • ネットワーク、シグナル、ランダムデータ、ストリーム、およびバッファ付き I/O の追加 API。
  • 公正かつ決定論的なスケジューラを備えた軽量ランタイム。
  • 高速なユーザースペースのコンテキスト切り替え。ほぼアセンブリを使用。
  • スケジューラによって完全にライフタイムが管理される、ネスト可能なスタックコルーチン。
  • クロスプラットフォーム。Linux、Mac、FreeBSD。(一部の制限付きで、WebAssembly および Windows も)
  • 単一ファイル。依存関係なし。
  • サニタイザと Valgrind を使用した 100% カバレッジのテストスイート。

※ README から抜粋

この neco の何が凄いのかというと、その呼び出し API です。サンプルにある echo サーバのソースを見てみましょう。

#include <stdlib.h>
#include <unistd.h>
#include "../neco.h"

void client(int argc, void *argv[]) {
    int conn = *(int*)argv[0];
    printf("client connected\n");
    char buf[64];
    while (1) {
        ssize_t n = neco_read(conn, buf, sizeof(buf));
        if (n <= 0) {
            break;
        }
        printf("%.*s", (int)n, buf);
    }
    printf("client disconnected\n");
    close(conn);
}

int neco_main(int argc, char *argv[]) {
    int ln = neco_serve("tcp", "localhost:19203");
    if (ln == -1) {
        perror("neco_serve");
        exit(1);
    }
    printf("listening at localhost:19203\n");
    while (1) {
        int conn = neco_accept(ln, 0, 0);
        if (conn > 0) {
            neco_start(client, 1, &conn);
        }
    }
    close(ln);
    return 0;
}

これのどこがコルーチンなんですか?

と聞きたくなるくらい、普通の echo サーバのソースです。まず neco_main は neco のスケジューラにより起動されます。そして neco_acceptneco_read といった待ちが発生するシステムコールは、スケジューラにより待ち時間に他の処理がスケジュール実行されます。neco_start により client はスケジュールに追加され、以降は内部で呼び出される neco API の呼び出し時にスイッチされます。
例えば2つの接続を行った場合、片方の接続で neco_read が呼び出されている間、もう片方のコルーチンでは printf 等の処理を継続できる事になります。

まるで Go 言語の様ですね。neco にはソケット通信だけでなく Go の channel の様な物も用意されています。

#include <stdlib.h>
#include <assert.h>
#include <unistd.h>
#include "../neco.h"

void sum(int argc, void *argv[]) {
    int *s = argv[0];
    int n = *(int*)argv[1];
    neco_chan *c = argv[2];

    int sum = 0;
    for (int i = 0; i < n; i++) {
        sum += s[i];
    }

    neco_chan_send(c, &sum);
    neco_chan_release(c);
}

int neco_main(int argc, char *argv[]) {
    int s[] = {7, 2, 8, -9, 4, 0};
    int n = sizeof(s)/sizeof(int);

    neco_chan *c;
    neco_chan_make(&c, sizeof(int), 0);
    
    neco_chan_retain(c);
    neco_start(sum, 3, &s[0], &(int){n/2}, c);

    neco_chan_retain(c);
    neco_start(sum, 3, &s[n/2], &(int){n/2}, c);

    int x, y;
    neco_chan_recv(c, &x);
    neco_chan_recv(c, &y);
    
    printf("%d %d %d\n", x, y, x+y);
    neco_chan_release(c);
    return 0;
}

このプログラムは、変数 s に格納されている前半の合計と後半の合計をそれぞれのコルーチンで算出し、その結果を neco_chan_make で作成した channel を経由して x と y に受け取っています。(前半 17、後半 -5、結果 12)

まるで Go の goroutine の様ですね。その他にも Go の signal や select とほぼ同等の機能をC言語から使えるのです。素晴らしいですね。

例えばC言語で、プログラミング言語を実装し、そのプログラミング言語からソケットを扱えるとします。その処理にて read/write の変わりに neco_read/neco_write、pthread_create の変わりに neco_start を使えばそれだけでスレッド型の並列処理からコルーチンを使った並行処理の実装に入れ替えられるのです。

HTTP サーバを作ってみた

どれくらいのパフォーマンスが出るか気になりますよね。という事で HTTP サーバを実装してみました。

https://github.com/mattn/neco-http-server

カレントティレクトリにあるファイルをサーブできる(GETのみサポートした) 簡易の HTTP サーバです。
比較対象としては libuv を使った HTTP サーバです。

https://github.com/mattn/http-server

こちらのサーバもファイルをサーブできる HTTP サーバですが、libuv 特有のコールバック登録地獄になっています。それに比べて neco-http-server の方はとてもシンプルです。
では計測してみます。

$ ab -k -c 100 -n 100000 http://localhost:8888/
libuv: 71704.53 req/sec
neco:  64861.44 req/sec

neco の方が少し遅いですが、これは open(2) による遅延と思っています。libuv は uv_fs_open を使って open(2) までも非同期にする事で高度な非同期処理を実現できていると言って良いでしょう。一方で neco も負けてはおらず、これだけ POSIX 流儀を崩さずに非同期が実現できている時点で素晴らしいと感じます。

おわりに

C言語から扱えるコルーチンおよび非同期 I/O ライブラリ neco を紹介しました。手軽に使えてそれでいて POSIX API ととても親和性の高いので、おそらくこれから何度か使っていく事になると思います。皆さんもぜひ試してみて下さい。

Discussion