C++多线程和锁

目录

1. 基本概念

1.1. 进程(Process)

1.2. 线程(Thread)

1.3. 并发与并行

2. 线程创建与管理

2.1. 线程的创建

2.1.1. 普通参数传递

2.1.1.1. 示例代码

2.1.1.2. 关键点

2.1.2. 引用参数传递

2.1.2.1. 示例代码

2.1.2.2. 关键点

2.1.3. 指针参数传递

2.1.3.1. 示例代码

2.1.3.2. 关键点

2.1.4. 常量参数传递

2.1.4.1. 示例代码

2.1.4.2. 关键点

2.1.5. 移动语义与线程

2.1.5.1. 示例代码

2.1.5.2. 关键点

2.1.6. lambda表达式与捕获变量

2.1.6.1. 按值捕获

2.1.6.3. 关键点

2.2. 线程的生命周期

2.3. 可重入函数与线程安全函数

2.3.1. 可重入函数(Reentrant Function)

2.3.1.1. 特征

2.3.2. 线程安全函数(Thread-Safe)

2.3.2.1. 实现线程安全的方式

2.3.3 可重入函数与线程安全函数比较

 2.4 其他操作

2.4.1 std::this_thread::sleep_for()

2.4.2 std::this_thread::get_id()

2.4.3 std::thread::hardware_concurrency()

2.4.4 std::thread::joinable()

3. 数据同步

3.1. 共享资源问题

3.2. 互斥锁(Mutex)

3.2.1. 什么是互斥锁(Mutex)?

3.2.2. 互斥锁的工作原理

3.2.3. C++中的互斥锁

3.2.4. 互斥锁的使用方法

3.2.4.1. 使用 std::mutex 手动加锁和解锁

3.2.4.2. 使用 std::lock_guard 自动管理锁

3.2.4.3. 使用 std::unique_lock 提供更灵活的锁管理

3.2.5. 常见的互斥锁问题

3.2.5.1. 死锁(Deadlock)

3.2.5.2. 长时间锁定

3.2.5.3. 优先级反转(Priority Inversion)

3.3. 条件变量(Condition Variable)

3.3.1. 什么是条件变量?

3.3.2. 条件变量的工作原理

3.3.3. C++中的条件变量

3.3.4. 条件变量的使用方法

3.3.4.1. 等待条件满足:wait()

3.3.4.2. 通知等待线程:notify_one() 和 notify_all()

3.3.4.3. 带超时的等待:wait_for() 和 wait_until()

3.3.5. 条件变量的常见使用场景

3.3.5.1. 生产者-消费者模型

3.3.5.2. 线程间信号传递

3.3.6. 条件变量的注意事项

3.3.6.1. 条件变量必须与互斥锁配合使用

3.3.6.2. 虚假唤醒

3.3.6.3. 选择 notify_one() 还是 notify_all()

3.3.7. 条件变量的典型使用模式

3.4. 读写锁(Read-Write Lock)

3.4.1. 什么是读写锁?

3.4.2. 读写锁的工作原理

3.4.3. C++中的读写锁

3.4.4. 读写锁的使用方法

3.4.4.1. 获取读锁:std::shared_lock

3.4.4.2. 获取写锁:std::unique_lock

3.4.5. 读写锁的常见使用场景

3.4.5.1. 高频读、低频写

3.4.6. 读写锁的具体使用方法

3.4.6.1. 基本使用:多个线程并发读,一个线程写

3.4.6.2. 关键解释

3.4.6.3. 写优先与读优先

3.4.7. 读写锁的注意事项

3.4.7.1. 死锁问题

3.4.7.2. 性能影响

3.4.7.3. 避免写饥饿

3.4.8. 读写锁的典型使用场景

3.4.8.1. 缓存或配置管理

3.4.8.2. 数据库查询

3.4.8.3. 日志系统

3.5. 自旋锁(Spinlock)

3.5.1. 什么是自旋锁?

3.5.2. 自旋锁的工作原理

3.5.3. C++中的自旋锁

3.5.4. 自旋锁的使用方法

3.5.4.1. 实现基本自旋锁

3.5.4.2. 获取和释放自旋锁

3.5.5. 自旋锁的适用场景

3.5.5.1. 锁定时间短的临界区

3.5.5.2. 高性能、低争用场景

3.5.6. 自旋锁的具体使用方法

3.5.6.1. 线程安全的临界区操作

3.5.6.2. 关键解释

3.5.7. 自旋锁的注意事项

3.5.7.1. 忙等待导致的 CPU 资源浪费

3.5.7.2. 自旋锁适合多核处理器

3.5.7.3. 不适合高竞争场景

3.5.8. 自旋锁的典型使用场景

3.5.8.1. 短临界区锁定

3.5.8.2. 硬件访问

3.5.9. 自旋锁与互斥锁的比较

3.5.10. 完整示例:线程安全计数器

4. 线程间通信

4.1. 什么是线程间通信?

4.2. 线程间通信的常见方式

4.2.1. 共享内存

4.2.2. 条件变量

4.2.3. 信号量

4.2.4. 消息队列

5. 并发编程的挑战

5.1. 死锁(Deadlock)

5.2. 竞态条件(Race Condition)

5.3. 活锁(Livelock)

6. 线程池

7. 条件变量与互斥锁的配合


1. 基本概念

1.1. 进程(Process)

  • 进程是操作系统分配资源的基本单位,每个进程都有自己独立的内存空间、堆栈和系统资源。
  • 进程间不能直接共享内存,需要通过进程间通信(IPC)来传递信息。

1.2. 线程(Thread)

  • 线程是操作系统调度的基本单位,属于进程的一个子任务。一个进程可以包含多个线程,它们共享同一个进程的内存空间。
  • 线程共享进程的资源,因此创建和管理线程的开销比进程小。

1.3. 并发与并行

  • 并发(Concurrency):在单个处理器上“同时”运行多个任务,通过任务切换实现多个任务交替执行。
  • 并行(Parallelism):在多核处理器或多台机器上真正同时运行多个任务。

2. 线程创建与管理

2.1. 线程的创建

  • 普通参数:按值传递,线程中的修改不影响原始值。
  • 引用参数:通过 std::ref() 实现引用传递,线程可以修改原始变量。
  • 指针参数:传递指针,线程可以通过指针修改变量的值。
  • 常量参数:使用 const 修饰符保证线程中的参数不可修改。
  • 移动语义:通过 std::move() 转移对象所有权,避免不必要的拷贝。
  • lambda表达式:可以按值或按引用捕获变量,简化线程创建的代码

2.1.1. 普通参数传递

普通参数在线程创建时是按值传递的,意味着在线程函数中得到的是传递参数的拷贝,修改该参数不会影响原始值。

2.1.1.1. 示例代码
#include 
#include 

void threadFunction(int x) {
    std::cout << "Thread Function: x = " << x << std::endl;
    x = 100;  // 修改x,不影响原来的值
}

int main() {
    int num = 10;
    std::thread t(threadFunction, num);  // 按值传递
    t.join();
    std::cout << "Main Function: num = " << num << std::endl;  // num 不会被修改
    return 0;
}
2.1.1.2. 关键点
  • 传递给线程函数的是 num 的拷贝,因此在线程函数中对 x 的修改不影响 main 中的 num

2.1.2. 引用参数传递

如果想要线程函数中的参数修改能够影响到主线程中的原始变量,可以通过传递引用。在 C++ 中,std::ref() 用于显式传递引用参数。

2.1.2.1. 示例代码
#include 
#include 

void threadFunction(int& x) {
    std::cout << "Thread Function: x = " << x << std::endl;
    x = 100;  // 修改x,影响到原始的变量
}

int main() {
    int num = 10;
    std::thread t(threadFunction, std::ref(num));  // 引用传递
    t.join();
    std::cout << "Main Function: num = " << num << std::endl;  // num 被修改为100
    return 0;
}
2.1.2.2. 关键点
  • 通过 std::ref(),可以传递引用参数,从而在线程中修改原始变量的值。
  • 如果不使用 std::ref(),C++ 仍然会按值传递,导致变量不会被修改。

2.1.3. 指针参数传递

指针也是一种常用的参数传递方式。传递指针时,线程函数接收到的参数是原始变量的地址,因此在函数中修改指针指向的内容会影响到主线程中的变量。

2.1.3.1. 示例代码
#include 
#include 

void threadFunction(int* x) {
    std::cout << "Thread Function: x = " << *x << std::endl;
    *x = 200;  // 修改指针指向的值,影响原始变量
}

int main() {
    int num = 10;
    std::thread t(threadFunction, &num);  // 传递指针
    t.join();
    std::cout << "Main Function: num = " << num << std::endl;  // num 被修改为200
    return 0;
}
2.1.3.2. 关键点
  • 通过指针传递,线程函数可以修改指针指向的值,影响原始变量。
  • 小心指针的生命周期,确保指针在线程执行期间有效,避免使用野指针或悬空指针。

2.1.4. 常量参数传递

常量参数传递表示线程函数中的参数不能被修改。可以通过 const 修饰符来传递常量参数,确保线程中的参数不会被改变。

2.1.4.1. 示例代码
#include 
#include 

void threadFunction(const int x) {
    std::cout << "Thread Function: x = " << x << std::endl;
    // x = 100;  // 错误,x 是常量,不能修改
}

int main() {
    int num = 10;
    std::thread t(threadFunction, num);  // 按值传递,但不能修改
    t.join();
    return 0;
}
2.1.4.2. 关键点
  • 通过 const 修饰符,可以确保线程函数中的参数不会被修改。
  • 常量参数可以增强代码的安全性,防止意外的参数修改。

2.1.5. 移动语义与线程

C++11 引入了移动语义,通过 std::move() 将对象的所有权转移给线程。对于大对象或需要避免拷贝的场景,使用移动语义可以提高性能。

2.1.5.1. 示例代码
#include 
#include 
#include 

void threadFunction(std::vector vec) {
    std::cout << "Thread Function: size of vec = " << vec.size() << std::endl;
}

int main() {
    std::vector vec(1000, 1);  // 大型对象
    std::thread t(threadFunction, std::move(vec));  // 移动语义传递
    t.join();
    std::cout << "Main Function: size of vec = " << vec.size() << std::endl;  // vec 变为空
    return 0;
}
2.1.5.2. 关键点
  • 通过 std::move(),可以将对象的所有权转移到线程函数中,避免拷贝大对象,提升性能。
  • 被转移的对象在主线程中将不再可用(如示例中的 vec 变为空)。

2.1.6. lambda表达式与捕获变量

在多线程编程中,使用 lambda 表达式创建线程并捕获变量是一种简洁的方式。通过按值或按引用捕获变量,可以决定是否允许在线程中修改变量。

2.1.6.1. 按值捕获
#include 
#include 

int main() {
    int num = 10;
    std::thread t([num]() mutable {
        std::cout << "Thread Function: num = " << num << std::endl;
        num = 100;  // 只修改捕获的副本,不影响原变量
    });
    t.join();
    std::cout << "Main Function: num = " << num << std::endl;  // num 没有改变
    return 0;
}

2.1.6.2. 按引用捕获

#include 
#include 

int main() {
    int num = 10;
    std::thread t([&num]() {
        std::cout << "Thread Function: num = " << num << std::endl;
        num = 100;  // 修改原变量
    });
    t.join();
    std::cout << "Main Function: num = " << num << std::endl;  // num 被修改为100
    return 0;
}
2.1.6.3. 关键点
  • 按值捕获:lambda 表达式中修改的是值的副本,不影响原变量。
  • 按引用捕获:lambda 表达式中可以直接修改原变量。

2.2. 线程的生命周期

  • 创建:当通过 std::thread 构造函数创建线程时,线程进入创建状态,并立即开始执行给定的任务。
  • 等待(join):主线程等待子线程执行结束,通过 join() 实现。如果主线程不等待,子线程可能会在主线程结束后被强制终止。当调用 join() 时,调用线程会阻塞,直到被 join() 的线程执行完毕。这样可以保证主线程不会在子线程执行完之前退出,从而避免子线程被强制终止的情况。
    void threadTask() {
        std::cout << "Thread is running..." << std::endl;
    }
    
    int main() {
        std::thread t(threadTask);
        
        std::cout << "Waiting for thread to finish..." << std::endl;
        
        // 等待线程 t 执行完毕
        t.join();
    
        std::cout << "Thread has finished." << std::endl;
    
        return 0;
    }
    
    关键点:
    1.如果不调用 join(),那么主线程可能会在子线程执行结束之前结束,这可能导致资源泄漏或未定义行为。
    2.不可重复调用:join() 只能对一个线程调用一次。如果试图对同一个线程对象再次调用 join(),会导致程序崩溃(抛出异常 std::system_error)。

  • 分离(detach):使线程与主线程分离,变成一个后台线程。当一个线程被分离后,它会在后台独立运行,不再与主线程同步。主线程将不会等待该线程执行完毕,线程资源在其结束后会被自动释放。
    void backgroundTask() {
        for (int i = 0; i < 5; ++i) {
            std::cout << "Background thread is working..." << std::endl;
        }
    }
    
    int main() {
        std::thread t(backgroundTask);
    
        // 将线程 t 与主线程分离,t 成为后台线程
        t.detach();
    
        std::cout << "Main thread is done." << std::endl;
    
        return 0;
    }
    关键点:
    1.后台线程:一旦线程被 detach(),它将在后台独立执行,主线程不会等待它执行完毕。主线程结束后,后台线程会继续执行,直到它自己执行完。
    2.不可 join():一旦线程被 detach(),你无法再通过 join() 操作来等待它。
    3.线程资源的回收:线程执行完后,其资源将被自动回收,不需要显式调用 join()。

2.3. 可重入函数与线程安全函数

2.3.1. 可重入函数(Reentrant Function)

可重入函数是指在任何时刻都能安全地被多个线程或多次调用,不会因共享资源而产生冲突的函数。它强调的是函数的执行不依赖外部的状态,且不会修改共享的全局状态。换句话说,同一个函数可以在不同的线程或中断上下文中“重入”而不会出现问题。

2.3.1.1. 特征
  • 不使用静态或全局变量:可重入函数不会使用全局变量或静态变量,除非它们是只读的(即不会被修改)。
  • 不依赖外部状态:函数的执行只依赖传递进来的参数,不依赖外部的环境。
  • 局部变量是自动分配的:所有变量都是局部的,并且在栈上分配,以保证每个调用都有自己的变量副本。
  • 不调用不可重入的函数:函数内部不会调用其他不可重入的函数。

2.3.2. 线程安全函数(Thread-Safe)

线程安全意味着函数或代码块在多线程环境中可以安全地并发执行。换句话说,线程安全的代码能够在多个线程同时执行时,保证数据一致性和正确性,不会产生竞态条件(race conditions)。

2.3.2.1. 实现线程安全的方式
  • 锁机制(Mutex/Lock):通过互斥锁来保护共享数据,确保同时只有一个线程可以访问或修改。
  • 原子操作:使用原子操作(例如 std::atomic 类型)来确保某些操作不可被打断。
  • 局部变量:线程安全的代码尽可能避免使用全局变量,采用局部变量来保证线程之间的数据隔离。
  • 线程本地存储(Thread Local Storage):为每个线程提供私有的变量副本,以避免线程间的数据共享问题。

2.3.3 可重入函数与线程安全函数比较

特性 可重入函数 线程安全函数
依赖外部状态 不依赖任何外部状态,只使用局部变量 可以依赖外部状态,但使用锁机制保护
全局变量使用 不使用全局或静态变量 可以使用,但需要同步机制来保护
锁的使用 不需要使用锁 可以使用锁来实现线程安全
中断安全 是的,可在中断上下文中安全调用 不一定,使用锁的函数可能在中断中出错
示例 递归函数、纯函数(如数学计算) 使用互斥锁保护的共享资源访问函数

 2.4 其他操作

2.4.1 std::this_thread::sleep_for()

让当前线程睡眠指定的时间。

#include 
#include 
#include 

void task() {
    std::cout << "Task started" << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(2));  // 让当前线程睡眠2秒
    std::cout << "Task finished after 2 seconds" << std::endl;
}

int main() {
    std::thread t(task);
    t.join();

    return 0;
}

2.4.2 std::this_thread::get_id()

返回当前线程的唯一标识符。

#include 
#include 

void printThreadId() {
    std::cout << "Thread ID: " << std::this_thread::get_id() << std::endl;
}

int main() {
    std::thread t(printThreadId);
    t.join();

    return 0;
}

2.4.3 std::thread::hardware_concurrency()

返回硬件并发线程的数量(即 CPU 的核心数)。这是一个估计值,表示系统能够并发运行的线程数目。

#include 
#include 

int main() {
    unsigned int n = std::thread::hardware_concurrency();
    std::cout << "Number of concurrent threads supported: " << n << std::endl;
    return 0;
}

2.4.4 std::thread::joinable()

  • 检查线程是否可以被 join(),即是否是一个有效线程。
    #include 
    #include 
    
    void dummyTask() {
        std::cout << "Task running..." << std::endl;
    }
    
    int main() {
        std::thread t(dummyTask);
        
        if (t.joinable()) {
            t.join();
        }
    
        return 0;
    }
    

3. 数据同步

3.1. 共享资源问题

  • 多线程之间可以共享进程的全局数据,但这样会带来竞争条件(Race Condition)的问题,即多个线程同时读写共享资源,导致结果不可预测。

3.2. 互斥锁(Mutex)

3.2.1. 什么是互斥锁(Mutex)?

互斥锁是一种同步机制,用于防止多个线程同时访问共享资源。它保证在任何时刻只有一个线程能够获取锁并访问被保护的资源。其他线程在访问这个资源之前必须等待锁被释放。

3.2.2. 互斥锁的工作原理

  • 锁定(Locking):当一个线程想要访问共享资源时,它必须首先锁定互斥锁。如果锁已经被其他线程持有,该线程将被阻塞,直到锁被释放。
  • 解锁(Unlocking):当线程完成对共享资源的操作后,它必须释放互斥锁,以便其他线程可以获取锁并访问该资源。

3.2.3. C++中的互斥锁

C++11 标准库提供了 头文件中的 std::mutex 类,它是互斥锁的基础实现。下面是常用的互斥锁相关操作:

  • std::mutex:基本的互斥锁。
  • std::lock_guard:RAII 风格的锁管理器,用于自动管理互斥锁的加锁和解锁。
  • std::unique_lock:提供更多灵活性的锁管理器,可以手动控制锁的获取和释放。

3.2.4. 互斥锁的使用方法

3.2.4.1. 使用 std::mutex 手动加锁和解锁

使用 std::mutex 时,需要显式调用 lock()unlock() 方法。在程序中可以通过如下方式使用互斥锁:

#include 
#include 
#include 

std::mutex mtx;  // 定义一个互斥锁
int counter = 0; // 共享资源

void increaseCounter() {
    for (int i = 0; i < 10000; ++i) {
        mtx.lock();  // 加锁
        ++counter;   // 访问和修改共享资源
        mtx.unlock();  // 解锁
    }
}

int main() {
    std::thread t1(increaseCounter);
    std::thread t2(increaseCounter);

    t1.join();
    t2.join();

    std::cout << "Final counter value: " << counter << std::endl;
    return 0;
}
3.2.4.2. 使用 std::lock_guard 自动管理锁

为了避免手动 unlock() 出现错误(如忘记解锁或异常退出导致未解锁),C++ 提供了 RAII 风格的锁管理器 std::lock_guard。当 std::lock_guard 对象创建时,它会自动锁定互斥锁,当对象销毁时,互斥锁会自动解锁。

#include 
#include 
#include 

std::mutex mtx;
int counter = 0;

void increaseCounter() {
    for (int i = 0; i < 10000; ++i) {
        std::lock_guard lock(mtx);  // 自动加锁和解锁
        ++counter;
    }
}

int main() {
    std::thread t1(increaseCounter);
    std::thread t2(increaseCounter);

    t1.join();
    t2.join();

    std::cout << "Final counter value: " << counter << std::endl;
    return 0;
}

关键点

  • lock_guard 对象 lock 创建时,它自动加锁。
  • lock_guard 对象超出作用域时,它自动解锁,不需要显式调用 unlock()
3.2.4.3. 使用 std::unique_lock 提供更灵活的锁管理

std::unique_lock 提供比 std::lock_guard 更灵活的锁管理,可以手动控制锁的获取、释放,甚至延迟加锁或提前解锁等。

#include 
#include 
#include 

std::mutex mtx;
int counter = 0;

void increaseCounter() {
    std::unique_lock lock(mtx);  // 自动加锁
    ++counter;
    lock.unlock();  // 手动解锁
    
    // 后续操作不需要锁定
}

int main() {
    std::thread t1(increaseCounter);
    std::thread t2(increaseCounter);

    t1.join();
    t2.join();

    std::cout << "Final counter value: " << counter << std::endl;
    return 0;
}

关键点

  • unique_lock 支持延迟加锁、提前解锁、锁的显式获取和释放,适合需要更多灵活性的场景。
  • 可以在特定条件下加锁和解锁,避免长时间占用锁资源。

3.2.5. 常见的互斥锁问题

3.2.5.1. 死锁(Deadlock)

死锁是指两个或多个线程相互等待对方释放锁,导致所有线程都无法继续执行。在开发中,以下几种情况可能会引发死锁:

  • 多重锁定:当多个线程试图以不同顺序获取多个锁时,很容易发生死锁。

    避免方式

    • 确保所有线程按照相同顺序获取锁。
    • 使用 std::lock() 来同时获取多个锁。
std::mutex mtx1, mtx2;

void thread1() {
    std::lock(mtx1, mtx2);  // 同时获取 mtx1 和 mtx2
    std::lock_guard lock1(mtx1, std::adopt_lock);  // 采用已锁定的互斥锁
    std::lock_guard lock2(mtx2, std::adopt_lock);
    // Critical section
}

void thread2() {
    std::lock(mtx1, mtx2);  // 确保线程2与线程1顺序一致
    std::lock_guard lock1(mtx1, std::adopt_lock);
    std::lock_guard lock2(mtx2, std::adopt_lock);
    // Critical section
}
  • 忘记解锁:如果某个线程获取了锁但在某些情况下未能正确释放锁(如异常抛出时),可能会导致其他线程永久阻塞。

        避免方式

     使用 std::lock_guardstd::unique_lock 自动管理锁的获取与释放,确保锁在作用域结束时被自动释放。

3.2.5.2. 长时间锁定

如果一个线程持有锁的时间过长,其他线程将无法及时获取锁,导致性能下降。

解决方法

  • 尽量减少加锁代码的范围(即临界区的大小),确保只在需要访问共享资源时才加锁,执行完后尽快解锁。
  • 使用读写锁(如 std::shared_mutex)以允许多个线程同时读取共享资源,只有在写入时才加独占锁。
3.2.5.3. 优先级反转(Priority Inversion)

优先级反转是指低优先级线程占有资源,而高优先级线程等待资源释放,导致系统无法充分利用高优先级线程。

解决方法

  • 使用优先级继承机制或避免优先级反转的锁策略(如避免长时间持有锁)。

3.3. 条件变量(Condition Variable)

3.3.1. 什么是条件变量?

条件变量(Condition Variable) 用于让线程等待某个特定的条件满足后再继续执行。与互斥锁不同,条件变量通常用于线程之间的通信,即一个线程可以等待另一个线程触发某个事件或满足某个条件。 它和互斥锁一起使用,常用于让线程间相互通知并等待某个事件的发生。

3.3.2. 条件变量的工作原理

  • 挂起线程:线程调用 wait() 方法进入等待状态,并自动释放与之关联的互斥锁,允许其他线程修改共享资源。
  • 通知线程:通过 notify_one()notify_all() 通知等待线程条件满足,线程会被唤醒并重新获取互斥锁,继续执行后续操作。

3.3.3. C++中的条件变量

C++11 标准库提供了 头文件中的 std::condition_variable,配合 std::mutexstd::unique_lock 使用,实现线程间的同步。常用的条件变量操作包括:

  • std::condition_variable::wait():等待某个条件满足。
  • std::condition_variable::notify_one():通知一个等待线程。
  • std::condition_variable::notify_all():通知所有等待线程。

3.3.4. 条件变量的使用方法

3.3.4.1. 等待条件满足:wait()

等待某个条件成立,并在等待期间自动释放互斥锁。常见用法有两种:

  • wait(lock):等待通知并在被唤醒时重新获取锁。
  • wait(lock, predicate):不仅等待通知,还可以检查传入的谓词(条件表达式),谓词为真时继续执行。
std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void waitForWork() {
    std::unique_lock lock(mtx);
    cv.wait(lock, [] { return ready; });  // 等待 ready 为 true
}
3.3.4.2. 通知等待线程:notify_one()notify_all()
  • notify_one():唤醒一个等待的线程。
  • notify_all():唤醒所有等待该条件变量的线程。
void doWork() {
    {
        std::lock_guard lock(mtx);
        ready = true;
    }
    cv.notify_one();  // 通知一个线程
}
3.3.4.3. 带超时的等待:wait_for()wait_until()
  • wait_for(lock, duration):等待指定时间。
  • wait_until(lock, time_point):等待到某个时间点。
if (cv.wait_for(lock, std::chrono::seconds(2), [] { return ready; })) {
    // 条件成立,继续执行
} else {
    // 超时,处理超时逻辑
}

3.3.5. 条件变量的常见使用场景

3.3.5.1. 生产者-消费者模型

在生产者-消费者模型中,生产者和消费者使用条件变量相互同步:

std::queue dataQueue;
std::mutex mtx;
std::condition_variable cv;
bool finished = false;

void producer() {
    std::lock_guard lock(mtx);
    dataQueue.push(42);  // 生产数据
    cv.notify_one();     // 通知消费者
}

void consumer() {
    std::unique_lock lock(mtx);
    cv.wait(lock, [] { return !dataQueue.empty(); });  // 等待数据
    int data = dataQueue.front();
    dataQueue.pop();  // 消费数据
}
3.3.5.2. 线程间信号传递

多个线程等待某一事件发生,通知后所有线程继续工作:

void setReady() {
    std::lock_guard lock(mtx);
    ready = true;
    cv.notify_all();  // 通知所有等待线程
}

3.3.6. 条件变量的注意事项

3.3.6.1. 条件变量必须与互斥锁配合使用

在调用 wait() 时,必须使用互斥锁保护条件检查,防止竞态条件。

3.3.6.2. 虚假唤醒

虚假唤醒是条件变量随机唤醒线程的现象,因此必须反复检查条件是否满足:

cv.wait(lock, [] { return ready; });
3.3.6.3. 选择 notify_one() 还是 notify_all()
  • notify_one() 用于唤醒单个线程,适用于资源有限的场景。
  • notify_all() 适用于多个线程等待同一条件的场景。

3.3.7. 条件变量的典型使用模式

  • 等待条件满足并执行工作:线程进入等待状态,其他线程修改共享资源后唤醒它。
  • 生产者-消费者模型:用条件变量协调生产者生产数据、消费者消费数据。
  • 线程间信号传递:多个线程等待事件发生,用 notify_all() 通知它们继续执行。
#include 
#include 
#include 
#include 
#include 

std::queue dataQueue;
std::mutex mtx;
std::condition_variable cv;
bool finished = false;

void producer() {
    for (int i = 0; i < 10; ++i) {
        std::lock_guard lock(mtx);
        dataQueue.push(i);
        cv.notify_one();  // 通知消费者
    }
}

void consumer() {
    while (true) {
        std::unique_lock lock(mtx);
        cv.wait(lock, [] { return !dataQueue.empty(); });  // 等待数据
        int data = dataQueue.front();
        dataQueue.pop();
        std::cout << "Consumed: " << data << std::endl;
        if (data == 9) break;  // 停止条件
    }
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);
    
    t1.join();
    t2.join();
    return 0;
}

3.4. 读写锁(Read-Write Lock)

3.4.1. 什么是读写锁?

读写锁(Read-Write Lock)是一种用于控制多线程访问共享资源的同步机制。与普通的互斥锁不同,读写锁允许多个线程同时读取共享资源(读操作),但当有线程需要写入共享资源时,读写锁会确保只有一个线程进行写操作,并阻止其他线程的读写操作。

3.4.2. 读写锁的工作原理

读写锁分为两种模式:

  • 读模式:允许多个线程同时读取数据,但不允许任何线程写入数据。
  • 写模式:只有一个线程可以进行写操作,且在写操作完成前,其他线程(包括读线程和写线程)都将被阻塞。

读写锁的基本思路是:

  • 多个线程可以同时读,但不能写
  • 写线程独占锁,阻止其他读和写线程

3.4.3. C++中的读写锁

在 C++11 中,没有直接提供读写锁(如 std::shared_mutex),直到 C++17 标准中才引入了读写锁。读写锁在 头文件中通过 std::shared_mutex 提供。该类允许多个线程同时获得共享的读访问,但只允许一个线程获得独占的写访问。

  • std::shared_mutex:用于实现读写锁。
  • std::shared_lock:获取共享(读)锁。
  • std::unique_lock:获取独占(写)锁。

3.4.4. 读写锁的使用方法

3.4.4.1. 获取读锁:std::shared_lock

读锁允许多个线程同时读取共享数据。在获取读锁时,不会阻塞其他读线程,但会阻塞写线程。

std::shared_mutex rwlock;

void readData() {
    std::shared_lock lock(rwlock);  // 获取读锁
    // 读取共享数据
}
3.4.4.2. 获取写锁:std::unique_lock

写锁是独占的,获取写锁后,其他线程(无论是读线程还是写线程)都必须等待,直到当前写操作完成。

std::shared_mutex rwlock;

void writeData() {
    std::unique_lock lock(rwlock);  // 获取写锁
    // 写入共享数据
}

3.4.5. 读写锁的常见使用场景

3.4.5.1. 高频读、低频写

读写锁特别适用于读操作多、写操作少的场景。例如,在一个配置管理系统中,读取配置的操作频繁,但只有管理员才会偶尔更新配置。这时,可以使用读写锁保证多个线程能高效读取数据,同时在需要修改时提供线程安全性。

3.4.6. 读写锁的具体使用方法

3.4.6.1. 基本使用:多个线程并发读,一个线程写

下面是一个简单的示例,展示了如何使用 std::shared_mutex 实现多个线程同时读取数据,只有一个线程写入数据:

#include 
#include 
#include 
#include 

std::shared_mutex rwlock;
int sharedResource = 0;

void readTask(int threadId) {
    std::shared_lock lock(rwlock);  // 获取读锁
    std::cout << "Thread " << threadId << " is reading value: " << sharedResource << std::endl;
}

void writeTask(int threadId) {
    std::unique_lock lock(rwlock);  // 获取写锁
    sharedResource += 1;
    std::cout << "Thread " << threadId << " is writing value: " << sharedResource << std::endl;
}

int main() {
    std::vector readers;
    std::vector writers;

    // 创建多个读线程
    for (int i = 0; i < 5; ++i) {
        readers.emplace_back(readTask, i);
    }

    // 创建写线程
    for (int i = 0; i < 2; ++i) {
        writers.emplace_back(writeTask, i);
    }

    // 等待所有读线程完成
    for (auto& reader : readers) {
        reader.join();
    }

    // 等待所有写线程完成
    for (auto& writer : writers) {
        writer.join();
    }

    return 0;
}
3.4.6.2. 关键解释
  • 读线程:可以同时获取共享锁,并且在获取共享锁期间,允许多个读线程并发访问 sharedResource
  • 写线程:独占写锁,修改共享数据 sharedResource,阻止其他读和写线程的访问。
3.4.6.3. 写优先与读优先
  • 写优先:当有写线程请求锁时,系统会优先满足写线程的请求,暂时阻止读线程,以保证写操作的及时性。
  • 读优先:系统可能允许读线程先执行,尤其在读线程占据大量时间的情况下,写线程可能会面临“写饥饿”问题。

3.4.7. 读写锁的注意事项

3.4.7.1. 死锁问题

使用读写锁时,应该注意避免死锁。特别是当读线程和写线程同时请求锁时,如果不小心设计可能导致死锁。例如,读线程在持有读锁时,尝试获取写锁,会导致死锁。

3.4.7.2. 性能影响

在高频写操作的场景下,读写锁可能反而不如普通的互斥锁有效,因为写锁会阻塞所有其他线程的读写操作。在这种情况下,互斥锁可能是更好的选择。

3.4.7.3. 避免写饥饿

在某些实现中,可能会发生“写饥饿”现象,即当有多个读线程持续进行读操作时,写线程可能长期得不到执行。为了避免写饥饿,可以选择写优先的策略或对读写锁进行调优。

3.4.8. 读写锁的典型使用场景

3.4.8.1. 缓存或配置管理

在需要频繁读取缓存或配置信息的系统中,读写锁可以保证多个线程安全地读取数据,并且当需要修改配置时,提供互斥的写访问。

#include 
#include 
#include 
#include 
#include 

std::unordered_map cache;
std::shared_mutex cacheMutex;

void readCache(const std::string& key) {
    std::shared_lock lock(cacheMutex);  // 获取读锁
    if (cache.find(key) != cache.end()) {
        std::cout << "Read key: " << key << " -> " << cache[key] << std::endl;
    } else {
        std::cout << "Key: " << key << " not found in cache" << std::endl;
    }
}

void writeCache(const std::string& key, int value) {
    std::unique_lock lock(cacheMutex);  // 获取写锁
    cache[key] = value;
    std::cout << "Write key: " << key << " -> " << value << std::endl;
}

int main() {
    std::thread writer1(writeCache, "a", 10);
    std::thread reader1(readCache, "a");
    std::thread writer2(writeCache, "b", 20);
    std::thread reader2(readCache, "b");
    std::thread reader3(readCache, "c");

    writer1.join();
    reader1.join();
    writer2.join();
    reader2.join();
    reader3.join();

    return 0;
}
3.4.8.2. 数据库查询

在某些数据库系统中,读写锁用于确保多个查询操作可以并发进行,而当数据库更新时,阻止其他读操作。

3.4.8.3. 日志系统

日志系统中,多个线程可以并发读取日志文件或缓存日志记录,当需要写入新的日志条目时,使用写锁保证写入操作的原子性。

3.5. 自旋锁(Spinlock)

3.5.1. 什么是自旋锁?

自旋锁(Spinlock)是一种忙等待锁(busy-wait lock),是用于多线程同步的轻量级锁机制。与互斥锁不同,线程在尝试获取自旋锁时,如果锁已经被其他线程持有,它不会进入休眠状态,而是不断尝试重新获取锁。这种“自旋”的行为使得自旋锁在短期锁定时效率较高,但在长时间锁定的场景下可能会浪费 CPU 资源。

  • 自旋锁的优点:在短期锁定、低争用场景下,自旋锁的性能较好,因为它避免了线程的上下文切换。
  • 自旋锁的缺点:在长时间锁定或高争用场景下,自旋锁会导致 CPU 资源浪

3.5.2. 自旋锁的工作原理

自旋锁的核心思想是:

  • 不断尝试:线程反复检查锁是否已被释放。由于线程不会休眠或被挂起,能够快速响应锁的释放。
  • 忙等待:线程处于忙等状态,不断轮询锁的状态,直到成功获取锁。
  • 适用场景:适用于锁持有时间非常短的场景,否则会浪费大量 CPU 资源。

3.5.3. C++中的自旋锁

C++ 标准库中没有直接提供自旋锁,不过可以通过 std::atomic_flagstd::atomic 来实现自旋锁。std::atomic_flag 是最简单的原子类型,提供了一种无锁的方式来实现自旋锁的行为。

  • std::atomic_flag:用于实现基本的自旋锁。
  • test_and_set():设置标志位并返回先前的值,用于判断锁是否已经被持有。
  • clear():清除标志位,释放锁。

3.5.4. 自旋锁的使用方法

3.5.4.1. 实现基本自旋锁

通过 std::atomic_flag 实现简单的自旋锁:

#include 
#include 

class Spinlock {
private:
    std::atomic_flag flag = ATOMIC_FLAG_INIT;  // 初始化自旋锁

public:
    void lock() {
        while (flag.test_and_set(std::memory_order_acquire)) {
            // 忙等待,直到成功获取锁
        }
    }

    void unlock() {
        flag.clear(std::memory_order_release);  // 释放锁
    }
};
3.5.4.2. 获取和释放自旋锁

线程调用 lock() 方法获取锁,并使用 unlock() 释放锁:

Spinlock spinlock;

void criticalSection() {
    spinlock.lock();    // 获取自旋锁
    // 临界区代码
    spinlock.unlock();  // 释放自旋锁
}

3.5.5. 自旋锁的适用场景

3.5.5.1. 锁定时间短的临界区

自旋锁适合用于锁持有时间非常短的场景,尤其在多核处理器上,线程在自旋等待时不会被挂起,可以快速获取锁,从而减少上下文切换的开销。

3.5.5.2. 高性能、低争用场景

在一些高性能计算中,线程争用较低且锁持有时间短,自旋锁能够有效避免线程被挂起后导致的性能损耗。在这种场景下,自旋锁的忙等待策略更为高效。

3.5.6. 自旋锁的具体使用方法

3.5.6.1. 线程安全的临界区操作

在多线程程序中,使用自旋锁保护临界区,保证多个线程不会同时进入临界区:

#include 
#include 
#include 
#include 

class Spinlock {
private:
    std::atomic_flag flag = ATOMIC_FLAG_INIT;

public:
    void lock() {
        while (flag.test_and_set(std::memory_order_acquire)) {
            // 自旋等待
        }
    }

    void unlock() {
        flag.clear(std::memory_order_release);  // 释放锁
    }
};

Spinlock spinlock;
int counter = 0;

void incrementCounter() {
    for (int i = 0; i < 1000; ++i) {
        spinlock.lock();
        ++counter;  // 临界区:访问共享资源
        spinlock.unlock();
    }
}

int main() {
    std::vector threads;
    
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(incrementCounter);
    }
    
    for (auto& t : threads) {
        t.join();
    }

    std::cout << "Final counter value: " << counter << std::endl;

    return 0;
}
3.5.6.2. 关键解释
  • 自旋锁保护的临界区spinlock.lock() 确保线程在进入临界区之前先获取锁,防止多个线程同时访问共享资源。
  • 自旋等待:在 lock() 中,线程会忙等待,直到成功获取锁。
  • 多线程增量操作:10 个线程同时对 counter 进行增量操作,每个线程执行 1000 次递增,最终的 counter 值应为 10000。

3.5.7. 自旋锁的注意事项

3.5.7.1. 忙等待导致的 CPU 资源浪费

自旋锁的一个缺点是,如果锁持有时间较长,自旋等待的线程会一直占用 CPU 资源,可能导致其他任务得不到及时调度。因此,自旋锁适合锁持有时间非常短的场景,不适合长时间占用锁的任务。

3.5.7.2. 自旋锁适合多核处理器

自旋锁适合在多核处理器上使用,因为多个线程可以并发执行,等待锁的线程可以快速响应锁的释放。而在单核处理器上,自旋锁可能会导致性能问题,因为忙等待的线程无法让出 CPU。

3.5.7.3. 不适合高竞争场景

如果有大量线程频繁争夺同一个自旋锁,自旋等待可能导致大量 CPU 时间被浪费,应该考虑使用互斥锁(std::mutex)等更适合高竞争的同步机制。

3.5.8. 自旋锁的典型使用场景

3.5.8.1. 短临界区锁定

自旋锁非常适合那些锁定时间非常短的临界区操作。例如,在某些内核态程序中,自旋锁用于保护极短时间的关键操作,减少线程调度开销。

3.5.8.2. 硬件访问

在访问某些硬件设备或高速缓存时,自旋锁可以保证线程间的安全操作,尤其是在短时间内访问硬件资源的场景中,减少了线程被阻塞的可能性。

3.5.9. 自旋锁与互斥锁的比较

  • 性能:在短期锁定的情况下,自旋锁比互斥锁性能更好,因为它避免了线程的休眠和唤醒操作。
  • 忙等待:自旋锁会在锁不可用时忙等待,浪费 CPU 资源;而互斥锁在锁不可用时会让线程进入睡眠,等待锁释放。
  • 适用场景:自旋锁适合短期锁定、低争用的场景;互斥锁更适合长时间锁定或高竞争的场景。

3.5.10. 完整示例:线程安全计数器

下面是一个使用自旋锁保护计数器的完整示例,展示自旋锁如何在多线程环境中保证线程安全:

#include 
#include 
#include 
#include 

class Spinlock {
private:
    std::atomic_flag flag = ATOMIC_FLAG_INIT;

public:
    void lock() {
        while (flag.test_and_set(std::memory_order_acquire)) {
            // 忙等待,直到获取锁
        }
    }

    void unlock() {
        flag.clear(std::memory_order_release);  // 释放锁
    }
};

Spinlock spinlock;
int counter = 0;

void increment() {
    for (int i = 0; i < 10000; ++i) {
        spinlock.lock();
        ++counter;  // 保护共享资源的临界区
        spinlock.unlock();
    }
}

int main() {
    std::vector threads;
    
    // 启动多个线程
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(increment);
    }

    // 等待所有线程完成
    for (auto& t : threads) {
        t.join();
    }

    std::cout << "Final counter value: " << counter << std::endl;

    return 0;
}

4. 线程间通信

4.1. 什么是线程间通信?

线程间通信是指在多线程编程中,不同线程之间交换数据或信号,以协调彼此的执行和共享资源。由于线程在同一进程内共享内存空间,因此可以通过多种方式实现通信,如使用共享变量、信号量、条件变量、消息队列等机制。

4.2. 线程间通信的常见方式

4.2.1. 共享内存

通过共享变量实现线程间的通信,但需要通过同步机制(如互斥锁、条件变量)保证数据一致性。

  • 优点:效率高,直接在共享内存中传递数据。
  • 缺点:需要妥善处理同步问题,避免数据竞争。
4.2.2. 条件变量

条件变量允许线程阻塞,等待特定的条件满足后继续执行。常与互斥锁结合使用,用于线程间的信号通信。

  • 优点:高效的线程唤醒机制。
  • 缺点:需要依赖互斥锁,增加代码复杂性。
4.2.3. 信号量

信号量可以控制多个线程对共享资源的访问,既可以实现线程同步,也可以作为线程间通信的手段。

  • 优点:适用于控制资源的并发访问。
  • 缺点:需要仔细管理信号量的增减,避免死锁。
4.2.4. 消息队列

线程之间通过消息队列传递数据或消息,实现解耦和异步通信。消息队列可以避免直接使用共享内存,减少同步操作的复杂性。

  • 优点:解耦线程间的直接通信,方便扩展。
  • 缺点:引入了额外的消息管理开销。

5. 并发编程的挑战

5.1. 死锁(Deadlock)

  • 死锁是指两个或多个线程相互等待对方释放资源,导致所有线程都无法继续执行。解决死锁的常用方法:
    • 资源有序分配:所有线程按照统一顺序申请资源。
    • 超时机制:给锁设置超时,防止线程无限等待。
    • 避免嵌套锁:减少不同锁之间的嵌套。
  • 5.2. 竞态条件(Race Condition)

  • 多个线程同时访问共享资源导致结果不确定。解决办法是对共享资源进行适当的同步。
  • 5.3. 活锁(Livelock)

  • 活锁是指线程不断改变自己的状态,导致任务无法完成。不同于死锁,活锁中的线程是活跃的,但无法取得进展。
  • 线程池是一种预先创建一组固定数量的线程,所有任务在需要时可以从线程池中获取线程执行。这样可以避免频繁创建和销毁线程的开销。
  • 6. 线程池

    #include 
    #include 
    #include 
    #include 
    
    class ThreadPool {
    public:
        ThreadPool(size_t numThreads);
        ~ThreadPool();
        void enqueueTask(std::function task);
    private:
        std::vector workers;
        std::queue> tasks;
        std::mutex queueMutex;
        std::condition_variable condition;
        bool stop;
    };
    

7. 条件变量与互斥锁的配合

条件变量与互斥锁一起使用,通常用来解决以下两类问题:

  1. 等待某个条件满足后再继续执行。
  2. 让一个线程通知其他线程某个条件已经满足。
#include 
#include 
#include 
#include 

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void printId(int id) {
    std::unique_lock lock(mtx);
    cv.wait(lock, []{ return ready; });  // 等待 ready 为 true
    
    std::cout << "Thread " << id << " is running\n";
}

void setReady() {
    std::lock_guard lock(mtx);
    ready = true;
    cv.notify_all();  // 通知所有等待的线程
}

int main() {
    std::thread t1(printId, 1);
    std::thread t2(printId, 2);
    
    std::this_thread::sleep_for(std::chrono::seconds(1));
    setReady();  // 改变条件并唤醒线程
    
    t1.join();
    t2.join();
    return 0;
}

你可能感兴趣的:(C++,c++)