コンピューター:C言語講座:マルチスレッドについて


マルチスレッドの特徴

 一般的なサーバプログラムでは、多数のクライアントからの処理をできるだけリアルタイムに処理するために、何らかの形で並列処理を行うことが多いものです。たとえば、データベースサーバで、ある検索処理に時間がかかっている間ほかのクライアントが接続すらできない、という状態ではサーバとして失格でしょう。

 並列処理を実現する方法としては、

・プログラム自体を並列処理可能に記述する
・プロセスをもともと多数起動しておく
・fork()を使って子プロセスを起動するマルチプロセス処理
・マルチスレッドを使用する

などが考えられます。プログラム自体をリアルタイム性を考慮しながら記述するのはなかなか大変で、しかもソースが複雑になりメンテナンス性も問題が多くなりがちです。プロセスをもともと多数起動するのは簡単ですが、クライアントがどのプロセスに依頼するかを判断するか、あるいはそれらに振り分けるサーバが必要になるでしょう。固定的な処理には向いています。fork()を使って子プロセス起動するのはUNIXでは非常に伝統的な方法で、クライアントからの接続を受け付け、それから子プロセスに分岐することが可能で、プロセス空間もそれぞれ独立していますから、プログラミングも比較的楽ですし、問題が発生してもサーバ本体が死ぬまでの自体は防げることも多く、安全性も高い方法です。マルチスレッドは比較的最近出てきた方法で、1プロセス内で並列処理を実現できます。

 fork()によるマルチプロセスとマルチスレッドが汎用的なサーバ構築の手法として良さそうですが、この二つの違いを見てみましょう。

・マルチプロセス

○プロセス空間が独立しているので、グローバル変数や、スタティック変数を心配なく使用できる。
○個別にデバッグが容易。
○1プロセスに対する制限(同時オープンファイル数など)を気にせず、多数の処理が可能。
○子プロセスを殺したりしても全体に影響が出にくい。
○子プロセス処理で多量のメモリを確保しても、そのプロセスが終了すれば開放され、サーバ本体のサイズが大きくなりにくい。
×プロセス空間が多数必要になるので、メモリ消費量が多くなりがち。
×プロセスサイズが大きい場合、fork()自体に時間がかかる。
×UNIX以外のOSでサポートされていない場合が多い。
×排他制御・同期制御などを、プロセス間通信などを使い手間がかかる。
×子プロセス終了時に親プロセスはwait()などで終了ステータスを得ないとゾンビプロセスが残る。

・マルチスレッド

○1プロセス空間だけで並列処理するのでメモリを無駄に消費しない。
○スレッド生成自体はほとんど時間がかからない。
○UNIX以外のOSでもサポートされている場合が多い。
○スレッドの終了ステータスは特に待つ必要はない。
○排他処理などが提供されている
×1プロセス空間内での並列処理なので、グローバル変数やスタティック変数は危険。
×デバッグが行いにくい。
×1プロセスに対する制限(同時オープンファイル数など)により、処理数が限定される。
×スレッドの途中終了が難しい。
×スレッド内で動的に多量のメモリを確保すると、プロセスサイズが大きくなり、戻らない

 このような感じでしょうか。私自身、長年UNIXでのサーバプログラミングにはfork()によるマルチプロセスを使い続けてきましたが、これらの長所・短所の中で、特に、UNIX以外でサポートされていない場合が多い、重たい、という点でマルチスレッドも使うようになってきました。いまだに安全面ではマルチプロセスだと考えておりますが、UNIX,Windowsなどマルチプラットホームでアルゴリズムを活かすためにだけでもマルチスレッドの価値はあるのではないでしょうか。もちろん、軽量で、頻繁に子処理が生まれたり死んだりする場合にはパフォーマンス的にも魅力的です。

 マルチスレッドでプログラミングしておけば、あとからマルチプロセスにするのは比較的簡単です。マルチスレッドの方が制限が多いためです。逆にマルチプロセスのソースをマルチスレッドに変更するのは危険性が高いです。特にグローバル変数、スタティック変数の扱いが雑ですと、組み直した方が早いかも知れません。

マルチプロセスプログラミング

 マルチスレッドとの比較のために、まずはfork()によるマルチプロセスプログラミングを見てみましょう。

----- mp.c -----
#include        <stdio.h>
#include        <sys/types.h>
#include        <sys/wait.h>

void counter()
{
int     i;
pid_t   pid;

        pid=getpid();

        for(i=0;i<10;i++){
		sleep(1);
                printf("[%d]%d\n",pid,i);
        }

	return;
}

void main()
{
pid_t   p_pid,pid;

	p_pid=getpid();

        printf("[%d]start\n",p_pid);

        switch(pid=fork()){
                case    0:      /* child */
                        counter();
                        exit(0);
                case    -1:
                        perror("fork");
                        break;
                default:        /* parent */
                        printf("[%d]child pid = %d\n",p_pid,pid);
                        break;
        }

        switch(pid=fork()){
                case    0:      /* child */
                        counter();
                        exit(0);
                case    -1:
                        perror("fork");
                        break;
                default:        /* parent */
                        printf("[%d]child pid = %d\n",p_pid,pid);
                        break;
        }

        pid=wait(0);
        printf("[%d]pid = %d end\n",p_pid,pid);
        pid=wait(0);
        printf("[%d]pid = %d end\n",p_pid,pid);

        printf("[%d]end\n",p_pid);
}

コンパイル・リンク

cc mp.c -o mp

実行結果

[7257]start
[7257]child pid = 7258
[7257]child pid = 7259
[7258]0
[7259]0
[7258]1
[7259]1
[7258]2
[7259]2
[7258]3
[7259]3
[7258]4
[7259]4
[7258]5
[7259]5
[7258]6
[7259]6
[7258]7
[7259]7
[7258]8
[7259]8
[7258]9
[7257]pid = 7258 end
[7259]9
[7257]pid = 7259 end
[7257]end

 実にシンプルなサンプルですが、簡単に説明しますと、main()では、自分のプロセスIDをはじめに調べ、以後printf()で出力する際にどのプロセスが出力したのかをわかるようにしています。fork()で子プロセスが生成されますが、戻り値が0の場合が子プロセス、-1はエラー、それ以外は親プロセスで、戻り値は子プロセスのプロセスIDになります。サンプルでは2つ子プロセスを起動しています。

 子プロセスではcounter()関数をコールしています。はじめに自分のプロセスIDを調べ、1秒に一度、0から9まで数値を表示するだけの関数です。関数が終了すると、exit()で終了します。

 親プロセスはwait()で子プロセスの終了を待ちます。サンプルでは2つ起動したのがわかっているので、単純にブロックするwait()で2回待っています。

 実行結果を見ていただくとわかるように、カウントはそれぞれ同時に行われており、並列処理が行われているのがわかります。psコマンドでプロセス状態を見ると、下記のように3つのプロセスが見つかります。

> ps -efl | grep mp
  F S UID        PID  PPID  C PRI  NI ADDR    SZ WCHAN  STIME TTY          TIME CMD
000 S komata    7257  3790  0  69   0    -   317 wait4  11:22 pts/2    00:00:00 mp
040 S komata    7258  7257  0  69   0    -   317 nanosl 11:22 pts/2    00:00:00 mp
040 S komata    7259  7257  0  69   0    -   317 nanosl 11:22 pts/2    00:00:00 mp

 fork()の特徴は、fork()した時点でプロセスが複製され、そこからそのまま別々に処理が継続する点です。次に説明するマルチスレッドではスレッド開始は関数コールによる形になっています。

マルチスレッドプログラミング

 マルチスレッドのプログラミング自体はfork()によるマルチプロセスプログラミング同様、実に簡単です。なお、スレッドにはいろいろな種類がありますが、ここでは汎用性の高いPOSIXスレッドを扱います。

----- mt.c -----
#include        <stdio.h>
#include        <sys/types.h>
#include        <pthread.h>

void *counter(void *arg)
{
int     i;
pid_t   pid;
pthread_t	thread_id;

        pid=getpid();
	thread_id=pthread_self();

        for(i=0;i<10;i++){
		sleep(1);
                printf("[%d][%d]%d\n",pid,thread_id,i);
        }

	return(arg);
}

void main()
{
pid_t   p_pid;
pthread_t	thread_id1,thread_id2;
int	status;
void 	*result;

	p_pid=getpid();

        printf("[%d]start\n",p_pid);

	status=pthread_create(&thread_id1,NULL,counter,(void *)NULL);
	if(status!=0){
		fprintf(stderr,"pthread_create : %s",strerror(status));
	}
	else{
		printf("[%d]thread_id1=%d\n",p_pid,thread_id1);
	}

	status=pthread_create(&thread_id2,NULL,counter,(void *)NULL);
	if(status!=0){
		fprintf(stderr,"pthread_create : %s",strerror(status));
	}
	else{
		printf("[%d]thread_id2=%d\n",p_pid,thread_id2);
	}

	pthread_join(thread_id1,&result);
	printf("[%d]thread_id1 = %d end\n",p_pid,thread_id1);
	pthread_join(thread_id2,&result);
	printf("[%d]thread_id2 = %d end\n",p_pid,thread_id2);

        printf("[%d]end\n",p_pid);
}

コンパイル・リンク

cc mt.c -o mt -lpthread

実行結果

[7349]start
[7349]thread_id1=1026
[7349]thread_id2=2051
[7351][1026]0
[7352][2051]0
[7351][1026]1
[7352][2051]1
[7351][1026]2
[7352][2051]2
[7351][1026]3
[7352][2051]3
[7351][1026]4
[7352][2051]4
[7351][1026]5
[7352][2051]5
[7351][1026]6
[7352][2051]6
[7351][1026]7
[7352][2051]7
[7351][1026]8
[7352][2051]8
[7351][1026]9
[7349]thread_id1 = 1026 end
[7352][2051]9
[7349]thread_id2 = 2051 end
[7349]end

 マルチプロセスのサンプルと全く同じ感じにしました。pthread_create()でスレッドを生成します。第3引数に関数を指定していますが、これがスレッドの開始処理になります。pthreadではエラーはerrnoやperror()では得られず、関数の戻り値をstrerror()に渡して使用します。

 counter()はマルチスレッドと同様なものですが、プロセスIDのほかに、スレッドIDも表示するようにしています。本来、マルチスレッドはプロセスは1つなので、プロセスIDはどのスレッドでも同一になるべきですが、LINUXではプロセスIDが別になり、psで見たときにも複数のプロセスがあるように見えてしまいます。Solarisでは1つになるのでこの辺はpthread実装の違いでしょう。スレッド関数には一つ引数を渡せます。本来はアドレス渡しで動的に確保した領域のアドレスを渡したりしても問題ないのですが、LINUXではスレッド生成直後に限り、不安定な状態があるようで、渡したアドレスの内容が正常に見れないことがありました。Solarisでは問題ありませんでしたが。確認してから使うようにしましょう。値渡しはLINUXでも問題ないようです。

 スレッドの終了はpthread_join()で待つことができます。待つ必要が無い場合は、pthread_detach()で終了状態を保持しないように宣言しておかないと内部テーブルに残るようで、ある数以上スレッドが生成できなくなります。

 psコマンドで実行中に見ると、LINUXでは下記のように4つのプロセスがあるように見えてしまいます。Solarisでは1つしかありません。スレッドとしては全部で3つなのに4つあるというのも気分が悪いのですが、一つはおそらくスレッドを実装する際に管理的な目的に使っているのでしょう。動作的にはスレッド生成直後の不安定さ以外は全く普通のマルチスレッドになっていますので問題はありませんが。

> ps -efl | grep mt
  F S UID        PID  PPID  C PRI  NI ADDR    SZ WCHAN  STIME TTY          TIME CMD
000 S komata    7349  3790  0  60   0    -  1365 rt_sig 11:40 pts/2    00:00:00 mt
040 S komata    7350  7349  0  60   0    -  1365 do_pol 11:40 pts/2    00:00:00 mt
040 S komata    7351  7350  0  60   0    -  1365 nanosl 11:40 pts/2    00:00:00 mt
040 S komata    7352  7350  0  60   0    -  1365 nanosl 11:40 pts/2    00:00:00 mt

Windowsでのマルチスレッドプログラミング

 せっかくマルチスレッドを使用し、UNIX以外でもOKということですのでWindowsでの様子も紹介しましょう。

----- mt-win.c -----
#include        <stdio.h>
#include        <sys/types.h>
#include        <process.h>
#include        <windows.h>

unsigned __stdcall counter(void *arg)
{
int     i;
int	no;
int	pid;

	pid=_getpid();

	no=(int)arg;

        for(i=0;i<10;i++){
		_sleep(1000);
                printf("[%d][%d]%d\n",pid,no,i);
        }

	return(0);
}

void main()
{
int	thread_id1,thread_id2;
unsigned	dummy;
int	p_pid;

	p_pid=_getpid();

        printf("[%d]start\n",p_pid);

	thread_id1=_beginthreadex(NULL,0,counter,(void *)1,0,&dummy);
	if(thread_id1==0){
		fprintf(stderr,"pthread_create : %s",strerror(thread_id1));
	}
	else{
		printf("[%d]thread_id1=%d\n",p_pid,thread_id1);
	}

	thread_id2=_beginthreadex(NULL,0,counter,(void *)2,0,&dummy);
	if(thread_id2==0){
		fprintf(stderr,"pthread_create : %s",strerror(thread_id2));
	}
	else{
		printf("[%d]thread_id2=%d\n",p_pid,thread_id2);
	}

	WaitForSingleObject( (HANDLE)thread_id1, INFINITE );
	printf("[%d]thread_id1 = %d end\n",p_pid,thread_id1);
	WaitForSingleObject( (HANDLE)thread_id1, INFINITE );
	printf("[%d]thread_id2 = %d end\n",p_pid,thread_id2);

	printf("[%d]end\n",p_pid);
}

 Windowsでは、_beginthreadex()でスレッドを生成できます。VC++では「プロジェクトの設定」「C/C++」のカテゴリ「コード生成」で、使用するライブラリをマルチスレッドのものに指定しておきます。プロセスIDと、スレッドの番号を表示してみました。実行結果は下記のようになります。

実行結果

[3292]start
[3292]thread_id1=44
[3292]thread_id2=40
[3292][1]0
[3292][2]0
[3292][1]1
[3292][2]1
[3292][1]2
[3292][2]2
[3292][1]3
[3292][2]3
[3292][1]4
[3292][2]4
[3292][1]5
[3292][2]5
[3292][1]6
[3292][2]6
[3292][1]7
[3292][2]7
[3292][2]8
[3292][1]8
[3292][2]9
[3292][1]9
[3292]thread_id1 = 44 end
[3292]thread_id2 = 44 end
[3292]end

 このようにプロセスIDはどのスレッドでも同一で、動作はUNIXと同様です。

マルチスレッドでの注意点

 マルチスレッドはサンプルのように、プログラミング自体は特殊なことは無いのですが、一つ重要な注意事項があります。グローバル変数とスタティック変数です。スレッドが複数同時に処理され、それぞれのスレッドで同一のグローバル変数をアクセスするということは、マルチプロセスとは異なり、本当に同じ変数をアクセスすることになります。うまく利用すれば共通のパラメーターや状態なに使用できて便利なのですが、気軽に使ってしまうと、特に値を格納する際にスレッド間での衝突が発生します。

 グローバル変数よりさらに深刻な問題が、各関数で持っているスタティック変数です。ローカル変数は別々のスタックで確保されますので、独立ですが、スタティック変数はプロセスとして1つの領域に確保されますので、一カ所の領域になります。たとえば、strtok()はトークンの切り出しに便利な関数ですが、下記のようなソースで実際に問題が発生してしまいます。

void *client_thread(void *arg)
{
char	buf[80]="This is a pen.";
char	*ptr;

	for(ptr=strtok(buf," "),ptr!=NULL;ptr=strtok(NULL," ")){
		printf("[%s]\n",ptr);
	}

	return(arg);
}

 この関数がpthread_create()でスレッドとして複数並列実行されると、1回目のstrtok()で、strtok()内のスタティック領域にコピーした内容、ポインタが他のスレッドからのstrtok()で上書きされてしまいます。要するにスタティック変数を使用する関数は並列スレッド内では使用できないのです。

 これに対応するため、下記のようなスレッドセーフ関数が提供されています。これらを使用するように書き換えても良いですし、あるいは別のアルゴリズムに変更することが必要な場合もあるでしょう。並列スレッド内以外では問題ありません。

スレッドセーフ関数

int getlogin_r(char *name,size_t namesize);
int readdir_r(DIR *dirp,struct dirent *entry,struct dirent **result);
char *strtok_r(char *s,const char *sep,char **lasts);
char *asctime_r(const struct tm *tm,char *buf);
char *ctime_r(const time_t *clock,char *buf);
struct tm *gmtime_r(const time_t *clock,struct tm *result);
struct tm *localtime_r(const time_t *clock,struct tm *result);
int rand_r(unsigned int *seed);
int getgrgid_r(gid_t gid,struct group *group,char *buffer,size_t bufsize,struct group **result);
int getgrnam_r(const char *name,struct group *group,char *buffer,size_t bufsize,struct group **result);
int getpwuid_r(uid_t uid,struct passwd *pwd,char *buffer,size_t bufsize,struct passwd **result);
int getpwnam_r(const char *name,struct passwd *pwd,char *buffer,size_t bufsize,struct passwd **result);

 その他、実はmalloc()など、ライブラリ側でのスタティック変数を使用する物も並列呼び出しで問題になります。が、これらはlibpthread.aライブラリをリンクすると、マルチスレッド対応のライブラリ関数が使用されるようになり、問題は発生しないようになっています。具体的にはmalloc()などで使用するヒープ領域の情報などを保護するため、malloc()などの関数開始時にロック機構のmutexを使い、ロックし、関数終了時にアンロックし、処理中には同時に他のmalloc()が処理を行わないように排他制御がされています。このように、ある関数内で排他制御すれば良いものはライブラリ側で対応していますが、上記問題関数の用に、関数内スタティック領域を保持することを前提に設計された関数は代替関数が必要になります。これらの問題があるので、マルチプロセスを前提に作成したソースをマルチスレッドに変更するのは危険なのです。自分で作成した関数も良く注意しましょう。


よろしければブログもご覧ください
ゴルフ練習場紹介サイト:ゴルフ練習場行脚録更新中!
ipv400033230 from 1998/3/4