はじめに
スレッド(thread)は、代表的な非同期処理の仕組みの1つで、例えるならプロセスの中で動くプロセスという感じで、軽量プロセス(lightweight process)と呼ばれたりもします。pthreadはPOSIXが仕様化したスレッドモデルで、POSIX仕様を満たしているOS間では基本的には移植が可能ですが、個人的な感想では、主にUNIX系OSで使用されているようです。
大変便利なんですが、しかし複数のスレッドを矛盾無く動かす事はとても難しく、またデバックも困難で、作りを誤ると環境次第で動きが違ったりします。有用な実装方法はネット上でもそれ程見当たらず、本も少ないうえに英文を強引に訳してるだけの古い本ばかりで、なかなか理解が困難だと思います。
当レポートはpthreadに関する調査・試行錯誤した結果、躓きやすい箇所、実装依存と思われる箇所等、独断と偏見と誤解とたくさんのサンプルを記載します。サンプルはCと一部C++、調査環境はFedora 8(2.6.23.1-49.fc8)、32bit、glibc-4.1-2、gcc-4.1.2-33およびFedora Core 6(2.6.18-1.2798.fc6)、32bit、glibc-2.5-3、gcc-4.1.1-30です。
レポートの概要等
以下のような内容について述べていきます。
項目 | 説明 |
概要 | スレッドの概要、およびスレッドとプロセスの違いについて。 |
生成 | スレッドの生成方法、起動可能最大数について。 |
同期(mutex・spin・rwlock) | スレッド間やプロセスとの同期処理について。 |
条件変数 | スレッド間やプロセスとの条件変数の使用法について。 |
モデル | 生成・同期・条件変数を使用したアプリケーションと解説。 |
スレッド固有データ | スレッド独自のデータ作成・管理方法について。 |
スタックサイズ | スタックサイズの変更法、および影響について。 |
スケジューリング | スレッドのスケジューリング設定法、およびその影響や注意点について。 |
キャンセル | スレッドのキャンセル方法、およびその影響や注意点について。 |
シグナル | スレッドでシグナルを安全に使う方法について。 |
その他(バリア・mutexattr) | バリアの使用法、mutexattrの種類と動きの違いについて。 |
サンプルプログラムは100行前後程度までは画面に記載します。全プログラムは圧縮してダウンロード可能にしています。make
コマンドでコンパイルできます。
また、プログラムのボリューム上、エラー処理やdestroy処理や引数チェックなどを省いていますが、どうかご容赦ください。ご指摘や質問や調査など大歓迎、可能な限りの対応に努めます。
1. スレッドの概要(1/2)
スレッドの意味は、あくまでもコンピュータ上の処理の単位です。例えばバッチプログラムも1つのスレッドです。"マルチ(多重化)"でないスレッドは、"シングルスレッド"と呼べますが、わざわざスレッドと呼ばすに"シングルプロセス"とか"シングルタスク"と呼びます。
ではマルチスレッドとシングルスレッドの違いは何か。そしてマルチタスクとの違いは何か。概念的な話をするよりかは実際のコードとその動きを用いて説明します。
1.1 非同期プログラミング例
以下のアラームプログラムは、一度に一個のアラームしか処理できません。アラームが動いている時は他のアラームを処理できませんし、新規アラームを受け付ける事もできません。このプログラムは"シングルスレッド"および"シングルタスク"と呼ばれる類に属します。
/* gcc alarm_single.c -o alarm_single -g -W -Wall */ #include <stdio.h> #include <string.h> #include <unistd.h> int main( ) { int sec; int cnt; char line[256]; char msg[256]; while( 1 ) { printf( "Alarm ( sec msg ) --> " ); fgets( line, sizeof( line ), stdin ); if( strlen( line ) <= 1 ) { continue; } if( memcmp( line, "quit", 4 ) == 0 ) { break; } line[strlen( line ) - 1] = '\0'; if( sscanf( line, "%d %s", &sec, msg ) < 2 ) { continue; } for( cnt = 0; cnt < sec; cnt ++ ) { printf( "[%d] (%d) %s\n", sec, cnt, msg ); sleep( 1 ); } } return 0; }
このプログラムを非同期にする方法として、例えばプログラム自体を複数分コピーしてしまう事で対応します。つまりアラームをforkする事でアラーム自体は子プロセスで対処し、アラーム受付を親プロセスが行います。元のプログラムに比べてもさほど複雑ではありません。
/* gcc alarm_fork.c -o alarm_fork -g -W -Wall */ #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <signal.h> #include <wait.h> int main( int argc, char ** argv ) { struct sigaction sa; int sec = 0; int i = 0; char line[256]; char msg[256]; pid_t pid = 0; if( argc < 2 ) return( 1 ); FILE * fp = fopen( argv[1], "w" ); if( fp == 0 ) { return( 1 ); } sa.sa_handler = SIG_IGN; sigemptyset( &sa.sa_mask ); sigaction( SIGCHLD, &sa, 0 ); while( 1 ) { printf( "Alarm ( sec msg ) --> " ); fgets( line, sizeof( line ), stdin ); if( strlen( line ) <= 1 ) { continue; } if( memcmp( line, "quit", 4 ) == 0 ) break; line[strlen( line ) - 1] = '\0'; if( sscanf( line, "%d %s", &sec, msg ) < 2 ) { continue; } pid = fork( ); if( pid == 0 ) { for( i = 0; i < sec; i ++ ) { fprintf( fp, "[%d] (%d) %s\n", sec, i, msg ); fflush( fp ); sleep( 1 ); } exit( 0 ); } } fclose( fp ); kill( -( getpid( )), SIGINT ); return 0; }
上記プログラムは画面からアラームを受け付けたら子プロセスを起動し、子プロセスがアラーム処理を行っています。複数プロセスが同時にアラーム内容を出力する事になり、出力先が画面だと邪魔臭いので、出力先ファイル名を引数として受け取り、そこにアラーム内容を書き出します。
ターミナル上で、"./alarm_fork alarm.log"と起動した場合、別のターミナルで、"tail -f ./alarm.log"とすることでアラーム内容をリアルタイムで観察できます。
マルチプロセスの場合、子プロセスの終了を親プロセスが検知し適切に処理しなくてはいけません。子プロセスがexitした際に親プロセスへ配信するSIGCHLDを無視する事で対処しています。このプログラムはアラーム処理を"プロセス"が行っています。複数のプロセスが処理を平行して行っていて、つまり"マルチプロセス"化されています。
もう1つ別のアプローチ、スレッドを用いてプログラムを書きます。アラーム処理を行うのがプロセスかスレッドかの違いくらいで、動きおよび引数等だけ見れば、まったく同じです。
/* gcc alarm_thread.c -o alarm_thread -g -W -Wall -lpthread */ #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <pthread.h> #define BUF_LEN 256 void * alarm_func( void * arg ); typedef struct { int sec; FILE * fp; char msg[BUF_LEN]; } alarm_t; int main( int argc, char ** argv ) { alarm_t * alarm; pthread_t pt; char line[BUF_LEN]; if( argc < 2 ) return( 1 ); FILE * fp = fopen( argv[1], "w" ); if( fp == 0 ) { return( 1 ); } while( 1 ) { printf( "Alarm ( sec msg ) --> " ); fgets( line, sizeof( line ), stdin ); if( strlen( line ) <= 1 ) { continue; } if( memcmp( line, "quit", 4 ) == 0 ) { break; } alarm = malloc( sizeof( alarm_t )); alarm -> fp = fp; if( sscanf( line, "%d %s", &alarm -> sec, alarm -> msg ) < 2 ) { free( alarm ); continue; } pthread_create( &pt, NULL, &alarm_func, alarm ); } fclose( fp ); return 0; } void * alarm_func( void * arg ) { alarm_t * alarm = ( alarm_t * )arg; int cnt; pthread_detach( pthread_self( )); for( cnt = 0; cnt < alarm -> sec; cnt ++ ) { flockfile( alarm -> fp ); fprintf( alarm -> fp, "[%d] (%d) %s\n", alarm -> sec, cnt, alarm -> msg ); fflush( alarm -> fp ); funlockfile( alarm -> fp ); sleep( 1 ); } free( alarm ); return 0; }
プログラム中の alarm_func 関数がスレッド本体です。pthread_create によって生成されたスレッドは第4引数で指定された引数を持って第3引数で指定された関数を起動し、当関数が終了すればスレッドも終了します。alarm_func 関数は pthread_detach 関数を呼ぶことで親スレッド(つまり親プロセス)から分離し、勝手に終る事を宣言しています。こうする事で親プロセスはスレッドの生死を管理しなくてもよくなります。
マルチプロセスの場合、子プロセスを起動する際に親プロセスのメモリ情報をそのままコピーしているため、ファイルディスクリプタなどのスタック情報をそのまま使用できますが、スレッドの場合、呼び出し元とは同じメモリ領域で動いては居ますが、スタックが異なるため、呼び出し元(alarm_thread.cで言うところのmain関数)のスタック上の情報は参照できません。よって、わざわざ構造体を用意して渡しています。
以降、当レポートではスレッドを「pthread_createによって生成された、親プロセスから分離した処理」という意味で使用します。