「C++ 多线程」std::call_once, std::once_flag

文章大图来源:pixiv_id=104231610

1 问题背景

在 C++ 多线程中,我们有时会遇到共享资源初始化的情况。假设有一个全局变量 val,有多个线程同时尝试初始化这个全局变量,一方面重复的初始化会导致资源的浪费,另一方面多个线程同时开始初始化,可能会导致初始化过程互相干扰(一个线程初始化还未完成,另一个线程也开始初始化)。

为了实现多线程正确互斥地进行共享资源的初始化,我们可以简单地使用 互斥锁 来实现多线程并发互斥初始化共享数据。以下是一个简单的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>

std::mutex mtx; // 互斥量
bool flag; // 标记是否初始化
int val; // 待初始化数据

void init(){
std::lock_guard<std::mutex> lck(mtx); // 互斥锁
if(!flag){ // 还未初始化
std::cout << "value initializing ... " << "\n";
val = 1; // 初始化数据为 1
std::cout << "value initialized as: " << val << '\n';
flag = true; // 标记为已经初始化
}
}

int main(){
std::vector<std::thread> threads(10);
for(auto& t : threads) t = std::thread(init);
for(auto& t : threads) t.join();

return 0;
}

在上面的代码中,我们定义了待初始化数据 val 和一个记录数据是否被初始化的标记 flag(若 flagfalse,表示 未初始化)。

我们创建了 10 个线程,每个线程都执行初始化函数 init()。在函数 init() 中,我们通过 std::lock_guard 实现了多个线程对初始化标记 flag 和 共享数据 val 的互斥访问和修改等操作。如果线程尝试获得互斥锁成功,那么接下来判断数据是否初始化过(flagfalse),如果没有初始化,则进行数据的初始化,并将 flag 标记置为 true;否则线程返回。如果线程没有成功获得锁,那么当前线程会被阻塞,直到其他线程释放锁,然后再进行标记 flag 的判断等一系列操作。

虽然上面的代码成功实现了多个线程并发互斥初始化共享数据,并且共享数据保证只会被初始化一次,但是每个线程执行初始化函数 init() 时都需要通过 std::lock_guard 尝试获得锁,并且最终都需要获得锁进行 flag 初始化标记的判断(即使初始化已经完成)。这显然造成了多余的开销,效率是比较低的。

2 双重检查锁定

我们可以修改上面 问题背景 中的代码,引入一种称为 “双重检查锁定模式”(Double-Checked Locking Pattern, DCLP)的技术。这种模式可以减少一定的开销,但是实际上在 问题背景 中的代码还是会存在问题。

我们可以修改代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>

std::mutex mtx; // 互斥量
bool flag; // 标记是否初始化
int val; // 待初始化数据

void init(){
if(!flag){ // 双重检查
std::lock_guard<std::mutex> lck(mtx); // 互斥锁
if(!flag){ // 还未初始化
std::cout << "value initializing ... " << "\n";
val = 1; // 初始化数据为 1
std::cout << "value initialized as: " << val << '\n';
flag = true; // 标记为已经初始化
}
}
}

int main(){
std::vector<std::thread> threads(10);
for(auto& t : threads) t = std::thread(init);
for(auto& t : threads) t.join();

return 0;
}

在上面的代码中,我们可以在原本代码块的上增加一个外层的 flag 标记的判断。如果 flag 标记为 true,表明数据初始化过了,那么在外层的判断中,发现 flagtrue,那么就会直接返回,而不会再尝试获得锁并且最后需要去获得锁进行内层 flag 标记的判断;如果 flag 标记为 false,那么数据没有初始化过,然后会尝试获得锁,获得锁之后内层进行 flag 标记的判断,再进行一系列初始化的操作。

上面的代码看似可以解决加锁造成的多余开销的问题,运行起来有时候看似也没问题,但实际上有着比较明显的 线程安全 的问题。

在上面的代码中,flag 标记的读取和写入操作可能通过系统的调度、排序。一个线程正在设置 flag 标记为 true 时,另一个线程可能看到还未更新完的状态(可能出现多个线程同时判断 flagfalse 的情况)。这造成了内部的混乱,出现了多线程 数据竞争 等问题,破坏了并发安全

如果要保持上面代码形式的基础上去进行修改,我们需要考虑将 flag 设定为 std::atomic 原子布尔值等。

但是,如果我们暂时还没有具体学习过 std::atomic 原子操作的知识,也不想增加多余的加锁的开销,该如何实现多线程并发互斥初始化数据,并且保证数据只被初始化一次呢?

3 std::call_once, std::once_flag

3.1 基本概念

std::call_once 是 C++ 11 引入的一个函数模板,定义在 <mutex> 头文件中。这个函数模板主要作用是 保证一个函数(可调用对象)在多线程环境下只被调用一次

std::call_once 的函数原型如下:

1
2
template<class Callable, class... Args> 
void call_once( std::once_flag& flag, Callable&& f, Args&&... args );

其中 flag 是一个 std::once_flag 类型对象,用于标记函数(可调用对象) f 是否已经被调用过。Callable 是一个可调用类型对象(如函数、函数指针、lambda 表达式等),args 是传递给函数(可调用对象)Callable 的参数。

3.2 代码示例

我们可以通过 std::call_once 配合 std::once_flag 来实现 问题背景 中的代码所实现的多线程初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>

std::mutex mtx; // 互斥量
std::once_flag flag; // 标记
int val; // 共享数据

void init(){
std::call_once(flag, [](){
std::cout << "value initializing ... " << "\n";
val = 1; // 初始化为 1
std::cout << "value initialized as: " << val << '\n';
});
}

int main(){
std::vector<std::thread> threads(5);
for(auto& t : threads) t = std::thread(init);
for(auto& t : threads) t.join();

return 0;
}

在上面的代码中,我们定义了一个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename _Callable, typename... _Args>
void call_once(once_flag& __once, _Callable&& __f, _Args&&... __args)
{
// 创建一个可调用对象 __callable,该对象调用 __f 函数并传递参数 __args...
auto __callable = [&] {
std::__invoke(std::forward<_Callable>(__f), std::forward<_Args>(__args)...);
};

// 创建一个 __exec 对象,并在其析构时调用 __callable
once_flag::_Prepare_execution __exec(__callable);

// 调用 __gthread_once 函数执行一次性操作
if (int __e = __gthread_once(&__once._M_once, &__once_proxy))
__throw_system_error(__e);
}

在上面的源码中,创建了一个 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 对象的可调用对象

参考

  1. C++11 call_once 和 once_flag

  2. cppreference std::call_once

  3. cppreference std::once_flag


「C++ 多线程」std::call_once, std::once_flag
https://marisamagic.github.io/2024/12/30/20241230/
作者
MarisaMagic
发布于
2024年12月30日
许可协议