文章目录
并行与并发
首先,并行表示多个任务同时执行,即多个任务处理单元可以同时处理多个任务;
并发则表示两个任务交替执行,以达到看似并行的目的,即不用等待上一个任务执行完再执行下一个任务;
C++11中的并行和并发实现
-
C++11中主要通过线程的方式作为并发编程的基础,需要包含thread头文件;可通通过get_id获得线程的Id;使用join加入一个线程;
-
在并发编程时,为了能够保持对内存某块区域操作的独占性,需要对该操作设置锁机制。C++11对临界区的保护可以通过在起开始部分创建一个互斥变量,通过其成员函数lock()来进行上锁,通过unlock来进行解锁;
-
发生死锁的四个必要条件:
-
互斥条件(一个资源每次只能被进程使用);
-
请求与保持条件(当一个进程因请求资源而发生阻塞时,其对以获得的资源保持不放);
-
不剥夺条件(进程已获得的资源,在未使用完之前,不能强行进行剥夺);
-
循环等待条件(若干进程呢形成一种头尾相接的资源等待关系);
所以,要想避免发生死锁,需要为资源设定锁机制,确立资源的合理分配算法,避免进程永久占据系统资源;
-
-
一般在进行并发编程时,使用RAII语法的模板类std::lock_guard和std::unique_lock来实现对临界区的锁机制;如下代码所示:
// 并发编程测试 int v = 1; void critical_section(int change_v) { static std::mutex mtx; //std::lock_guard<std::mutex> lock(mtx); //保证其下的对象在生命周期结束时被销毁,自动调用unlock() std::unique_lock<std::mutex> lock(mtx); v = change_v; std::cout << v << std::endl; lock.unlock(); //开始另一组竞争操作 v += 1; std:: cout << v << std::endl; } int main() { std::thread t1(critical_section, 2), t2(critical_section, 3); t1.join(); t2.join(); //std::cout << v << std::endl; std::cout << "end."; return 0; }
需要注意的是,std::lock_guard无法显示地调用lock和unlock,其在临界区中地对象销毁时自动调用unlock,所以在使用时最好为临界区加上块符号{…临界区…},这样可以使得其保护的区域仅限于临界区内部;而std::unique_lock则可以显示调用lock()和unlock()函数,更加灵活;
-
期物(future)
-
当我们需要在线程A中启动一个线程B时,一般在启动B之后A还是会干自己的事,然后等待B将结果返回到全局变量中,并向A发送一个事件,A需要结果时调用一个线程等待函数来获得全局变量中地结果即可。
-
在C++11中,可以通过future来实现。首先,我们将任务封装在packaged_task中,然后通过该task的get_future函数返回一个future对象result,即在一个线程中执行task,然后将该task传递给thread,代码为std::thread(std::move(task)).detach();
-
然后启动屏障,result.wait(),等待future的完成;之后就可以实施线程的同步;
代码如下:
int main() { std::packaged_task<int()> task([] {return 7; }); //获得task地future, std::future<int> result = task.get_future(); std::thread(std::move(task)).detach(); std::cout << "waiting ..."; result.wait(); std::cout << "done" << std::endl << "future result is " << result.get() << std::endl; std::cout << "end."; return 0; }
-
-
条件变量
-
条件变量std::condition_variable可以用于解决死锁,当线程需要等待某个条件为真才能继续运行时,如果在一个忙等循环中,可能所有的线程都无法进行临界区,这是需要有一种机制能够唤醒等待的线程而避免死锁,std::condition_variable的notify_one()用于唤醒一个线程,notify_all()则可以通知所有的线程;
-
例子:
int main() { std::queue<int> produced_nums; std::mutex mtx; std::condition_variable cv; bool notified = false; //通知信号 auto producer = [&]() { for (int i = 0;; ++i) { std::this_thread::sleep_for(std::chrono::milliseconds(900)); std::unique_lock<std::mutex> lock(mtx); std::cout << "producing " << i << std::endl; produced_nums.push(i); notified = true; cv.notify_all(); //也可用notify_one } }; auto consumer = [&]() { while (true) { std::unique_lock<std::mutex> lock(mtx); while (!notified) { //避免虚假唤醒 cv.wait(lock); //锁住lock直到被通知才放开lock } lock.unlock(); //短暂取消锁,使得生产者有机会在消费者消费完之前继续生产 std::this_thread::sleep_for(std::chrono::milliseconds(1000));//消费慢于生产 lock.lock(); while (!produced_nums.empty()) { std::cout << "consuming " << produced_nums.front() << std::endl; produced_nums.pop(); } notified = false; } }; //在不同的线程中执行 std::thread p(producer); std::thread cs[2]; for (int i = 0; i < 2; ++i) { cs[i] = std::thread(consumer); } p.join(); for (int i = 0; i < 2; ++i) { cs[i].join(); } return 0; }
-
polo
-
-
原子操作与内存模型
-
首先,一下面这段代码说明问题
int main() { int a = 0; int flag = 0; std::thread t1([&]() { while (flag != 1) { std::cout << "before b=a, a = " << a << std::endl; std::cout << "before b=a, flag = " << flag << std::endl; int b = a; std::cout << "b: " << b << std::endl; std::cout << "after b=a, a = " << a << std::endl; std::cout << "after b=a, flag = " << flag << std::endl; } }); std::thread t2([&]() { a = 5; flag = 1; }); t1.join(); t2.join(); return 0; }
以上的代码运行结果如下所示:
>>before b=a, a = 0 before b=a, flag = 1 b: 5 after b=a, a = 5 after b=a, flag = 1 >>
由此可以看出,在进入while (flag != 1)循环时,首先执行的是flag=1,然后执行a=5, 再使用a对b赋值。可以看出,这并不是代码中的顺序,因为在多线程并行的时候,线程中的每一个操作并没有作出限制,其同步过程也没有做出规范。
-
std::mutex可以解决以上问题,这是一个操作系统级的功能,其实现通常包含两条原理:
-
提供线程间自动状态的转换,即“锁住”这个状态;
-
保障在互斥锁操作期间,所操作变量的内存与临界区外进行隔离;
上述条件是一组非常强的同步条件,其最终编译为cpu指令时表现为非常多的指令,但我们需要的就是一个原子级的操作。
-
-
在C++11中多线程共享变量的读写上,使用std::atomic模板,从此可以实例化一个原子类型,将一个原子类型读写操作从一组指令最小化到单个CPU指令;
std::atomic<int> counter;
其为整数或浮点数的原子模型提供了基本的数值成员函数,如fetch_add, fetch_sub等,并通过重载实现了对应的+和-。如下例所示:
int main() { std::atomic<int> counter = { 0 }; std::thread t1([&]() { counter.fetch_add(1); }); std::thread t2([&]() { counter++; counter += 1; }); t1.join(); t2.join(); std::cout << counter << std::endl; return 0; }
上述代码执行的结果为3,这符合我们的期望。因为线程每次对counter操作都是一个原子操作,别的线程无法操作;
-
但不是所有的类型都可以提供原子操作。其取决于CPU架构以及所实例化的类型结构是否满足该架构对内存对齐条件的要求,我们可以通过std::atomic::is_lock_free来检查该原子类型是否需要支持原子操作。例如:
int main() { struct A { float x; int y; long long z; }; std::atomic<A> a; std::cout << std::boolalpha << a.is_lock_free() << std::endl; return 0; }
其运行结果为false;
-
一致性模型
- 如果强行将多个线程之间的操作的某个变量声明为std::atomic,会使得在该变量处的操作为顺序执行的,由此拉低了程序运行的效率。所以,需要消弱这种原子操作在进程之间的同步条件;
- 将每个线程对应到一个集群节点,线程间的通信看作集群节点之间的通信。考虑四种方式消弱进程之间的同步条件:
- 线性一致性:即强一致性或原子一致性,任何一次读操作都能读到某个数据的最近一次写的数据,并且所有线程的操作顺序与全局时钟下顺序一致;
- 顺序一致性:任意一次读操作都读到数据最近一次的写入,但不要求与全局时钟的顺序一致;
- 因果一致性:因果关系操作得到保障,非因果关系的操作不做要求;
- 最终一致性:只保障某个操作在未来的某个时间节点上会被观察到,但不要求被观察到的时间;
-
内存顺序
- 为了实现各种强度要求的一致性,C++为原子操作定义了六种不同的内存顺序std::memory_order的选项,表达了四种多线程间的同步模型;
-