「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 对象的可调用对象。