最近看了《七周七并发模型》[1],对自己熟悉的C/C++并发编程有了很多新的思考。在Google上搜索“C C++ 并发 编程”,结果主要是Anthony的《C++ Concurrency in Action》以及零散的一些博文。Anthony的书主要是教授C++最基础的线程与锁模型和无锁编程的知识,但是其它的并发模型书中并未提及。线程与锁模型因其资料丰富“简单易学”被广大C/C++程序员所使用。该模型导致的死锁、饥饿等等问题也是大家很头痛的事情。实际上对于C/C++并发模型,我们还有很多其它的选择,比如Actor、CSP、协程等,而这正是这个C/++并发编程系列要告诉大家的。开篇先说一下并发编程的基础知识,并发与并行的区别和C/C++多线程内存模型。
并发与并行的区别?
网络上有很多关于“并发”与“并行”的解释,大家比较认同的是Golang大神Rob Pike在“并发不是并行[3]”的技术分享上的解释:
Concurrency vs. parallelism
Concurrency is about dealing with lots of things at once.
Parallelism is about doing lots of things at once.
Not the same, but related.
Concurrency is about structure, parallelism is about execution.并发关乎结构,并行关乎执行。
Concurrency provides a way to structure a solution to solve a problem that may (but not necessarily) be parallelizable.并发提供了一种方式让我们能够设计一种方案将问题(非必须的)并行的解决。[2]
按我个人对以上的理解,“并行”和“并发”的区别,可以简单理解为“并行 = 并发执行”。不管是多线程程序、多进程程序,在设计和实现阶段应该称之为“并发”,而运行时应该称之为“并行”。可以类比我们熟悉的“程序 vs. 进程”,运行时的程序称之为进程。它们都是对同一个事物处在不同阶段/状态时的定义。
C/C++多线程内存模型
以前我认为内存模型和内存布局是一回事,比如Linux下ELF可执行文件格式,堆、栈、.data段、.text段等等。实际上ELF这样的内存布局格式是Linux操作系统对可执行程序的规范,不管用什么编程语言生成了直接(依赖运行时“虚拟机”的语言除外)可运行的程序,最终都是ELF的内存布局。而内存模型是编程语言和计算机系统(包括编译器,多核CPU等可能对程序进行乱序优化的软硬件)之间的契约,它规定了多个线程访问同一个内存位置时的语义,以及某个线程对内存位置的更新何时能被其它线程看见[4]。
在C11/C++11标准之前,C/C++语言没有内存模型的定义。在此期间,我们天真的认为程序是按顺序一致性(Sequential consistency)模型去运行的,而实际上编译器和多核CPU却是不满足顺序一致性模型的。Leslie在其论文[6]中定义了顺序一致性模型需要满足的两个条件:
Rl: Each processor issues memory requests in the order specified by its program.
R2: Memory requests from all processors issued to an individual memory module are serviced from a single FIFO queue. Issuing a memory request consists of entering the request on this queue.
条件“R1”可以理解为“单个线程内指令的执行顺序和代码的顺序是一致的”,而条件“R2”则让多线程的指令执行顺序从全局来看是“串行”执行的。现代CPU的缓存、流水线和乱序执行机制以及编译器的代码优化、重排都无法满足顺序一致性模型。所以,机器实际执行的代码并不是你写的代码[9]。
为了在性能和易编程性之间找到平衡,C++11提出了“sequential consistency for data race free programs”内存模型,即没有数据竞跑(data race)的程序符合顺序一致性。数据竞跑是指多个线程在没有同步的情况下去访问相同的内存位置[5]。所以,在C11/C++11后,我们只要对多线程之间需要同步的变量和操作,使用正确的同步原语进行同步,就能保证程序的执行符合顺序一致性。编译器、多核CPU能保证其优化措施不会破坏顺序一致性。
理论有些晦涩,我引用个例子说明。如下:
x = y = 0;
Thread1 Thread2
x = 1; y = 1;
r1 = y; r2 = x;
按照顺序一致性模型,会有以下5种可能的执行顺序
从分析来看是不会出现“r1 = 0,r2 = 0”的情况的。但是C11/C++11之前并未规定多线程内存模型,也没有多线程的标准库。pthread多线程库是按照“单线程执行模型(Single thread execution model)”来实现的。从编译器的角度来看,不存在什么多线程这样的东西,程序就是一个代码序列。只要编译优化措施不影响顺序执行的结果,就可以执行这项优化。比如下面这种优化:
6
Thread1 Thread2
r1 = y;
y = 1;
r2 = x;
x = 1;
r1 = 0,r2 = 0
Thread1内的“r1 = y”被换到了“x = 1”之前,这在C11/C++11标准之前是可能发生的。因为按单线程执行模型,“给x赋值1”与“读取y赋值给r1”是两个不相关的事情,调换执行顺序不影响最终结果。而对于C11/C++11标准来说,因为这段代码不存在数据竞跑,只要使用标准库提供的线程操作来实现,其执行就符合顺序一致性,不会优化出现“6”这种情况。
另外,C11/C++11标准还明确了“内存位置”的定义。
一个内存位置要么是标量,要么是一组紧邻的具有非零长度的位域。
两个线程可以互不干扰地对不同的内存位置进行读写操作
比如有如下的结构体:
struct
{
int a : 17;
int b : 15;
} x;
两个线程分别读写a和b,是否会互相干扰呢?毕竟CPU是按32/64位来取操作数的,而不是按17/15位来的。C11/C++11之前这样的操作是未定义的,按C11/C++标准规定a和b则属于同一个内存位置。两个线程分别对a、b进行读写操作是会相互干扰的,需要进行同步。或者将a、b分割成两个内存位置:
struct
{
int a : 17; // 内存位置1
int : 0;
int b : 15; // 内存位置2
} x;
这样编译器会自动自行内存对齐,保证两个线程分别读写a、b互不干扰。
参考
[1]《七周七并发模型》,Paul Butcher 著,黄炎 译
[2] 也谈并发与并行,Tony Bai
[3] Concurrency is not parallelism,Rob Pike
[4] 浅析C++多线程内存模型,Guancheng (G.C.)
[5] Race Condition vs. Data Race,John Regehr
[6] How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs,1979,Leslie Lamport
[7] 《C++0x漫谈》系列之:多线程内存模型,刘未鹏
[8] ISO/IEC JTC1 SC22 WG21 N3690,Programming Languages — C++
[9] C++ Memory Model,Valentin .etc
修订记录
2017-11-01 AM:从网易博客迁移到
2017-09-30 PM:完成初稿
版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)