C++ `std::shared_mutex` (读写锁):读多写少场景下的性能优化

C++ std::shared_mutex (读写锁):读多写少场景下的性能优化

大家好!今天咱们来聊聊C++里一个非常实用的工具:std::shared_mutex,也就是常说的读写锁。这玩意儿在读多写少的场景下,能让你的程序性能嗖嗖地往上窜,简直就是性能优化的秘密武器。

啥是读写锁?为啥我们需要它?

想象一下,你和你的小伙伴们在图书馆里学习。

  • 读操作(共享模式): 大部分时间,大家都在安安静静地查阅资料,互不干扰。这就像多个线程同时读取共享资源。
  • 写操作(独占模式): 偶尔,你需要修改书本上的内容,这时候你得确保别人不能同时也在修改,也不能有人在阅读,要独占这本书。这就像一个线程需要独占式地写入共享资源。

传统的互斥锁(std::mutex)就像图书馆管理员,每次只允许一个人进入。不管你是看书还是写字,都得排队等着,效率太低了!

读写锁的出现就是为了解决这个问题。它允许:

  • 多个线程同时读取共享资源(共享模式)。
  • 只有一个线程可以写入共享资源(独占模式),并且在写入时,不允许任何其他线程读取或写入。

简单来说,读写锁区分了读操作和写操作,允许多个读者同时访问,但只允许一个写者访问,或者没有读者的时候一个写者访问。

std::shared_mutex 的基本用法

std::shared_mutex 定义在 <shared_mutex> 头文件中。它提供了以下几个关键方法:

  • lock(): 以独占模式锁定互斥量。如果互斥量已经被锁定(无论共享或独占),调用线程将阻塞,直到互斥量可用。
  • unlock(): 释放独占模式的互斥量。
  • lock_shared(): 以共享模式锁定互斥量。如果互斥量已经被一个线程以独占模式锁定,调用线程将阻塞,直到互斥量可用。
  • unlock_shared(): 释放共享模式的互斥量。
  • try_lock(): 尝试以独占模式锁定互斥量。如果互斥量可用,则锁定并返回 true;否则,立即返回 false
  • try_lock_shared(): 尝试以共享模式锁定互斥量。如果互斥量可用,则锁定并返回 true;否则,立即返回 false

为了方便管理锁的生命周期,C++还提供了以下几个 RAII (Resource Acquisition Is Initialization) 锁:

  • std::unique_lock<std::shared_mutex>: 用于独占模式锁定。
  • std::shared_lock<std::shared_mutex>: 用于共享模式锁定。

RAII 锁会在构造时尝试获取锁,并在析构时自动释放锁,避免了手动 lock()unlock() 可能导致的错误。

示例代码:

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

std::shared_mutex mtx; // 读写锁
int data = 0;         // 共享数据

// 读者线程
void reader(int id) {
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<> distrib(100, 500); // 模拟读操作耗时
    for (int i = 0; i < 5; ++i) {
        std::shared_lock<std::shared_mutex> lock(mtx); // 共享模式锁定
        std::cout << "Reader " << id << ": Data = " << data << std::endl;
        std::this_thread::sleep_for(std::chrono::milliseconds(distrib(gen))); // 模拟读操作
    }
}

// 写者线程
void writer(int id) {
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<> distrib(500, 1000); // 模拟写操作耗时
    for (int i = 0; i < 2; ++i) {
        std::unique_lock<std::shared_mutex> lock(mtx); // 独占模式锁定
        std::cout << "Writer " << id << ": Writing data..." << std::endl;
        data = id * 100 + i; // 修改数据
        std::this_thread::sleep_for(std::chrono::milliseconds(distrib(gen))); // 模拟写操作
        std::cout << "Writer " << id << ": Data updated to " << data << std::endl;
    }
}

int main() {
    std::vector<std::thread> threads;

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

    // 创建多个写者线程
    for (int i = 0; i < 2; ++i) {
        threads.emplace_back(writer, i);
    }

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

    std::cout << "All threads finished." << std::endl;

    return 0;
}

代码解释:

  • 我们创建了一个 std::shared_mutex 类型的 mtx 对象,用于保护共享数据 data
  • reader() 函数模拟读者线程,使用 std::shared_lock 以共享模式锁定互斥量,读取 data 的值。
  • writer() 函数模拟写者线程,使用 std::unique_lock 以独占模式锁定互斥量,修改 data 的值。
  • main() 函数创建了多个读者线程和写者线程,并等待它们完成。

运行这段代码,你会发现读者线程可以并发地读取数据,而写者线程在写入数据时会阻塞其他线程,保证了数据的一致性。

读写锁的性能优势:读多写少场景

std::shared_mutex 在读多写少的场景下,性能优势非常明显。 想象一下,一个在线商品目录,用户大部分时间都在浏览商品信息(读操作),只有管理员偶尔会更新商品信息(写操作)。

在这种情况下,使用 std::mutex 会导致大量的线程阻塞在锁上,即使它们只是想读取数据。而使用 std::shared_mutex,允许多个读者线程同时访问商品目录,大大提高了程序的并发性能。

性能对比:std::mutex vs std::shared_mutex (简化版)

为了更直观地展示性能差异,我们来做一个简单的性能测试。

#include <iostream>
#include <mutex>
#include <shared_mutex>
#include <thread>
#include <vector>
#include <chrono>
#include <random>

const int NUM_THREADS = 10;
const int NUM_ITERATIONS = 10000;

// 使用 std::mutex 的情况
void test_mutex(std::mutex& mtx, int& data) {
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<> distrib(1, 10);

    for (int i = 0; i < NUM_ITERATIONS; ++i) {
        std::lock_guard<std::mutex> lock(mtx);
        // 模拟读操作和少量写操作
        if (distrib(gen) > 2) {
            // 读操作
            int temp = data;
        } else {
            // 写操作
            data++;
        }
    }
}

// 使用 std::shared_mutex 的情况
void test_shared_mutex(std::shared_mutex& mtx, int& data) {
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<> distrib(1, 10);

    for (int i = 0; i < NUM_ITERATIONS; ++i) {
        if (distrib(gen) > 2) {
            // 读操作 (共享模式)
            std::shared_lock<std::shared_mutex> lock(mtx);
            int temp = data;
        } else {
            // 写操作 (独占模式)
            std::unique_lock<std::shared_mutex> lock(mtx);
            data++;
        }
    }
}

int main() {
    // 测试 std::mutex
    std::mutex mutex;
    int data1 = 0;
    auto start_mutex = std::chrono::high_resolution_clock::now();
    std::vector<std::thread> mutex_threads;
    for (int i = 0; i < NUM_THREADS; ++i) {
        mutex_threads.emplace_back(test_mutex, std::ref(mutex), std::ref(data1));
    }
    for (auto& thread : mutex_threads) {
        thread.join();
    }
    auto end_mutex = std::chrono::high_resolution_clock::now();
    auto duration_mutex = std::chrono::duration_cast<std::chrono::milliseconds>(end_mutex - start_mutex);

    // 测试 std::shared_mutex
    std::shared_mutex shared_mutex;
    int data2 = 0;
    auto start_shared_mutex = std::chrono::high_resolution_clock::now();
    std::vector<std::thread> shared_mutex_threads;
    for (int i = 0; i < NUM_THREADS; ++i) {
        shared_mutex_threads.emplace_back(test_shared_mutex, std::ref(shared_mutex), std::ref(data2));
    }
    for (auto& thread : shared_mutex_threads) {
        thread.join();
    }
    auto end_shared_mutex = std::chrono::high_resolution_clock::now();
    auto duration_shared_mutex = std::chrono::duration_cast<std::chrono::milliseconds>(end_shared_mutex - start_shared_mutex);

    std::cout << "std::mutex time: " << duration_mutex.count() << " ms" << std::endl;
    std::cout << "std::shared_mutex time: " << duration_shared_mutex.count() << " ms" << std::endl;

    return 0;
}

代码解释:

  • 我们分别使用 std::mutexstd::shared_mutex 来保护一个共享的整数 data
  • 每个线程都会进行多次读操作和少量写操作。
  • 我们记录了使用两种互斥量所花费的时间,并打印出来。

在读多写少的场景下,你会发现 std::shared_mutex 的性能明显优于 std::mutex

总结:

特性 std::mutex std::shared_mutex 适用场景
独占模式 支持 支持 所有场景
共享模式 不支持 支持 读多写少
并发读 不支持 支持 读多写少,需要高并发读性能的场景
复杂度 较低 稍高
锁粒度 粗粒度 可以根据需求调整

读写锁的潜在问题与注意事项

虽然 std::shared_mutex 在读多写少的场景下很给力,但使用不当也会带来一些问题:

  1. 写饥饿(Writer Starvation): 如果读操作非常频繁,写操作可能会一直被阻塞,无法获得锁。想象一下,图书馆里永远有人在看书,你想修改书本内容,永远排不上队。

    解决方法: 可以使用公平锁策略,或者限制读者的数量,给写者一定的优先级。std::shared_mutex 本身没有公平性保证,但是可以通过一些技巧来实现。 例如,可以使用条件变量配合一个计数器来控制读写顺序。

  2. 死锁(Deadlock): 与其他锁一样,std::shared_mutex 也可能导致死锁。 例如,一个线程持有共享锁,然后尝试获取独占锁,而另一个线程持有独占锁,也尝试获取共享锁。

    解决方法: 避免循环依赖的锁请求,使用 try_lock()try_lock_shared() 来避免无限期阻塞。

  3. 过度使用: 不要为了使用读写锁而使用读写锁。 如果读写操作的比例差不多,或者写操作非常频繁,使用 std::mutex 可能更简单高效。

  4. 锁的粒度: 锁的粒度是指锁保护的资源范围。 锁的粒度太粗,会导致并发性降低; 锁的粒度太细,会导致锁的开销增加。 需要根据实际情况选择合适的锁粒度。

高级用法:条件变量与读写锁

std::condition_variable_any 可以与 std::shared_mutex 配合使用,实现更复杂的同步逻辑。 例如,可以在写操作完成后通知所有等待的读者线程。

示例代码:

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

std::shared_mutex mtx;
std::condition_variable_any cv;
int data = 0;
bool data_ready = false;

void reader(int id) {
    std::unique_lock<std::shared_mutex> lock(mtx, std::defer_lock); //延迟锁定
    while (true) {
        lock.lock_shared(); // 获取共享锁
        cv.wait(lock, []{ return data_ready; }); // 等待数据准备好
        std::cout << "Reader " << id << ": Data = " << data << std::endl;
        data_ready = false; // 消费数据后重置标志
        lock.unlock_shared();
        std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟读操作
    }
}

void writer(int id) {
    for (int i = 0; i < 5; ++i) {
        std::unique_lock<std::shared_mutex> lock(mtx); // 获取独占锁
        std::cout << "Writer " << id << ": Writing data..." << std::endl;
        data = id * 100 + i;
        data_ready = true; // 设置数据准备好的标志
        std::cout << "Writer " << id << ": Data updated to " << data << std::endl;
        cv.notify_all(); // 通知所有等待的读者
        std::this_thread::sleep_for(std::chrono::milliseconds(500)); // 模拟写操作
    }
}

int main() {
    std::vector<std::thread> readers;
    for (int i = 0; i < 3; ++i) {
        readers.emplace_back(reader, i);
    }

    std::vector<std::thread> writers;
    for (int i = 0; i < 1; ++i) {
        writers.emplace_back(writer, i);
    }

    for (auto& reader : readers) {
        reader.detach(); //detach线程,防止程序阻塞
    }

    for (auto& writer : writers) {
        writer.join();
    }

    std::cout << "All writers finished." << std::endl;
    return 0;
}

代码解释:

  • data_ready 标志用于指示数据是否准备好。
  • 读者线程在获取共享锁后,使用 cv.wait() 等待 data_ready 变为 true
  • 写者线程在更新数据后,将 data_ready 设置为 true,并使用 cv.notify_all() 通知所有等待的读者线程。
  • 读者线程消费数据后,重置data_readyfalse,防止重复读取。

总结

std::shared_mutex 是一个强大的工具,可以在读多写少的场景下显著提高程序的并发性能。 但是,需要注意潜在的问题,并根据实际情况选择合适的锁策略和粒度。 掌握了 std::shared_mutex 的用法,你就可以写出更加高效、并发的 C++ 程序了!

记住,没有银弹。选择合适的同步机制,需要根据具体的应用场景和性能需求进行权衡。 希望今天的分享对大家有所帮助! 谢谢!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注