やかんです。

今日はOSのスレッド間の同期について復習していこうと思います。内容としては、若干高度な気がしてます。

東大生やかんのブログ
やかん

そうでもないのか?

同期について。

同期で扱うのは以下3つ。

  1. 排他制御
  2. バリア同期
  3. 条件変数
東大生やかんのブログ
やかん

他にもあるのかもしれませんが、授業で扱われたのは上記3つなはず。

今回扱う同期ですが、これの理解のためにはスレッドのスケジューリングとかについて理解しておく必要があります。悪しからず。

排他制御

排他制御に限らずですが、スレッド間の同期が必要になるのは2つ以上のスレッドを作った場合です。当然のことですが、これをそこそこ強く意識しておくと、実装が楽だったりします。

東大生やかんのブログ
やかん

あくまで主観ですが。

まずはシンプルな例。

排他制御では、pthread_mutex_lockpthread_mutex_unlockの2つのAPIを主に使います。

イメージとしては、pthread_mutex_lockpthread_mutex_unlockで挟まれた部分については、プログラムの実行の不可分性が保証される、と言った感じ。

実装において意識しておくとやりやすいな、と感じるのは以下2つ。

  • lock, unlockのapiは、スレッドに実行させる関数内で呼ぶ(つまり、pthread_createの第三引数に指定する関数)
  • lock, unlockは、スレッドを2つ以上作る場合に登場する
東大生やかんのブログ
やかん

これもまたあくまで僕の主観。

ということで、色々端折ったけどちょいちょい余計なものが含まれているサンプルコードがこちら。

#include <pthread.h>

pthread_mutex_t lock; // これはとりあえず「おまじない」と考えて良いと思う。詳細は後ほど。

void *f()
{
  pthread_mutex_lock(&lock);
  /**
   * ここに記述されたプログラムは不可分性が保証される。
   */
  pthread_mutex_unlock(&lock);
}

int main()
{
  pthread_mutex_init(&lock, 0); // これもとりあえず「おまじない」と考えて良いと思う。

  pthread_t t1, t2; // 排他制御はスレッドの個数が2つ以上の時に登場。よって、2つのpthread_tを宣言。

  pthread_create(&t1, 0, f, 0); // 1つ目のスレッド作成。
  pthread_create(&t2, 0, f, 0); // 2つ目のスレッド作成。

  pthread_join(t1, 0); // 1つ目のスレッドの終了を待つ。
  pthread_join(t2, 0); // 2つ目のスレッドの終了を待つ。

  return 0;
}

pthread_mutex_lockpthread_mutex_unlockの使い方自体はとても直感的ですよね。不可分性を保証したいプログラムの箇所を囲むだけなので。

となると、ここで気になるのはpthread_mutex_tとして宣言されたlockという変数なはず。

東大生やかんのブログ
やかん

私だけでしょうか。

pthread_mutex_tってなんぞ?

※ここの内容はGPTを根拠にしているのでちょっと怪しいかも(こちら)。

pthread_mutex_tは、「排他制御の実現のために必要な情報を詰め込んだ構造体」です。

例えば、

  • ロック状態(ロック中なのかそうでないのか)
  • ロック状態を変更したスレッドのスレッドID
  • 待機しているスレッド

などの情報が含まれています。

こうした、比較的複雑な構造体を格納する変数だからこそ、宣言(グローバルにpthread_mutex_tとして宣言される)と値の初期化(pthread_mutex_initで値が書き込まれる)が別々に行われている、というように理解することも可能なんじゃないかな、とか思ったりします。

で、こちらの排他制御ですが、実際の実装においては、排他制御用のデータ構造を定義して実装を進めたりします。まあでもこれは本質的じゃない気がするのでここでははしょります。

バリア同期

バリア同期のイメージは、「複数スレッドの足並みを揃える」です。これもまた同期についての実装なので、以下の状態までは機械的に記述することができます。

#include <pthread.h>

void *f() {}

int main()
{
  pthread_t t1, t2;

  pthread_create(&t1, 0, f, 0);
  pthread_create(&t2, 0, f, 0);

  pthread_join(t1, 0);
  pthread_join(t2, 0);

  return 0;
}

実際にこのコード書いたら怒られそうですけどね。。エラー処理や冗長な記述など。

東大生やかんのブログ
やかん

まあ、お勉強目的なので。

で、バリア同期を実装したシンプルサンプルコードがこちら。pthread_barrier apiを使用します(太字)。

#include <stdio.h>
#include <pthread.h>

pthread_barrier_t barrier;

void *f()
{
  pthread_barrier_wait(&barrier);

  return 0; // 全てのスレッドでpthread_barrier_waitが実行されないとreturnされない。
}

int main()
{
  pthread_t t1, t2;

  pthread_barrier_init(&barrier, 0, 2); // 第三引数でスレッドの数を指定。

  pthread_create(&t1, 0, f, 0);
  pthread_create(&t2, 0, f, 0);

  pthread_join(t1, 0);
  pthread_join(t2, 0);

  pthread_barrier_destroy(&barrier);

  return 0;
}

実装において意識しておくと便利なのは「pthread_barrier_waitはスレッドに渡す関数の中で呼ぶ」ということですかね。

主な流れとしては、

  1. pthread_barrier_tを宣言
  2. pthread_barrier_initでバリア同期の初期化

の2ステップで、最後のpthread_barrier_destroyは、「バリアをリセットするよ」くらいの意味合いで理解して大丈夫だと思います。

バリア同期の気持ちとしては、「スレッドの終了は揃えるよ」といったところじゃないでしょうか。開始のタイミングはバラバラでも、終了のタイミングはpthread_barrier_waitによって揃えてあげると。

バリア同期については授業でもさらっと扱われた程度だったはずなので、復習としてもこの辺で切り上げようと思います。

東大生やかんのブログ
やかん

機会があればもっとしっかり勉強します。

条件変数

はい。きました条件変数。僕は条件変数が同期の主役だと思っています。

東大生やかんのブログ
やかん

なぜなら授業で中心的に扱われていたから!

実装におけるコツは

  • 条件変数を利用する際は、必ず排他制御を用いるから、最初に排他制御オブジェクトと条件変数オブジェクトを宣言してあげる
  • 2個以上のスレッドを作成する
  • スレッドの数に応じた関数を作成する

の3点を、機械的に最初に行なってしまうことだと思います。3つ目についてはマストではないんですが、実装のしやすさの観点から、最初に行なってしまうのが楽だと感じます。

で1点目について付言すると、条件変数は排他制御をもっとフレキシブルにしたもの、というイメージです。排他制御の上に成り立っているので、排他制御オブジェクトと条件変数オブジェクトをどっちも宣言してあげる必要があるんですね。

東大生やかんのブログ
やかん

条件変数は割と高度な話なんじゃないかなあ。

ということで、以下の状態までは機械的に記述してしまうのがいいんじゃないかなと思っています。

#include <pthread.h>

pthread_mutex_t lock;
pthread_cond_t cond;

void *f1() {}

void *f2() {}

int main()
{
  pthread_t t1, t2;

  pthread_mutex_init(&lock, 0);
  pthread_cond_init(&cond, 0);

  pthread_create(&t1, 0, f1, 0);
  pthread_create(&t2, 0, f2, 0);

  pthread_join(t1, 0);
  pthread_join(t2, 0);

  return 0;
}

条件変数の定型文を書く。

条件変数を使うときは、条件変数の仕組み自体がまあまあ複雑なので条件変数の定型文みたいなものを念頭に、そこからアレンジを加えていくと楽です。

で、その定型文みたいなものが以下。

#include <pthread.h>

pthread_mutex_t lock;
pthread_cond_t cond;

void *f1()
{
  pthread_mutex_lock(&lock);

  if ("ここに何らかの条件を書く")
  {
    pthread_cond_wait(&cond, &lock);
  }

  pthread_mutex_unlock(&lock);
}

void *f2()
{
  pthread_mutex_lock(&lock);

  if ("ここに何らかの条件を書く")
  {
    pthread_cond_broadcast(&cond);
  }

  pthread_mutex_unlock(&lock);
}

int main()
{
  pthread_t t1, t2;

  pthread_mutex_init(&lock, 0);
  pthread_cond_init(&cond, 0);

  pthread_create(&t1, 0, f1, 0);
  pthread_create(&t2, 0, f2, 0);

  pthread_join(t1, 0);
  pthread_join(t2, 0);

  return 0;
}

でも、これだとうまく動作しません。結論を先んじて述べると、以下のコードに修正する必要があります。

#include <pthread.h>

pthread_mutex_t lock;
pthread_cond_t cond;

void *f1()
{
  pthread_mutex_lock(&lock);

  while (1)
  {
    if ("ここに何らかの条件を書く")
    {
      break;
    }

    pthread_cond_wait(&cond, &lock);
  }

  pthread_mutex_unlock(&lock);
}

void *f2()
{
  pthread_mutex_lock(&lock);

  if ("ここに何らかの条件を書く")
  {
    pthread_cond_broadcast(&cond);
  }

  pthread_mutex_unlock(&lock);
}

int main()
{
  pthread_t t1, t2;

  pthread_mutex_init(&lock, 0);
  pthread_cond_init(&cond, 0);

  pthread_create(&t1, 0, f1, 0);
  pthread_create(&t2, 0, f2, 0);

  pthread_join(t1, 0);
  pthread_join(t2, 0);

  return 0;
}

はい。pthread_cond_waitを呼び出す際、ただ条件分岐して呼び出すのではなく、その外側をループ処理で囲ってあげる必要があるんですね。

これは、スレッドの状態(running, runnable, blocked)はpthread_cond api以外によっても変更されうるためです。

東大生やかんのブログ
やかん

↑これをすんなり理解できるか、というのは条件変数への理解のメルクマールになるんじゃないかな。。ちょっと、わからないけど。

ということで、あとはこの定型文をアレンジして実装していこうね、という話になります。

同期は結構ボリューミーなので量がかさんでしまいました。が、とりあえずこんな感じでいいんじゃないかな!わからないけど!

次回は同期の関連話題としてLost Wake Up問題と不可分更新命令について復習しようと思います。

最後までお読みいただき、ありがとうございます。