「C++ 多线程」std::call_once, std::once_flag
文章大图来源:pixiv_id=104231610
1 问题背景
在 C++ 多线程中,我们有时会遇到共享资源初始化的情况。假设有一个全局变量 val
,有多个线程同时尝试初始化这个全局变量,一方面重复的初始化会导致资源的浪费,另一方面多个线程同时开始初始化,可能会导致初始化过程互相干扰(一个线程初始化还未完成,另一个线程也开始初始化)。
为了实现多线程正确互斥地进行共享资源的初始化,我们可以简单地使用 互斥锁 来实现多线程并发互斥初始化共享数据。以下是一个简单的示例:
1 |
|
在上面的代码中,我们定义了待初始化数据 val
和一个记录数据是否被初始化的标记 flag
(若 flag
为 false
,表示 未初始化)。
我们创建了 10
个线程,每个线程都执行初始化函数 init()
。在函数 init()
中,我们通过 std::lock_guard
实现了多个线程对初始化标记 flag
和 共享数据 val
的互斥访问和修改等操作。如果线程尝试获得互斥锁成功,那么接下来判断数据是否初始化过(flag
为 false
),如果没有初始化,则进行数据的初始化,并将 flag
标记置为 true
;否则线程返回。如果线程没有成功获得锁,那么当前线程会被阻塞,直到其他线程释放锁,然后再进行标记 flag
的判断等一系列操作。
虽然上面的代码成功实现了多个线程并发互斥初始化共享数据,并且共享数据保证只会被初始化一次,但是每个线程执行初始化函数 init()
时都需要通过 std::lock_guard
尝试获得锁,并且最终都需要获得锁进行 flag
初始化标记的判断(即使初始化已经完成)。这显然造成了多余的开销,效率是比较低的。
2 双重检查锁定
我们可以修改上面 问题背景 中的代码,引入一种称为 “双重检查锁定模式”(Double-Checked Locking Pattern, DCLP)的技术。这种模式可以减少一定的开销,但是实际上在 问题背景 中的代码还是会存在问题。
我们可以修改代码如下:
1 |
|
在上面的代码中,我们可以在原本代码块的上增加一个外层的 flag
标记的判断。如果 flag
标记为 true
,表明数据初始化过了,那么在外层的判断中,发现 flag
为 true
,那么就会直接返回,而不会再尝试获得锁并且最后需要去获得锁进行内层 flag
标记的判断;如果 flag
标记为 false
,那么数据没有初始化过,然后会尝试获得锁,获得锁之后内层进行 flag
标记的判断,再进行一系列初始化的操作。
上面的代码看似可以解决加锁造成的多余开销的问题,运行起来有时候看似也没问题,但实际上有着比较明显的 线程安全 的问题。
在上面的代码中,flag
标记的读取和写入操作可能通过系统的调度、排序。一个线程正在设置 flag
标记为 true
时,另一个线程可能看到还未更新完的状态(可能出现多个线程同时判断 flag
为 false
的情况)。这造成了内部的混乱,出现了多线程 数据竞争 等问题,破坏了并发安全。
如果要保持上面代码形式的基础上去进行修改,我们需要考虑将 flag
设定为 std::atomic
原子布尔值等。
但是,如果我们暂时还没有具体学习过 std::atomic
原子操作的知识,也不想增加多余的加锁的开销,该如何实现多线程并发互斥初始化数据,并且保证数据只被初始化一次呢?
3 std::call_once, std::once_flag
3.1 基本概念
std::call_once
是 C++ 11 引入的一个函数模板,定义在 <mutex>
头文件中。这个函数模板主要作用是 保证一个函数(可调用对象)在多线程环境下只被调用一次。
std::call_once
的函数原型如下:
1 |
|
其中 flag
是一个 std::once_flag
类型对象,用于标记函数(可调用对象) f
是否已经被调用过。Callable
是一个可调用类型对象(如函数、函数指针、lambda 表达式等),args
是传递给函数(可调用对象)Callable
的参数。
3.2 代码示例
我们可以通过 std::call_once
配合 std::once_flag
来实现 问题背景 中的代码所实现的多线程初始化:
1 |
|
在上面的代码中,我们定义了一个 std::once_flag
标记,用于标记 std::call_once
中的 lambda
匿名函数是否被调用过。std::call_once
中的 lambda
匿名函数执行将共享数据 val
初始化为 1
。
如果 lambda
函数未被调用过,flag
处于未被调用状态,此时 std::call_once
会调用这个 lambda
函数,进行数据的初始化,然后 flag
会标记为已经调用状态;
如果 lambda
函数已经调用过,flag
处于已经调用状态,那么函数不会被执行,参数也不会被拷贝或移动(参数传递)。
运行结果如下:
3.3 内部原理
std::call_once
的内部原理依赖于原子操作和内存屏障(memory barriers)来确保线程安全,并且只调用一次指定的函数。
以下是 std::call_once
的部分源码:
1 |
|
在上面的源码中,创建了一个 once_flag::_Prepare_execution
对象 __exec
,该对象的构造函数接受一个可调用对象,并在其析构时调用该对象。这个对象的作用是确保在 std::call_once
函数执行结束后,可调用对象 __callable
被正确地执行。(__callable
中调用 std::invoke
函数来执行可调用对象 __f
)
然后,调用了 __gthread_once
函数来执行一次性操作。该函数接受两个参数中,第一个是指向 __once._M_once
变量的指针,第二个是指向 __once_proxy
函数的指针。__once._M_once
是一个 原子类型的变量,用于记录一次性操作是否已经被执行过;__once_proxy
函数是一个辅助函数,其作用是 调用 __exec
对象的可调用对象。