目录

C++ std::call_once

互斥锁的局限性

多线程同步技术中,最常用的就是互斥锁(mutex)。互斥锁是一个同步原语(synchronous primitive),通过一个原子指令,比如 CAS,或者 test-and-set,结合让渡 CPU 的指令 yield,实现同一时刻只能有一个线程访问临界资源。现在主流语言中,还会把互斥锁和RAII 技术结合起来,比如 C++ 的 lock_guard / scoped_lock / unique_lock,在构造函数中加锁,在析构函数中解锁,从而防止临界区中出现 exception 时解锁失败。

互斥锁适用于多个线程访问时没有严格先后要求的场合,即无论哪个线程先获取锁,都不会导致程序逻辑错误。但有两种情况下,互斥锁无法满足需求:

  • 线程有严格的先后要求,比如当一个消息队列中没有数据时,消费者线程必须挂起等待,直到有生产者线程往队列里添加数据。
  • 共享数据只有在初始化时需要保护,而之后的使用无须保护并发访问。

第一种情况,可以用条件变量(condition variable)来实现。

第二种情况,看似极端,其实很常见。比如,对于只读数据,多线程无需用同步机制来控制并发访问,但是对于只读数据的创建,却需要同步保护的。如果多个线程,都可以在同一块内存区创建数据,而没有用任何同步机制保护的话,那么结果是 undefined。

那如果对于第二种情况,同样用互斥锁来保护呢?由于互斥锁保护的是整个数据块,所以在初始化完成后,多线程对其访问也会受限于互斥锁的保护,显然,对于只读数据这种方式是非常低效的。

 

看一个例子

假定我们有一个共享资源,初始化成本比较高,比如打开一个数据库连接或者分配大量内存,所以我们希望只在需要时去创建并初始化这个资源。这个叫“惰性初始化”——lazy initialization, 和 Python 里的 lazy producing 类似。代码也许可以这样写:

1
2
3
4
5
6
7
8
9
std::shared_ptr<resource> resource_ptr;
void do()
{
    if(!resource_ptr)
    {
        resource_ptr.reset(new resource);
    }
    resource_ptr->do_reading();
}

这段代码在单线程程序中当然是没有问题的。在多线程程序中,显然需要加上保护机制,因为多个线程可能同时进入 do() 函数,而 resource_ptr 是共享的智能指针,没有保护机制的话会导致出错。

如果我们尝试用互斥锁来保护这个资源的初始化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
std::shared_ptr<resource> resource_ptr;
std::mutex resource_mutex;
void do()
{
    std::unique_lock<std::mutex> lk(resource_mutex);
    if(!resource_ptr)
    {
        resource_ptr.reset(new resource);
    }
    lk.unlock();
    resource_ptr->do_reading();
}

这段代码逻辑上没有问题,但它意味着,任何线程每次调用 do() 函数,都会尝试获取锁;即便初始化已经完成,任何获取了锁的线程都会阻止其它线程进入。这大大降低了程序的执行效率。

 

双重检查加锁(double-checked locking)的问题

对于这个问题,曾经有人尝试提出一个方法,叫做 double-checked locking —— 双重检查加锁。简单地说,就是在加锁之前检查一下是否需要加锁,仅在需要的时候才加锁。对应上述的例子,就是先检查 resource_ptr 是否为空,只有为空时才尝试获取锁,获取锁之后再检查一遍 resource_ptr 是否为空(防止首次检查和加锁之间,其它线程已初始化 resource_ptr),仍为空的话才尝试初始化,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void do_with_double_checked_locking()
{
    if(!resource_ptr)
    {
        std::lock_guard<std::mutex> lk(resource_mutex);
        if(!resource_ptr)
        {
            resource_ptr.reset(new resource);
        }
    }
    resource_ptr->do_reading();
}

初看,这段代码似乎没什么问题,然而不幸的是,双重检查加锁已经被事实证明是个错误的方案。由于 resource_ptr.reset(new resource) 并非原子指令,而且在加锁后的临界区中指令是可能 reorder 的(互斥锁不保证它保护的临界区内的指令同步),所以存在一种可能:resource_ptr 已经被赋予一个值,但内存分配(new做的事)还没有完成。如果此时该线程被 OS 调度挂起,而另一个线程进入后,绕过 if 语句块,直接执行 resource_ptr->do_reading(),将导致 undefined behavior。C++ 标准把这种类型的竞争条件称为数据竞争(data race)。

关于 double-checked locking 问题的细节,我会在另一篇文章中展开介绍。

 

解决方案

针对这种场景,C++11 的标准库中引入了 std::once_flagstd::call_once 。相较于每次获取互斥锁并检查指针状态,每个线程可以使用 std::call_once 来确保指针初始化只发生一次,且当该函数返回时,指针已经被正确初始化(被其它线程或被该线程自己)。控制同步的数据结构保存在 std::once_flag 中,每个 std::once_flag 负责不同的初始化事件。代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
std::shared_ptr<resource> resource_ptr;
std::once_flag resource_flag;
void init_resource()
{
    resource_ptr.reset(new resource);
}
void do()
{
    std::call_once(resource_flag,init_resource);
    resource_ptr->do_something();
}

在该例子中,初始化被放在一个独立函数中,并把该函数传给 std::call_once。C++ 中任何可调用对象(函数对象、lambda表达式等等)都可以按照这个方式工作。std::call_once 也可以用于其它只发生一次的事件。比起互斥锁,std::call_once 执行成本更低,尤其当指定事件(init_resource)已经完成的情况下(std::once_flag 被置位)。

 

其它

需要注意的是,和 std::mutex 类似,std::once_flag 对象无法被拷贝,也无法被移动,因为它的实现中拷贝赋值(copy-assignment)和移动赋值(move-assignment)都被设为 delete。所以,当你想在类中定义这些数据成员,并且该类需要支持拷贝或移动,你需要为该类重新定义这些拷贝控制(copy-control)函数。


- 全文完 -

相关文章

「 您的赞赏是激励我创作和分享的最大动力! 」