競合する複数スレッドからグローバル変数の参照・更新。スレッドセーフでないことの確認

スポンサーリンク

プログラミングのお話です。

社内で使っているツールのソースコードにスレッドセーフでない処理があったので、それを管理者に話したら「スレッドが競合する瞬間なんてまれだよ。確率はほぼゼロだから、これでいい!」と言い切られました。

そこで「確率は低いかもしれんけど、スレッドセーフでないから不具合は起きる」ということを説明するために小さなコードを書きました。

その記録です。

スレッド競合の内容

今回見つけたスレッド競合の内容は、次のようなものです。

  • 複数のワーカースレッドから共通のグローバル変数を参照・更新する。
  • あるグローバル変数が=falseだった時のみ、ワーカースレッドで某処理Aを行う。
  • 某処理Aの開始時にグローバル変数=trueに更新する(つまり他スレッドの処理を排他する)。
  • 処理A終了時にグローバル変数=falseに戻す。

まあよく見るコードで、意図はわかります。
某処理Aの部分だけ、排他的に(シーケンシャルに)実行したいのです。

しかし、そのグローバル変数の参照・更新処理時には、スレッド間の競合を防止するコードが何も入っていませんでした。なのでグローバル変数=falseだと思って処理を開始しても、その瞬間に別スレッドがグローバル変数=trueに更新するかもしれません。全くスレッドセーフではありません。

敵の言い分

「コレまずいでしょ」と思って、そのソースコードの管理者に話をしたら「スレッドが競合する瞬間なんてまれだよ。確率はゼロに等しいから、これでいい!」と言い切りました。

「いやいや、まずいでしょ」と説明したのですが、コード管理者は「理論的にはスレッドセーフじゃなくても、現実には競合は起きないから問題ない」の一点張り。

なので、現実にワーカースレッドが競合するサンプルコードを作ってみました。

スレッド競合が起きるサンプルコード

サンプルは Visual Studio 2019 Community Edition で作成しました。

その主要部分はこちら。

/*****************************************************
* worker thread
*****************************************************/
UINT WorkerThread(LPVOID arg)
{
    DWORD thread_id = GetCurrentThreadId();
    ::printf(">>[%08X] スレッド開始\n", thread_id);

    unsigned int cnt = 0;
    bool l_flag = false;

    while (cnt++ < MAX_LOOP) {
        
        if (g_flag == false) {  /* (1) 某処理Aが実行できるか確認 */

            /* 別スレッドを排他するつもり */
            g_flag = true;      /* (2) 排他開始 */

            if (g_flag == false) {  /* (3) */
                /* (2)と(3)の間に、別スレッドが割り込んで g_flag = false に更新した! */
                ::printf("[%08X] 割り込み発生:cnt=%u\n", thread_id, cnt);

                /* 割り込まれた回数をカウントする */
                InterlockedIncrement(&g_intcnt);
            }
            else {
                /* ok */
            }

            /* (*4) 某処理A */
            /* 例えば・・・ */
            double a = 100*3.14;

            /* (*5) 排他解除 */
            g_flag = false;
        }
        else {
            /* do nothing */
        }
    }
    ::printf("<<[%08X] スレッド終了\n", thread_id);
    return 0;
}

 

(プロジェクト全体のzipファイルはこちらからダウンロード

処理の概要は以下のとおりです。

  • 2個のワーカースレッドから共通のグローバル変数を参照・更新する。
  • グローバル変数g_flag=falseの時のみ、ワーカースレッドで某処理Aを行う。
  • 某処理Aの開始時にグローバル変数gflags=trueに更新する(他スレッドの処理を排他する)。
  • 処理A終了時にグローバル変数g_flag=falseに戻す。

ワーカースレッドはそれぞれWhileループしながら、グローバル変数g_flag=falseの時のみ、某処理Aを行います。これによりスレッドを排他的に実行させる目論見です。

上のコードでは(*3)~(*5)の間が排他的に行われる(と期待している)期間です。

しかしこの目論見は失敗します。

上のコードでは、(*3)~(*5) の期間はg_flag=trueのはずです(排他できてるはず)。しかし、もしg_flag=falseになっていれば、他スレッドがg_flagを更新したことになり、排他は失敗しています。

このような排他失敗が何回起きたかを、以下のコードでカウントしています。

InterlockedIncrement(&g_intcnt);

InterlockedIncrement()は、スレッドセーフなインクリメント関数です。

今回のサンプルコードを実行すると、全体のループ回数のうち排他に失敗した割合を表示します。私のPCでは、だいたい 0.0004% になりました(これはPCによっては変わるかもです)。

確かに0.0004%は低いと言えば低いですが、ゼロではありません。スレッドが排他できないケースは必ず発生します。そして原因不明な不具合事象を引き起こす原因になり得ます。

スレッドセーフにするには

グローバル変数を参照・更新するタイミングでは、(私の知る限りでは)何らかのロック機構を入れるしかないでしょう。例えばセマフォやミューテックスを使うとか。
また機会があればサンプルを書きたいと思います。

まとめ

スレッドセーフでないプログラムサンプルを紹介しました。
複数スレッドからのグローバル変数へのアクセスは、必ず同期を取って排他的に行うべきと思います。そうしてないと、心配で枕を高くして眠れません。私は小心者なので!

タイトルとURLをコピーしました