共通のスレッド: POSIX スレッドの説明

メモリー共用のための、単純で柔軟性のあるツール

POSIX (Portable Operating System Interface) のスレッドは、コードの応答性とパフォーマンスを向上させる優れた手段です。本稿では、コードでスレッドを使用する方法を説明します。詳細な背景情報が多数網羅されているため、このシリーズの終わりまでには、独自のマルチスレッド・プログラムを作成する準備が整っていることでしょう。

Daniel Robbins (drobbins@gentoo.org), President/CEO, Gentoo Technologies, Inc.

Daniel Robbins氏は、ニューメキシコ州アルバカーキーに住んでいます。彼は、Gentooプロジェクトのチーフ・アーキテクト、Gentoo Technologies Inc. の社長/CEOです。著書に、Macmillanから出版されているCaldera OpenLinux Unleashed、SuSE Linux Unleashed、Samba Unleashed があります。Daniel氏は、小学2年のとき初めてLogoプログラム言語や、中毒になる恐れのあったPac Manに出会って以来、何らかの形でコンピューターに関係してきています。これで、彼がなぜSONY Electronic Publishing/Psygnosisでリード・グラフィック・アーチストを務めているかが分かるでしょう。愛妻Maryさんや、生まれたばかりの愛娘Hadassahちゃんとの時間をとても大切にしています。彼の連絡先はdrobbins@gentoo.org です。



2000年 7月 01日

スレッドの容易な拡張性

プログラマーとして、スレッドの使用方法の知識は必要条件です。スレッドはプロセスと似ています。スレッドは、プロセス同様、カーネルによってタイム・スライスされます。単一プロセッサー・システムの場合、カーネルは、タイム・スライシングを使用して、スレッドの同時実行をシミュレートします。これは、プロセスに対してタイム・スライシングを使用するのとほぼ同じです。マルチプロセッサー・システムの場合、スレッドは実際に同時に実行することができます。これも、2 つ以上のプロセスの場合とまったく同様です。

では、ほとんどの協調型のタスクにおいて、複数の独立したプロセスを使用するよりも、マルチスレッド化する方が好ましいのはなぜでしょうか。スレッドは同じメモリー・スペースを共有するからです。独立した複数のスレッドは、メモリー内の同じ変数にアクセスすることができます。それで、プログラムのスレッドはすべて、宣言済みのグローバル整数を読み書きすることができるのです。fork() を使ってコードをプログラミングした経験がある方であれば、このツールの重要性を分かっていただけることと思います。それはなぜでしょうか。fork() は複数のプロセスを作成することを可能にする一方で、次のような通信上の問題を引き起こします。すなわち、それぞれ独立したメモリー・スペースを持つ複数のプロセスが、どのようにして通信するかという問題です。この問題に対する、1 つの単純な答えはありません。さまざまな種類のローカル IPC (プロセス間通信) が存在しますが、それらにはすべて、2 つの大きな欠点があります。

  • 何らかの形でカーネルの付加的なオーバーヘッドの影響を受け、パフォーマンスが低下する。
  • ほとんどの場合、IPC はコードの "自然な" 拡張ではないという点。IPC は、プログラムを非常に複雑にしてしまうことがよくあります。

オーバーヘッドと複雑さという困ったことが同時に起きるのは、良くありませんね。IPC をサポートするようにプログラムに大幅な変更を加えた経験をお持ちなら、スレッドが備えている単純なメモリー共有の方法を高く評価されることでしょう。POSIX のスレッドでは、高価で複雑な長距離電話をかけるようなことは必要はありません。スレッドはすべて同じ家に住むことになるからです。わずかな同期を取るだけで、すべてのスレッドがプログラムの既存のデータ構造を読んだり修正したりできるようになります。ファイル記述子を通してデータをくみ出す必要もありませんし、狭い共用メモリー・スペースにデータを押し込む必要もありません。この理由 1 つだけでも、マルチプロセス/シングルスレッド・モデルの代わりに、1 プロセス/マルチスレッド・モデルを考慮する必要があるというものです。


スレッドの柔軟性

ほかにも理由はあります。スレッドは、極めて柔軟性が高いものであるということです。標準の fork() と比べると、オーバーヘッドが断然に少なくて済みます。カーネルは、プロセス・メモリー・スペースやファイル記述子などの新しい独立したコピーを作成する必要がなくなります。これは CPU 時間の節約となるため、スレッドを作成するのは、新しいプロセスを作成するのに比べて 10 - 100 倍高速です。そのため、CPU およびメモリーのオーバーヘッドをそれほど心配せずにスレッドを大量に使用することも可能なのです。fork() の場合には、CPU に大きな負担がかかってしまいます。このように、プログラムの中で必要があればいつでもスレッドを作成できるという柔軟性がスレッドにはあるのです。

当然、プロセスの場合とまったく同様、スレッドも複数 CPU の恩恵を受けます。ソフトウェアがマルチプロセッサー・マシンで使用されるように設計されている場合、これは非常に大きな特色となります (ソフトウェアがオープン・ソースの場合には特に、その多くがマルチプロセッサー・マシンで稼動することになるでしょう)。スレッド化されたある種のプログラムの場合 (CPU 集中型の場合は特に)、システムのプロセッサーの数に応じてパフォーマンスがほとんど線形に向上していきます。CPU 集中度が非常に高いプログラムを作成する場合は、コードの中で複数のスレッドを使用する方法を実現するべきです。スレッド化されたコードを書けるようになるということは、同時に新しい創造的な方法によるコーディングへの挑戦でもあります。多数の IPC という面倒な方法を使ったり、無意味な慣習を踏襲する必要がなくなるからです。これらの利点は相乗効果を挙げるので、マルチスレッド化プログラミングがより楽しく、高速で、柔軟なものになります。


クローンとの類似性

Linux プログラミングの経験をお持ちの方であれば、__clone() システム呼び出しをご存じかもしれません。__clone() は fork() に似ていますが、スレッドが行えるさまざまな事柄を行うことができます。たとえば、__clone() を使用すると、親の実行コンテキスト (メモリー・スペース、ファイル記述子など) を選択的に子プロセスと共有することができます。これは良いことです。ただし、__clone() については、あまり良くない点もあります。__clone() のマニュアル・ページには、以下のように書いてあります。

「__clone 呼び出しは Linux 特有のものであって、移植可能にするつもりのプログラムには使用すべきではありません。スレッド化されたアプリケーション (同一のメモリー・スペース内の、制御の複数のスレッド) をプログラミングする場合は、Linux-Threads ライブラリーなどのような POSIX 1003.1c スレッド API をインプリメントしたライブラリーを使用する方が賢明です。pthread_create(3thr) をご覧ください。」

このように、__clone() はスレッドの多くの利点を提供するものではありますが、移植可能ではありません。これは、決して __clone() 呼び出しをコードの中で使用すべきではないという意味ではありません。ソフトウェアの中で __clone() を使用するときには、この事実を比較考慮する必要があるということです。ここでは、__clone() のマニュアル・ページにも書かれていますが、より良い代替手段となる POSIX スレッドをご紹介します。移植可能なマルチスレッド化コード、Solaris、FreeBSD、Linux などで動作するコードを書く場合は、POSIX スレッドが最も適しています。


スレッドの開始

POSIX スレッドを使用した簡単なプログラムの例を見てみましょう。

thread1.c
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
void *thread_function(void *arg) {
  int i;
  for ( i=0; i<20; i++ ) {
    printf("Thread says hi!\n");
    sleep(1);
  }
  return NULL;
}
int main(void) {
  pthread_t mythread;
  if ( pthread_create( &mythread, NULL, thread_function, NULL) ) {
    printf("error creating thread.");
    abort();
  }
  if ( pthread_join ( mythread, NULL ) ) {
    printf("error joining thread.");
    abort();
  }
  exit(0);
}

このプログラムをコンパイルするには、プログラム名を thread1.c として保管して、次のように入力してください。

$ gcc thread1.c -o thread1 -lpthread

実行するには、次のように入力します。

$ ./thread1

thread1.c の説明

thread1.c は極めて簡単なプログラムです。実用的ではありませんが、スレッドがどのように機能するかを理解する手助けになります。プログラムの内容を段階的に調べてみましょう。まず、main() では、mythread という変数を宣言しています。型は pthread_t です。pthread_t 型は pthread.h で定義されていて、よく「スレッド ID」と呼ばれるものになります (通常、"tid" と省略されます)。一種のスレッド・ハンドルだと考えてください。

mythread を宣言した後で (mythread は 「tid」、つまりこれから作成するスレッドのハンドルだということを思い出してください)、pthread_create 関数を呼び出して、実際の、生きたスレッドを作成します。pthread_create() が「if」ステートメントの中にありますが、混乱しないようにしてください。pthread_create() は成功するとゼロを戻し、失敗するとゼロ以外を戻すので、この関数を if() の中に入れるのは、失敗した pthread_create() 呼び出しを検出するために一ひねりした方法であるというだけのことです。次に pthread_create への引数を見てみましょう。最初の引数は、mythread へのポインター、&mythread です。2 番目の引数は、現時点では NULL に設定されていますが、スレッドに何らかの属性を定義するときに使用することができます。今回の場合は、デフォルトのスレッド属性で十分であるため、引数を単に NULL に設定しています。

3 番目の引数は、新しいスレッドが開始したときに実行する関数の名前です。この例では、関数の名前は thread_function() です。thread_function() が戻って来ると、新しいスレッドは終了します。この例では、スレッド関数は特別なことは行っていません。単に "Thread says hi!" と 20 回プリントして、終了するだけです。ここで、thread_function() が void * を引数として受け入れ、戻り値としても void * を戻すことに注意してください。これは、void * を使用してデータの任意の断片を新しいスレッドに渡せるということと、新しいスレッドは終了するとデータの任意の断片を戻すことができるということを示しています。では、どのようにして任意の引数をスレッドに渡すのでしょうか。簡単です。pthread_create() 呼び出しへの 4 番目の引数を使用します。この例では、この引数を NULL に設定します。thread_function() に渡さなければならないデータがないためです。

お気付きかもしれませんが、pthread_create() が正常に戻った後、プログラムは 2 つのスレッドから構成されることになります。本当に2 つのスレッドだったでしょうか。1 つのスレッドしか作成しなかったのではありませんか。そのとおりです。しかし、メイン・プログラムもスレッドと見なされるのです。このように考えて見てください。POSIX スレッドを全く使用しないプログラムを作成した場合、プログラムのスレッドはたった 1 つです (この 1 つのスレッドを「メイン」スレッドと言います)。ということは、新しいスレッドを 1 つ作成すると、合計 2 つのスレッドがプログラムにあることになります。

この時点で、2 つの重要な疑問が生まれるはずです。最初の疑問は、新しいスレッドが作成された後、メイン・スレッドは何をするのかということです。スレッドはそのまま進んで行き、続けてプログラムの次の行 (この例では、「if (pthread_join(...))」の行) を実行します。2 番目の疑問は、新しいスレッドが終了すると、このスレッドがどうなるかということです。スレッドは停止し、クリーンアップ処理のプロセスの一部として、別のスレッドとマージ、または結合されるまで待機します。

次に、pthread_join() に話を移しましょう。pthread_create() が 1 つのスレッドを 2 つのスレッドに分割したのと同じように、pthread_join() は 2 つのスレッドを 1 つのスレッドにマージします。pthread_join() への最初の引数は、tid mythread です。2 番目の引数は、void ポインターへのポインターです。void ポインターが NULL でない場合、pthread_join は、指定した位置にスレッドの void * 戻り値を戻します。この場合、thread_function() の戻り値を心配する必要はないので、これを NULL に設定します。

thread_function() は終了するのに 20 秒はかかります。そのため、thread_function() が完了するより前に、メイン・スレッドが pthread_join() を呼び出してしまいます。この現象が生じると、メイン・スレッドはブロックし (スリープ状態に入り)、thread_function() の完了を待ちます。thread_function() が完了すると、pthread_join() が戻ります。ここで、プログラムは再び 1 つのメイン・スレッドを持つようになります。プログラムが終了する時点では、新しいスレッドはすべて pthread_join() されていることになります。このような処理を、プログラムの中で作成する新しいスレッドすべてについて行うようにしましょう。新しいスレッドを結合しないでいると、スレッドはシステムの最大スレッド数の制限の中に数えられたままになります。つまり、適切なクリーンアップ処理を行わないと、結果的に新しい pthread_create() 呼び出しが失敗することになってしまうのです。


親子の概念がない

fork() システム呼び出しを使った経験がある方は、親プロセスと子プロセスの概念をご存知のことと思います。あるプロセスが fork() を使って新しいプロセスを作成した場合、新しいプロセスは子、元のプロセスは親とみなされます。この階層関係は、便利なものとなり得ます。特に、子プロセスが終了するのを待つ場合はそうです。たとえば、waitpid() 関数は、現在のプロセスに対して、すべての子プロセスが完了するまで待つように命令します。waitpid() は、親プロセスの中で簡単なクリーンアップ処理ルーチンをインプリメントするために使用されます。

では、POSIX スレッドについて、見てみましょう。本稿ではこれまで、「親スレッド」、「子スレッド」という言葉を意識的に避けてきたことにお気づきでしたか。これは、POSIX スレッドにはこのような階層関係が存在しないためです。メイン・スレッドが新しいスレッドを作成したり、その新しいスレッドが別の新しいスレッドを作成したりすることは可能ですが、POSIX スレッドの標準からすると、スレッドはすべて、対等な関係で、1 つのプールと見なされます。したがって、子スレッドが終了するのを待つという概念は存在しません。POSIX スレッドの標準では、「家族」の情報は記録されないからです。家系に当たるものがないということは、重要なポイントを暗示しています。あるスレッドが終了するのを待つ場合は、どのスレッドを待つのかを指定しなければならないということです。これは、該当する tid を pthread_join() に渡すことによって指定します。スレッド・ライブラリーは、自動的にこれを見分けることができません。

これでは、2 つ以上のスレッドから構成されるプログラムが複雑なものとなってしまう可能性があるため、嫌がる方も多いかもしれません。心配しなくても大丈夫です。POSIX スレッドの標準には、複数のスレッドを上手に管理するために必要なツールがすべて揃っています。親子関係がないということで返って、プログラムの中でスレッドを創造的な方法で使用する道が開かれます。たとえば、thread 1 というスレッドがあり、thread 1 が thread 2 というスレッドを作成するとします。この場合、thread 1 自身が thread 2 のために pthread_join() を呼び出す必要はありません。プログラム内の他のどのスレッドがこれを呼び出してもかまわないのです。こういう性質は、多数のマルチスレッド化を図ったコードを作成する場合の可能性を広げます。たとえば、グローバルな「停止スレッド・リスト」を作っておいて、停止したスレッドをすべてそこに含めます。そして、そのリストに 1 つの項目が追加されるまでクリーンアップ処理を待機するスレッドを用意することができます。クリーンアップ処理スレッドは pthread_join() を呼び出して、pthread_join() を自分自身にマージします。こうして、クリーンアップ処理全体を、1 つのスレッドで手際よく効果的に処理することができます。


同時実行

ここで、少し予想外の事柄を行うコードを見てみましょう。以下に thread2.c を示します。

thread2.c
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
int myglobal;
 void *thread_function(void *arg) {
  int i,j;
  for ( i=0; i<20; i++ ) {
    j=myglobal;
    j=j+1;
    printf(".");
    fflush(stdout);
   sleep(1);
    myglobal=j;
  }
  return NULL;
}
int main(void) {
  pthread_t mythread;
  int i;
  if ( pthread_create( &mythread, NULL, thread_function, NULL) ) {
    printf("error creating thread.");
    abort();
  }
  for ( i=0; i<20; i++) {
    myglobal=myglobal+1;
    printf("o");
    fflush(stdout);
    sleep(1);
  }
  if ( pthread_join ( mythread, NULL ) ) {
    printf("error joining thread.");
    abort();
  }
  printf("\nmyglobal equals %d\n",myglobal);
  exit(0);
}

thread2.c の説明

このプログラムは、最初のプログラムと同様、新しいスレッドを作成します。プログラムでは、メイン・スレッド、新しいスレッド共に、myglobal というグローバル変数を 20 回増分します。しかし、このプログラムは、予想外の結果を出します。次のように入力して、プログラムをコンパイルしてみてください。

$ gcc thread2.c -o thread2 -lpthread

プログラムの実行は、次のようにして行います。

$ ./thread2

私のシステムで実験した結果を示します。

$ ./thread2
..o.o.o.o.oo.o.o.o.o.o.o.o.o.o..o.o.o.o.o
myglobal equals 21

まったく予想外の結果になってしまいました。myglobal はゼロから始まり、メイン・スレッド、新しいスレッド共に 20 まで増分するのですから、プログラムの終了時点で myglobal は 40 になるはずです。結果として myglobal が 21 になっていると言うことは、ここで何か怪しいことが起こっているということになりますね。一体何が起こっているというのでしょうか。

お分かりになりますか。では、発生原因を説明します。thread_function() を見てみましょう。このプログラムにおける、「j」というローカル変数への myglobal のコピーのしかた、「j」の増分のしかた、また、増分後 1 秒間スリープした後で、初めて新しい 「j」値を myglobal にコピーしているというコピーのタイミングなどに注目してください。これらが鍵になります。新しいスレッドが myglobal の値を「j」にコピーした後で、メイン・スレッドが myglobal を増分すると、どうなるでしょうか。thread_function() が「j」の値を myglobal に書き戻すときに、メイン・スレッドが加えた変更が上書きされてしまうことになります。

スレッド化されたプログラムを作成するときには、ここで見たような役に立たない無益な副次作用は、時間の無駄になるので避けるべきです (もちろん、POSIX スレッドの記事を書いているというなら話は別ですが)。では、この厄介な問題を起こさないようにするにはどうしたらいいのでしょうか。

myglobal を「j」にコピーし、1 秒間これをそこに保持してから書き戻すことが原因で問題が生じるわけですから、一時ローカル変数の使用と、myglobal を直接的に増分することを避けるというのは一つの案です。この解決法は、この特定の例では功を奏するはずですが、正しい解決法とは言えません。単に増分するのではなく、比較的複雑な数学的操作を myglobal に対して実行するような場合、この方法ではおそらく失敗することになるでしょう。なぜでしょうか。

スレッドが同時に実行されるということを思い出してみてください。単一プロセッサー・マシン (カーネルが、本当のマルチタスクをシミュレートするためにタイム・スライシングを使用する) の場合であっても、プログラマーの観点からすれば、スレッドは両方とも同時に実行されていると考えることができます。thread2.c に問題があるのは、thread_function() のコードが、増分される前の 1 秒間に myglobal に変更が加えられることはないであろうということに依存しているからです。一方のスレッドがもう一方のスレッドに対し、自分が myglobal に変更を加えている間は myglobal に「近寄らない」ように指示する方法が必要ですね。次の記事ではこの方法について説明します。次回をお楽しみに。

参考文献

コメント

developerWorks: サイン・イン

必須フィールドは(*)で示されます。


IBM ID が必要ですか?
IBM IDをお忘れですか?


パスワードをお忘れですか?
パスワードの変更

「送信する」をクリックすることにより、お客様は developerWorks のご使用条件に同意したことになります。 ご使用条件を読む

 


お客様が developerWorks に初めてサインインすると、お客様のプロフィールが作成されます。会社名を非表示とする選択を行わない限り、プロフィール内の情報(名前、国/地域や会社名)は公開され、投稿するコンテンツと一緒に表示されますが、いつでもこれらの情報を更新できます。

送信されたすべての情報は安全です。

ディスプレイ・ネームを選択してください



developerWorks に初めてサインインするとプロフィールが作成されますので、その際にディスプレイ・ネームを選択する必要があります。ディスプレイ・ネームは、お客様が developerWorks に投稿するコンテンツと一緒に表示されます。

ディスプレイ・ネームは、3文字から31文字の範囲で指定し、かつ developerWorks コミュニティーでユニークである必要があります。また、プライバシー上の理由でお客様の電子メール・アドレスは使用しないでください。

必須フィールドは(*)で示されます。

3文字から31文字の範囲で指定し

「送信する」をクリックすることにより、お客様は developerWorks のご使用条件に同意したことになります。 ご使用条件を読む

 


送信されたすべての情報は安全です。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Linux
ArticleID=229867
ArticleTitle=共通のスレッド: POSIX スレッドの説明
publish-date=07012000