LoginSignup
80
75

More than 5 years have passed since last update.

スレッドを使う前に知るべき C 言語の特性

Last updated at Posted at 2014-07-23

この話はレベル感が重要なので、僕の経歴と対象読者を説明します。

僕はプログラマの仕事を始めて10年、ここ5年ほどは組み込み機器向け GUI プログラムを PC 上でデバッグしています。対象プログラムは基本的にシングルスレッドですが、ネットワーク通信などでマルチスレッドの機構を使うことがあり、スレッドの使い方を見よう見まねで覚えた中級者です。

そんな中級者から見て初心者は、スレッドの使い方を覚えた人ならみんなが勘付いているあることに気付いていないことがしばしばあります。

ここでは、マルチスレッドプログラミングの中級者から初心者に対して、スレッドを使う前に知るべきことを説明します。

説明に用いる環境は基本的に Linux の pthreads です。たまに Win32 のスレッドにも言及します。

C 言語のスレッドは自分が見たいものしか見ない

ここで、GUI プログラムでネットワーク通信だけは別スレッドに任せたときのことを考えてみましょう。例えば DNS 名前解決は場合によっては1秒以上かかることもあり、メインスレッドで実行すると終わるまで GUI イベント(マウス・キーボード)に反応しなくなります。そこで DNS 名前解決を他スレッドで行うことにします。(実はスレッドを使わないけどブロックしないオープンソースの DNS リゾルバはすでにあります。例えば UDNS があります。ここでは、それらは使わないことにします)

しかし、メインスレッドのプログラミングにちょっと困ったことがあって、とても長い時間がかかる計算ループを回してしまったとします。

以下にサンプルコードを示します。

/* 解決した IP アドレスは大域変数に格納 */
struct addrinfo *res;

/* pthreads で生成したスレッドでは getaddrinfo() を実行 */
dns_resolver_thread()
{
  getaddrinfo(....., &res);
}

/* pthreads で DNS 名前解決をするスレッドを生成 */
void
start_dns_resolver()
{
  pthread_create(..., dns_resolver_thread, ...);
}

int
main_loop()
{
  int i, j, dumped, sockfd;

  /* まず名前解決のスレッドを作成 */
  start_dns_resolver();

  /* ... だけど、メインスレッドでものすごい重い計算 */
  dumped = 1;
  for (i = 0; i < 0xfffffff; i++) {
    for (j = 0; j < 0xfffffff; j++) {
      dumped *= 2;
      dumped += 2;
      dumped /= 2;
    }
  }

  /* ここでようやく IP アドレスを使用 */
  sockfd = socket(...);
  connect(sockfd, res, ...);
}

このとき、メインスレッドは i と j をカウンタにして dumped を計算しているループの途中で DNS 名前解決が終わったことを認識してループを抜ける方法はあるでしょうか?

いいえ。このままではメインスレッドはループで計算している間、他のスレッドの動きに一切気づきません。何故か? ここで C 言語のスレッドの重要な特性がかかわってきます。

C 言語のスレッドは、自分が使うと決めた変数を自分が使うと決めた順番でしか参照しません。

上のサンプルコードでは、ループの中で i と j と dumped しか参照していませんから、メインスレッドは他のスレッドの動きに一切気づきません。

では、長いループではなく、ブロックする関数だとしたらどうでしょう?

Linux の GUI ライブラリとして DirectFB を選んで、イベントのメインループを書いてみます。

GUI イベントを取得する関数として WaitForEvent() 関数 を使うことにします。この関数はイベントが発生するまで無期限で停止します。

/* 解決した IP アドレスは大域変数に格納 */
struct addrinfo *res;

/* pthreads で生成したスレッドでは getaddrinfo() を実行 */
dns_resolver_thread()
{
  getaddrinfo(....., &res);
}

/* pthreads で DNS 名前解決をするスレッドを生成 */
void
start_dns_resolver()
{
  pthread_create(..., dns_resolver_thread, ...);
}

int
main_loop()
{
  int sockfd, exit;
  DFBResult res;

  /* まず名前解決のスレッドを作成 */
  start_dns_resolver();

  /* イベントループを実行 */
  while (true) {

    /* イベントが発生するまでブロック */
    res = WaitForEvent();

    /* ここでようやく IP アドレスを使用 */
    sockfd = socket(...);
    connect(sockfd, res, ...);

    /* さらに処理を行って... */
    ....
    exit = ...;
    ....
    /* 終了条件が整えばイベントループを抜けます */
    if (exit) {
      break;
    }
  }
}

このとき、メインスレッドは DirectFB の WaitForEvent() 関数がリターンする前に DNS 名前解決が終わったことを認識してイベントループの処理を実行する方法はあるでしょうか?

いいえ。このままではメインスレッドは WaitForEvent() 関数がブロックしている間、他のスレッドの動きに一切気づきません。何故か? ここでも C 言語のスレッドの重要な特性がかかわってきます。

C 言語のスレッドは、関数を呼び出すと、呼び出された関数がリターンするまで、他の変数を参照することも他の関数を呼び出すこともできません。

上のサンプルコードでは、WaitForEvent() 関数を呼び出していますから、WaitForEvent() 関数がリターンするまで他の変数を参照することも他の関数を呼び出すこともできません。

上に描いた2つの特性を一言にまとめると次のようになります。

C 言語のスレッドは、変数も関数も、自分が使うと決めたものだけを、自分が使うと決めた順序でしか、参照しません。自分が見たいものしか見ません。

この特性が、C 言語でマルチスレッドプログラミングを行う上で大変な制約になります。

ここで人間を思い浮かべてみましょう。他の人をからかって、こんなことをしたことはありませんか? 背中をつついて、相手が振り返ったところで頬に指をあてる。他愛ないいたずらです。人間は背中をつついて気付かせることができます。経験から、人間は、何か実体があると、外部から刺激して気づかせることができると考えます。

しかし C 言語のスレッドの背中をつつくことはできません。C 言語のスレッドに気づいてもらうには、あらかじめ「ここを見て」と指図したうえで、指示した場所に情報を書き込むしかないのです。

寝た C 言語のスレッドにイベントに起床してもらうには?

見たいものしか見ない C 言語のスレッドが寝てしまった場合、起床してもらうにはどうすればいいのでしょうか。それは、起床してほしいイベントのすべてをスレッド自身に参照してもらうしかありません。

GUI イベントで起床してもらうには GUI イベントを参照してもらいます。ネットワークイベントで起床してもらうにはネットワークイベントを参照してもらいます。GUI イベントとネットワークイベントのどちらかが発生すれば起床してもらいたいとしたら、療法参照してもらうしかありません。

先ほど例に挙げた DirectFB の開発者はそのことに気づいていて、救済策を用意しています。Wakeup() 関数 は、メインスレッドが WaitForEvent() 関数か WaitForEventWithTimeout() 関数 で寝ているときに呼び出すと、WaitForEvent() 関数と WaitForEventWithTimeout 関数を強制的にリターンさせます。メインスレッドは、呼び出した関数がリターンするので、次の処理を始められるのです。

さきのサンプルコードを修正すると次のようになります。

/* 解決した IP アドレスは大域変数に格納 */
struct addrinfo *res;

/* DirectFB のイベントバッファも大域変数に格納 */
IDirectFBEventBuffer eb;

/* getaddrinfo() が終わったら WakeUp() でメインスレッドを起床させる */
dns_resolver_thread()
{
  getaddrinfo(....., &res);
  WakeUp(&eb);
}

/* pthreads で DNS 名前解決をするスレッドを生成 */
void
start_dns_resolver()
{
  pthread_create(..., dns_resolver_thread, ...);
}

int
main_loop()
{
  int sockfd, exit;
  DFBResult res;

  /* まず名前解決のスレッドを作成 */
  start_dns_resolver();

  /* イベントループを実行 */
  while (true) {

    /* イベントのほかに、WakeUp() 関数が実行されるとリターンする */
    res = WaitForEvent(&eb);

    /* ここでようやく IP アドレスを使用 */
    sockfd = socket(...);
    connect(sockfd, res, ...);

    /* さらに処理を行って... */
    ....
    exit = ...;
    ....
    /* 終了条件が整えばイベントループを抜けます */
    if (exit) {
      break;
    }
  }
}

Linux の他のライブラリの話をすると、生の select() を使った場合にネットワークのソケットしか監視しないと GUI イベントに気づかず起床しなくなります。GLib を使っていれば、メインループにネットワークソケットも登録できます(例: g_source_attach() 関数)から、GUI イベントとネットワークイベントを同時に参照することも可能です。

Win32 だと、初期 WinSock はメインループにウィンドウメッセージを送りましたから GUI イベントとネットワークイベントを同時に参照できましたが性能が低く、WinSock2 からは Win32 のスレッドの同期機構を使うようになりました。解説書だと WaitForSingleObject() API が紹介されることもありますが、それだとウィンドウメッセージで起床しなくなってシステムがデッドロックすることがあります(MSDN の注意書きを確認しましょう)から、他の API を使うか、メインスレッドをブロックさせる設計をあきらめるなどの対策が必要になることがあります。

では、もう話は終わったのかというと、そうではありません。

さきの DirectFB の場合、切り札だった WakeUp 関数について、寝ているときに呼び出すと、と書きました。メインスレッドが寝ていない場合、WakeUp 関数は痕跡を残しません。もしもスレッドを生成してから WaitForEvent() 関数を呼び出すまでの間に DNS 名前解決が終わった場合、WakeUp 関数は痕跡を残さないので、WaitForEvent() 関数は他のスレッドの動作に気づかず GUI イベントが来るまでブロックします。これでは困ります。

これを解決するには、何かしら DNS 名前解決が終わったことを示す痕跡を残さなくてはなりません。スレッド間で状態を伝達する変数を作ることになります。複数のスレッドで操作されるので同期も必要です。

サンプルコードをもう一度修正すると次のようになります。

/* 解決した IP アドレスは大域変数に格納 */
struct addrinfo *res;

/* DirectFB のイベントバッファも大域変数に格納 */
IDirectFBEventBuffer eb;

/* イベント間で状態を受け渡す変数も大域変数に用意 */
int global_resolved = false;

/* mutex も大域変数に用意 */
pthread_mutex_t mutex;

/* getaddrinfo() が終わったら
   大域変数に終了したことを書き込み
   WakeUp() でメインスレッドを起床させる */
dns_resolver_thread()
{
  getaddrinfo(....., &res);
  pthread_mutex_lock(&mutex);
  global_resolved = true;
  pthread_mutex_unlock(&mutex);
  WakeUp(&eb);
}

/* pthreads で DNS 名前解決をするスレッドを生成 */
void
start_dns_resolver()
{
  pthread_create(..., dns_resolver_thread, ...);
}

int
main_loop()
{
  int sockfd, exit, resolved;
  DFBResult res;

  /* まず名前解決のスレッドを作成 */
  start_dns_resolver();

  /* イベントループを実行 */
  while (true) {

    /* DNS 名前解決のスレッドの結果を確認 */
    pthread_mutex_lock(&mutex);
    resolved = global_resolved;
    global_resolved = false;
    pthread_mutex_unlock(&mutex);

    /* DNS の名前解決が終わっていれば GUI イベントを待たない */
    if (!resolved) {
      /* GUI イベントを待っている場合、WakeUp() 関数が実行されるとリターンする */
      res = WaitForEvent(&eb);
    }

    /* ここでようやく IP アドレスを使用 */
    sockfd = socket(...);
    connect(sockfd, res, ...);

    /* さらに処理を行って... */
    ....
    exit = ...;
    ....
    /* 終了条件が整えばイベントループを抜けます */
    if (exit) {
      break;
    }
  }
}

1回の DNS 名前解決を待つだけで、これだけ複雑になります。

(追記

後で見直したら、このコードも全く同じバグを抱えていました。大域変数 global_resolved を読む箇所と WaitForEvent() 関数を呼ぶところが分かれているので、スレッド切り替えによって次の順序で実行されるとブロックするんです。

  1. メインスレッドが global_resolved を読み込むが false
  2. DNS 名前解決が終わり global_resolved を true にする
  3. WakeUp() 関数を呼び出すが WaitForEvent() 関数が呼び出されていないので何も起きない
  4. WaitForEvent() 関数を呼び出してブロック

pthreads の mutex をロックしたまま WaitForEvent() 関数を呼び出すわけにいかないので、フラグ操作と GUI イベント待ちを不可分に行う方法を思いつきません。

たったこれだけでもバグ持ちのコードを公開して、信用されてないですよね。)

もしワーカースレッドを作って複数の仕事を順番に実行したら? 複数のスレッドが同時に実行されて、どれか一つのイベントが発生したらメインスレッドを起床させるとしたら? そのときは、参照するイベントをすべて列挙しなければいけないのです。

関数型言語を推す方からは、そんなことを考えなければいけないから手続型言語は古いしおかしいと言われます。納得します。でも、C 言語でスレッドを生で使う分野はまだ絶滅していなくて、僕はその分野でお給料をいただいています。

C 言語でマルチスレッドプログラミングをするときは、寝たスレッドが起床するために必要なイベントをすべて参照しているかと自問自答すると、自分のアプリに必要な同期機構が分かります。

80
75
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
80
75