C++11中定义的data race是“不同线程中的两个互相冲突的动作,其中至少有一个不是atomic的,而且无一个动作发生在另一个动作之前”。data race总会导致不可预期的行为。在C++11以前,并不能保证“不同的对象拥有各自的内存区”,也就是,在C++98/C++03这个标准是针对单线程的进程的标准,严格来讲,从C++11之前,并行处理不同的对象也可能会导致不可预期的行为。这里只是谈C++11以后的并发。并发往往是针对相同数据,这里的相同数据是说,使用相同的内存区的数据,从C++11开始,一般变量都能保证拥有自己的内存区,但是bitfield例外,不同的bitfield有可能共享同一块内存区。
与java不同:
- volatile在C++中是一个关键字,主要用来阻止“过度优化”
- 在java中,volatile提供了某系atomicity和order的一些保证
在C++中,volatile只保证对变量的读取的load内存的操作不会被编译器优化,其他的优化几乎没有,并没有像java中提供类似于atomicity和order的保证。
处理并发通常用到的就是锁和互斥量,c++11的标准中提供了需要的互斥量,这里简单演示一下用法,具体的内容后续再说:
本次主要是讨论关于互斥量的使用。首先看一个建的例子:
#include
#include
#include
#include
using namespace std;
int val=20;
std::mutex valMutex; //control exclusive access to val
int func(int argc){
//thread 1 add
valMutex.lock();
val += 2;
cout << "func : " << val << endl;
valMutex.unlock();
return 0;
}
int foo(int argc){
//thread 2
valMutex.lock();
val *= 2;
cout << "foo : " << val << endl;
valMutex.unlock();
return 0;
}
int main()
{
val = 10;
valMutex.lock(); //保证thread 是在线程1、2前输出
thread t1(func, 0);
thread t2(foo, 0);
cout << "thread:" << endl;
valMutex.unlock();
t1.join();
t2.join();
system("pause");
return 0;
}
上面的代码在10行的位置声明看一个互斥量valMutex,并通过该互斥量实现读写的互斥,但是上面的做法有很多的隐患,我们如果程序在加锁以后抛出了异常,那么这个锁有可能永远无法被获取,标准中提供了一种利用代码域的概念使用局部变量的形式进行加锁和解锁操作。
但是并不推荐这么使用,这么使用不符合RAII的规范,建议使用基于RAII规范,你会发现C++11为你提供了这一个规范,我们不妨使用lock_guard
这个模板,如下:
// Concurr001.cpp : 定义控制台应用程序的入口点。
//
#include "stdafx.h"
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
std::mutex sMutex;
int solve()
{
{
std::lock_guard lg(sMutex);
/*
需要互斥的代码
*/
}//释放锁
}
上面的程序利用了RAII的机制(关于RAII机制的问题,我们通过一个专门的地方进行详细的分析,其实是个很有用,很有趣的东西),避免了因为异常跳出函数而不能或者忘记释放锁带来的问题。这种思想同样也应用在了智能指针中,RAII的使用,确实很有趣。
其实,我们去看这些源码,甚至我们可以想象在其中干了些什么,mutex本身有四种可供选择:
- mutex
- recursove_mutex
- timed_mutex
- recursive_timed_mutex
对应的lock的操作也有四种:
- lock()
: 捕获mutex,否则阻塞
- try_lock()
: 捕获mutex,不成功返回false
- unlock()
: 释放锁定的互斥量
- try_lock_for()
: (适用于timed_mutex
和recursive_timed_mutex
)试着在时间段内捕获一个lock
- try_lock_until()
:(适用于timed_mutex
和recursive_timed_mutex
)试着捕获一个lock知道某个时间点
recursove_mutex
和recursive_timed_mutex
是支持多个lock的,其余的不行。class std::timed_mutex
额外允许你传递一个时间段或者时间点,用来定义多长时间内他可以尝试捕获一个lock,为它提供了try_lock_for()
和try_lock_until()
。recursive_timed_mutex
允许一个线程多次取得其lock我们并不会经常使用mutex的成员函数中的这些操作,如果上面提到了,我们经常会用一点小技巧,使用RAII来完成。C++11为此提供了:
- lock_guard
- unique_lock
lock_guard
提供了如下的操作:
操作 | 效果 |
---|---|
lock_guard lg(m) |
为mutex m建立一个lock guard并锁定之 |
lock_guard lg(m, adopt_lock) |
为已经被锁定的mutex m见了一个 lock guard |
lg.~lock_guard() |
解锁mutex并销毁lock guard |
unique_lock
的操作略多一些
操作 | 效果 |
---|---|
unique_lock l |
default构造函数,建立一个lock但不关联任何mutex |
unique_lock l(m) |
为mutex m 建立一个lock guard并锁定它 |
unique_lock l(m,adopt_lock) |
为已锁定的mutex m建立一个lock guard |
unique_lock l(m,defer_lock) |
为mutex m建立一个lock guard,但不锁定它 |
unique_lock l(m,try_lock) |
为mutex m建立一个lock guard, 并试图锁定它 |
unique_lock l(m,dur) |
为mutex m建立一个lock guard,并试图在时间段dur内锁定它 |
unique_lock l(m,tp) |
为mutex m建立一个lock guard,并试图锁定直到时间点tp |
unique_lock l(rv) |
move构造函数 |
if(l) |
检查关联的mutex是否被锁定 |
l.mutex() |
返回一个指针指向关联的mutex |
l.lock() |
锁定 |
l.try_lock() |
尝试锁定,如果成功返回true |
l.try_lock_for(dur) |
尝试在时间段dur内锁定关联的mutex,如果成功返回true |
l.try_lock_until(tp) |
尝试锁定关联的mutex到时间点tp,成功返回true |
l.unlock() |
解除关联的mutex |
上面的函数并没有列完,只是列了一些我关心的函数,具体的详情,请参看各个编译器的源码或者去看《C++标准库》
如果做到只调用一次?C++提供的一个方案:
std::once_flag oc;
/* .... */
std::call_once(oc,func);
完美解决
简单的实例:
#include
#include
#include
#include
#include
#include
using namespace std;
using namespace std::chrono;
bool readyFlag;
std::mutex readyMutex;
std::condition_variable readyCondVar;
void thread1()
{
cout << "" << endl;
auto s = cin.get();
//cout << s << endl;
{
std::lock_guard<std::mutex> lg(readyMutex);
readyFlag = true;
}
readyCondVar.notify_one();
}
void thread2()
{
{
std::unique_lock<std::mutex> ul(readyMutex);
readyCondVar.wait(ul, [] { return readyFlag; });
}
cout << "done" << endl;
}
int main()
{
auto f1 = async(launch::async, thread1);
auto f2 = async(launch::async, thread2);
/* 增加wait是为了防止主线程先结束 */
f1.wait();
f2.wait();
system("pause");
return 0;
}
条件变量的用法和Posix中类似,存在经常使用的函数如下:
notify类的有:
1. notify_one()
2. notify_all()
wait类的有:
1. wait
2. wait_for
3. wait_until
一般可以通过一个判断条件变量保护的数据是否可用的lambda函数作为其中的处理函数。【lambda函数真是便利无限啊】
操作 | 内容 |
---|---|
cv.wait(ul) |
使用unique lock ul 来等待 |
cv.wait(ul,pred) |
使用unique lock ul来等待通知,并且直到pred在一次苏醒之后结果为true |
cv.wait_for(ul, dur) |
使用unique lock ul来等待通知,并且等待期为dur |
cv.wait_for(ul, dur, pred) |
使用unique lock ul来等待通知,并且等待期为dur,或者直到pred在一次苏醒之后结果为true |
cv.wait_until(ul, tp) |
使用unique lock ul来等待通知,直接时间节点tp |
cv.wait_until(ul, tp, pred) |
使用unique lock ul来等待通知,直接时间节点tp,或者直到pred在一次苏醒之后结果为true |
notify_all_at_thread_exit(cv, ul) |
在调用所在之本线程(calling thread)唤醒所有使用unique lock ul来等待cv的线程 |
// 条件变量_生产者消费者.cpp : 定义控制台应用程序的入口点。
#include "stdafx.h"
#include
#include
//多线程
#include
#include
#include
#include
//容器
#include
using namespace std;
queue<int> ique;
mutex iqueMutex;
condition_variable iqueCondVar;
mutex outMutex;
void provider(int val)
{
for (int i = 0; i < 60; i++)
{
std::lock_guard lg(iqueMutex);
ique.push(val + i);
}//释放锁
iqueCondVar.notify_one();
this_thread::sleep_for(chrono::milliseconds(val));
}
void consumer(int num)
{
while (true)
{
int val;
{
std::unique_lock ul(iqueMutex);
iqueCondVar.wait(ul, []()->bool { return !ique.empty(); });
val = ique.front();
ique.pop();
}//释放锁
{
lock_guard lg(outMutex);
cout << "consumer " << num << ": " << val << endl;
}//释放输出锁
}
}
int main()
{
auto p1 = async(launch::async, provider, 100);
auto p2 = async(launch::async, provider, 300);
auto p3 = async(launch::async, provider, 500);
//
auto c1 = async(launch::async, consumer, 1);
auto c2 = async(launch::async, consumer, 2);
//this_thread::sleep_for(chrono::minutes(1));
//system("pause");
p1.wait();
c2.wait();
cout << " End..." << endl;
return 0;
}
其实atomic是最直接的,在C++20的标准中即将提供shared_ptr
的atomic的版本,值得期待。
相比于基于C语言以及过程编程的pthread“原子操作API”而言,C++11对于“原子操作”概念的抽象遵从了面向对象的思想——C++11标准定义的都是所谓的“原子类型”。编译器可以保证原子类型在线程间被互斥的访问,这样设计从并行编程的角度看,是由于需要同步的总是数据而不是代码,因此C++11对数据进行了抽象,会有利于产生行为更为良好的并行代码。而进一步地,一些琐碎的概念,比如互斥锁、临界区则可被C++11的抽象所掩盖,因此并行代码的编写也会变得更加简单。我们可以在
看到内置类型的原子类型的定义:
atomic_bool abool; //对应bool
atomic_char achar; //char
atomic_schar aschar; //signed char
atomic_uchar auchar; //unsigned char
atomic_int aint; //int
atomic_uint auint; //unsigned int
atomic_short ashort; //short
atomic_ushort aushort; //unsigned short
atomic_long along; //long
atomic_ulong aulong; //unsigned long
atomic_llong allong; //long long
atomic_ullong aullong; //unsigned long long
atomic_char16_t achar16_t; //char16_t
atomic_char32_t achar32_t; //char32_t;
atomic_wchar_t awchar_t; //wchar_t
不过更为普遍的应该是使用atomic类模板。通过该模板,可以定义出任意需要的原子类型:
std::atomic t;
如上,声明可一个类型为T的原子类型变量t。编译器会保证产生并行情况下行为良好的代码,以避免线程之间对于数据t的竞争。对于线程而言,原子类型通常属于“资源型”的数据,这意味着多个线程通常只能访问的原子类型的拷贝。因此在C++11中,原子类型只能从其模板参数类型中进行构造,标准不允许原子类型经行拷贝构造、移动构造,以及operator=等,防止以外发生如下面:
atomic<float> af{ 1.2f };
//atomic af1{ af }; //这里无法编译
从上面可以看到,af1{ af }
的构造方式在C++11中是不允许的,我们可以通过以前的经验轻松知道如何在类的代码中禁止这些行为,事实上,atomic模板类的拷贝构造函数、移动构造函数、operator=等总是默认被删除的。不过从atomic
类型的变量来构造其他模板参数类型T的变量则是可以的,比如:
atomic<float> af{ 1.2f };
//atomic af1{ af }; //这里无法编译
//下面的都是正确的
float af2 = af;
vector<float> vfl{ af };
stack<float> sfl;
sfl.push(af);
这是由于atomic类模板总是定义了从atomic到T的类型转换函数的缘故,在需要的时候,编译器会隐式地完成完成原子类型到其对应的类型的转化。能够实现在线程间保持原子性的原因是编译器能够保证针对原子类型的操作都是原子操作。正如之前所说,原子操作都是平台相关的,因此有必要为常见的原子操作进行抽象,定义统一的结构,并根据编译选项,并根据编译选项(或环境)产生其平台相关的实现。在C++11中,标准将原子操作定义为atomic模板类的成员函数,这囊括了绝大多数典型的操作,如读、写、交换等,当然,对于内置类型而言,主要是通过重载一些全局操作符来完成的,在编译的时候,会产生一条特殊的lock前缀的x86指令,lock能够控制总线及实现x86平台上的原子性。下面是atomic类型及相关的操作:
操作 | atomic_flag | atomic_bool | atomic_integral-type | atomic | atomic | atomic | atomic |
---|---|---|---|---|---|---|---|
test_and_set | Y | ||||||
clear | Y | ||||||
is_lock_free | y | y | y | y | y | y | |
load | y | y | y | y | y | y | |
store | y | y | y | y | y | y | |
exchange | y | y | y | y | y | y | |
compare_exchange_weak+strong | y | y | y | y | y | y | |
fetch_add,+= | y | y | y | ||||
fetch_sub,-= | y | y | y | ||||
fetch_or,|= | y | y | |||||
fetch_and,&= | y | y | |||||
fetch_xor,^= | y | y | |||||
++,– | y | y | y | y |
这里的atomic-integral-type和integraltype指的是前面提到的所有的原子类型的整型,而class-type则是指自定义类型。可以看到,对于大多数的原子类型而言,都可以执行读(load)、写(store)、交换(exchange)、比较并交换(compare_exchange_weak/compare_exchange_stronge)等操作。通常情况下,这些原子操作已经足够使用了。如下:
atomic<int> a;
a = 1; //a.store(1);
int b = a; //b = a.load();
这里的赋值语句b=a其实就等价b=a.load()。同样,a=1也相当于a.store(1),由于这些操作都是原子的,所以原来的从操作也是原子的,从而避免了线程间关于a的竞争。由于封装内部的实现和平台相关,所以,一般来说这些接口都封装了平台上最高性能的实现。
这里首先要注意的是atomic_flag,这个类型和其他不同,atomic_flag是无锁的(lock-free),级线程对其访问不需要加锁,因此对于atomic_flag而言,也就不需要使用load、store等成员函数进行读写,但是可以通过atomic_flag的成员test_and_set以及clear实现一个自旋锁(spinlock):
// 自旋锁实现.cpp : 定义控制台应用程序的入口点。
//
#include "stdafx.h"
#include
#include
#include
#include
using namespace std;
std::atomic_flag lock = ATOMIC_FLAG_INIT; //声明了全局变量,初始化为值ATOMIC_FLAG_INIT,即false状态
void func(int n){
while (lock.test_and_set(std::memory_order_acquire)) //尝试获得自旋锁
{
cout << "Waiting from thread : " << n << endl; //自旋
}
cout << "Thread " << n << " starts working" << endl;
}
void foo(int n){
cout << " Thread " << n << " is going to start" << endl;
lock.clear();
cout << " Thread " << n << " starting working" << endl;
}
int _tmain(int argc, _TCHAR* argv[])
{
lock.test_and_set();
thread t1(func, 1);
thread t2(foo, 2);
t1.join();
Sleep(100);
t2.join();
Sleep(100);
system("pause");
return 0;
}
在上面的程序,我们先是申明了一个全局变量的atomic_flag变量lock,并且初始化为ATOMIC_FLAG_INIT,也就是false的状态。在线程t1中,通过不停地通过lock的成员test_and_set()来设置lock为true。这里的test_and_set是一种原子操作,用于在一个内存空间原子地写入新植并返回旧值。因此test_and_set会返回之前的lock值。由于31行中设置了lock的值为true,所以线程t1会一直自旋在while循环中,知道线程t2将该设置位通过成员函数clear设置为false,此时线程t1自旋结束,开始后面的代码。当然还可以将lock封装为锁操作,比如:
void Lock(atomic_flag *lock) { while (lock->test_and_set()); }
void Ublock(atomic_flag *lock) { lock->clear(); }
这样一来,就可以想以前那样互斥的访问临界区了。除此之外,很多时候,了解底层的程序员会考虑使用无锁编程,以最大限度地挖掘并行编程的性能,而C++11的无锁机制为这样的实现提供了高级语言的支持。事实上,C++11中,原子操作还可以包含一个参数:memory_order,使用该参数能够进一步释放并行的潜在的性能。
如果只是简单地想在线程间进行数据的同步的话,上面说到的原子类型已经为程序员提供了一些同步保障。不过这样做的线程安全是基于一种假设,即所谓的顺序一致性(sequential consistent)的内存模型(memory model)。如下:
// 内存模型顺序一致性.cpp : 定义控制台应用程序的入口点。
//
#include "stdafx.h"
#include
#include
#include
using namespace std;
atomic<int> a{ 0 };
atomic<int> b{ 0 };
void ValueSet(int){
int t = 1;
a = t;
b = 2;
}
void Observer(int){
cout << "(" << a << "," << b << ")" << endl; //可能有多少输出
}
int _tmain(int argc, _TCHAR* argv[])
{
thread t1(ValueSet, 0);
thread t2(Observer, 0);
t1.join();
t2.join();
cout << "Got (" << a << "," << b << ")" << endl; //Got(1,2)
getchar(); //pause
return 0;
}
在上面的代码中,我们创建了两个线程t1和t2,分别执行ValueSet和Observer函数。在ValueSet中,为a和b的值分别是1和2,而在Observer中,只打印出a和b的值。其实由于两个线程的中关于a和b顺序的不确定性,因此,Observer输出的结果可能是多种可能性,如下所示:
在本例中Observer只是试图一窥线程ValueSet的执行状况,不过在 本例中Observer的窥探对于最终结果并不是必须的,因此赋值语句的执行顺序,也就是ValueSet的执行时间顺序并不会对最后的结果有什么影响。如果编译器认定a、b的赋值语句的执行先后顺序对输出结果有任何影响的话,则可以依情况将指令重排列(reorder)以提高性能,如果a、b赋值语句的执行顺序必须是先a后b,则编译器不会执行这样的优化。如果原子操作的先后顺序置之不理,很可能发生错误:
// 线程顺序.cpp : 定义控制台应用程序的入口点。
//
#include "stdafx.h"
#include
#include
#include
using namespace std;
atomic<int> a;
atomic<int> b;
void Thread1(int)
{
int t = 1;
a = t;
b = 2;
}
void Thread2(int){
while (b != 2); //自旋等待
cout << a << endl; //总是期待a的值为1
}
int _tmain(int argc, _TCHAR* argv[])
{
thread t1(Thread1, 0);
thread t2(Thread2, 0);
t1.join();
t2.join();
system("pause");
return 0;
}
在默认情况下,C++11中原子类型的变量在线程中总是保持着顺序执行的特点(非原子类型则没有必要,因为不需要在线程之间进行同步),我们称这样的特性为顺序一致的,即代码在线程中运行的顺序与程序员看到的代码顺序一致。事实上,顺序一致性只是C++11中多种内存模型中的一种,而在C++11中,并不是只支持顺序一致单个内存模型的原子变量,以为顺序一致性往往意味着最低效的同步方式,C++11提供了更高效的原子类型变量同步方式。
内存模型通常是一个硬件上的概念,表示的是机器指令是以什么样的顺序被处理器执行的。内存模型分为强顺序和弱顺序。多线程总是共享代码的,那么强顺序意味着:对于多个线程而言,其看到的指令执行顺序是一致的。具体而言,对于共享内存的处理器而言,需要看到内存中的数据被改变的顺序与机器指令中的一致,反之,如果线程间看到的内存数据被改变的顺序与机器指令中声明的不一致的话,则是弱顺序。在现实实现中采用强顺序的内存模型的平台,对于任何一个线程而言,其看到的原子操作都是顺序。而采用弱顺序内存模型的平台,比如PowerPC、Alpha、Itanlium、ArmV7,如果想要保证指令执行的顺序,通常要在汇编中加入一条所谓的内存栅栏(memory barrier)指令。为什么会有弱顺序内存模型?简单地说,弱顺序内存模型可以使得处理器进一步发掘指令中的并行性,使得指令执行的性能高效。
上面都是硬件上的一些可能的内存模型的描述。而C++11中定义的内存模型和顺序一致性跟硬件的内存模型的强顺序、弱顺序之间其实没有什么太强的关系。事实上,在高级语言与机器指令之间还有一层隔离,这层隔离是由编译器来完成的。编译器处于代码优化的考虑,会将指令前后移动,从而获得最佳的机器指令的排列以及产生最佳的运行时性能。究其C++11而言,要保证代码的顺序一致性,就必须同时做到:
编译器保证原子操作的指令间顺序不变,即保证产生的读写原子类型的变量的机器指令与代码编写者可拿到的是一致的。
处理器对原子操作的汇编指令的执行顺序不变。这对于x86这样的强顺序的体系结构而言没有任何问题;对于弱顺序的体系结构而言则需要编译器在每次原子操作后加入内存栅栏。
那么,C++11中,对于原子类型的成员函数(原子操作)总是保证了顺序一致性。这对于x86这些平台只需要禁止编译器对原子类型变量间的重排序优化。而对于PowerPC这样的平台来说,不仅禁止了编译器的优化,还加入了大量的内存栅栏。在C++11中总共定义了7中memory_order的枚举值:
枚举值 | 定义说明 |
---|---|
memory_order_relaxed | 不对执行顺序做任何保证 |
memory_order_acquire | 本线程中,所有后续的读操作必须在本条原子操作完成后执行 |
memory_order_release | 本线程中,所有之前的写操作完成后才能执行本条原子操作 |
memory_order_acq_rel | 同时包含memory_order_acquire和memory_order_release标记 |
memory_order_consume | 本线程中,所有后续的有关本原子类型的操作,必须在本条原子操作完成之后执行 |
memory_order_seq_cst | 全部存取都按顺序执行 |
// 内存模型_memory_order.cpp : 定义控制台应用程序的入口点。
//
#include "stdafx.h"
#include
#include
#include
using namespace std;
atomic<int> a;
atomic<int> b;
int Thread1(int)
{
int t = 1;
a.store(t, memory_order_relaxed);
b.store(2, memory_order_relaxed);
return 0;
}
int Thread2(int){
while (b.load(memory_order_relaxed) != 2); //自旋等待
cout << a.load(memory_order_relaxed) << endl;
return 0;
}
int _tmain(int argc, _TCHAR* argv[])
{
thread t1(Thread1, 0);
thread t2(Thread2, 0);
t1.join();
t2.join();
system("pause");
return 0;
}
上面的代码采用了memory_order_relaxed最为memory_order参数。那么排除顺序一致和松散两种方式,如何保证程序既快又对的运行呢?仔细分析上面的程序,发现只需要保证a.store先于b.store发生,b.load先于a.load发生就行。下面我们使用其他的memory_order实现这种原子操作:
// 内存模型_memory_order_1.cpp : 定义控制台应用程序的入口点。
//
#include "stdafx.h"
#include
#include
#include
using namespace std;
atomic<int> a;
atomic<int> b;
int Thread1(int)
{
int t = 1;
a.store(t, memory_order_relaxed); //不对执行顺序做任何保证
b.store(2, memory_order_release); //本原子操作前多有的写原子操作必须完成
return 0;
}
int Thread2(int){
while (b.load(memory_order_acquire) != 2); //本原子操作必须完成才能执行之后的所有读原子操作
cout << a.load(memory_order_relaxed) << endl; //不对执行顺序做任何保证
return 0;
}
int _tmain(int argc, _TCHAR* argv[])
{
thread t1(Thread1, 0);
thread t2(Thread2, 0);
t1.join();
t2.join();
system("pause");
return 0;
}
上面的代码中我们只是确定了代码执行的部分顺序。
这里面提供了一些时间处理的函数,可以和一些需要时间的参数的函数(wait_for
之类的)共同处理,也可以单独使用,比如获取时间:
auto t1 = chrono::system_clock::to_time_t(chrono::system_clock::now());
struct tm myt;
localtime_s(&myt, &t1);
printf("%d-%d-%d %d:%d:%d %d\n", myt.tm_year+1900, myt.tm_mon, myt.tm_mday, myt.tm_hour, myt.tm_min, myt.tm_sec, myt.tm_wday);
输出得到
2018-3-17 15:14:33 2