Linux スレッド シリーズでは、スレッドが終了する方法と、終了したスレッドから親スレッドに戻りステータスが渡される方法について説明しました。この記事では、スレッド同期として知られる重要な側面に光を当てます。
Linux スレッド シリーズ:パート 1、パート 2、パート 3、パート 4 (この記事)。
スレッド同期の問題
コード例を見て同期の問題を調べてみましょう:
#include<stdio.h> #include<string.h> #include<pthread.h> #include<stdlib.h> #include<unistd.h> pthread_t tid[2]; int counter; void* doSomeThing(void *arg) { unsigned long i = 0; counter += 1; printf("\n Job %d started\n", counter); for(i=0; i<(0xFFFFFFFF);i++); printf("\n Job %d finished\n", counter); return NULL; } int main(void) { int i = 0; int err; while(i < 2) { err = pthread_create(&(tid[i]), NULL, &doSomeThing, NULL); if (err != 0) printf("\ncan't create thread :[%s]", strerror(err)); i++; } pthread_join(tid[0], NULL); pthread_join(tid[1], NULL); return 0; }
上記のコードは単純なもので、2 つのスレッド (ジョブ) が作成され、これらのスレッドの開始関数で、開始されたジョブ番号と完了したときにユーザーがログを取得するカウンターが維持されます。コードとフローは問題ないように見えますが、出力を見ると :
$ ./tgsthreads Job 1 started Job 2 started Job 2 finished Job 2 finished
最後の 2 つのログに注目すると、「ジョブ 1 が終了しました」というログは見られませんが、「ジョブ 2 が終了しました」というログが 2 回繰り返されていることがわかります。
ここで、コードに戻って論理的な欠陥を見つけようとしても、おそらく簡単に欠陥を見つけることはできないでしょう。しかし、コードの実行を詳しく見て視覚化すると、次のことがわかります。
- 「ジョブ 2 開始」というログは「ジョブ 1 開始」の直後に出力されるため、スレッド 1 の処理中にスケジューラがスレッド 2 をスケジュールしたと簡単に結論付けることができます。
- 上記の仮定が正しければ、ジョブ 1 が終了する前に「カウンター」変数の値が再び増加します。
- そのため、ジョブ 1 が実際に終了したときに、カウンタの値が間違っていると、スケジューラに依存しているため、実際のジョブ 2 の「ジョブ 2 が終了しました」の後に「ジョブ 2 が終了しました」というログが生成されました。
- 繰り返しのログではなく、「カウンター」変数の値が間違っていることが問題であることがわかりました。
実際の問題は、最初のスレッドが使用中または使用しようとしているときに、2 番目のスレッドが変数「counter」を使用することでした。つまり、共有リソース「カウンター」を使用しているときにスレッド間の同期が取れていなかったために問題が発生したと言えます。一言で言えば、この問題は 2 つのスレッド間の「同期の問題」が原因で発生したと言えます。
ミューテックス
基本的な問題を理解したので、その解決策について説明しましょう。スレッド同期を実現する最も一般的な方法は、ミューテックスを使用することです。
Mutex は、共有リソースを使用する前に設定し、使用後に解放するロックです。ロックが設定されると、他のスレッドはコードのロックされた領域にアクセスできなくなります。したがって、スレッド 1 が共有リソースへのアクセスを完了していない間にスレッド 2 がスケジュールされていて、コードがミューテックスを使用してスレッド 1 によってロックされている場合でも、スレッド 2 はコードのその領域にアクセスすることさえできないことがわかります。したがって、これにより、コード内の共有リソースへの同期アクセスが保証されます。
内部的には次のように機能します:
- あるスレッドがミューテックスを使用してコードの領域をロックし、そのコードを実行しているとします。
- スケジューラがコンテキスト スイッチを実行することを決定した場合、同じ領域を実行する準備ができている他のすべてのスレッドのブロックが解除されます。
- すべてのスレッドの 1 つだけが実行に到達しますが、このスレッドが既にロックされているコードの同じ領域を実行しようとすると、再びスリープ状態になります。
- コンテキストの切り替えは何度も行われますが、ミューテックス ロックが解除されるまで、コードのロックされた領域を実行できるスレッドはありません。
- Mutex ロックは、それをロックしたスレッドによってのみ解放されます。
- これにより、スレッドがコードの一部をロックすると、それをロックしたスレッドによってロックが解除されるまで、他のスレッドは同じ領域を実行できなくなります。
- したがって、このシステムは、共有リソースで作業している間、スレッド間の同期を保証します。
ミューテックスが初期化され、次の 2 つの関数を呼び出すことによってロックが達成されます。
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); int pthread_mutex_lock(pthread_mutex_t *mutex);
最初の関数はミューテックスを初期化し、2 番目の関数を使用してコード内の重要な領域をロックできます。
次の関数を呼び出すことで、ミューテックスのロックを解除して破棄できます:
int pthread_mutex_unlock(pthread_mutex_t *mutex); int pthread_mutex_destroy(pthread_mutex_t *mutex);
上記の最初の関数はロックを解放し、2 番目の関数はロックを破棄して、将来どこでも使用できないようにします。
実際の例
スレッド同期にミューテックスが使用されているコードを見てみましょう
#include<stdio.h> #include<string.h> #include<pthread.h> #include<stdlib.h> #include<unistd.h> pthread_t tid[2]; int counter; pthread_mutex_t lock; void* doSomeThing(void *arg) { pthread_mutex_lock(&lock); unsigned long i = 0; counter += 1; printf("\n Job %d started\n", counter); for(i=0; i<(0xFFFFFFFF);i++); printf("\n Job %d finished\n", counter); pthread_mutex_unlock(&lock); return NULL; } int main(void) { int i = 0; int err; if (pthread_mutex_init(&lock, NULL) != 0) { printf("\n mutex init failed\n"); return 1; } while(i < 2) { err = pthread_create(&(tid[i]), NULL, &doSomeThing, NULL); if (err != 0) printf("\ncan't create thread :[%s]", strerror(err)); i++; } pthread_join(tid[0], NULL); pthread_join(tid[1], NULL); pthread_mutex_destroy(&lock); return 0; }
上記のコードでは:
- メイン関数の先頭でミューテックスが初期化されます。
- 共有リソース「カウンター」を使用している間、同じミューテックスが「doSomeThing()」関数でロックされています
- 関数「doSomeThing()」の最後で、同じミューテックスがロック解除されます。
- 両方のスレッドが完了するメイン関数の最後で、ミューテックスは破棄されます。
出力を見ると、次のことがわかります:
$ ./threads Job 1 started Job 1 finished Job 2 started Job 2 finished
したがって、今回は両方のジョブの開始ログと終了ログが存在していたことがわかります。そのため、Mutex を使用してスレッド同期が行われました。