(注: これの多くは、 std::lock (c++11) を使用した Massive CPU loadに関する解説と重複していますが、このトピックは独自の質問と回答に値すると思います。)
最近、次のようなサンプル C++11 コードに遭遇しました。
std::unique_lock<std::mutex> lock1(from_acct.mutex, std::defer_lock);
std::unique_lock<std::mutex> lock2(to_acct.mutex, std::defer_lock);
std::lock(lock1, lock2); // avoid deadlock
transfer_money(from_acct, to_acct, amount);
std::lock
うわー、面白いと思いました。標準はそれが何をしていると言っているのだろうか?
C++11 セクション 30.4.3 [thread.lock.algorithm]、段落 (4) および (5):
template void lock(L1&, L2&, L3&...);
4必須:各テンプレート パラメータ タイプは Lockable 要件を満たすものとする [ 注:
unique_lock
クラス テンプレートは、適切にインスタンス化された場合にこれらの要件を満たす。— エンドノート]5効果:すべての引数は、各引数に対する
lock()
、try_lock()
、またはへの一連の呼び出しによってロックされます。unlock()
一連の呼び出しでデッドロックが発生することはありませんが、それ以外は指定されていません。[ 注: try-and-back-off などのデッドロック回避アルゴリズムを使用する必要がありますが、実装の過度の制約を回避するための特定のアルゴリズムは指定されていません。— 終了注 ] または の呼び出しが例外をスローする場合、lock()
またはの呼び出しによってロックされていた引数に対して、 が呼び出されます。try_lock()
unlock()
lock()
try_lock()
次の例を考えてみましょう。それを「例 1」と呼びます。
Thread 1 Thread 2
std::lock(lock1, lock2); std::lock(lock2, lock1);
このデッドロックはできますか?
標準を単純に読むと、「いいえ」と言われます。すごい!たぶん、コンパイラが私のロックを注文してくれるかもしれません。
次に、例 2 を試してください。
Thread 1 Thread 2
std::lock(lock1, lock2, lock3, lock4); std::lock(lock3, lock4);
std::lock(lock1, lock2);
このデッドロックはできますか?
ここでも、標準を単純に読むと「いいえ」と言われます。ええとああ。これを行う唯一の方法は、ある種のバックオフと再試行のループを使用することです。詳細については、以下をご覧ください。
最後に、例 3:
Thread 1 Thread 2
std::lock(lock1,lock2); std::lock(lock3,lock4);
std::lock(lock3,lock4); std::lock(lock1,lock2);
このデッドロックはできますか?
繰り返しますが、標準を単純に読むと「いいえ」と言われます。(これらの呼び出しのいずれかで「 への呼び出しのシーケンスがlock()
「デッドロックを引き起こす」ものではない場合、正確には何ですか?)しかし、これは実装できないと確信しているので、彼らが意味したものではないと思います。
これは、私がこれまで C++ 標準で見た中で最悪のものの 1 つです。私はそれが興味深いアイデアとして始まったと推測しています: コンパイラにロック順序を割り当てさせます。しかし、委員会がそれを噛み砕くと、その結果は実装不可能になるか、再試行ループが必要になります。はい、それは悪い考えです。
「バックオフして再試行」が役立つ場合があると主張できます。それは本当ですが、どのロックを前もってつかもうとしているのかわからない場合に限られます。たとえば、2 番目のロックの ID が 1 番目のロックによって保護されているデータに依存する場合 (たとえば、階層をトラバースしているため)、グラブ - リリース - グラブ スピンを実行する必要がある場合があります。しかし、その場合、事前にすべてのロックを知っているわけではないため、このガジェットを使用することはできません. 一方、事前に必要なロックがわかっている場合は、(ほとんどの場合) ループではなく、単純に順序付けを行う必要があります。
また、実装が単純にロックを順番に取得し、バックオフして再試行する場合、例 1 はライブロックになる可能性があることに注意してください。
要するに、このガジェットはせいぜい役に立たないと思います。悪い考えばかりです。
わかりました、質問です。(1) 私の主張や解釈は間違っていますか? (2) そうでなければ、彼らは一体何を考えていたのですか? (3) 「ベスト プラクティス」とはstd::lock
完全に回避することであることに全員が同意する必要がありますか?
[アップデート]
一部の回答は、私が標準を誤解していると言い、それから私と同じように解釈し続け、仕様と実装を混同しています。
したがって、明確にするために:
私の標準の読みでは、例 1 と例 2 はデッドロックできません。例 3 は可能ですが、その場合のデッドロックを回避することは実装できないためです。
私の質問の要点は、例 2 のデッドロックを回避するには、バックオフと再試行のループが必要であり、そのようなループは非常に貧弱な方法であるということです。(はい、この些細な例である種の静的分析を行うことで回避できる可能性がありますが、一般的なケースではそうではありません。) また、GCC はこれをビジー ループとして実装していることにも注意してください。
【アップデート2】
ここでの断絶の多くは、哲学の基本的な違いだと思います。
ソフトウェア、特にマルチスレッド ソフトウェアを作成するには、2 つのアプローチがあります。
1 つのアプローチでは、たくさんのものをまとめて実行し、どれだけうまく機能するかを確認します。今日、実際のシステムで誰かがその問題を実証できない限り、コードに問題があると確信することはできません。
もう 1 つのアプローチでは、データ競合がないこと、すべてのループが確率 1 で終了することなどを証明するために厳密に分析できるコードを記述します。この分析は、特定の実装ではなく、言語仕様で保証されているマシン モデル内で厳密に実行します。
後者のアプローチの支持者は、特定の CPU、コンパイラ、コンパイラのマイナー バージョン、オペレーティング システム、ランタイムなどに関するデモに感銘を受けません。アルゴリズムにデータ競合がある場合、実行時に何が起こっても、アルゴリズムは壊れています。アルゴリズムにライブロックがある場合、実行時に何が起こっても、アルゴリズムは壊れています。などなど。
私の世界では、2番目のアプローチは「エンジニアリング」と呼ばれています。最初のアプローチが何と呼ばれているのかわかりません。
私が知る限り、std::lock
インターフェースはエンジニアリングには役に立ちません。私は間違っていることが証明されたいです。