【C++】零基础入门 · 第 18 节:互斥锁与线程同步

在第 17 节中,我们学习了多线程编程的基础概念和 std::thread 的使用。本节将深入讲解线程同步 的核心工具------互斥锁 (Mutex),解决多线程编程中最常见的问题:数据竞争

1. 数据竞争:多线程的头号敌人

1.1 什么是数据竞争?

当两个或多个线程同时读写 同一块内存,且至少有一个线程在写入时,就会发生数据竞争 (Data Race)。数据竞争的结果是未定义行为,程序可能崩溃、产生错误结果,或者看似正常但偶尔出问题。

cpp 复制代码
#include <iostream>
#include <thread>
using namespace std;

int counter = 0;

void increment() {
    for (int i = 0; i < 100000; i++) {
        counter++;  // 这不是原子操作!
    }
}

int main() {
    thread t1(increment);
    thread t2(increment);
    
    t1.join();
    t2.join();
    
    // 期望 200000,实际结果不确定
    cout << "counter = " << counter << endl;
    
    return 0;
}

运行多次,结果可能为 134567、178234 等各种随机值,永远不是 200000。

1.2 为什么 counter++ 不安全?

counter++ 看似一条语句,实际包含三个步骤:

  1. 读取:从内存读取 counter 的值到寄存器
  2. 修改:在寄存器中将值加 1
  3. 写回:将新值写回内存

如果两个线程同时执行,可能的交错情况:

复制代码
线程A:读取 counter = 100
线程B:读取 counter = 100
线程A:写入 counter = 101
线程B:写入 counter = 101  ← 丢失了一次自增!

2. std::mutex:最基本的互斥锁

2.1 什么是互斥锁?

互斥锁(Mutex,Mutual Exclusion 的缩写)就像一把门锁。当一个线程进入临界区时,它把锁锁上,其他线程必须等待锁被释放才能进入。

复制代码
线程A:加锁 → 访问共享数据 → 解锁
线程B:等待... → 加锁 → 访问共享数据 → 解锁

2.2 修复数据竞争

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

int counter = 0;
mutex mtx;

void increment() {
    for (int i = 0; i < 100000; i++) {
        mtx.lock();
        counter++;
        mtx.unlock();
    }
}

int main() {
    thread t1(increment);
    thread t2(increment);
    
    t1.join();
    t2.join();
    
    cout << "counter = " << counter << endl;  // 总是 200000
    
    return 0;
}

运行结果:

复制代码
counter = 200000

2.3 lock() 和 unlock() 的注意事项

手动调用 lock()unlock() 有一个致命问题:如果在 lock() 之后、unlock() 之前发生异常,锁永远不会被释放,导致死锁

cpp 复制代码
void riskyFunction() {
    mtx.lock();
    // 如果这里抛出异常...
    doSomething();  // ← 异常!
    mtx.unlock();   // ← 永远不会执行!
}

3. std::lock_guard:RAII 自动锁

3.1 什么是 lock_guard?

std::lock_guard 是一个 RAII 风格的锁管理器。它在构造时加锁,在析构时自动解锁,即使发生异常也能保证锁被释放。

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

int counter = 0;
mutex mtx;

void increment() {
    for (int i = 0; i < 100000; i++) {
        lock_guard<mutex> lock(mtx);  // 构造时加锁
        counter++;
        // 离开作用域时自动解锁
    }
}

int main() {
    thread t1(increment);
    thread t2(increment);
    
    t1.join();
    t2.join();
    
    cout << "counter = " << counter << endl;  // 200000
    
    return 0;
}

3.2 作用域决定锁的范围

lock_guard 的生命周期就是锁的范围。可以用花括号控制:

cpp 复制代码
void example() {
    cout << "这里没有锁" << endl;
    
    {
        lock_guard<mutex> lock(mtx);
        cout << "这里是临界区,锁生效" << endl;
        // 在花括号结束时自动解锁
    }
    
    cout << "锁已经释放" << endl;
}

4. std::unique_lock:更灵活的锁

4.1 与 lock_guard 的区别

std::unique_locklock_guard 更灵活,支持:

  • 延迟加锁:构造时不立即加锁
  • 手动解锁和重新加锁unlock()lock()
  • 转移所有权:配合条件变量使用
cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

mutex mtx;

void flexibleLocking() {
    unique_lock<mutex> lock(mtx, defer_lock);  // 不立即加锁
    
    cout << "做一些不需要锁的操作..." << endl;
    
    lock.lock();  // 现在加锁
    cout << "临界区操作" << endl;
    lock.unlock();  // 手动解锁
    
    cout << "做一些不需要锁的操作..." << endl;
    
    lock.lock();  // 可以再次加锁
    cout << "再次进入临界区" << endl;
}

int main() {
    thread t1(flexibleLocking);
    t1.join();
    
    return 0;
}

4.2 延迟加锁(defer_lock)

cpp 复制代码
// 方式一:构造时加锁(默认)
unique_lock<mutex> lock1(mtx);

// 方式二:延迟加锁
unique_lock<mutex> lock2(mtx, defer_lock);
lock2.lock();  // 需要时再加锁

// 方式三:尝试加锁(不阻塞)
unique_lock<mutex> lock3(mtx, try_to_lock);
if (lock3.owns_lock()) {
    // 成功获取锁
}

4.3 lock_guard vs unique_lock

特性 lock_guard unique_lock
构造时加锁 必须 可选
手动 unlock/lock 不支持 支持
转移所有权 不支持 支持
性能 略快 略慢(更多检查)
使用场景 简单临界区 复杂同步需求

建议 :简单场景用 lock_guard,需要灵活性时用 unique_lock

5. std::scoped_lock(C++17)

5.1 同时锁多个互斥量

std::scoped_lock 可以同时锁住多个互斥量,避免死锁。

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

mutex mtx1, mtx2;

void threadA() {
    scoped_lock lock(mtx1, mtx2);  // 同时锁住两个锁
    cout << "线程A:同时持有两把锁" << endl;
}

void threadB() {
    scoped_lock lock(mtx1, mtx2);  // 相同顺序,不会死锁
    cout << "线程B:同时持有两把锁" << endl;
}

int main() {
    thread t1(threadA);
    thread t2(threadB);
    
    t1.join();
    t2.join();
    
    return 0;
}

5.2 为什么 scoped_lock 能避免死锁?

如果手动按不同顺序加锁:

cpp 复制代码
// 线程A
mtx1.lock();
mtx2.lock();  // 死锁风险!

// 线程B
mtx2.lock();
mtx1.lock();  // 死锁!

scoped_lock 内部使用死锁避免算法,无论你以什么顺序传入,都能安全加锁。

6. 读写锁:std::shared_mutex(C++17)

6.1 读多写少的场景

在很多场景中,读操作远多于写操作。如果用普通互斥锁,读操作之间也会互斥,浪费性能。

std::shared_mutex 支持:

  • 共享锁(读锁):多个线程可以同时持有
  • 独占锁(写锁):只有一个线程能持有
cpp 复制代码
#include <iostream>
#include <thread>
#include <shared_mutex>
#include <vector>
using namespace std;

shared_mutex rwMutex;
int sharedData = 0;

void reader(int id) {
    shared_lock<shared_mutex> lock(rwMutex);  // 共享锁
    cout << "读者 " << id << " 读取数据:" << sharedData << endl;
}

void writer(int id, int value) {
    unique_lock<shared_mutex> lock(rwMutex);  // 独占锁
    sharedData = value;
    cout << "写者 " << id << " 写入数据:" << value << endl;
}

int main() {
    vector<thread> threads;
    
    // 启动多个读者和写者
    for (int i = 0; i < 5; i++) {
        threads.emplace_back(reader, i);
    }
    threads.emplace_back(writer, 1, 100);
    threads.emplace_back(writer, 2, 200);
    for (int i = 5; i < 10; i++) {
        threads.emplace_back(reader, i);
    }
    
    for (auto& t : threads) t.join();
    
    return 0;
}

6.2 shared_lock 和 unique_lock 的配合

cpp 复制代码
shared_mutex rwMutex;

// 读操作:使用 shared_lock
void readData() {
    shared_lock<shared_mutex> lock(rwMutex);
    // 多个线程可以同时执行这里
    readFromDatabase();
}

// 写操作:使用 unique_lock
void writeData() {
    unique_lock<shared_mutex> lock(rwMutex);
    // 只有一个线程能执行这里
    writeToDatabase();
}

7. 原子操作:std::atomic

7.1 什么是原子操作?

对于简单的计数器等场景,使用互斥锁有点重。std::atomic 提供了无锁的线程安全操作。

cpp 复制代码
#include <iostream>
#include <thread>
#include <atomic>
using namespace std;

atomic<int> counter(0);

void increment() {
    for (int i = 0; i < 100000; i++) {
        counter++;  // 原子操作,线程安全
    }
}

int main() {
    thread t1(increment);
    thread t2(increment);
    
    t1.join();
    t2.join();
    
    cout << "counter = " << counter << endl;  // 总是 200000
    
    return 0;
}

7.2 atomic 的常用操作

cpp 复制代码
atomic<int> a(0);

a.store(10);           // 写入
int val = a.load();    // 读取
a.exchange(20);        // 交换,返回旧值

// 复合操作
a.fetch_add(5);        // 原子加
a.fetch_sub(3);        // 原子减
a.fetch_and(0xFF);     // 原子与
a.fetch_or(0x01);      // 原子或

// 前缀/后缀自增自减
a++;
++a;
a--;
--a;

// 比较并交换(CAS)
int expected = 10;
a.compare_exchange_strong(expected, 20);
// 如果 a == 10,则 a = 20,返回 true
// 如果 a != 10,则 expected = a 的值,返回 false

7.3 atomic vs mutex

特性 atomic mutex
适用范围 单个变量 代码块
性能 非常快(无锁) 较慢(有锁)
复杂操作 不支持 支持
死锁风险

建议 :简单计数器、标志位用 atomic,复杂数据结构用 mutex

8. 常见陷阱与最佳实践

8.1 不要返回引用给共享数据

cpp 复制代码
// 错误:返回引用后,锁已释放,调用者访问的是未保护的数据
int& getCounter() {
    lock_guard<mutex> lock(mtx);
    return counter;  // 危险!
}

// 正确:返回副本
int getCounter() {
    lock_guard<mutex> lock(mtx);
    return counter;  // 返回拷贝
}

8.2 锁的粒度要合适

cpp 复制代码
// 粒度太粗:整个函数都被锁住
void bad() {
    lock_guard<mutex> lock(mtx);
    doSlowComputation();  // 这部分不需要锁
    updateSharedData();   // 只有这里需要锁
}

// 粒度合适:只锁必要的部分
void good() {
    doSlowComputation();  // 无锁
    lock_guard<mutex> lock(mtx);
    updateSharedData();   // 有锁
}

8.3 避免嵌套锁

cpp 复制代码
// 危险:可能导致死锁
void dangerous() {
    lock_guard<mutex> lock1(mtxA);
    lock_guard<mutex> lock2(mtxB);  // 如果其他线程反序加锁 → 死锁
}

// 安全:使用 scoped_lock
void safe() {
    scoped_lock lock(mtxA, mtxB);  // 自动避免死锁
}

9. 总结

本节我们学习了线程同步的核心工具:

  • 数据竞争:多线程同时读写共享数据导致的未定义行为
  • std::mutex :基本互斥锁,lock() / unlock()
  • std::lock_guard:RAII 自动锁,简单场景首选
  • std::unique_lock:灵活锁,支持延迟加锁和条件变量
  • std::scoped_lock(C++17):同时锁多个锁,避免死锁
  • std::shared_mutex(C++17):读写锁,适合读多写少场景
  • std::atomic:无锁原子操作,适合简单变量

下一节我们将学习条件变量,它是线程间通信的核心工具,用于实现生产者-消费者模型等经典并发模式。

相关推荐
dnbug Blog1 小时前
C语言 简介
c语言·开发语言
码上有光1 小时前
c++:多态
java·jvm·c++·多态·多态原理
tangchao340勤奋的老年?1 小时前
C++ OpenGL显示地图
c++·opengl
plainGeekDev1 小时前
Fragment 手动跳转 → Navigation 组件
android·java·kotlin
炸炸鱼.1 小时前
Zabbix企业级高级应用:从自动化监控到自定义告警完全指南
开发语言·php
plainGeekDev1 小时前
XML 主题 → Compose Material3 主题
android·java·kotlin
I Promise341 小时前
C++ 多线程编程:从入门到实战
开发语言·c++
武子康1 小时前
Java-14 深入浅出 MyBatis 插件机制深度解析:四大对象拦截与动态代理原理
java·后端
kkeeper~1 小时前
0基础C语言积跬步之自定义类型联合和枚举
c语言·开发语言·算法