🦀

タラバガニはカニじゃない(C++ の condition_variable は variable じゃない)

2022/10/29に公開約8,000字

名前による先入観に惑わされることは、日常生活だけでなくプログラミングの世界でもそれなりに起きる。名前は大切だ。かの有名な The Art of Readable Code(邦訳・リーダブルコード)でも Chapter 3. Names That Can’t Be Misconstrued で1章丸ごと使って名前の大切さを説いている。

名前は大切だ。名は体を表す。そして、C++ の condition_variable は日本語で条件変数と訳されており、その名前からは「何らかの条件を保持する変数」のような性質を持つことが予想できるだろう。

実は C++ の condition_variable は variable ではない。何らかの状態を保持するという変数的な性質は全くない[1]のだ。名前は本当に大切である。

condition_variable の基本

condition_variable に対してできる操作は、基本的には notify と wait の2つだけ。この2つの操作を睡眠に例えると非常に分かりやすいので、これ以降 notify を「起こす」、wait を「寝る」と表現する[2]。condition_variable を使えば、スレッドは自らの意思で寝ることができ、別のスレッドがこの寝ているスレッドを起こすこともできる。系列毎の具体的な操作は以下の通り。

condition_variable のメンバ関数
notify 系 notify_one, notify_all
wait 系 wait, wait_for, wait_until

「起こす」は「1人だけ起こす (notify_one)」と「みんな起こす (notify_all)」の2種類。
「寝る」には「永遠に寝る (wait)」「一定時間寝る (wait_for)」「ある時刻まで寝る (wait_until)」の3種類がある。

片方のスレッドが condition_variable を介して眠り、それをもう片方のスレッドが起こすという簡単なサンプルを見てみよう。

#include <iostream>
#include <thread>
#include <condition_variable>
#include <mutex>
#include <chrono>

int main()
{
    std::mutex mtx; // wait で必要(とりあえず無視)
    std::condition_variable cv; // これを介して睡眠をコントロールする

    // サブスレッド
    std::thread th([&]() {
        std::unique_lock<std::mutex> lck(mtx); // wait で必須(とりあえず無視)
        cv.wait(lck); // もういきなり寝ちゃうよ
        std::cout << "woke up" << std::endl; // 起きたよ
    });

    // メインスレッド
    std::this_thread::sleep_for(std::chrono::seconds(1)); // 1秒もあれば寝るはず
    cv.notify_one(); // 寝てるはずだから起こすよ

    th.join();
}

最初にいきなり寝てしまうサブスレッドを作り、メインスレッドはそのサブスレッドが確実に寝るのを1秒だけ待ってから notify_one で起こしている。簡単なサンプルなので、処理の流れは分かりやすいと思う。condition_variable はスレッドの動作状態をコントロールするための仕組みと言えるだろう。

もう少し詳しく

ところで、このサンプルは物事を単純化しすぎていて、1つ大きな問題を抱えている。それはサブスレッドが1秒もあれば寝るだろうという仮定の上に成り立っている点だ。
もちろん1秒もあれば確実に寝るとは思う。ただ、そこには何の保証もないし、こういう何の保証もない仮定はここぞという場面で崩れてバグになりやすい。

仮にサブスレッドが1秒で寝なかったとしたらどうなるだろう? 何かの都合でサブスレッド側に修正が入り、寝る前に1秒以上かかる前処理が必要になってしまったとしたら? サブスレッドの担当者は最近入った新しいメンバーで、メインスレッドが1秒待ってから起こしにかかるなんてことは知らない。この新人は、前処理のコードを書いたら動かなくなってしまうプログラムに頭を抱えてしまうだろう[3]

#include <iostream>
#include <thread>
#include <condition_variable>
#include <mutex>
#include <chrono>

int main()
{
    std::mutex mtx; // wait で必要(とりあえず無視)
    std::condition_variable cv; // これを介して睡眠をコントロールする

    // サブスレッド
    std::thread th([&]() {
        std::this_thread::sleep_for(std::chrono::seconds(2)); // 前処理を追加
        std::unique_lock<std::mutex> lck(mtx);
        cv.wait(lck);
        std::cout << "woke up" << std::endl;
    });

    // メインスレッド
    std::this_thread::sleep_for(std::chrono::seconds(1)); // 1秒もあれば寝るはず
    cv.notify_one(); // 寝てるはずだから起こすよ

    th.join();
}

このプログラムは終わらない。"woke up" すら出力されない。自分が追加した前処理のどこかに不具合があるに違いないと慌てる新人さん、あなたは悪くない。

ここで「notify_one は寝ているスレッドが無ければ何もしない」という大事な仕様を押さえておこう。notify_all も寝ているスレッドに対してのみ起こしにかかる。タッチの差で眠りに入ったスレッドは、その直前の notify からの影響を全く受けないのだ。
つまり、メインスレッドのcv.notify_one(); // 寝てるはずだから起こすよは、この時点でサブスレッドが寝ていないので単純に無視される。続く th.join(); でサブスレッドの終了を待つが、サブスレッドは起こされるのを延々と待ち続ける。デッドロックの完成である。

条件が整うまで待つ仕組み

このケースでは「1秒もあれば寝るだろう」という仮定が良くなかった。そこで「お互いの準備が整い次第処理を開始したい」という後出しの要求を追加して説明を続けることにする。
この要求を実現するためには、サブスレッドが寝ているかどうかを状態として持ち、寝ていなければ逆に寝てしまってサブスレッドに起こしてもらうという処理に変えてしまえばよいだろう。

#include <iostream>
#include <thread>
#include <condition_variable>
#include <mutex>
#include <chrono>

int main()
{
    std::mutex mtx; // isMainReady と isSubReady を保護するミューテックス
    std::condition_variable cv; // これを介して睡眠をコントロールする
    bool isMainReady = false; // メインスレッドの準備完了フラグ
    bool isSubReady = false; // サブスレッドの準備完了フラグ

    // サブスレッド
    std::thread th([&]() {
        std::this_thread::sleep_for(std::chrono::seconds(2)); // 諸々の準備
        {
            std::unique_lock<std::mutex> lck(mtx); // isMainReady, isSubReady を保護
            isSubReady = true; // サブスレッド側、準備完了
            if (isMainReady) { // メイン側が準備できているなら
                std::cout << "[Sub] wakes main up" << std::endl;
                cv.notify_one(); // 寝ているはずのメインを起こす
            }
            else { // メイン側が準備に手間取っているなら
                std::cout << "[Sub] sleeps until main is ready" << std::endl;
                while (!isMainReady) // メインスレッドの準備が整うまで
                    cv.wait(lck); // 寝る(寝てる間はロックが解除される)
            }
        }
        std::cout << "[Sub] started" << std::endl;
    });

    // メインスレッド
    std::this_thread::sleep_for(std::chrono::seconds(2)); // 諸々の準備
    {
        std::unique_lock<std::mutex> lck(mtx); // isMainReady, isSubReady を保護
        isMainReady = true; // メインスレッド側、準備完了
        if (isSubReady) { // サブ側が準備できているなら
            std::cout << "[Main] wakes sub up" << std::endl;
            cv.notify_one(); // 寝ているはずのサブを起こす
        }
        else { // サブ側が準備に手間取っているなら
            std::cout << "[Main] sleeps until sub is ready" << std::endl;
            while (!isSubReady) // サブスレッドの準備が整うまで
                cv.wait(lck); // 寝る(寝てる間はロックが解除される)
        }
    }
    std::cout << "[Main] started" << std::endl;

    th.join();
}
実行結果(メインの準備に時間がかかるケース)
[Sub] sleeps until main is ready
[Main] wakes sub up
[Main] started
[Sub] started
実行結果(サブの準備に時間がかかるケース)
[Main] sleeps until sub is ready
[Sub] wakes main up
[Sub] started
[Main] started

ここでようやく mutex と unique_lock の出番となる。このサンプルは排他制御で isMainReadyisSubReady の読み書きを保護し、自分の準備の方が早ければ相手を待ち、相手の方が早ければ相手を起こすというコードになっている。

この例のように、condition_variable は「ある条件を満たすまで待ち続ける」という用途で使用する。条件を判定するためには状態を保持する必要があり、そのための状態変数は mutex で保護されていなければならない。ところが condition_variable 自体はそれらの機能を持っておらず、condition_variable を正しく使うためには以下の3つのセットを常に意識しておく必要がある。

  1. condition_variable
  2. 条件判定に必要な状態変数(群)
  3. 2 を保護するための mutex

ここで、condition_variable は操作(notify と wait)の部分のみを担当している点に注意したい。条件自体は wait 時に外から与えられ、状態を保持する変数は condition_variable の外に存在し、それを保護する機構も外部の mutex(と lock)に頼っている。つまり、C++ の条件変数とは「条件(判定に必要な)変数(とロック機構をうまいこと纏めて直感的に操作できるようにする仕組み)」の略である[4]。条件変数が変数の意味で保持する情報は、条件判定に使う状態変数ではなく、今どのスレッドが待ち状態になっているかという待ち行列だ。notify で起こすべきスレッドをリストとして持っているに過ぎない。

なお、Spurious wakeup の影響を考慮して、wait の部分はループで待ち続けるのが鉄則。これは定型なので、wait 系の関数には条件判定を引数で受け取る形式も用意されている。
つまり、この部分は、

    while (!isSubReady) // サブスレッドの準備が整うまで
        cv.wait(lck); // 寝る(寝てる間はロックが解除される)

こう書ける。(この2つは全くの等価。)

    // サブスレッドの準備が整うまで寝る(寝てる間はロックが解除される)
    cv.wait(lck, [&]() { return !isSubReady; });

Monitor としての condition_variable

マルチスレッドプログラミングの文脈において、monitor とは条件に応じたスレッドの動作を安全(スレッドセーフ)にコントロールする包括的な仕組みのことである。その歴史は古く色々なパターンが存在するが、ここで例として挙げたサンプルは Wikipedia で Implicit condition variable monitors として紹介されている Java style のモデルと酷似している。

A Java style monitor

isMainReady と isSubReady は mutex で保護されたエリアにて読み書きされる。notify で起こされたスレッドは順番にその保護エリアに入り、各状態に応じて動き始めたり (leave)、再度待ち状態に戻ったり (wait) する。notify 自体は保護エリアに入らなくても呼び出せるが、今回の例ではどちらのスレッドも isMainReady や isSubReady を読み書きするために保護エリアに立ち入って notify や wait を呼び出していた。

condition_variable を正しく扱うのは少々面倒だが、この図を意識しながら実装すれば monitor という強力な仕組みを少量のコードで実現できる。素晴らしい。
monitor パターンの胆は保護エリア内で待つことができる点で、この図では wait のドアの部分が要となる。この肝心な部分を担うのが condition_variable の wait 系関数で、寝ている間だけロックを解除し、目覚めたら保護エリア内に居るという処理を勝手にやってくれる点は非常にありがたい。もう名前が変とかどうでもいいくらいに感謝である。

参考

  • 条件変数の利用方法 (cpprefjp)
    condition_variable を適切に使うのは難しいため、別途解説記事が用意されている。
  • 条件変数 Step-by-Step入門 (yohhoyの日記(別館))
    スレッドセーフな FIFO キューを実装しながら condition_variable の使い方を紹介している。
  • Condition Variableの由来
    どうやら Condition Variable という名前はこの論文が起源らしい。
脚注
  1. 実際には待機しているスレッドのリストを保持しているので「全くない」は言い過ぎだが、条件判定に利用できる情報は持っていないので、利用者が名前から期待するような情報は持っていないという意味の「全くない」である。 ↩︎

  2. 英語としての notify は「通知する」、wait は「待つ」だが、スレッドに対しては wakeup や sleep といった単語を使うことが多いと思う。Spurious Wakeup などもソレ。 ↩︎

  3. ソフトウェアは作るよりもメンテナンスの方が何倍も大変なのである。 ↩︎

  4. 嘘です。 ↩︎

Discussion

ログインするとコメントできます