《C++ Concurrency In Action》读书笔记 - 如何设计并发代码
这本书前面几章介绍了C++并发编程中使用到的一些具体工具,掌握了这些具体工具并不能够保证你能够写出优秀的并发代码。
这章就是介绍如何设计你的并发代码、在写并发代码的时候需要考虑的一些因素和注意的地方。从一个更高的角度去思考如何写并发代码。
如何给线程分配工作
当我们起多个线程去做并行计算的时候我们需要把原来一个线程处理的数据分配给不同的线程。那么如何切分数据就有很多种选择:
简单粗暴的根据数量切分
比如有100个数据,我们起了m个线程那么每个线程处理100/m个数据。如果有必要的话最后将处理的结果进行合并。打个比方,我们现在要洗n个碗,那么我们可以把线程想象成洗碗工,如何给洗碗工分配工作,假设每个洗碗工洗碗速度差不多,那么只需要把盘子分成若干份分配给各个洗碗工即可。(当然你要有足够多的洗碗池)
递归的切分
有很多算法无法在一开始就很好的对数据进行切分,那么我们就可以用递归的方法。但是递归的话就不能暴力的去起线程,而是可以用std::async
让线程库去决定是否在另外线程执行。
按照任务类型进行切分
还是用厨房打比方,如果现在收到了一个订单需要做100份宫保鸡丁。如果按照第一种方法的话就是把100道分给n个厨师,每个厨师负责做100/n份。那么这个有什么问题呢?每个厨师都需要去洗菜、切菜、炒菜、装盘等。每一道工具之间会浪费厨师的时间,而且如果厨房不是足够大的话,厨师之间可能会出现竞争关系,比如说只有3个炒锅、那么有一些厨师可能需要排队才能用到炒锅。
现实中的厨房肯定不是这样的,每一个环节都是有专门的人负责的。洗菜工、打荷、厨师。这样流水线的工作可以提高效率。
按照任务类型的切分方法跟厨房的这个思想是一样的,每个线程都有自己的指责,接受输入处理相对独立的功能。
影响并发代码性能的因素
在说影响性能的因素之前,先来看一张表Latency Numbers Every Programmer Should Know
1 | Latency Comparison Numbers |
计算机的计算发生在CPU,而数据则来自内存、磁盘甚至网络上。从上表可以看到,CPU访问的数据来源对于它的访问速度差距巨大。从最快的L1缓存到网络访问,差距有3亿倍之巨。
下面要说的几点,基本上可以理解为需要经常从“很远的”读取数据。
线程数量
线程数量并非越多越好,当线程的数量太多,而且都有实际运算需要进行的时候。操作系统会进行调度来保证线程之间的公平,而切换线程的时候会出现上下文切换。也就是说要把当前线程相关的上下文存下来,然后把另一个线程的上下文载入,这回带来一定的开销。当线程数量增加的时候,带来的开销也就越来越大。
Data Contention(数据竞争)
可以理解为多个线程同时去操作一个内存,至少有一个去写这块内存。如果所有的操作都是只读的话就没有问题,因为数据从内存载入CPU以后会一直有效。但是,当其他核/CPU去操作这块内存的时候,载入的数据就会失效,当前的核就需要重新去内存中载入这块内存。
如果有多个核同时在操作某一个内存的时候,这将会是灾难性的。因为每一次写,其他所有用到这个数据的核都需要重新载入这块内存。而多个核频繁写的话,就会让用到数据的和核频繁去载入内存。而我们知道从内存读数据是很慢的。这个现象可以很形象的称为cache-pingpong。
False Sharing
CPU载入内存的时候是按照一个内存块载入的,而不是具体单个内存地址,这块内存又称为cache-line。这个机制会带来什么问题呢?如果我们需要的是一个小块数据,但是cache-line在如何很多我们不需要的数据。我们不需要不要紧,麻烦的是这些数据可能被其他线程用到而且需要修改,当他们改了这块内存以后我们就莫名的躺枪了。因为我们载入的这块cache-line失效了。
Data Proximity
如果你需要处理的数据不再同一个cache-line里面也会带来问题,就是你需要载入很多内存块去用到你的数据。
设计数据结构的时候需要注意什么
针对上面这几个可能会影响性能的地方,我们再设计数据结构的时候需要注意一下几点:
- 给不同线程分配数据的时候要考虑他们之间的数据不要靠的太近避免false sharing。
- 单个线程需要的数据尽量挨得近一点,从而不用去载入太多cache-line。
- 尽量减少线程对于数据的需求。
设计多线程代码其他注意事项
异常安全
如果你起的线程有未处理的异常,程序将会退出(C++调用std::terminate
)。当然你可以在线程代码内部处理掉异常,但是你需要将这个异常告诉外部从而外部能够知道错误状态来进行一些善后。
C++11里面可以用std::future
的方式自然地实现这个,因为在std::future::get
的时候如果线程有未处理的异常,会在调用的地方重新抛出来。
可扩展性
可扩展性的意思是当处理器数量增加的时候,程序能够在更短的时间内完成任务或者在相同时间内能够处理更多的数据。
一个程序内部可以分为两部分,一部分为串行部分,而另一部分则是可以被并行的部分。有一个Amdahl定律简单地描述了并发代码的性能,给出的公式如下:
$$ P = \dfrac{1}{f_s+\frac{1-f_s}{N}} $$
P为整体性能,$f_s$表示程序中串行的部分,N为处理器的数量。所以当试图优化代码的时候最好权衡一下优化能够带来的效益。应该让代码尽量的能够并行,这样能够充分利用好处理器增加带来的好处。另外如果串行的部分比例很大,比如说80%,那么优先去优化串行部分显然是比较划算的。
提高响应速度
在GUI应用中尽量将一些好使的操作放在非UI线程去做,这样子能够提高UI的响应速度从而提高用户体验。