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

jirou senju
2010/03/09 18:21
/community/blog/images/union/pg_high_logo.png

本連載ではシステムコールプログラミングの例も掲載していく予定ですが、本記事ではLinuxに追加されたepollを採りあげ、インターネットサーバでのPthread利用と比較してみます。

はじめに

マルチスレッドプログラミングが普及し、POSIX threadも制定され、Pthreadの利用は目新しいものではなくなりましたが、スレッドにまつわる迷信や誤った認識を、だいぶ減ったとはいえ、今でもたびたび耳にします。例として、

  • スレッドはプロセスよりも軽いので、多数作成しても軽快に動作する
  • スレッドはプログラミングを簡単にしてくれ、1つの処理だけに集中できる

などがあります。しかし、これらは常に真であるとは限りません。本記事ではマルチスレッドの概念や入門を繰り返すのではなく、その利用方法をHTTPサーバのサンプル実装を基に考察します。更にLinuxに追加された独自機能のepollインタフェースを用いて、マルチスレッドを見直すサンプルを実装してみます。

スケジューリング

数百、数千ものスレッドは作成可能でしょうか? また軽快に動作するのでしょうか? 単に作成可能か否かを言えば、環境により作成可能です。しかし軽快とは言い難いでしょう。

ご存知のようにカーネルは1つのCPU上で複数のタスク(ここではプロセス、スレッドを総称してタスクと呼びます)を並列して実行するため、ある決められたルールで順次タスクをスケジューリングします。このルールを スケジューリングポリシー と言い、複数用意されている中から用途に応じて選択することも可能です。どんなスケジューリングポリシーを選択しようとも、次にCPUが与えられ、自分が実行できる順番を待っているタスクの待ち行列から1つを選択し、CPUを与えるという基本的動作は変りません。システム内で多くのタスクが同時に実行されていると言うことは、スケジューリング待ち行列がそれだけ長くなり、順番が来るまでの待ち時間も長くなると言うことです。結果的にタスクの実行に時間がかかることになります。

スレッドもプロセス同様に1実行単位であり、何らかのスケジューリングが必要なことには違いありませんが、スレッドにはユーザスレッドとカーネルスレッドの2種類があり、スケジューリングに違いがあります。前掲の誤解/過信は、恐らくユーザスレッドとカーネルスレッドを混同することから生まれたものと思われます。システム内の実行単位をプロセスと呼ぶならば、ユーザスレッドはプロセス内の実行単位と言え、次にどのスレッドを実行するかをプロセスが決定するものを言います。すなわちカーネルがプロセスをスケジューリングし、プロセスがスレッドをスケジューリングします。この場合、スレッドを多数作成しても、カーネル内のスケジューリング待ち行列は長くなりません。ユーザスレッドがいつ実行されるかはプロセス内のスレッドスケジューリングポリシーに依存します。スレッドIDの管理もユーザ空間で行われるため、管理上の上限にあたるまでスレッドを多数作成できるでしょう。

/community/blog/images/epoll_fig01.png

それでは、プロセス内のスレッドスケジューリングとはどんな処理でしょうか? よほど独自の変ったポリシーを実装しない限りは、カーネルのプロセススケジューリングと同等であると言えるでしょう。また、実装の品質を考えると、多くの場合、カーネル内の実装の方が期待できるものでしょう。筆者が以前に読んだユーザスレッドのPthreadライブラリでは、スレッドの管理/スケジューリングにプロセス内でシグナルを使用していました。そのため多数のシグナル送信が発生し、シグナルハンドラのオーバヘッドの割合が高く、実行効率は期待できないものでした。パフォーマンス上もっとも大きな影響は、マルチプロセッサを活かせない(もしくは活かし難い)点でしょう。CPUはシステムスケジューリングによりプロセスに与えられるため、マルチプロセッサシステムでは他のCPU上でスレッドを並列して実行できません。メリットがゼロとまでは言いませんが、ユーザスレッドスケジューリングには、実行単位の選択という一度で済ませられるスケジューリングを二度に分けて実行し、また二度目のスケジューリングではアプリケーション(またはライブラリ)のバグが入る余地を持たせてしまうというデメリットがあります。

ユーザスレッドに対し、プロセスと同様にカーネルがスケジューリングするものをカーネルスレッドと言います。カーネルスレッドという用語はカーネルが自分のために内部で作成するスレッド(すなわちユーザプログラムではない)を意味する場合もありますが、ここではあくまでもユーザスレッドに対する意味で用いています。ユーザスレッドを「1:Nのユーザスレッド」と言うことがありますが、これは1つのシステムスケジューリング対象内に、N個の異なるスケジューリング対象が存在するという言い方です。

これに対してカーネルスレッドは「1:1のカーネルスレッド」と言い、システムがスケジューリングするものがすなわちスレッドになります。スレッドIDの管理はカーネル空間で行われ、作成数の上限も含め、プロセスIDと同様に管理されるでしょう。カーネルがスケジューリングするため、マルチプロセッサシステムでは、1プロセス内の複数のスレッドであっても、並列して実行可能です。 fork(2) は新規にプロセスを作成し、 pthread_create(3) はスレッドを作成しますが、LinuxのNPTL(Native POSIX Thread Library)ではどちらもclone(2)というシステムコールを実体としていて、パラメータを変更することで2つの動作に対応しています。もちろん、1プロセスで複数のカーネルスレッドを作成可能です。現在では、Linuxに限らず「1:1のカーネルスレッド」が主流でユーザスレッドは見かけることは無くなりました。もちろん教育目的で使用することはあるでしょう。

fork(2) によるプロセス作成は、プロセスアドレス空間複写のためカーネル内で大量のメモリコピーが発生し、プロセスアドレス空間を共有するスレッドの作成に比べるとずっと重い処理である」というのも、現代ではだいぶ軽減されています。メモリ管理ではコピーオンライトが実装されており、 fork(2) だけではメモリコピーが問題になることはあまりないでしょう。論理的に複数のプロセスがそれぞれのメモリ空間を持っていても、メモリ内容が同じである限りコピーする必要がないため、同じメモリページを参照するのがコピーオンライトです。メモリ内容を変更する時点で初めて新たにメモリページを用意し、コピーします。 fork(2) 時にプロセス空間すべてをコピーするわけではありません。

ここで冒頭で紹介したマルチスレッドに対する過信は、かつてはユーザスレッドにはあてはまる場合があったかも知れませんが、現代のカーネルスレッドにはあてはまらないことがわかります。多数のスレッドを作成すれば、多数のプロセスを作成した場合と同様に、そのままシステムのスケジューリングへの負荷となり、プロセスと比較してスレッドの方が軽快に動作するとは言い難いでしょう。

スレッドが重いと言いたいのではありません。プロセスはスレッドに比べ、言われるほど悪くないと言いたいのです。 fork(2)pthread_create(3) だけの所要時間を比較すれば、ほとんどの環境でやはり pthread_create(3) の方が高速に見えるでしょう。しかし現実にはそれほど頻繁に fork(2) / exit(2) / wait(2) 、または pthread_create(3) / pthread_exit(3) を繰り返す例は少なく、 fork(2) の時間が問題になることはあまりありません。 OpenMP などのスレッドベースのfork-joinモデルでは pthread_join(3)またはバリアなどによる待ち合わせを繰り返す場面が多く考えられますが、通常スレッドは終了せず、単に次の処理を待ち眠るだけです。さらに同期も含め共有変数アクセスを排除し、マルチスレッドの効率(スレッドの独立性)を追求するほど、プロセスアドレス空間を共有する意味は下がって行き、プロセスアドレス空間が独立したマルチプロセスの形態に近付くとも言えます。もちろんこれはマルチスレッドアプリケーションの処理内容、設計、実装に大きく依存する点です。

プログラミング難易度

インターネットサーバなど複数のクライアントからの要求に対応する場合、並列処理が重要となります。一般に並列処理はプログラミング難易度が高いと言われています。そのため、Pthreadを使用し、コードの見かけ上の並列性/難易度は下げるが、実行上の並列性は下げないアプローチが良いという意見があります。しかしマルチスレッドプログラミングには新たに考慮すべきことが多くあり、別の面の難易度が上がります。マルチスレッドプログラミングで考慮すべき点をすべて挙げると量が多くなるため、ここでは従来のシングルスレッドプログラムを最小の手間でマルチスレッド化する際にも必要になる注意点の一部を挙げるに留めます。

プロセスアドレス空間を共有することから、最初に注意すべき点はグロ-バル変数へのアクセスでしょう。スレッドAがグロ-バル変数Vを参照しているまさにその時、スレッドBがVの値を変更するかもしれません。スレッドAがVの値に応じて動作を変えるようになっていれば、期待とは異なる動作になるかもしれません。このような場合は一般に同期オブジェクトを用い、Vへのアクセスに排他制御を加えることで対応します。すなわちマルチスレッドによる並列動作がVへのアクセスだけは直列動作 [1] になります。これはオ-バヘッドという必要悪です。 アプリケ-ションが明示的にグロ-バル変数を使用していないとしても、リンクしているライブラリ内で使用されている場合があります。

[1]読み取りだけは並列可能にする排他制御もあります。

もっとも単純な例に fputs(3) などによるFILE(stdio)への出力があります。FILE(ディスク上のファイルとは限りません)はライブラリ内部でバッファリングされるため、複数のスレッドが同時に同じFILEへ出力をすると、内部バッファの整合性が維持できず正しく内容を出力できなくなります。このため、 flockfile(3) / funlockfile(3) によりFILE出力を保護する必要が出て来ます。この保護はマルチスレッド対応のもので、 flock(2) / lockf(3) の排他処理とは別種のものです。このようなマルチスレッドアプリケーションでそのままでは使用できないライブラリ関数を スレッドアンセーフ と言います。現実にはGNU libcなど現代のライブラリはすでにマルチスレッド対応が進んでおり、stdioもライブラリ関数単位ではスレッドセーフになっているため、プログラムの変更が不要な場合も多くあります。しかしそれでも、例えばstdioライブラリ関数を使って同じFILEに対して連続して出力するような場合は、やはり flockfile(3) を加えることが必須です。またその際にはライブラリ内部でのFILE出力保護処理を省いた fputs_unlocked(3) などの使用を考慮する必要もあるでしょう。

また errno というグローバル変数などにも注意が必要です。 errno はシステムコールの度に値が変更され、そのシステムコールのエラー原因を示すプログラムのグローバル変数です。例えば read(2)write(2) がエラーを返し、 errnoEAGAIN の場合、プログラムは同じI/Oをもう一度実行します。マルチスレッドアプリケーションでは、スレッドが read(2) / write(2) を実行しその後 errno を確認する間に、他のスレッドがシステムコールを発行する可能性があります。先のスレッドが errno を参照した時には、すでに上書きされ別のシステムコールのエラー原因を示しているかもしれません。現実には errno もstdio同様、すでにライブラリの対応が進んでいるため、プログラムを正しくビルドしていればプログラムの変更は不要ですが、 errno はライブラリ関数内でも参照される機会が多いため、コーディング以外のビルド環境に対しても注意が必要になります。

スレッドアンセーフなライブラリ関数は他にも多数存在しますが、マルチスレッドアプリケーションでも使用できるような同機能の関数が用意されており、単純かもしれませんが、プログラムの書き換えが必要になります。例えば asctime(3) のスレッドセーフ(またはリエントラント)なバージョンは asctime_r(3) です。後述するサンプルプログラムでは gethostbyname(3) の代わりに gethostbyname_r(3) を使用しています。スレッドアンセーフなライブラリ関数、ビルド環境に加え、シグナル処理、スレッドの管理/ライフタイム、同期/競合、デバッグ方法など多くのことを考慮しなければなりません。スレッド作成後の fork(2) などは悪魔のごとく嫌われています。1スレッドが単一の処理に集中できるというメリットはありますが、総合的に見ればマルチスレッド化によりプログラミングが単純化されると言うのは必ずしも真ではありません。

マルチスレッド化の例-1base.c

例としてもっとも単純なHTTPサーバをコーディングしてみました。付属の1base.cを見てください。単に静的ファイルのGETメソッドに対応するだけで、動的コンテンツ、Keep-Aliveや他のHTTPメソッドに対応していませんし、エラー処理も無きに等しいものです。HTTPリクエストを処理する構造にのみ注目してください。

1base.c要約 [2]

main()
{
    /* リスニングポート作成... */
    while (1) {
        accept();   /* 接続受付*/
        read();     /* HTTPリクエスト読み取り*/
        write();    /* HTTPレスポンスヘッダ送信*/
        sendfile(); /* 静的ファイル送信*/
        close();    /* 接続切断*/
    }
}
[2]サンプルコードについては /pub/pg_high/ph20090721.tar.bz2 を参照してください。サンプルコードのご利用にあたっては、必ずアーカイブ中のREADMEをお読みください。

sendfile(2) は比較的歴史の浅いシステムコールで、2つのファイルディスクリプタを採ります。ディスク上のファイルに対応するものとHTTP接続などソケットに対応するものです。従来はファイルから read(2) した内容をソケットへ write(2) / send(2) していましたが、 sendfile(2) はこの処理を1システムコールで行います。システムコールの回数が減るだけではなく、ファイル内容を保持するメモリをアプリケーションが用意する必要がなく、カーネルが管理するバッファから直接ソケットへ転送されると言う点が大きなメリットです。

サンプルプログラムでは、HTTPレスポンスヘッダとファイルを送信する際に、性能向上を狙い、送出パケットが必要以上に細切れになるのを防ぐため、 TCP_CORK オプションを使用しています。と、そんなところには気を使っているのに、読み取ったHTTPリクエストの内容確認はまったく行っていません。また一度の read(2) / recv(2) ですべて読めることを(勝手に)前提としています。:-)

1base.cでは並列性はまったくありません。HTTPクライアントからの要求を到着した順に1つずつ処理し、1つの処理が完了するまで別の要求を処理しません。多数の接続要求が連続して届くような状況では、このwhileループがずっと回り続け、ディスクI/OやHTTPクライアントとの通信が順調である限り、1CPUを独占するかもしれません。しかし、サーバのCPUが忙しい割には、マルチプロセッサを駆使できず、HTTPクライアントから見ると結果を得るまでの時間が長くかかる場合が発生し、「このサーバ遅いなぁ」と思われてしまうでしょう。仮におかしなクライアントが接続して来て、通常通りHTTPリクエストを送信してこないと、1baseはずっと待つため、その後のすべてのHTTPクライアントに応えられなくなります。

2pthread-unlimited.c

1base.cを乱暴にマルチスレッド化した例が2pthread-unlimited.cです。

2pthread-unlimited.c要約

func()
{
    read(); /* HTTPリクエスト読み取り*/
    write(); /* HTTPレスポンスヘッダ送信*/
    sendfile(); /* 静的ファイル送信*/
    close(); /* 接続切断*/
}

main()
{
    /* リスニングポート作成... */
    /* 以降のスレッドは終了を待ち合わせない*/
    pthread_attr_setdetachstate(PTHREAD_CREATE_DETACHED);
    while (1) {
        accept();               /* 接続受付*/
        pthread_create(func);   /* スレッド作成*/
    }
}

接続が完了した後の処理をマルチスレッド化しており、クライアントから見れば自分専用のサーバスレッドが処理してくれているような状態なので、応答性の向上は期待できます。スレッドは1リクエストを処理すると終了し、ループはしません。多数の接続要求が連続して届くような状況でも、HTTP処理開始までクライアントを待たせる時間は(コード上は) accept(2) + pthread_create(3) だけで済み、スレッド作成処理がそれほど重くなければ、充分な性能を期待できます。また、マルチプロセッサシステムでもそれぞれのCPU上でスレッドを実行できるため、サーバシステムを不必要に遊ばせることなく動作させられるでしょう。前述のようなおかしなクライアントが居ても、そのスレッドだけが待ち続けるだけで他のスレッドは基本的に影響を受けません。さらにこのサンプルでは、スレッドの終了も管理しておらず、アプリケーションレベルでの明示的な資源の共有/排他もないため、スレッドは単一の処理に集中できます。

良いことづくめに見えるかも知れませんが、無制限にスレッドを作成しては無用の問題を招く恐れがあります。仮にシステムの上限(またはユーザ/プロセスごとに設定されている上限)に達するまでスレッドを多数作成すると、このHTTPサーバはどうなるでしょうか。これはOSへの過負荷という話につながります。現代のOSの堅牢性は、一昔前に比べればずっと信頼できます。スレッド数の上限に達しても、単にこれ以上作成できないというエラーを返すのみで、スケジューラの出来が良ければ、作成済みのスレッドは順調にスケジューリングされ続けるかも知れません。

システム全体を見れば負荷がかかるのはスケジューラだけではないこともわかります。2pthread-unlimited.cでは、スケジューラ、メモリサブシステム以外にも、例えばディスクとネットワークへの過負荷が予想されます。ユーザ空間での read(2) ではないとはいえ、要求されたファイルをディスクから読み出していることには変わりありません。I/Oスケジューラの話になってしまいますが、ディスクのヘッドシーク時間は馬鹿にならないほど長く、ディスクが過負荷になれば単純なHTTP GETリクエストでも予想以上に長時間待たされる場合も考えられます。ネットワーク、特にTCPではソケットの寿命にも注意が必要です。TCPでは接続切断後も一定時間はソケットが特殊な状態として存在し続けます。ソケットは有限なポート番号を消費するため、システム内に同時に存在可能なポート数も考慮する必要があります。

システム内のさまざまな資源は有限であり、過負荷により上限に達し、その状態が続くと、システムがスローダウンする場合もありえます。上限の設定を変更したとしても、事前に過負荷を防ぐ方策を講じるのは良いことですし、また一般的です。仮に、システム設計として、次のような前提が成り立つならば、2pthread-unlimited.cのように可能な限りスレッドを作成し続けるサーバプログラムの運用もあり得るでしょう。もちろん、エラー対応や異常なクライアントからのセッションを強制的に切断するなどの処理は必須ですが、単純、高速というメリットは確かにあると考えられます。しかし決してお勧めするものではありません。

  • 自分のシステム、OSは過負荷時でもスローダウンせず、システムコールが単に EMFILE などのエラーを返すだけである
  • システムがその能力を限界まで発揮しているならば、それ以上の処理量も速度性能も要求しない
  • ディスクなどサブシステムが負荷によりレスポンスが低下しても、異常なスローダウンでなければ、システムの限界と考え許容する

蛇足ですが、利用可能資源の上限変更、特にシステムワイドな変更には注意が必要です。これはlinux-2.6.28で実際にあった話ですが、それまで1024とされていたオープン可能ファイル数の上限を無制限(実際には1024*1024)にできるようにカーネルを変更したところ、Debianシステムがスローダウンしたということがありました。原因はDebianのあるライブラリにオープン可能ファイル数を無制限に設定する処理と、利用可能なファイルディスクリプタをすべて close(2) する処理があり、従来はこれが1024回のclose(2)で済んでいたのが、1024*1024回ものループになり(単純に言ってこの処理に約千倍の時間がかかるようになった)、更にそのライブラリがほぼすべてのプロセス実行時に使用されていたため、システム全体の異常なレスポンス低下にまで発展してしまいました。本来はカーネルの不具合ではなく、ライブラリの問題ですが、「このカーネル変更は必須とは言えない。現実に問題が発生しているシステムがある」との考えから、カーネルバージョン2.6.28.5/2.6.29-rc4で元に戻されました。 [3]

[3]http://git.kernel.org/?p=linux/kernel/git/stable/linux-2.6.28.y.git;a=commit;h=ad649a5df5b1d4e1feb3f03fc452d05ad217e339 を参照

3pthread-pool.c

並列性は高めたいが、過負荷を防ぐため無制限にスレッドを作成したくないと考えるならば、ある決まった数のスレッドを事前に作成しておき、接続要求が届いたらそれぞれのスレッドに処理させるというという方法があります。これはスレッドプールと呼ばれ、広く利用されています。従来から推奨されていた方式ですが、その際にはマスタ役(Boss-Workerモデルで言うBoss)の専用スレッド(プロセス)が処理要求を受け付け、要求をリクエストキューにつなぎ、スレッドプールはキュー内の要求を取り出して処理するとされていました。しかし、OSにより差異はあるかもしれませんが、カーネルスレッドのHTTPサーバの場合では listen(2) のバックログと accept(2) がカーネル内でこの処理を行うため、ユーザプログラムがマスタ役を演じる必要は通常ありません。

3pthread-pool.c要約

func()
{
    while (1) {
        accept();   /* 接続受付*/
        read();     /* HTTPリクエスト読み取り*/
        write();    /* HTTPレスポンスヘッダ送信*/
        sendfile(); /* 静的ファイル送信*/
        close();    /* 接続切断*/
    }
}

main()
{
    /* リスニングポート作成... */
    listen(backlog);        /* キューの長さ*/
    for (i = 0; i < nthread; i++)
    pthread_create(func);   /* スレッド作成*/
}

3pthread-pool.cでは接続受付もスレッドで行っており、従来推奨されて来たBoss-Workerモデルには従っていないように見えますが、接続要求はアプリケーションではなくカーネル内部にキューイングされているため、実質的に違いはありません。 listen(2) に与える引数 backlog にはキューの長さを指定します。 accept(2) はこのキューから接続要求を1つ取り出します。キュー内に1つも接続要求が無ければ、 accept(2) は接続要求が到着するまで待ちます。複数のスレッドが同時に accept(2) で待っている状態から接続要求が1つ到着すると、動き出すスレッドは1つだけです。1つだけという点はスケジューリング的には重要なことで、待ち状態の全スレッドが動き出し、接続要求を獲得できなかったスレッドが再び待つと言う状況とは大きく異なります。この点は後述するepollを用いたサンプルでも重要になります。

listenキューの長さを超える接続要求が殺到した場合の挙動は、通常はクライアントに ECONNREFUSED が返されるか、または無視され結果的にクライアントが再要求することになります。listenキューの実装は環境により差異があります。Linuxでもバージョン、設定により異なります。例えば backlog は、古いバージョンのLinuxではTCP的に確立してない接続数の上限を表すものでしたが、現在ではTCP的に確立済みの接続数上限を表します。またアタック対策の一環として一時期積極的に利用されたsyncookies機能が有効になっていると、listenキューの長さを超える接続要求をアタックとみなし、サーバ過負荷の防ぐため本来のTCP接続の処理を行いません。この場合でも、アタックではない正常な接続ならば、その後のTCPシーケンスで適切に処理されます。しかし、厳密にはTCPの仕様から外れ、TCP拡張機能なども犠牲になるなどの理由から、現在ではそれほど利用されていないようです。

3pthread-pool.cの実装で実際問題となるのはプール内のスレッド数です。スレッド数の決定は、サーバの処理能力や想定要求数などキャパシティプランニングにも関係しますが、意外と悩んでしまうものです。簡単に言えばスレッド数=同時処理可能HTTPリクエスト数になりますが、大きくし過ぎると無駄を生み、悪ければ過負荷を招く恐れもあります。逆に小さくし過ぎると目標性能に届かないかもしれません。大雑把な目安としては下限をCPU数とし、上限は負荷テストや実運用から徐々に上げて行き決定するのが良いでしょう。関連して、もう1つ注意すべき点にオープン可能ファイル数の上限があります。プロセス内の資源は全スレッドで共有されるため、プロセスごとに設けられているオープン可能ファイル数の上限を超えるスレッドを作成し、同時に動作させてもどうせエラーになってしまいます。例えば、プロセスでのオープン可能ファイル数の上限が1024の場合で、3pthread-pool.cではスレッド内で最大2つのファイルディスクリプタを使用し、プロセスは通常初めから3つのファイルディスクリプタを使用していますので、(1024 - 3) / 2 = 510が同時実行可能なスレッド数上限の実質的な目安となります。

せっかくCPUもメモリもたくさんあるマシンを調達し、スレッドをいくらでもたくさん作成して最高性能を目指そうという方ならば、プロセスあたりのオープン可能ファイル数ごときの上限にぶち当たり、思惑が外れたかも知れません。そんな方ならばスレッドよりも多重I/Oとマルチプロセスというアプローチの方が魅力的に見えるかも知れません。

>> (2)につづく

Copyright © 2009 SENJU, Jiro

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

Bookfair

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

Feedback

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