インターネットサーバでのPthreadとepoll(2)

jirou senju
2010/03/09 19:34
/community/blog/images/union/pg_high_logo.png

>>(1)よりつづく
前回は単純な実装からマルチスレッド、スレッドプールと順に見て行きました。今回はいよいよepollを使った実装を紹介します。

epoll例- 4epoll.c

多重I/Oすなわち select(2) / poll(2) によるイベントループはマルチスレッドが普及する以前から利用されていました。 select(2) / poll(2) は複数のファイルディスクリプタ(ソケット)を調べ、I/O可能なものを返すシステムコールです。ソケットに対する読み取りはデフォルトではデータがなければブロック(データが到着するまで待つ)しますが、事前にI/O可能かを確認しておけばブロックすることはありません。1システムコールで複数のソケットを調べられる点も重要で、1プロセスで複数のクライアントに並行して対応できるようになります。しかし当然ながら、対象ソケット数の増加に応じて処理量が増えます。システムコールの実行時間がかかるだけではなく、ユーザ空間でもその結果を1ソケットづつ確認しながら処理を進めるため、性能劣化が問題となり、以前からこの問題が指摘されていました。Linuxでは poll(2) を拡張したepollインタフェース( epol_create(2) , epoll_ctl(2) , epoll_wait(2) )を実装しており、ファイルディスクリプタ数が多い場合の性能劣化を防いでいます。

前掲のもっとも単純なサンプル1base.cを、epollを用いて多重I/O化してみます。

4epoll.c要約

do_accept(int epfd)
{
    sock = accept(); /* 接続受付*/
    /* ソケットが読み取り可能になるかを見張るための準備*/
    ev.events = EPOLLIN | EPOLLET;
    ev.data.fd = sock;
    epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev;);
}

do_read(int sock, int epfd)
{
    read(); /* HTTPリクエスト読み取り*/
    /* 次はソケットが書込み可能になるかを見張るための準備*/
    ev.events = EPOLLOUT | EPOLLET;
    ev.data.fd = sock;
    epoll_ctl(epfd, EPOLL_CTL_MOD, sock, &ev;);
}

do_close(int sock, int epfd)
{
    /* このソケットはもうモニタ対象外*/
    epoll_ctl(epfd, EPOLL_CTL_DEL, sock, NULL);
    close(); /* 接続切断*/
}

/* sendfile and close */
do_sfc(int sock, int epfd)
{
    sendfile(); /* 静的ファイル送信*/
    do_close(sock, epfd);
}

main()
{
    /* リスニングポート作成... */
    lsock = socket();
    bind(lsock);
    listen(lsock);
    /* リスニングポートへのデータ到着を見張る*/
    epfd = epoll_create(1);
    ev.events = EPOLLIN;
    ev.data.fd = lsock;
    epoll_ctl(epfd, EPOLL_CTL_ADD, lsock, &ev;);
    while (1) {
        struct epoll_event e[];
        /* I/O可能なソケットを処理する*/
        nfd = epoll_wait(epfd, e);
        for (i = 0; i < nfd; i++) {
            sock = e[i].data.fd;
            if (sock == lsock)
                do_accept(sock, epfd);
            else if (e[i].events & EPOLLIN)
                do_read(sock, epfd);
            else if (e[i].events & EPOLLOUT)
                do_sfc(sock, epfd);
        }
    }
}

epollを用いたイベントループは構造的には従来の select(2) / poll(2) によるものと変わりません。このサンプルでは単純なHTTP GETメソッドのみに対応しているので、ソケットからの読み取りが一度しかなく、状態遷移も単純です。SMTPなどより複雑なプロトコルの場合はもっと複雑な状態遷移に対応する必要があるでしょう。

epollにはファイルディスクリプタ数が増加した時の性能劣化を防ぐ以外に、レベルトリガとエッジトリガという機能もあります。もともとは信号の変化を通知する仕組みを指す用語で、レベルトリガが信号レベルがある基準より上か下かを基に通知を判断するのに対し、エッジトリガは信号レベルが変化した時点で通知します。ソケットを読み取るプログラムでは、読み取るデータが存在している間はずっと「読み取り可能」と通知してくれるのがレベルトリガで、データが到着した時にのみ通知してくれるのがエッジトリガです。上記のサンプルコード要約では、 listen(2) しているソケットにはレベルトリガを、 accept(2) したソケットにはエッジトリガを用いています。また、上記のサンプルコード要約には含めませんでしたが、ノンブロッキングI/Oも用いています。詳細は付属のサンプルコードを参照してください。

このサンプルは単なる多重I/Oの例にしか過ぎませんが、マルチスレッド化せずとも複数のセッションを並行して処理可能だということを示しています。多数の接続でもプロセスがオープン可能なファイル数までは対応できます。しかし、シングルプロセスのためマルチプロセッサのパワーを活かせていません。また、まともにソケットI/Oをしてくれないおかしなクライアントが居ると、そこでプロセスがブロックしてしまい、他のクライアントの処理が遅れてしまう恐れもあります。この遅れに対しては、 epoll_wait(2) にタイムアウトを設定することで影響を抑えられますが、本来はそのクライアントだけを強制切断するなどの処理が必要です。

5epoll-multi.c

次にマルチプロセッサを活用するため、4epoll.cを(スレッドではなく)マルチプロセス化してみます。

5epoll-multi.c要約

/*
* do_read(), do_close(), do_sfc() は4epoll.cと同じもの
*/
do_sock(int pfd, int epfd)
{
    /* 親プロセスがacceptしたソケットを受け取る*/
    sock = recv_sock(pfd);
    /* ソケットが読み取り可能になるかを見張るための準備*/
    ev.events = EPOLLIN | EPOLLET;
    ev.data.fd = sock;
    epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev;);
}

void child(int pfd)
{
    /* 親プロセスからのデータ到着を見張る*/
    epfd = epoll_create(1);
    ev.events = EPOLLIN;
    ev.data.fd = pfd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, pfd, &ev;);
    while (1) {
        struct epoll_event e[];
        /* I/O可能なソケットを処理する*/
        nfd = epoll_wait(epfd, e);
        for (i = 0; i < nfd; i++) {
            sock = e[i].data.fd;
            if (sock == pfd)
                do_sock(sock, epfd);
            else if (e[i].events & EPOLLIN)
                do_read(sock, epfd);
            else if (e[i].events & EPOLLOUT)
                do_sfc(sock, epfd);
        }
    }
}

int main(int argc, char *argv[])
{
    for (i = 0; i < nchild; i++) {
        int fds[2];
        /* 子プロセスへの連絡用ソケット作成*/
        socketpair(AF_LOCAL, SOCK_DGRAM, 0, fds);
        fd[i] = fds[0];
        /* 子プロセス作成*/
        pid = fork();
        if (!pid) {
            /* child */
            for (j = 0; j < i; j++)
                close(fd[j]);
            close(fds[0]);
            child(fds[1]);
        }
        close(fds[1]);
    }
    /* リスニングポート作成... */
    n = 0;
    while (1) {
        sock = accept(); /* 接続受付*/
        /* acceptしたソケットを子プロセスへ渡す*/
        err = send_sock(fd[n++], sock);
        n %= nchild;
        /*
        * 親プロセスではソケットを使用しない
        * 接続切断ではない
        */
        close(sock);
    }
}

なぜスレッドではなく、(より重いと従来言われていた)プロセスを作成するのでしょうか。主な理由はPthread固有のプログラミングの難しさです。マルチプロセスプログラミングが易しいとまでは言えませんが、プロセス空間/資源を(論理的に)共有しないため、スレッド作成後の fork(2) などに悩まされませんし不測の競合も防げるでしょう。また、前述の通りプロセスあたりのファイルディスクリプタの上限も関係します。スレッドはプロセス内の資源を共有するため、スレッド数を多くしてもファイルディスクリプタの上限にあたる恐れがありますが、プロセスを分けることでこの問題を緩和できます。しかし、上限は変更することもできますし、またプロセスあたりの上限がシステムワイドの上限に置き換わっただけで、本質的には変らないとも言えます。

マルチプロセスにすると、ファイルディスクリプタはプロセス固有であるため、 fork(2) 後に作成したファイルディスクリプタをそのまま他のプロセスへ渡すことはできません。例えば、 fork(2) 後に accept(2) したソケットが、値が10のファイルディスクリプタとすると、10という値が意味を持つのは accept(2) したプロセスに限られ、他のプロセスで10をファイルディスクリプタ/ソケットとしては使用できません。このため、5epoll-multi.cでは特殊な方法でファイルディスクリプタを fork(2) 済みの子プロセスへ渡しています。受け取った子プロセスでは、ファイルディスクリプタの値は変化するかも知れませんが、有効なファイルディスクリプタとして使用できます。上記のサンプルコード要約では割愛しましたが、親子プロセス間に socketpair(2) による通信路を用意しておき、 struct cmsghdr を用いた sendmsg(2) / recvmsg(2) により、 accpet(2) したソケットを渡しています。 socketpair(2) は、 pipe(2) に似ていますが、双方向の接続済みソケットを作成するシステムコールです。 pipe(2) が片方向の通信に対し、 socketpair(2) の双方向性がよく取り上げられますが、ここでは双方向性は重要ではありません。プロセス間でファイルディスクリプタを渡すことに意味があります。詳細はサンプルコードを参照してください。

3pthread-pool.cのBoss-Workerモデルと比較すると良く似ていますが、 accept(2) のそのソケットを渡すための通信路が大きく違います。特に通信路が子プロセスそれぞれに存在する点は、一般的なBoss-Workerモデルとも異なる点です。Boss-Workerモデルらしいのは5epoll-multi.cでは accept(2) するのは親プロセスだけで、子プロセスへは accept(2) したソケットを渡す点でしょうか。3pthread-pool.cでは、 listen(2) のキューに基づきスレッドで accept(2) し、スケジューリングも効率良く動作していましたが、5epoll-multi.cでそうしていないのは何故でしょうか。それは accept(2) 前にepollを使用しているためです。

3pthread-pool.cの説明で、複数のタスクが同じソケットに対し accept(2) を発行しブロックしていても、接続要求の到着時に動き出すタスクは1つだけである点を強調しました。5epoll-multi.cでは子プロセスは accept(2) 前に epoll_wait(2) でブロックしています。仮にここでリスニングポートも epoll_wait(2) の対象とし、子プロセスで accept(2) する構造を採ると、ブロックする箇所が epoll_wait(2)accept(2) の2箇所になります。 epoll_wait(2) では、 accept(2) と異なり、ブロックしている全タスクへデータの到着を通知します。複数のタスクが同じソケットを epoll_wait(2) で見張り、そのソケットにデータが到着すると、全タスクが動き出すのです。その後、1タスクのみが accept(2) に成功し、他のタスクは accept(2) で再びブロックすることになります。この「全員起床」はスケジューリング的にデメリットが大きく、また子プロセスのイベントループが accept(2) でブロックしてしまって、他のクライアントの処理が遅れてしまうことになります。このスケジューリング上のデメリットと無駄なブロックを防ぐために、5epoll-multi.cでは親プロセスのみで accept(2) しています。

サンプルプログラムの拡張

エラー処理や異常なクライアントへの対応は必須ですが、それ以外の構造的な点としては、AIO(Asynchronous I/O)やLinuxで新たに実装されたsignalfd、timerfdなどの利用があります。 signalfd(2) は従来のシグナルハンドラ関数コールにより行われていたシグナル通知を、ファイルディスクリプタ経由で行うものです。シグナルハンドラは非同期にコールされ、プログラム内のグローバル変数の参照/変更などには注意が必要です。マルチスレッドプログラミングの注意点と同じ話になりますが、シグナルハンドラ内でのシステムコール発行はグローバル変数 errno を変更するため、バグの原因になりやすいものです。例えばユーザ空間に次のようなコードがあったとします。

do {
    ssz = read();
} while (ssz == -1 && errno == EINTR);

このようなコードはユーザが作成したプログラムかもしれませんし、stdioなどの使用ライブラリ内にあるかもしれません。ここで、 read(2) 完了から errno 参照までの短い間にもシグナルが発生し、シグナルハンドラがコールされるかも知れません。シグナルハンドラ内でシステムコールを発行し結果的に errno が変更されると、上記のようなコードが正しく動作できなくなってしまいます。ファイルディスクリプタ経由のシグナル通知ではこの種の問題を回避できるメリットがあります。すでにお気づきでしょうが、ファイルディスクリプタなのでepollなどのイベントループで処理できるのです。timerfdもsignalfdに似ていますが、こちらは設定したタイマの時間経過が読取れるファイルディスクリプタです。やはりepollなどで使用するのに適しています。

サンプルプログラムではもっとも単純なHTTP GETメソッドにしか対応しておらず、また静的ファイルを sendfile(2) で送信するというだけの実装ですが、実際にはもっと複雑な処理になるでしょう。ここでAIO(Asynchronous I/O)の利用も考えられます。AIOはPthread同様にPOSIXで規格化されたもので、ファイルI/Oを発行してもプロセスがブロックせず、I/Oが完了した時点で通知するというものです。例えばサイズが大きなファイルを読み取る際に aio_read(3) を使用すると、遅いディスクを待たずに別の処理(他のクライアントからの要求など)を進められます。ファイル読み取りが完了すると、AIOがプロセスへシグナルが送られるか、または指定した関数をスレッドとして起動します。epollを使用したサンプルでは前述のsignalfdを活用できる場面かも知れません(筆者はまだトライしていません)。ただ、AIOの規格は定まっていてもLinuxでの実装にはやや混乱が見られます。Linuxカーネルは io_submit(2) というシステムコールとaioというカーネルスレッド(カーネルが作成し、カーネル空間で動作するもの)を実装しましたが、GNU libcではこれを使用しておらず、aioカーネルスレッドに代わるスレッドをライブラリ内で作成しており、効率が良いとは言えない実装となっています。ではLinuxの io_submit(2) に対応したライブラリは開発されていないのだろうかと調べると、libaioというライブラリがありました。しかし、こちらはPOSIXのAIOとは異なるインタフェースになっており、そのまま使用するには躊躇してしまいます。

おわりに

別プロセスへファイルディスクリプタを送信する機能は以前から実装されていましたし、 select(2) によるイベントループ、pthreadによる並列処理も以前から使用されているものですが、複数プロセスで epoll(2) を用いたイベントループの実装は寡聞にして知りません。ファイルディスクリプタの送受信処理がどれほど重い(または軽い)のかは測定しておらず、本来ならば、この記事でサンプルプログラムの性能測定とその比較まで行うところですが、筆者はマルチプロセッサ(マルチコア)マシンを持っていないというプアな環境のため、またサンプルプログラムが単純過ぎるため、有意な差を認められませんでした。唯一有意な比較はCPU使用率で、無制限にスレッドを作成する2pthread-unlimited.cが予想通りもっともCPUを消費したというだけです。マルチプロセッサマシンがあれば是非、再度測定/比較したいところです。

Copyright © 2009 SENJU, Jiro

本稿で取り上げた内容は、記事の執筆時(2009年6月)の情報に基づいており、その後の実装の進展、変更等により、現在の情報とは異なる場合があります。
本連載「プログラマーズ・ハイ」では読者からのご意見、ご感想を切望しています。今後取り上げるテーマなどについてもご意見をお寄せください。

Bookfair

O'Reilly Japanのセレクションフェア、全国の書店さんで開催
[ブックフェアのページへ]

Feedback

皆さんのご意見をお聞かせください。ご購入いただいた書籍やオライリー・ジャパンへのご感想やご意見、ご提案などをお聞かせください。より良い書籍づくりやサービス改良のための参考にさせていただきます。
[feedbackページへ]