互斥锁、锁与临界区
掌握同步原语:mutex、lock_guard、unique_lock、scoped_lock、shared_mutex。学习保护共享数据、防止死锁,以及理解数据竞争与竞态条件的区别。
为什么同步很重要
当多个线程访问同一内存且其中至少一个进行写入时,就会发生数据竞争 ── 这是 C++ 中的一种未定义行为。标准并不只是说「结果不可预测」。它说*任何事情都可能发生*:数据损坏、崩溃、安全漏洞,或者看似正确但当你更改编译器标志时就会崩溃的行为。
互斥锁(互斥,mutex)是最基本的同步原语。在任何时刻,只有一个线程可以持有互斥锁的锁。任何其他尝试加锁的线程都会阻塞,直到互斥锁被释放。通过使用互斥锁保护共享数据,你可以序列化访问并消除数据竞争。
std::mutex 和 lock_guard
最简单且最安全的模式是 std::lock_guard ── 一个 RAII 包装器,在构造时加锁,在析构时解锁,即使抛出异常也是如此。
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
std::mutex mtx;
int shared_counter = 0;
void increment(int times) {
for (int i = 0; i < times; ++i) {
std::lock_guard<std::mutex> lock(mtx); // RAII lock
++shared_counter; // safe: only one thread at a time
} // lock released here automatically
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment, 10000);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Counter: " << shared_counter << "\n";
// Always prints 100000 — no data race
return 0;
}unique_lock ── 灵活的加锁方式
std::unique_lock 比 lock_guard 更灵活。它支持延迟加锁、定时加锁、手动加锁/解锁,并且是 std::condition_variable 所必需的。
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
std::mutex mtx;
void try_lock_example() {
// Deferred locking — don't lock yet
std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
// Try to lock with a timeout
if (lock.try_lock_for(std::chrono::milliseconds(100))) {
std::cout << "Acquired lock\n";
// ... do work ...
} else {
std::cout << "Could not acquire lock within 100ms\n";
}
// Automatically unlocks when lock goes out of scope
}
void manual_control() {
std::unique_lock<std::mutex> lock(mtx);
// ... critical section ...
lock.unlock(); // release early if needed
// ... non-critical work ...
lock.lock(); // re-acquire
// ... another critical section ...
}scoped_lock ── 无死锁的多重加锁(C++17)
当你需要同时锁定多个互斥锁时,std::scoped_lock(C++17)在内部使用死锁避免算法。它取代了容易出错的手动使用 std::lock 的方式。
#include <mutex>
#include <thread>
#include <iostream>
struct BankAccount {
std::mutex mtx;
double balance = 1000.0;
};
void transfer(BankAccount& from, BankAccount& to, double amount) {
// Lock both mutexes without risk of deadlock
std::scoped_lock lock(from.mtx, to.mtx);
if (from.balance >= amount) {
from.balance -= amount;
to.balance += amount;
std::cout << "Transferred " << amount << "\n";
}
} // both mutexes released
int main() {
BankAccount alice, bob;
// These two transfers lock accounts in different order,
// but scoped_lock prevents deadlock.
std::thread t1(transfer, std::ref(alice), std::ref(bob), 100.0);
std::thread t2(transfer, std::ref(bob), std::ref(alice), 50.0);
t1.join();
t2.join();
std::cout << "Alice: " << alice.balance
<< ", Bob: " << bob.balance << "\n";
return 0;
}shared_mutex ── 读写锁(C++17)
当读操作远多于写操作时,std::shared_mutex 允许多个并发读取者但保证独占的写入者访问。读取者使用 std::shared_lock,写入者使用 std::unique_lock。
#include <shared_mutex>
#include <mutex>
#include <thread>
#include <iostream>
#include <map>
#include <string>
class ThreadSafeCache {
mutable std::shared_mutex smtx_;
std::map<std::string, int> data_;
public:
int read(const std::string& key) const {
std::shared_lock lock(smtx_); // multiple readers OK
auto it = data_.find(key);
return it != data_.end() ? it->second : -1;
}
void write(const std::string& key, int value) {
std::unique_lock lock(smtx_); // exclusive access
data_[key] = value;
}
};数据竞争发生在两个线程同时访问同一内存位置且至少一个进行写入,而没有同步的情况下。在 C++ 中这是未定义行为 ── 不仅仅是「非确定性输出」,而是任何事情都可能发生的未定义行为。
竞态条件是一种更高层次的逻辑错误,程序的正确性取决于时序。即使没有数据竞争的代码也可能存在竞态条件(例如,对并发映射的先检查后执行操作)。互斥锁消除数据竞争;消除竞态条件需要仔细设计你的并发算法。
始终使用 RAII 锁包装器(lock_guard、scoped_lock、unique_lock)而不是原始的 mutex::lock()/unlock() 调用。保持临界区尽可能短 ── 如果可以避免,永远不要在持有锁时执行 I/O、内存分配或阻塞调用。当需要多个互斥锁时,使用 std::scoped_lock 来避免死锁。如果必须手动加锁,始终按照一致的全局顺序加锁。
- 数据竞争是未定义行为 ── 使用互斥锁保护每个共享的可变变量
std::lock_guard是最简单的 RAII 锁;对于简单的临界区优先使用它std::unique_lock增加了灵活性:延迟加锁、定时加锁和手动加锁/解锁std::scoped_lock(C++17)同时锁定多个互斥锁而不会死锁std::shared_mutex(C++17)允许多个并发读取者但保证独占写入者- 保持临界区短小,永远不要在等待另一个线程时持有锁
测验 — 检验你的知识
(20 经验值)1. `std::scoped_lock` 相比手动对多个互斥锁调用 `lock()` 的主要优势是什么?
2. 与 `std::condition_variable` 一起应该使用哪种锁类型?
3. 在 C++ 中,数据竞争的后果是什么?