上文介绍了使用std::lock()同时锁定两个互斥元来避免死锁。《并发编程》对如何避免死锁进行了总结:

  1. 避免嵌套所
  2. 在持有锁是,避免调用用户提供的代码
  3. 以固定顺序获取锁
  4. 使用层次锁

这里不再对这些方法进行探讨。

使用std:unique_lock灵活锁定

先看一个std::unique的例子:

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(some_big_object const& sd): some_detail(sd) {}

    friend void swap(X& lhs, X& rhs)
    {
        if(&lhs==&rhs)
            return;
        std::unique_lock<std::mutex> lock_a(lhs.m, std::defer_lock);
        std::unique_lock<std::mutex> lock_b(rhs.m, std::defer_lock);
        std::lock(lock_a, lock_b);
        swap(lhs.some_detail, rhs.some_detail);
    }
};

std::unique_lock相比std::lock_guard提供了更多的灵活性。示例代码中构造std::unique_lock的第二个参数std::defer_lock,表示将第一个参数给定的互斥元保留为未被锁定。这样可以在之后需要的时候调用std::unique_lock的成员函数lock()或者将其传入std::lock()来锁定。而对于std::lock_guard,其进行构造时,互斥元已经被锁定。std::unique_lock实例内部需要一个标识来表示它是否拥有互斥元,这样才能保证它在析构时调用unlock()释放锁。标准库提供了owns_lock()成员函数查询是否拥有互斥元。显然,std::unique_lock的实例会大于std::lock_guard,且有些许性能损失。std::unique_lock的这种灵活性同样使用于解锁,可以在其被销毁前调用unlock()释放锁。

在作用域之间转移锁的所有权

std::unique_lock具有移动语义,实例不拥有与其相关的互斥元,互斥元的所有权可以在实例之间移动,可以通过std::move()显示完成。当其作为函数的返回值时会自动发生移动,参考下面的例子:

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

在函数get_lock返回时编译器会调用移动构造函数,该函数拥有的std::unique_lock实例将会被移动到lk。此例展示了。一个函数锁定了一个互斥元,再将锁的所有权转移给了它的调用者,后者可以在同一个锁的保护下进行其他操作。

锁定在恰当的粒度

锁的粒度是一个抽象描述,表示由某个锁保护的数据量。细粒度锁保护的数据量少,粗粒度锁保护的数据量大。选择一个合适的粒度至关重要。如果粒度太大,一个线程持有锁的时间太长,会增加其他线程等待的时间,因此尽量在实际访问共享数据的时候锁定互斥元,在锁的外面进行消耗时间的处理。这种情况下,std::unique_lock是个不错的选择,因为可以在适当的适合进行lock()unlock()。下面是一个例子:

void get_and_process_data()
{
    std::unique_lock<std::mutex> my_lock(the_mutex);
    some_class data_to_process = get_next_data_chunk();
    my_lock.unlock();
    result_tyoe result = process(data_to_process);
    my_lock.lock();
    write_result(data_to_process, result);
}

在处理数据时不需要上锁,因此提前进行unlock(),再在处理完成之后写数据之前重新上锁。
再看另一个例子。本文的第一个例子是并发访问两个对象(X类)的情况,如果是并发访问两个更简单的数据,比如int类型,我们将粒度再次细化,得到如下代码:

class Y
{
private: 
    int some_detail;
    mutable std::mutex m;
    
    int get_detail() const
    {
        std::lock_guard<std::mutex> lock_a(m);
    }
public:
    Y(int sd): some_detail(sd) {}

    friend bool operator==(Y const& lhs, Y const& rhs)
    {
        if(&lhs == &rhs)
            return true;
        int const lhs_value = lhs.get_detail();
        int const rhs_value = rhs.get_detail();
        return lhs_value == rhs_value;
    }
};

比较运算符重载函数首先通过调用get_detail获取要进行比较的值,该函数通过一个锁保护数据并其返回,重载函数在获取到数据之后再进行比较。锁的粒度细化了,且只有一个锁,这看起来是不是很美好?然而你仔细思考,在两次调用get_detail()之间,lhs.detailrhs.detail的值有可能发生变化,比如进行了一次交换操作,这样之后的比较还有意义吗?上面代码的真正语义是,lhs.detail在一个时间点的值和rhs.detail在另一个时间点的值相同,即使它们从未同时相等过。