c++中使用互斥量

19 篇文章 0 订阅
6 篇文章 0 订阅

c++中使用互斥量

std::lock_guard

C++中通过实例化 std::mutex 创建互斥量实例,通过成员函数lock()对互斥量上锁,unlock()进行解锁。不过,实践中不推荐直接去调用成员函数,调用成员函数就意味着,必须在每个函数结束都要去调用unlock(),也包括异常的情况。C++标准库为互斥量提供了一个RAII语法的模板类 std::lock_guard,在构造时就能提供已锁的互斥量,并在析构的时候进行解锁,从而保证了一个已锁互斥量能被正确解锁。std::mutexstd::lock_guard 都在 < mutex > 头文件中声明。

#define N 100000

mutex m;

void sum(int &val)
{
    for (int i = 0; i < N; ++i)
    {
        lock_guard<mutex> lock(m);
        ++val;
    }
}

int main(int argc, char **argv)
{
    int val{0};
    thread t1(sum, ref(val));
    thread t2(sum, ref(val));

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

    cout << "val = " << val << endl;
    return 0;
}

然而加锁并不能百分百保护共享数据,例如当成员函数返回指针或引用,指向受保护的共享数据,那么即使成员函数全都良好有序的方式锁定互斥,仍然无济于事,因为保护被打破了,出现了漏洞。只要存在任何能访问该指针和引用的代码,他就可以访问、修改受保护的共享数据,而无需上锁。所以使用互斥保护共享数据,需要谨慎设计程序接口,从而确保互斥先行锁定,在对保护的共享数据进行访问。

class some_data
{
    int a;
    std::string b;

public:
    void do_something() {}
};

class data_wrapper
{
private:
    some_data data;
    std::mutex m;

public:
    template <typename Function>
    void process_data(Function func)
    {
        std::lock_guard<std::mutex> l(m);
        func(data); // 1 传递“保护”数据给用户函数
    }
};
some_data *unprotected;

void malicious_function(some_data &protected_data)
{
    unprotected = &protected_data;
}
data_wrapper x;

void foo()
{
    x.process_data(malicious_function); // 2 传递一个恶意函数
    unprotected->do_something();        // 3 在无保护的情况下访问保护数据
}

上述代码中process_data看起来没啥问题,lock_guard对数据做了保护,但是调用用户提供的函数func,这就意味着foo()能够绕过保护机制将函数malicious_function传递进去,在没有锁定互斥的情况下调用do_something()。

这段代码的问题在于根本没有保护, 只是将所有可访问的数据结构代码标记为互斥。 函数 foo() 中调用 unprotected->do_something() 的代码未能被标记为互斥。 这种情况下,C++线程库无法提供任何帮助, 只能由开发者使用正确的互斥锁来保护数据。 从乐观的角度上看, 还是有方法可循的: 切勿将受保护数据的指针或引用传递到互斥锁作用域之外, 无论是函数返回值, 还是存储在外部可见内存, 亦或是以参数的形式传递到用户提供的函数中去。

死锁

死锁一般都是因为资源数量有限且对资源加锁顺序不合理造成的。比如,两个线程t1、t2,分别需要资源A,B,但是A、B只有一份,假如t1线程先获取到了A资源并且加锁,准备获取B资源完成任务,但是在获取B资源前,t2线程已经获取到了B资源,并加锁,等待获取A资源来完成任务。那么此时,两个线程都等待对方的资源释放,且都持有对方继续执行下去所需要的唯一资源,这就造成了死锁。

避免死锁的一般建议,就是让两个互斥量总以相同的顺序上锁。如果总在互斥量B之前锁住互斥量A,就永远不会死锁。某些情况下是可以这样用,因为不同的互斥量用于不同的地方。不过,事情没那么简单,比如,当有多个互斥量保护同一个类的独立实例时,一个操作对同一个类的两个不同实例进行数据的交换操作,为了保证数据交换操作的正确性,就要避免数据被并发修改,并确保每个实例上的互斥量都能锁住自己要保护的区域。不过,选择一个固定的顺序(例如,实例提供的第一互斥量作为第一个参数, 提供的第二个互斥量为第二个参数), 可能会适得其反,在参数交换了之后,两个线程试图在相同的两个实例间进行数据交换时,程序又死锁了!

C++标准库有办法解决这个问题,std::lock——可以一次性锁住多个(两个以上)的互斥量,并且没有死锁风险。下面的程序中,就来看一下怎么在一个简单的交换操作中使用 std::lock。

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::lock(lhs.m, rhs.m);                                    // 1
        std::lock_guard<std::mutex> lock_a(lhs.m, std::adopt_lock); // 2
        std::lock_guard<std::mutex> lock_b(rhs.m, std::adopt_lock); // 3
        swap(lhs.some_detail, rhs.some_detail);
    }
};

调用std::lock() ①锁住两个互斥量,并且两个std:lock_guard 实例已经创建好②③。提供 std::adopt_lock 参数除了表示 std::lock_guard 对象可获取锁之外,还将锁交由 std::lock_guard 对象管理,而不需要 std::lock_guard 对象再去构建新的锁。这样,就能保证在大多数情况下,函数退出时互斥量能被正确的解锁。 还有,当使用 std::lock 去锁lhs.m或rhs.m时,可能会抛出异常;这种情况下,异常会传播到 std::lock 之外。当 std::lock 成功的获取一个互斥量上的锁,并且当其尝试从另一个互斥量上再获取锁时,就会有异常抛出,第一个锁也会随着异常的产生而自动释放,所以 std::lock 要么将两个锁都锁住,要不一个都不锁。

C++17对这种情况提供了支持,std::scoped_lock<> 一种新的RAII类型模板类型,与 std::lock_guard<> 的功能等价,这个新类型能接受不定数量的互斥量类型作为模板参数,以及相应的互斥量(数量和类型)作为构造参数。 互斥量支持构造即上锁,与 std::lock 的用法相同,其解锁阶段是在析构中进行。 上述代码可以重写成:

void swap(X &lhs, X &rhs)
{
    if (&lhs == &rhs)
        return;
    std::scoped_lock guard(lhs.m, rhs.m); // 1
    swap(lhs.some_detail, rhs.some_detail);
}

这里使用了C++17的另一个特性:自动推导模板参数。 如果你手头上有支持C++17的编译器(那你就能使用 std::scoped_lock 了,因为其是C++17标准库中的一个工具,如果你此时编译器不支持c++17可以升级你的编译器), C++17可以通过隐式参数模板类型推导机制,通过传递的对形象类型来构造实例①。这行代码等价于下面参数全给的版本:

std::scoped_lock<std::mutex,std::mutex> guard(lhs.m,rhs.m);

std::scoped_lock 的好处在于,可以将所有 std::lock 替换掉,从而减少潜在错误的发生。虽然 std::lock (和 std::scoped_lock<> )可以在这情况下(获取两个以上的锁)避免死锁,但它没办法帮助你获取其中一个锁。 这时,依赖于开发者的经验,来确保你的程序不会死锁。下面有一些建议可以帮助避免死锁。

避免死锁

死锁常常是因为锁的操作错误导致的,但有时候没有使用锁,也会发生死锁的现象。假如两个线程,各自关联了std::thread实例,若它们同时在对方的实例上调用join(),就会造成死锁的现象。下面给出一些建议用于辨别和排除其它线程是否正在等待当前线程。

避免嵌套锁

假如已经持有锁,就不要试图获取第二个锁。

一旦持锁,就避免调用由用户提供的程序接口

若程序是由用户自行实现,我们无法得知它会做什么,可以会包括获取锁。一但我们持有锁,若在调用用户提供的程序接口,而它恰好也获取锁,那就违反了避免嵌套所的准则,就有可能发生死锁。

依次固定顺序获取锁

当硬性条件要求你获取两个或两个以上的锁,并且不能使用 std::lock 单独操作来获取它们; 那么最好在每个线程上,用固定的顺序获取这些锁。

按层级加锁

锁的层级划分就是按照特定的方式规定加锁顺序,在运行期间检查加锁操作是否遵循预设的规则。按照构想,我们把程序分层,并且明确规定每个互斥位于那个层级。若某线程已经对低层级互斥加锁,就不准它在对高层级互斥加锁。具体做法是将层级的编号赋予对应层级应用程序上的互斥,并记录各线程分别锁定了那些互斥。

std::unique_lock灵活加锁

类模板std::unique_lock<>放宽了不变量的成立条件,因此它相较std::lock_guard<>更灵活一些。std::unique_lock对象不一定始终占有与之关联的互斥。首先,其构造函数接受第二个参数,可以传递std::adopt_lock实例,借此指明std::unique_lock对象管理互斥上的锁;也可以传递std::defer_lock实例,从而是互斥在完成构造时处于无锁状态,等以后由需要时,才在std::unique_lock上调用lock()而获取锁,或者把std::unique_lock对象交给lock()函数加锁。我们可以使用unique_lock实现前面的swap代码,两种写法功能等效,唯一不同是,unique_lock占用更多的空间,也比lock_guard慢。但是unique_lock可以不占用关联互斥,所以具备这份灵活需要付出代价,需要存储并且更新互斥信息。

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;
        
        // 1 std::defer_lock 将互斥保留为无锁状态
        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); // 2 互斥量在这里上锁
        swap(lhs.some_detail, rhs.some_detail);
    }
};

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

std::unique_lock 实例没有与自身相关的互斥量,一个互斥量的所有权可以通过移动操作,在不同的实例中进行传递。某些情况下,这种转移是自动发生的,例如:当函数返回一个实例;另些情况下,需要显式的调用 std::move() 来执行移动操作。从本质上来说,这取决于移动数据的来源到底是左值还是右值。若是左值,则必须显示转移,以避免归属权意外的转移到别处;如果是右值,归属权转移便会自动发生。std::unique属于可移动不可复制的型别。

转移有一种用途,准许函数锁定互斥,然后把互斥的归属权转移给函数调用者,好让它在同一个锁的保护下执行其他操作。下面的代码片段就此做了示范:get_lock()函数,先锁定互斥,接着对数据做前期准备,在将归属权返回给调用者。

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

初始化过程中保护共享数据

假设有一个共享源,构建代价很昂贵,它可能会打开一个数据库连接或分配出很多的内存。延迟初始化(Lazy initialization)在单线程代码很常见,每一个操作都需要先对源进行检查,为了了解数据是否被初始化,然后在其使用前决定, 数据是否需要初始化:

std::shared_ptr<some_resource> resource_ptr;
void foo()
{
    if (!resource_ptr)
    {
        resource_ptr.reset(new some_resource); // 1
    }
    resource_ptr->do_something();
}

上述代码若是在多线程下,则需要在①的位置需要保护。然而,数据被多线程使用,无法并发的访问,线程只能毫无必要的循序运行,因为每个线程都必须在互斥上轮候,等待查验数据是否已经完成初始化。下面给出互斥实现多线程的延迟初始化。

std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo()
{
    std::unique_lock<std::mutex> lk(resource_mutex); // 所有线程在此序列化
    if (!resource_ptr)
    {
        resource_ptr.reset(new some_resource); // 只有初始化过程需要保护
    }
    lk.unlock();
    resource_ptr->do_something();
}

然而上述代码写法很常见,但是效率很低,因为毫无必要迫使多个线程循序运行。那么有没有效率高且安全的方式呢?c++标准库中提供了std::once_flag类和std::call_once()函数。call_once()函数只有一个线程唯一完成,同步数据由once_flag实例存储。每个once_flag实例对应一次不同的初始化。相比显示的使用互斥,call_once()函数的额外开销往往更低。

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::mutex保护数据,则过于严苛,明明没有改动数据,照样禁止并发访问。所以为了提高效率,我们采用新的类型互斥,新的互斥有两种方式,对改写数据的线程,进行排他访问;对于读线程,可以允许并发访问。

c++17标准库提供了两种新的互斥,std::shared_mutexstd::shared_timed_mutex。c++14仅支持后者,c++11都不支持。所以要想使用,先升级自己的编译器。所以以后对于更改数据,可以使用std::lock_guard< std::shared_mutex >std::unique_lock< std::shared_mutex >锁定,代替对应的std::mutex特化。它们与std::mutex一样, 都保证了访问的排他性。对于无需更新的数据,可以使用共享锁std::shared_lock< std::shared_mutx >实现共享访问。

递归加锁(嵌套锁)

假如线程已经持有某个std::mutex实例,试图再次对其加锁会导致未定义的行为。在某些场景下,需要线程在同一个互斥上重复加锁,无需解锁,c++标准库提供了std::recursive_mutx,其工作方式与std::mutex相似,不同之处在于允许一个线程对某一个互斥多次加锁。当另外线程想要使用该互斥时,则需要解掉该互斥上所有的锁。比如调用2次lock(),就需要调用2次unlock()才可以继续使用。正确使用 std::lock_guard<std::recursive_mutex>std::unique_lock<std::recursive_mutex> 可以帮你处理这些问题。

  • 21
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值