■排他制御の難しさ
*注意 用語について 前回は「排他開始」と「排他終了」という書き方にしていましたが、ここでは「セマフォを取得」「セマフォを解放」という書き方にしています。前回は排他制御とは何ぞやという説明が中心で、最後に「でも使うには細心の注意が必要」と不安がらせて終了してしまいました。今回は具体的に何が怖いのかのお話をします。一言で言うと「デッドロックが怖い」です。複数のリソース群をそれぞれ排他制御するとなるとセマフォもリソース群の数だけ必要になります。それらの複数のセマフォを、複数のタスクが並行動作して取得したり解放したりすることになります。これだけでなんとなくヤバい空気を感じますよね。いくつか例を挙げます。
例1:セマフォ取ったまま返さないコードにしてしまった
セマフォの基本的な使い方は以下の通りで、これだとぱっとコードを見て「取ったセマフォが確実に返される」ことがわかるので安心できます。void func_A(void)
{
...各種処理...
wai_sem(sem_a);
...sem_aで排他される処理...
sig_sem(sem_a);
...各種処理...
}
そして、このプログラムを膨らませていったときに、エラー処理などでsig_sem()せずにreturnしてしまうのがありがちなミスです。
void func_A(void)
{
...各種処理...
wai_sem(sem_a);
...sem_aで排他される処理...
if (エラーが発生) {
return ; ←本当はこの前に「sig_sem(sem_a);」が必要!
}
...sem_aで排他される処理つづき...
sig_sem(sem_a);
...各種処理...
}
これはセマフォに限らず、メモリ取得/解放でも同じですね。とにかく関数の最後まで行かずにリターンさせる必要が出たら、取得したセマフォやメモリなどのリソースがないか確認する癖をつけましょう。実際のコードは上記よりずっと気づきにくいですし、おそらくコードレビューしてくれる先輩は必ずこの点を見ると思います。
例2:確保と解放を別の関数に置く
これ自体は別に悪いことではないのですが、問題ないことがわかりにくいですし、現状問題なくても将来意識せずにコードを追加変更していくと不具合が起きがちなので避けた方が良いです。void func_A(void)
{
...各種処理...
wai_sem(sem_a);
func_A_sub();
...各種処理...
}
void func_A_sub(void)
{
...sem_Aで排他される処理...
sig_sem(sem_a);
}
例3:複数のセマフォの確保/解放順番が異なる
特に当初設計に含まれていなかった機能を後から追加するケースで「このセマフォとこのセマフォを取得した状態でこの処理を実行」とやりたくなるケースが出てきます。しかし、これが発生すると我々は単純にそうせず、一旦リソース管理方法までさかのぼって再検討します。それくらい、複数セマフォを取得して処理するのは危ないです。例を挙げにくいのですが、例えば以下の2つの関数を別々のタスクで実行するケースです。void func_A(void)
{
...各種処理...
wai_sem(sem_a);
...sem_aで排他される処理...
wai_sem(sem_b);
...sem_a、sem_bで排他される処理
sig_sem(sem_b);
...sem_aで排他される処理...
sig_sem(sem_a);
...各種処理...
}
void func_B(void)
{
...各種処理...
wai_sem(sem_b);
...sem_bで排他される処理...
wai_sem(sem_a);
...sem_a、sem_bで排他される処理
sig_sem(sem_a);
...sem_bで排他される処理...
sig_sem(sem_b);
...各種処理...
}
これら、動作タイミングによっては両方がセマフォ取得待ちになって止まる、所謂デッドロック状態になることがわかりますでしょうか。一方のタスクがfunc_A()を頭から実行してwai_sem(sem_a)まで実行完了したタイミングで、func_B()を実行するもう一方のタスクに処理がスイッチしたとします。func_B()ではwai_sem(sem_b)を実行してsem_bを取得した後wai_sem(sem_a)に到達しますが、sem_aは既に最初のタスクが取得しているのでsem_aの取得待ちで止まります。そこでまた最初のタスクに処理が戻ってwai_sem(sem_b)に到達しますが、今度はsem_bが他タスクに取得されている状態なのでsem_bの取得待ちで止まります。結局両方のタスクがセマフォ取得待ちで止まるわけです。
苦しい例かもしれませんが、2人の人間と、1組の箸と茶碗で考えてみてください。2人が同時に食事をしようとして、一方は箸→茶碗の順に手を伸ばし、他方は茶碗→箸の順に手を伸ばすと、当然いつまでたっても2人は食事ができません。こういう現実世界の例も案外馬鹿にはできなくて、「じゃあどうしたら良いか」が想像しやすいと思います。
●二人とも箸→茶碗の順に入手することにする(取得順をシステム全体で統一する)
●「箸・茶碗」という1つのセットにする(より大きな単位で排他制御する)
また、上記は「どういう順番で取得するか」という話ですが、「どういう順番で解放するか」も同じくらい重要です。箸茶碗の例で言うと、「お代わり」システムを追加したときに問題が発生します。一方がお代わりするため茶碗を持ったまま箸を一旦場に置いたら、他方は箸を取った後に茶碗待ちになります。お代わりした方は茶碗を持ったまま今度は箸待ちになって結局2人とも食事ができなくなります。これもどうしたら良いかと言うと取得と逆の順番で解放すると良いですよね。箸→茶碗の順番に取得するなら、茶碗→箸の順に解放します。
ということで、セマフォを使うときの注意事項をまとめると以下の様になります。
1 できるだけ狭い範囲で使おう
2 取得後、解放に到達しないケースがないか気を付けよう
3 できるだけ別関数で取得、解放しないようにしよう
4 複数のセマフォを同時取得する場合は取得の順番をシステム全体で統一し、解放の順番はその逆となるよう統一しよう
デッドロックは本当にタイミングに依存する問題で、環境によって発生確率が異なる場合があります。顧客からファームウエア動作停止の報告を受けて、社内環境で全く同じ通信テストを繰り返しても全く再現しない、と来たら、セマフォデッドロックも疑いましょう。
■今日の閑話
私はもともと課題を与えられてパッとプログラムが書けるタイプではありませんでした。そんな私がプログラムを設計・課題解決を考えるときによく使っていたのが「人がやっていることに例える」でした。今回の「箸茶碗」のように「この人とこの人がこういうやりとりして...」「まず受付窓口の人を設けて、申込書の山から一枚づつ内容に応じた作業担当に渡して...」みたいな感じです。もし皆さんがプログラム設計は苦手かも、と思い始めたらこの擬人化が腹落ちが良いかもしれません。Cente:
https://www.cente.jp/
お問合せはこちら:
https://www.cente.jp/otoiawase/