以下内容均是从另一个角度来看待多线程编程的方式,并不是用常规的共享变量或者锁来解析多线程编程
1、多用const关键字
C++多线程编程注意读而不写”就不会有数据竞争,在 C++ 多线程编程里读取 const 变量总是安全的,对类调用 const 成员函数、对容器调用只读算法也总是线程安全的。
2、C++4个用于多线程工具实践
多线程下要防止多次初始化数据,如果不控制,会导致初始化函数多次运行。
call_once()仅调用一次
意义:仅调用一次
static std::once_flag flag; // 全局的初始化标志
使用方式:
1. 先声明一个 once_flag 类型的变量,最好是静态、全局的(线程可见),作为初始化的标志;
2. 调用专门的 call_once() 函数,以函数式编程的方式,传递这个标志和初始化函数。
效果:即使多个线程重入 call_once(),保证只会有一个线程会成功初始化。
auto f = []() // 在线程里运行的lambda表达式
{
std::call_once(flag, // 仅一次调用,注意要传flag
[](){ // 匿名lambda,初始化函数,只会执行一次
cout << "only once" << endl;
} // 匿名lambda结束
); // 在线程里运行的lambda表达式结束
};
thread t1(f); // 启动两个线程,运行函数f
thread t2(f);
熟悉单例模式的可能会想到一个“双重检查锁定”的问题,双重检查锁定其实是有安全隐患的 , 具体的安全隐患等以后补充,先自己想想。
使用call_once()就消除了这个问题。
thread_local线程局部存储
多线程下经常会用读写全局变量或者局部静态变量,通过一个共享数据来达到一个数据同步的目的。
首先要明白,为什么要使用全局变量或者局部静态变量,如果仅仅是为了数据单独的方便线程传入传出数据,而不是真正用来共享使用这个数据,可以把他换成线程局部存储来使用,这样叫做线程独占所有权。
意义:有 thread_local 标记的变量在每个线程里都会有一个独立的副本,是“线程独占”的,所以就不会有竞争读写的问题。
thread_local int n = 0; // 线程局部存储变量
//static int n = 0; // 静态全局变量,如果使用静态,结果为30
auto f = [&](int x) // 在线程里运行的lambda表达式,捕获引用
{
n += x; // 使用线程局部变量,互不影响
cout << n; // 输出,验证结果
};
thread t1(f, 10); // 启动两个线程,运行函数f
thread t2(f, 20);
程序结果为10和20.
注意:必须清楚的知道数据的使用方式。
原子变量
线程同步:一种方式就是互斥量,但是开销大。所以,如果是比较小的数据,可以使用原子化方案。
目前,C++ 只能让一些最基本的类型原子化,比如 atomic_int、atomic_long,等等:
using atomic_bool = std::atomic<bool>; // 原子化的bool
using atomic_int = std::atomic<int>; // 原子化的int
using atomic_long = std::atomic<long>; // 原子化的long
这些使用多线程读写一般不会出错。
注意:
原子变量禁用了拷贝构造函数,所以在初始化的时候不能用“=”的赋值形式,只能用圆括号或者花括号
atomic_int x {0}; // 初始化,不能用=
atomic_long y {1000L}; // 初始化,只能用圆括号或者花括号
assert(++x == 1); // 自增运算
y += 200; // 加法运算
assert(y < 2000); // 比较运算
static atomic_flag flag {false}; // 原子化的标志量
static atomic_int n; // 原子化的int
auto f = [&]() // 在线程里运行的lambda表达式,捕获引用
{
auto value = flag.test_and_set(); // TAS检查原子标志量
if (value) {
cout << "flag has been set." << endl;
} else {
cout << "set flag by " <<
this_thread::get_id() << endl; // 输出线程id
}
n += 100; // 原子变量加法运算
this_thread::sleep_for( // 线程睡眠
n.load() * 10ms); // 使用时间字面量
cout << n << endl;
}; // 在线程里运行的lambda表达式结束
thread t1(f); // 启动两个线程,运行函数f
thread t2(f);
t1.join(); // 等待线程结束
t2.join();
上述的例子还是看到了使用thread,最好的方法是隐藏到底层使用,如何隐藏到底层,具体如下。
async()
含义:“异步运行”一个任务,隐含的动作是启动一个线程去执行,但不绝对保证立即启动(也可以在第一个参数传递 std::launch::async,要求立即启动线程)。
这种调用线程的方式是把线程封装到底部使用,不会看到明显的线程。
auto task = [](auto x) // 在线程里运行的lambda表达式
{
this_thread::sleep_for( x * 1ms); // 线程睡眠
cout << "sleep for " << x << endl;
return x;
};
auto f = std::async(task, 10); // 启动一个异步任务
f.wait(); // 等待任务完成
assert(f.valid()); // 确实已经完成了任务
cout << f.get() << endl; // 获取任务的执行结果
这种不调用thread
线程的方式其实还是函数式编程的思路,在更高的抽象级别上去看待问题,异步并发多个任务,让底层去自动管理线程,要比我们自己手动控制更好(比如内部使用线程池或者其他机制)。
C++ 标准库里有专门的线程类 thread,使用它就可以简单地创建线程,在名字空间 std::this_thread 里,还有 yield()、get_id()、sleep_for()、sleep_until() 等几个方便的管理函数。可以直接百度查以下他们的用法。
async() 会返回一个 future 变量,可以认为是代表了执行结果的“期货”,如果任务有返回值,就可以用成员函数 get() 获取。
注意:get()只能调用一次。再次获取结果会发生错误,抛出异常 std::future_error。
另一个注意点:如果你不显式获取 async() 的返回值(即 future 对象),它就会同步阻塞直至任务完成(由于临时对象的析构函数),于是“async”就变成了“sync”。
std::async(task, ...); // 没有显式获取future,被同步阻塞
auto f = std::async(task, ...); // 只有上一个任务完成后才能被执行
所以尽量用 auto 来配合 async(),避免同步阻塞,就像上面的示例代码那样。
3、总结
上述的整体思想就是尽量不用线程,或者把线程尽量封装到底层去使用。
- 多线程是并发最常用的实现方式,好处是任务并行、避免阻塞,坏处是开发难度高,有数据竞争、死锁等很多“坑”;
- call_once() 实现了仅调用一次的功能,避免多线程初始化时的冲突;
- thread_local 实现了线程局部存储,让每个线程都独立访问数据,互不干扰;
- atomic 实现了原子化变量,可以用作线程安全的计数器,也可以实现无锁数据结构;
- async() 启动一个异步任务,相当于开了一个线程,但内部通常会有优化,比直接使用线程更好。