Bootstrap

C++学习38、锁

在多线程编程中,确保数据的一致性和正确性是非常重要的。为了防止多个线程同时访问共享资源导致的数据竞争和不一致问题,我们需要使用同步机制。锁(Lock)是最常用的同步机制之一。本文将详细介绍 C++ 中的几种锁机制,包括互斥锁(Mutex)、递归锁(Recursive Mutex)、读写锁(Read-Write Lock)以及自旋锁(Spin Lock),并探讨它们的使用场景和优缺点。

1. 互斥锁(Mutex)

1.1 基本概念

互斥锁是一种最基本的锁机制,用于保护共享资源不被多个线程同时访问。互斥锁在任意时刻只能被一个线程持有,其他试图获取锁的线程将被阻塞,直到锁被释放。

1.2 使用方法

在 C++11 及更高版本中,可以使用 头文件提供的 std::mutex 类来实现互斥锁。以下是一个简单的示例:

#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx; // 定义一个互斥锁

void print_block (int n, char c) {
    std::lock_guard<std::mutex> lck (mtx); // 自动管理锁的生命周期
    for (int i=0; i<n; ++i) { std::cout << c; }
    std::cout << '\n';
}

int main ()
{
    std::thread th1 (print_block,50,'*');
    std::thread th2 (print_block,50,'$');

    th1.join();
    th2.join();

    return 0;
}

在这个示例中,我们定义了一个全局的互斥锁 mtx,并在 print_block 函数中使用 std::lock_guard 来自动管理锁的生命周期。std::lock_guard 在构造时自动获取锁,在析构时自动释放锁,从而避免了忘记释放锁的问题。

1.3 优缺点

优点:
实现简单,使用方便。
性能较好,适用于大多数场景。
缺点:
如果一个线程已经持有了某个互斥锁,再次尝试获取同一个锁会导致死锁。
不支持递归锁。

2. 递归锁(Recursive Mutex)

2.1 基本概念

递归锁允许同一个线程多次获取同一个锁而不会导致死锁。每次获取锁后,递归锁会记录当前线程的锁计数,只有当锁计数为零时,锁才会被释放。

2.2 使用方法

在 C++11 及更高版本中,可以使用 头文件提供的 std::recursive_mutex 类来实现递归锁。以下是一个简单的示例:

#include <iostream>
#include <thread>
#include <mutex>

std::recursive_mutex mtx; // 定义一个递归锁

void print_block (int n, char c) {
    std::unique_lock<std::recursive_mutex> lck (mtx); // 自动管理锁的生命周期
    for (int i=0; i<n; ++i) { std::cout << c; }
    std::cout << '\n';

    // 再次获取同一个锁
    lck.lock();
    for (int i=0; i<n; ++i) { std::cout << c; }
    std::cout << '\n';
    lck.unlock();
}

int main ()
{
    std::thread th1 (print_block,50,'*');
    std::thread th2 (print_block,50,'$');

    th1.join();
    th2.join();

    return 0;
}

在这个示例中,我们定义了一个全局的递归锁 mtx,并在 print_block 函数中使用 std::unique_lock 来自动管理锁的生命周期。std::unique_lock 支持手动锁定和解锁,因此可以在同一个线程中多次获取同一个锁。

2.3 优缺点

优点:
支持递归锁,避免了死锁问题。
适用于需要在一个线程中多次获取同一个锁的场景。
缺点:
性能略低于普通的互斥锁。
实现复杂度较高。

3. 读写锁(Read-Write Lock)

3.1 基本概念

读写锁允许多个读操作同时进行,但写操作必须独占锁。这种锁机制适用于读多写少的场景,可以提高并发性能。

3.2 使用方法

在 C++11 及更高版本中,可以使用 <shared_mutex> 头文件提供的 std::shared_timed_mutex 类来实现读写锁。以下是一个简单的示例:

#include <iostream>
#include <thread>
#include <shared_mutex>
#include <vector>

std::shared_timed_mutex mtx; // 定义一个读写锁
std::vector<int> data;

void read_data () {
    std::shared_lock<std::shared_timed_mutex> lck (mtx); // 共享锁
    for (int i : data) {
        std::cout << i << " ";
    }
    std::cout << "\n";
}

void write_data (int value) {
    std::unique_lock<std::shared_timed_mutex> lck (mtx); // 独占锁
    data.push_back(value);
}

int main ()
{
    std::thread t1 (read_data);
    std::thread t2 (read_data);
    std::thread t3 (write_data, 42);

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

    return 0;
}

在这个示例中,我们定义了一个全局的读写锁 mtx,并在 read_data 和 write_data 函数中分别使用 std::shared_lock 和 std::unique_lock 来管理锁的生命周期。std::shared_lock 用于读操作,允许多个读操作同时进行;std::unique_lock 用于写操作,确保写操作独占锁。

3.3 优缺点

优点:
适用于读多写少的场景,可以提高并发性能。
支持多种锁模式,灵活性高。
缺点:
实现复杂度较高。
性能略低于普通的互斥锁。

4. 自旋锁(Spin Lock)

4.1 基本概念

自旋锁是一种忙等待锁,当一个线程尝试获取锁但无法立即获得时,它会不断循环检查锁的状态,直到锁可用为止。自旋锁适用于锁持有时间非常短的场景,因为忙等待会消耗 CPU 资源。

4.2 使用方法

在 C++11 及更高版本中,可以使用 头文件提供的 std::atomic_flag 类来实现自旋锁。以下是一个简单的示例:

#include <iostream>
#include <thread>
#include <atomic>

std::atomic_flag lock = ATOMIC_FLAG_INIT; // 定义一个自旋锁

void critical_section (int id) {
    while (lock.test_and_set(std::memory_order_acquire)) { } // 尝试获取锁
    std::cout << "Thread " << id << " is in the critical section.\n";
    std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟耗时操作
    lock.clear(std::memory_order_release); // 释放锁
}

int main ()
{
    std::thread t1 (critical_section, 1);
    std::thread t2 (critical_section, 2);

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

    return 0;
}

在这个示例中,我们定义了一个全局的自旋锁 lock,并在 critical_section 函数中使用 test_and_set 和 clear 方法来管理锁的生命周期。test_and_set 方法尝试获取锁,如果锁已被占用则进入忙等待状态;clear 方法释放锁。

4.3 优缺点

优点:
适用于锁持有时间非常短的场景。
实现简单,性能高。
缺点:
消耗 CPU 资源,不适合长时间持有锁的场景。
可能导致优先级反转问题。

5. 锁的性能分析

5.1 锁的竞争程度

锁的竞争程度是指多个线程同时尝试获取同一个锁的频率。竞争程度越高,锁的性能越差。以下是一些常见的锁竞争场景:
低竞争:锁很少被多个线程同时请求,适合使用互斥锁或递归锁。
中等竞争:锁被多个线程频繁请求,但持有时间较短,适合使用自旋锁。
高竞争:锁被多个线程频繁请求且持有时间较长,适合使用读写锁。

5.2 锁的开销

锁的开销主要包括以下几个方面:

  • 获取锁的开销:获取锁的时间复杂度和系统调用次数。
  • 释放锁的开销:释放锁的时间复杂度和系统调用次数。
  • 上下文切换的开销:线程在等待锁时可能会被调度器切换出去,导致上下文切换开销。

5.3 锁的选择策略

选择合适的锁机制需要综合考虑以下因素:

  • 应用场景:读多写少的场景适合使用读写锁,写多读少的场景适合使用互斥锁。
  • 锁的持有时间:持有时间非常短的场景适合使用自旋锁,持有时间较长的场景适合使用互斥锁或读写锁。
  • 性能要求:对性能要求较高的场景适合使用自旋锁,对性能要求较低的场景适合使用互斥锁或读写锁。

6. 锁的高级用法

6.1 条件变量

条件变量是一种用于线程间通信的机制,通常与互斥锁一起使用。条件变量允许线程在某个条件满足时被唤醒,否则进入等待状态。以下是一个简单的示例:

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

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

void worker_thread() {
    std::unique_lock<std::mutex> lck(mtx);
    cv.wait(lck, []{return ready;}); // 等待条件变量
    std::cout << "Worker thread is processing data.\n";
}

int main() {
    std::thread worker(worker_thread);

    std::this_thread::sleep_for(std::chrono::seconds(1));
    {
        std::lock_guard<std::mutex> lck(mtx);
        ready = true;
    }
    cv.notify_one(); // 通知条件变量

    worker.join();
    return 0;
}

在这个示例中,我们定义了一个全局的互斥锁 mtx 和条件变量 cv,并在 worker_thread 函数中使用 cv.wait 方法等待条件变量。当主程序设置 ready 为 true 并调用 cv.notify_one 时,等待的线程会被唤醒并继续执行。

6.2 锁的组合使用

在实际应用中,有时需要组合使用多种锁机制来实现复杂的同步需求。例如,可以使用互斥锁保护共享资源,同时使用条件变量实现线程间的通信。以下是一个简单的示例:

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>

std::mutex mtx;
std::condition_variable cv;
std::queue<int> data_queue;

void producer(int id) {
    for (int i = 0; i < 10; ++i) {
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
        std::lock_guard<std::mutex> lck(mtx);
        data_queue.push(i);
        std::cout << "Producer " << id << " produced " << i << "\n";
        cv.notify_one();
    }
}

void consumer(int id) {
    while (true) {
        std::unique_lock<std::mutex> lck(mtx);
        cv.wait(lck, []{return !data_queue.empty();});
        int data = data_queue.front();
        data_queue.pop();
        std::cout << "Consumer " << id << " consumed " << data << "\n";
        if (data == 9) break;
    }
}

int main() {
    std::thread p1(producer, 1);
    std::thread c1(consumer, 1);

    p1.join();
    c1.join();

    return 0;
}

在这个示例中,我们定义了一个全局的互斥锁 mtx 和条件变量 cv,并使用 std::queue 作为共享数据结构。生产者线程在生产数据时使用 std::lock_guard 自动管理锁的生命周期,并在生产完数据后调用 cv.notify_one 通知消费者线程。消费者线程在消费数据时使用 cv.wait 方法等待条件变量,并在条件满足时消费数据。

7. 锁的常见问题及解决方法

7.1 死锁

死锁是指两个或多个线程互相等待对方持有的锁,导致所有线程都无法继续执行。解决死锁的方法包括:

  • 锁顺序:确保所有线程按照相同的顺序获取锁。
  • 超时机制:使用带超时的锁获取方法,如 std::try_lock 或 std::timed_lock。
  • 死锁检测:使用工具或算法检测死锁并采取相应措施。

7.2 饿死

饿死是指某个线程长时间无法获取锁,导致其无法继续执行。解决饿死的方法包括:

  • 公平锁:使用公平锁机制,确保每个线程都能按顺序获取锁。
  • 优先级调度:调整线程的优先级,确保重要线程优先获取锁。

7.3 优先级反转

优先级反转是指低优先级线程持有锁,高优先级线程等待锁,导致高优先级线程被阻塞。解决优先级反转的方法包括:

  • 优先级继承:临时提升低优先级线程的优先级,使其尽快释放锁。
  • 优先级天花板:为每个锁设置一个优先级天花板,确保高优先级线程优先获取锁。
;