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 类似。代码也许可以这样写:
|
|
这段代码在单线程程序中当然是没有问题的。在多线程程序中,显然需要加上保护机制,因为多个线程可能同时进入 do() 函数,而 resource_ptr 是共享的智能指针,没有保护机制的话会导致出错。
如果我们尝试用互斥锁来保护这个资源的初始化:
|
|
这段代码逻辑上没有问题,但它意味着,任何线程每次调用 do() 函数,都会尝试获取锁;即便初始化已经完成,任何获取了锁的线程都会阻止其它线程进入。这大大降低了程序的执行效率。
双重检查加锁(double-checked locking)的问题
对于这个问题,曾经有人尝试提出一个方法,叫做 double-checked locking
—— 双重检查加锁
。简单地说,就是在加锁之前检查一下是否需要加锁,仅在需要的时候才加锁。对应上述的例子,就是先检查 resource_ptr 是否为空,只有为空时才尝试获取锁,获取锁之后再检查一遍 resource_ptr 是否为空(防止首次检查和加锁之间,其它线程已初始化 resource_ptr),仍为空的话才尝试初始化,代码如下:
|
|
初看,这段代码似乎没什么问题,然而不幸的是,双重检查加锁已经被事实证明是个错误的方案。由于 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_flag
和 std::call_once
。相较于每次获取互斥锁并检查指针状态,每个线程可以使用 std::call_once 来确保指针初始化只发生一次,且当该函数返回时,指针已经被正确初始化(被其它线程或被该线程自己)。控制同步的数据结构保存在 std::once_flag 中,每个 std::once_flag 负责不同的初始化事件。代码如下:
|
|
在该例子中,初始化被放在一个独立函数中,并把该函数传给 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)函数。
「 您的赞赏是激励我创作和分享的最大动力! 」
- 原文链接:https://zhuyinjun.me/2022/stdcall_once/
- 版权声明:本创作采用 CC BY-NC 4.0 国际许可协议,非商业性使用可以转载,但请注明出处(作者、链接),商业性使用请联系作者获得授权。