EchoサーバーをI/O Multiplexingに育てる

グリーンスレッドの実装を考えていたらいつの間にかタイトルのようなことをやっていた。 実装した全部載せはリポジトリに置いてあるので、何を考えたかを吐き出す。

github.com

simple echo

echo サーバー( "hello" と受け取って "hello" と返す)のように、 クライアントからの読み込みと書き込み間でコンテキストが発生する通信のサーバーの実装を考える。 シンプルに単一な accept ループを使う場合、一つのクライアントとの読み書きを終えるまで次のクライアントとは何もできない。 read/write の処理ではブロックするし、同期的に全ての読み書きが処理される。 なので処理中に現れる新規のクライアントは listen で指定された backlog に積まれ、次の accept が来るのを待つことになる。

  • simple_echo.c
int main(int argc, char** argv) {
        soc = socket(AF_INET, SOCK_STREAM, 0) == -1;
        bind(soc, (struct sockaddr *)&saddr, saddrlen);
        listen(soc, CONNECTION);

        while(1) {
                acc = accept(soc, (struct sockaddr *)&caddr, &caddrlen);

                do {
                        read(acc, buf, sizeof(buf));
                        write(acc, buf, strlen(buf));
                } while (strcmp(buf, "Bye!!") != 0);

                read(acc, buf, sizeof(buf));

                close(acc);
        }

        close(soc);
        return 0;
}

asynchronous echo

クライアントは同時に複数捌けたほうが効率は良いので、 まずは同期的な処理を改善する。 accept は単体であれば非同期に呼び出しても問題はなく、読み書きも fd 単位で独立していれば非同期にできる。 非同期処理には thread(または process )を用いる。 この方法にも色々種類はあって、例えば accept 毎に thread(process)を作成する方法や、 予め thread(process)を作成しておいてそれぞれで accept ループを実行する方法がある。

thread か process かによっても違いは生じる。 thread は process に比べて軽量であるが、ヒープやファイルディスクリプタを共有しているため、 適切にロックを入れることによって実行順序やデッドロックを考慮する必要がある。 逆に process はまったく別の(実行単位としての)プロセスとしてプログラムが実行されるため、 上記のように注意する点は少ないが、例えば、fork を呼び出すまでに使用していた fd は fork 後に再度有効になるなど、多少は考慮する点がある。

実装は thread を accept 毎に立ち上げるもの、process を accept 毎に立ち上げるもの、 予め固定数の thread を立ち上げておくもの、予め固定数の process を立ち上げておくものを実装した(つまり全部)。 一応コネクション数には上限を設けている。

I/O Multiplexing echo

非同期的に処理を行うことによって複数のクライアントを同時に扱うことができるようになったが、 各 thread(process)別に見てみると、単一なクライアントとの処理を全て終えるまで他のクライアントに関する処理は行っていない。 加えて、read/write 処理中は fd が無効であれば待ちが発生するためブロックされている。 これを epoll を使用して改善を行う。

I/O Multiplexing な状態であるとは、同時に複数の fd に対して状態監視を行うことで、単一の fd の状態によって全体がブロックされるのを防いでいることである。 これを複数 thread(process)な状態で適用する方法は色々あるが、以下の方針を取ることにした。

  • 対象とするプログラムは pre_thread_echopre_fork_echo
  • accept ループは本体行う
  • 各 thread(process)は epoll_wait(2) で受け取った fd に対して一度きりの I/O 操作を行う

これによってどういう状態になるかというと、 各 thread(process)は複数のクライアントと同時にやり取りを行うようになる。 thread(process)別に見るとクライアントとのやり取りは同期的であるが、 read/write による個別のブロッキングを避けるため、待ちの状態が発生しなくなり、効率的に読み書きを行うことが可能になる。

捕捉

epoll は今回 EPOLLONESHOT を適用して epoll_wait で一度受け取った fd は無効化するようにしていたが、 accept と異なってこの処理は複数 thread(process)間で同期的に行う必要があった*1。 ここが今回のハマりポイントで、気がつくまで結構時間を取られた。 epoll_pre_thread_echo.c での実装では、結局 epoll_wait 時には lock を入れなければならなかった。 lock フリーな実装を行う方法として、read/write は結局一つの thread しか成功しないため、 ノンブロッキングな状態で read/write 実行し返り値を見る方法を考えたが、 例えば read する対象のサイズが大きいため realloc が必要になる場合、 複数回の read を発行する必要があり、別 thread での read 防ぐために lock が必要になるなど、本末転倒。 よって実装では epoll_wait に lock を入れている。

epoll_pre_fork_echo.c の実装は不可解な処理をしているが、それは複数 process 間で fd の受け渡しを行うために行っている。 メインプロセス中の accept の返り値でクライアントとの有効な fd を受け取るが、もちろん子プロセスでは有効なはずはない。 そのため、メインプロセスと子プロセス間で sendmsg/recvmsg を使ったやり取りを行った fd を受け渡す。 この辺は全然理解せずに使用したので、いつかまた仕組みを詳しく見たい。

ちなみに、epoll を用いたこれらの実装では、クライアントと echo なんてしていなくて、実際は固定の文字列を返しているだけである。 というのも、I/O Multiplexing な状態で echo のコンテキストスイッチが結構面倒で、 やればできるし実装はいいやということで投げている(epoll_event 構造体メンバのdata.ptrにポインタを入れるだけなので楽といえば楽)。

performance

今回は特に計測を目的としていないが、一応どれほど違いがあるのかについて計測は行った。 結果だけ述べると epoll_pre_fork_echo.c が最も速く、次点で epoll_pre_thread_echo.c という結果だった。 epoll を用いて I/O Multiplexing を行っている実装が高速なのは納得であるが、 実は epoll_pre_fork_echo.cepoll_pre_thread_echoc.c にも明確に差が出ている。 これは上記の lock の問題が効いているのかなぁと思う。真面目に実装したら変わるのだろうか。

ちなみに計測は golang の適当なスクリプトで行った。 https://github.com/Everysick/io_multiplexing_echo_server/blob/master/client.go

reference