プログラミングのお話です。
社内で使っているツールのソースコードにスレッドセーフでない処理があったので、それを管理者に話したら「スレッドが競合する瞬間なんてまれだよ。確率はほぼゼロだから、これでいい!」と言い切られました。
そこで「確率は低いかもしれんけど、スレッドセーフでないから不具合は起きる」ということを説明するために小さなコードを書きました。
その記録です。
スレッド競合の内容
今回見つけたスレッド競合の内容は、次のようなものです。
- 複数のワーカースレッドから共通のグローバル変数を参照・更新する。
- あるグローバル変数が=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%は低いと言えば低いですが、ゼロではありません。スレッドが排他できないケースは必ず発生します。そして原因不明な不具合事象を引き起こす原因になり得ます。
スレッドセーフにするには
グローバル変数を参照・更新するタイミングでは、(私の知る限りでは)何らかのロック機構を入れるしかないでしょう。例えばセマフォやミューテックスを使うとか。
また機会があればサンプルを書きたいと思います。
まとめ
スレッドセーフでないプログラムサンプルを紹介しました。
複数スレッドからのグローバル変数へのアクセスは、必ず同期を取って排他的に行うべきと思います。そうしてないと、心配で枕を高くして眠れません。私は小心者なので!