スレッドの3状態
スレッドは大きくWAIT/READY/RUNの3つの状態のいずれかにあります。mutex::lock()でロックが得られるまで待っているのがWAIT状態、READYはWAITではなくなったもののCPU(あるいはコア)が他のスレッドで使われていて足踏みしている状態、RUNは実際にCPU上で走っている状態です。RUN状態にあるスレッドの数は当然ながら計算機のCPU数(コア数)が最大となります。いくつかのスレッドがRUN状態を占有すると他のREADYなスレッドが動けなくなってしまうので、短い周期でREADYとRUNを行き来するよう制御されています。READYとRUNの切り替えはかなり高速に行われる一方、WAITに入る/WAITから出るには相応の時間を要します。そのため、mutexのlock()/unlock()がひっきりなしに繰り返されるとパフォーマンスが低下するんですわ。
加えてmutex::lock()からmutex::unlock()までの間、ロックの取得を待っている他のスレッドはすべてWAIT状態なのですから、その間はシングルスレッドで動いているのと同じ、この時間が長いとマルチスレッドのウマミがなくなっちゃいます。とあるスレッドがロックを長時間取得し続けることで他のスレッドがなかなか動けなくなることをlock convoyといいます。convoyとはトラックの隊列のこと、先頭のトラックがモタモタしていると後続のトラックたちが前に進めずダンゴになってしまうって現象ですな。
そんなわけでmutexでatomic性を保証する回数と期間は、ともに極力小さくなる設計/実装を心がけてください。mutexは控えめに。
それともう一つ、lock()とunlock()で挟まれた処理の中で例外がthrowされると困ったことになります。例外で処理が中断するとunlock()に達しないのでロックが解放されず、他のスレッドが動けなくなっちゃいます。std::lock_guardまたはstd::unique_lock を使うと、これらはデストラクタでunlock()してくれるので安心です。
std::mutex mtx;
……
{
std::unique_lock<std::mutex> lock(mtx); // ここでmtx.lock()される
……
……
// ここで(自動的に)mtx.unlock()される
}
mutexが引き起こすトラブル:deadlock
data raceを避けるため「ココからココまでは他のスレッドが邪魔しない」を実現すべく用意されたmutexですが、使い方を誤るとdata race以上に厄介なトラブル:deadlock(デッドロック)の罠にはまります。
deadlockの典型例を示しますね。グローバル変数countA、countBがあり、それぞれを+1する関数incA()、incB()を書きました。マルチスレッド下でdata raceを起こさぬよう、それぞれmutex mtxA、mtxBでガードします。
std::mutex mtxA;
std::mutex mtxB;
int countA;
int countB;
void incA() {
mtxA.lock(); // lock-A
countA += 1;
mtxA.unlock(); // unlock-A
}
void incB() {
mtxB.lock(); // lock-B
countB += 1;
mtxB.unlock(); // unlock-B
}
ここまでは問題ありません……その後仕様に変更が生じ、incA()、incB()の双方でcountA、countBを+1してくれ、と。そこでこんな修正を加えます。
void incA() {
mtxA.lock(); // lock-A
countA += 1;
// 追加ココカラ
mtxB.lock(); // lock-B
countB += 1;
mtxB.unlock(); // unlock-B
// 追加ココマデ
mtxA.unlock(); // unlock-A
}
void incB() {
mtxB.lock(); // lock-B
countB -= 1;
// 追加ココカラ
mtxA.lock(); // lock-A
countA -= 1;
mtxA.unlock(); // unlock-A
// 追加ココマデ
mtxB.unlock(); // unlock-B
}
incA()とincB()がほとんど同時にコールされたとしましょう、incA()はmtxA、incB()はmtxBをlock()します。つぎにincA()はmtxBをlock()しますが、incB()が先にlock()しているので待ちに入ります。incB()も同様にmtxAのlock()地点で待ちに入り、2つのスレッドは立ち往生、アプリケーションは固まって動かなくなります。これがdeadlock、データの競合を避けるべくロックで排他したはいいが、今度はロックが競合するというヤレヤレな事態です。
複数のmutexをlock()するときはlock順を揃えておかないとこんなことになっちゃいます。incA()、incB()ともにmtxA、mtxBの順でlock()すればdeadlockは起こりません。mutexのプライオリティ(lock順)は設計時にちゃんと決めておくべきことではありますが、順不同でもdeadlockしないstd::lock()ってのがありましてね。
void incA() {
std::lock(mtxA,mtxB);
countA += 1;
// 追加ココカラ
countB += 1;
mtxB.unlock(); // unlock-B
// 追加ココマデ
mtxA.unlock(); // unlock-A
}
void incB() {
std::lock(mtxB,mtxA);
countB -= 1;
// 追加ココカラ
countA -= 1;
mtxA.unlock(); // unlock-A
// 追加ココマデ
mtxB.unlock(); // unlock-B
}
std::lock()に引き渡すmutexはいくつでもOK、引数の順がどうであれ、deadlockせずに全mutexをlock()してくれます。
Visual C++のヘッダを追いかけてstd::lock()のコードを読んでみたんですよ。そしたらその実装は……1つめのmutexをtry_lock()します。ロックできたら2つめをtry_lock()……を繰り返して全mutexのロックを取得するのですが、その途中でtry_lock()に失敗したらそこまで取得したロックをすべて解放し、再度1つめからやり直してました。なのでロック取得の頻度が高くロック期間が長いmutexが引数に含まれていると、なかなかstd::lock()から戻ってこなくなる恐れがあります。mutexは控えめに(二度目)。
さらにこのdeadlock、たった1つのスレッドでも起こり得ます。
std::mutex mtx;
void f() {
mtx.lock();
……
mtx.unlock();
}
void g() {
mtx.lock();
……
f();
……
mtx.unlock();
}
g()の中でf()をコールしてますから、mtxは二度lock()されますが、二度目のlock()でロックが取得できるわけがなく、あえなくdeadlockします(実はstd::mutexを同一スレッドが複数回lock()するとsystem_error例外がthrowされるのでdeadlockにはならんのですが)。
そんなこともあろうかと、同一スレッドからの複数回lock()を許すstd::recursive_mutexがあります。recursive_mutexは同一スレッドからのlock()のたびに内部のカウンタが+1、unlock()のたびに-1され、カウンタ値が0になればロックが解放されます。この例ではstd::mutex mtx改めstd::recursive_mutex mtxとすれば無問題。
