複数のスレッドが動作するプログラムにおいて、2つ以上の共有リソース(データ)を同時に操作する必要があるケースは少なくありません。例えば、ある銀行口座から別の口座へ送金する処理などがこれにあたります。
このような処理を単純に実装すると、デッドロックという、プログラム全体が停止してしまう非常に危険な状態に陥ることがあります。この記事では、デッドロックがなぜ発生するのか、そしてそれを回避するための現代的で安全な方法を解説します。
デッドロックとは?
デッドロックとは、2つ以上のスレッドが、互いに相手が保持しているリソースの解放を永遠に待ち続けてしまい、処理が進まなくなる状態のことです。
これを、2人の作業員と2本の鍵(鍵A、鍵B)で例えてみましょう。
- 作業員1は、手順として「まず鍵Aを取り、次に鍵Bを取る」。
- 作業員2は、手順として「まず鍵Bを取り、次に鍵Aを取る」。
もし、作業員1が鍵Aを手に取り、ほぼ同時に作業員2が鍵Bを手に取ったとします。すると、作業員1は作業員2が持つ鍵Bを待ち、作業員2は作業員1が持つ鍵Aを待つという、永遠の待ち状態が発生します。これがデッドロックです。
マルチスレッドプログラミングにおいて、ロックを取得する順序がスレッド間で異なると、これと全く同じ問題が発生します。
解決策:std::scoped_lock
このデッドロック問題を解決するため、標準ライブラリには**std::scoped_lock
**という非常に便利な仕組みが用意されています。
std::scoped_lock
は、複数のMutex(リソースを保護するためのロック機構)を同時に、かつデッドロックを発生させない特別なアルゴリズムを使ってロックしてくれます。また、スコープを抜ける際に自動的に全てのロックを解放するため、非常に安全です。
実践:scoped_lock
による安全な複数ロック
銀行口座間の送金処理を例に、scoped_lock
の具体的な使い方を見てみましょう。
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
#include <string>
// 銀行口座を表現する構造体
struct BankAccount {
explicit BankAccount(std::string name, double initial_balance)
: account_name{name}, balance{initial_balance} {}
std::mutex mtx;
std::string account_name;
double balance;
};
// ある口座から別の口座へ送金する関数
void transfer(BankAccount& from, BankAccount& to, double amount) {
// 1. scoped_lockで両方の口座のMutexを安全にロックする
std::scoped_lock guard(from.mtx, to.mtx);
// 2. このスコープ内では両方の口座がロックされているため、安全に操作できる
from.balance -= amount;
to.balance += amount;
std::cout << "Thread [" << std::this_thread::get_id() << "]: "
<< amount << " transferred from " << from.account_name
<< " to " << to.account_name << std::endl;
}
int main() {
BankAccount alice_account("Alice", 1000.0);
BankAccount bob_account("Bob", 1000.0);
// 異なる順序でリソース(口座)をロックしようとする2つのスレッドを生成
// t1: Alice -> Bob
std::thread t1(transfer, std::ref(alice_account), std::ref(bob_account), 100.0);
// t2: Bob -> Alice
std::thread t2(transfer, std::ref(bob_account), std::ref(alice_account), 50.0);
t1.join();
t2.join();
std::cout << "\nFinal balances:\n"
<< "Alice: " << alice_account.balance << "\n"
<< "Bob: " << bob_account.balance << std::endl;
return 0;
}
コードのポイント解説
- デッドロックの状況設定:
main
関数では、スレッドt1
がAlice -> Bob
の順で、スレッドt2
がBob -> Alice
の順でリソースを操作しようとします。これは、前述のデッドロックがまさに発生する状況です。 std::scoped_lock guard(from.mtx, to.mtx);
: この一行が、デッドロックを防ぐ鍵となります。scoped_lock
のコンストラクタは、渡された全てのMutexを、内部のデッドロック回避アルゴリズムを用いて安全にロックします。どちらかのMutexが取得できない場合でも、もう片方を無闇にロックし続けることはありません。- RAIIによる自動解放:
scoped_lock
はRAII(Resource Acquisition Is Initialization)という原則に基づいています。transfer
関数のスコープを抜ける際(正常終了でも、例外が発生しても)、guard
オブジェクトのデストラクタが自動的に呼び出され、管理している全てのMutexを確実に解放します。これにより、ロックのかけ忘れや解放忘れといったバグを防ぐことができます。
旧来の方法:std::lock
関数
scoped_lock
が登場する前は、std::lock
関数とstd::unique_lock
を組み合わせて同じことを実現していました。
void old_style_transfer(BankAccount& from, BankAccount& to, double amount) {
// ロックは後で取得することを指定 (defer_lock)
std::unique_lock<std::mutex> lock1(from.mtx, std::defer_lock);
std::unique_lock<std::mutex> lock2(to.mtx, std::defer_lock);
// デッドロックを回避して両方のロックを取得
std::lock(lock1, lock2);
// ...送金処理...
// unique_lockのデストラクタが自動でロックを解放する
}
この方法は柔軟性がありますが、記述が冗長になりがちです。スコープ内で複数のMutexを同時にロック・解放するだけであれば、std::scoped_lock
を使用するのが現代的で、よりシンプルかつ安全な方法です。
まとめ
複数の共有リソースを扱うマルチスレッドプログラムにおいて、デッドロックは非常に厄介な問題です。ロックを取得する順序をスレッド間で統一するというルールを守ることでも回避できますが、std::scoped_lock
を使えば、そのようなルールを意識せずとも、安全に複数のMutexを扱うことができます。
複数のリソースを同時にロックする必要がある場合は、std::scoped_lock
の使用を第一に検討してください。