C++并发编程实战笔记 [03] :在线程间共享数据

std::mutex

#include <mutex>
#include <list>
std::mutex some_mutex;
std::list<int> mylist;

void func(int value) {
    some_mutex.lock();          // 加锁
    mylist.push_back(value);
    some_mutex.unlock();        // 解锁
}

std::lock_guard<>

类模板 std::lock_guard<> 使用了RAII技术,在构造时给互斥加锁,在析构时解锁。

#include <list>
#include <mutex>

std::list<int> mylist;
std::mutex some_mutex;

void foo(int value) {
    std::lock_guard<std::mutex> guard(some_mutex); // 自动加锁
    mylist.push_back(value);
} // 析构时解锁互斥

C++17新特性:类模板参数推导

std::lock_guard<std::mutex> guard(some_mutex); // before C++ 17
std::lock_guard guard(some_mutex); // C++ 17

std::lock()

需要锁住多个互斥时,为了防范死锁,应该始终按相同的顺序锁住互斥。std::lock() 帮我们解决了这一问题,它可以同时锁住多个互斥,而没有发生死锁的风险。

// 使用std::lock() 和 std::lock_guard<>,进行内部的数据互换操作
class some_big_object;
void swap(some_big_object& lhs, some_big_object& rhs);

class X {
private:
    some_big_object some_detail;
    std::mutex m;
public:
    X(const some_big_object &sd) : some_detail(sd) {}
    friend void swap(X& lhs, X& rhs) {
        if(&lhs == &rhs) return;
        std::lock(lhs.m, rhs.m);
        std::lock_guard<std::mutex> lock_a(lhs.m, std::adopt_lock);
        std::lock_guard<std::mutex> lock_b(rhs.m, std::adopt_lock);
        swap(lhs.some_detail, rhs.some_detail);
    }
}

std::adopt_lock

std::adopt_lock 指明互斥已被锁住,即互斥上有锁存在,std::lock_guard 实例应当据此接收互斥的归属权,不得在构造函数内试图另行加锁。

std::mutex m1, m2;

void func() {
    std::lock(m1, m2);
    std::lock_guard<std::mutex> lock_a(m1, std::adopt_lock);
    std::lock_guard<std::mutex> lock_b(m2, std::adopt_lock);
}

std::scoped_lock<>

std::scoped_lock<> 是C++17引入的增强版的lock_guard,以多个互斥对象作为构造函数的参数列表,在构造时同时锁定多个互斥,在析构时同时解锁互斥。以上代码可以被简化为:

std::mutex m1, m2;

void func() {
    std::scoped_lock guard(m1, m2);
}

建议

  • 将互斥和受保护的数据组成一个类。
  • 警惕成员函数返回指针或引用,若它们指向受保护的数据,互斥会被打破。
  • 若成员函数在自身内部调用了别的函数,而这些函数却不受我们掌控,那么也不得向它们传递指针或引用。
  • 警惕接口是否存在固有的条件竞争。

防范死锁

  1. 避免嵌套锁。假如已经持有锁,就不要试图获取第二个锁。如确有需要获取多个锁,应该使用 std::lock()std::scoped_lock<>,一次性获取全部锁来避免死锁。

  2. 一旦持有锁,就必须避免调用由用户提供的程序接口。

  3. 依从固定顺序获取锁。如果多个锁是绝对必要的,却无法通过 std::lock() 在一步操作中全部获取,我们只能退而求其次,在每个线程内部都依从固定顺序获取这些锁。

  4. 按层级加锁。若某线程已对低层级互斥加锁,就不准它对高层级互斥加锁。

  5. 将准则推广到锁操作之外。死锁现象并不单单因加锁操作而发生,任何同步机制导致的循环都会导致死锁的出现。

std::unique_lock<>

std::unique_lock<> 更为灵活,不一定始终占有与之关联的互斥,但性能相比 std::lock_guard 较低。其构造函数接受第二个参数:可以传入 std::adopt_lock 实例,指明 std::unique_lock 对象管理互斥上的锁;也可以传入 std::defer_lock 实例,从而使互斥在完成构造时处于无锁状态,等以后有需要时才在 std::unique_lock 对象上调用 lock() 而获取锁,或把 std::unique_lock 对象交给 std::lock() 函数加锁。std::unique_lock 也允许它的实例在被销毁前解锁,其成员函数 unlock() 负责解锁操作。

std::mutex m1, m2;
void func() {
    std::unique_lock<std::mutex> lock_a(m1, std::defer_lock);
    std::unique_lock<std::mutex> lock_b(m2, std::defer_lock); // 实例 std::defer_lock 将互斥保留为无锁状态
    std::lock(lock_a, lock_b); // 到这里才对互斥加锁
}

在不同作用域之间转移互斥归属权

std::unique_lock<std::mutex> get_lock() {
    extern std::mutex some_mutex;
    std::unique_lock<std::mutex> lk(some_mutex);
    prepare_data();
    return lk;
}

void process_data() {
    std::unique_lock<std::mutex> lk(get_lock());
    do_something();
}

std::call_once()std::once_flag

令所有线程共同使用 std::call_once() 调用初始化函数,可以确保初始化由其中某线程安全且唯一地完成。必要的同步数据由 std::once_flag 实例存储,每个 std::once_flag 实例对应一次不同的初始化。std::once_flag 实例既不可复制也不可移动。

实现延迟初始化

std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag;

void init_resource() {
    resource_ptr.reset(new some_resource);
}

void foo() {
    std::call_once(resource_flag, init_resource);   // 初始化函数被准确地唯一一次调用
    resource_ptr->do_something();
}

// 利用 std::call_once() 函数对类 X 的数据成员实施线程安全的延迟初始化
class X {
private:
    connection_info connection_details;
    connection_handle connection;
    std::once_flag connection_init_flag;

    void open_connection() {
        connection = connection_manager.open(connection_details);
    }

public:
    X(const connection_info& connection_details_):
        connection_details(connection_details_) {}
    
    void send_data(const data_packet& data) {
        std::call_once(connection_init_flag, &X::open_connection, this);
        connection.send_data(data);
    }

    data_packet receive_data() {
        std::call_once(connection_init_flag, &X::open_connection, this);
        return connection.receive_data();
    }

};

C++标准规定,只要控制流第一次遇到静态数据的声明语句,变量即进行初始化。C++11规定初始化只会在某一线程上单独发生。某些类的代码只需要用到唯一一个全局实例,这种情形可用以下方法代替 std::call_once:

class my_class;
my_class& get_my_class_instance() {
    static my_class instance;
    return instance;
}

读写互斥

允许单独一个 “写线程” 进行完全排他的访问,也允许多个 “读线程” 共享数据或并发访问。

排他锁(写锁)

std::lock_guard<std::shared_mutex>
std::unique_lock<std::shared_mutex>

共享锁(读锁)

std::shared_lock<std::shared_mutex>

多个线程能够同时锁住同一个 std::shared_mutex。若共享锁已被某些线程所持有,若别的线程试图获取排他锁,就会发生阻塞,直到那些线程全部都释放该共享锁。反之,若任一线程持有排他锁,那么其他线程全部无法获取共享锁或排他锁,直到持锁线程将排他锁释放为止。

// 运用 std::shared_mutex 保护数据结构,以简易的 DNS 缓存表为例
#include <map>
#include <string>
#include <mutex>
#include <shared_mutex>

class dns_entry;
class dns_cache {
private:
    std::map<std::string, dns_entry> entries;
    mutable std::shared_mutex entry_mutex;
public:
    dns_entry find_entry(const std::string& domain) const { // 多线程可以同时调用
        std::shared_lock<std::shared_mutex> lk(entry_mutex);    // 共享锁
        const std::map<std::string, dns_entry>::const_iterator it = entries.find(domain);
        return (it == entries.end() ? dns_entry() : it->second);
    }

    void update_or_add_entry(const std::string& domain, const dns_entry& dns_details) { // 多线程进行排他访问
        std::lock_guard<std::shared_mutex> lk(entry_mutex); // 排他锁
        entries[domain] = dns_details;    
    }
};
posted @ 2023-08-31 22:13  AE酱  阅读(25)  评论(0编辑  收藏  举报