线程间共享数据-各种锁(总结)

发布时间 2023-09-21 11:26:01作者: 白伟碧一些小心得

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;    
    }
};

共享锁,也叫读写锁,主要应用与读多写少的场景。

比如,在多线程环境下,多个线程操作同一个文件,其中读文件的操作比写文件的操作更加频繁,那么在进行读操作时,不需要互斥,线程间可以共享这些数据,随意的读取。但是一旦有写操作,那么一定要进行互斥操作,否则读取到的数据可能存在不一致。

C++14 共享超时互斥锁 shared_timed_mutex

读线程 调用 lock_shared()获取共享锁,写线程 调用 lock() 获取互斥锁。

  • 当调用lock()的时候,如果有线程获取了共享锁,那么写线程会等待,直到所有线程将数据读取完成释放共享锁,再去锁定资源,进行修改;
  • 当调用lock_shared()时,如果有写线程获取了互斥锁,那么需要等待
  • 当调用lock_shared()时,如果有读线程获取共享锁,也会直接返回,获取成功

 

递归锁

MutexLock mutex;  
 
void foo()  
{  
    mutex.lock();  
    // do something  
    mutex.unlock();  
}  
 
void bar()  
{  
    mutex.lock();  
    // do something  
    foo();  
    mutex.unlock();   
}

foo函数和bar函数都获取了同一个锁,而bar函数又会调用foo函数。如果MutexLock锁是个非递归锁,则这个程序会立即死锁。因此在为一段程序加锁时要格外小心,否则很容易因为这种调用关系而造成死锁。
不要存在侥幸心理,觉得这种情况是很少出现的。当代码复杂到一定程度,被多个人维护,调用关系错综复杂时,程序中很容易犯这样的错误。庆幸的是,这种原因造成的死锁很容易被排除。
但是这并不意味着应该用递归锁去代替非递归锁。递归锁用起来固然简单,但往往会隐藏某些代码问题。比如调用函数和被调用函数以为自己拿到了锁,都在修改同一个对象,这时就很容易出现问题。因此在能使用非递归锁的情况下,应该尽量使用非递归锁,因为死锁相对来说,更容易通过调试发现。程序设计如果有问题,应该暴露的越早越好。

 

问题就是:加锁的操作需要相互嵌套,如果使用std::mutex 肯定会导致死锁,而重构代码,提取出共用部分的工作量又很大。

这个时候我发现了好东西 std::recursive_mutex 递归锁

递归锁可以允许一个线程对同一互斥量多次加锁,解锁时,需要调用与lock()相同次数的unlock()才能释放使用权

这边再介绍一个好东西:

std::lock_guard<std::recursive_mutex> 

std::lock_guard在构造函数中加锁,在析构函数中解锁,利用这个类可以减少我们对加锁可解锁操作的管理工作,专注于逻辑实现。