1.2.1 为了分离关注点(SOC)
编写软件时,分离关注点是个好主意。通过将相关的代码与无关的代码分离,可以使程序更容易理解和测试,从而减少出错的可能性。即使一些功能区域中的操作需要在同一时刻发生的情况下,依旧可以使用并发分离不同的功能区域;若不显式地使用并发,就得编写一个任务切换框架,或者在操作中主动地调用一段不相关的代码。
考虑一个有用户界面的处理密集型应用——DVD播放程序。
这样的应用程序,应具备这两种功能:
一,要从光盘中读出数据,对图像和声音进行解码,之后把解码出的信号输出至视频和音频硬件,从而实现DVD的无误播放;(读数据解码
)
二,还需要接受来自用户的输入,当用户单击“暂停”、“返回菜单”或“退出”按键的时候执行对应的操作。当应用是单个线程时,应用需要在回放期间定期检查用户的输入,这就需要把“DVD播放”代码和“用户界面”代码放在一起,以便调用。如果使用多线程方式来分隔这些关注点,“用户界面”代码和“DVD播放”代码就不再需要放在一起:一个线程可以处理“用户界面”事件,另一个进行“DVD播放”。它们之间会有交互(用户点击“暂停”),不过任务间需要人为的进行关联。(处理用户交互
)
这会给响应性带来一些错觉,因为用户界面线程通常可以立即响应用户的请求,在当请求传达给忙碌线程,这时的相应可以是简单地显示代表忙碌的光标或“请等待”字样的消息。类似地,独立的线程通常用来执行那些必须在后台持续运行的任务,例如:桌面搜索程序中监视文件系统变化的任务。因为它们之间的交互清晰可辨,所以这种方式会使每个线程的逻辑变的更加简单。
(关注点分离,对线程的划分是基于概念上的涉及)
这种情况下,线程的数量不再依赖CPU中的可用内核的数量,因为对线程的划分是基于概念上的设计
,而不是一种增加吞吐量的尝试。
1.2.2 为了性能
多处理器系统已经存在了几十年,但直到最近,它们也只在超级计算机、大型机和大型服务器系统中才能看到。然而,芯片制造商越来越倾向于多核芯片的设计,即在单个芯片上集成2、4、16或更多的处理器,从而获取更好的性能。因此,多核台式计算机、多核嵌入式设备,现在越来越普遍。它们计算能力的提高不是源自使单一任务运行的更快,而是并行运行多个任务。在过去,程序员曾坐看他们的程序随着处理器的更新换代而变得更快,无需他们这边做任何事。但是现在,就像Herb Sutter所说的,“没有免费的午餐了。”[1] 如果想要利用日益增长的计算能力,那就必须设计多任务并发式软件。程序员必须留意这个,尤其是那些迄今都忽略并发的人们,现在很有必要将其加入工具箱中了。两种方式利用并发提高性能:第一,将一个单个任务分成几部分,且各自并行运行,从而降低总运行时间。 这就是任务并行(task parallelism)
。虽然这听起来很直观,但它是一个相当复杂的过程,因为在各个部分之间可能存在着依赖。
区别可能是在过程方面——一个线程执行算法的一部分,而另一个线程执行算法的另一个部分——或是在数据方面——每个线程在不同的数据部分上执行相同的操作(第二种方式)。 后一种方法被称为数据并行(dataparallelism)
。
第一种可并行执行的算法常被称为易并行(embarrassingly parallel)算法
。尽管易并行算法的代码会让你感觉到头痛,但这对于你来说是一件好事:我曾遇到过自然并行(naturallyparallel)和便利并发(conveniently concurrent)的算法。易并行算法具有良好的可扩展特性——当可用硬件线程的数量增加时,算法的并行性也会随之增加。这种算法能很好的体现人多力量大。如果算法中有不易并行的部分,你可以把算法划分成固定(不可扩展)数量的并行任务。
第8章和第10章将会再来讨论,在线程之间划分任务的技巧。
第二种方法是使用可并行的方式,来解决更大的问题
;与其同时处理一个文件,不如酌情处理2个、10个或20个。虽然,这是数据并行的一种应用(通过对多组数据同时执行相同的操作),但着重点不同。处理一个数据块仍然需要同样的时间,但在相同的时间内处理了更多的数据。
(方法2缺点)
当然,这种方法也有限制,并非在所有情况下都是有益的 。不过,这种方法所带来的吞吐量提升,可以让某些新功能成为可能,例如:并行处理图片就能提高视频的分辨率。
1.2.3 什么时候不使用并发
知道何时不使用并发与知道何时使用它一样重要。
基本上,不使用并发的唯一原因就是收益比不上成本。
具体:
使用并发的代码在很多情况下难以理解,因此编写和维护的多线程代码就会直接产生脑力成本,同时额外的复杂性也可能引起更多的错误
。除非潜在的性能增益足够大或关注点分离地足够清晰,能抵消所需的额外的开发时间以及与维护多线程代码相关的额外成本(代码正确的前提下);否则,勿用并发。- 同样地,
性能增益可能会小于预期
;因为操作系统需要分配内核相关资源和堆栈空间,所以在启动线程时存在固有的开销,然后才能把新线程加入调度器中,这都需要时间。如果在线程上的任务完成得很快,那么实际执行任务的时间要比启动线程的时间小很多,这就会导致应用程序的整体性能不如直接使用“产生线程”的方式。 - 此外,
线程的资源有限
。如果太多的线程同时运行,则会消耗很多操作系统资源,从而使得操作系统整体上运行得更加缓慢。不仅如此,因为每个线程都需要一个独立的堆栈空间,所以运行太多的线程也会耗尽进程的可用内存或地址空间。对于一个可用地址空间为4GB(32bit)的架构来说,这的确是个问题:如果每个线程都有一个1MB的堆栈(很多系统都会这样分配),那么4096个线程将会用尽所有地址空间,不会给代码、静态数据或者堆数据留有任何空间。
即便64位(或者更大)的系统不存在这种直接的地址空间限制,但其他资源有限:如果你运行了
太多的线程,最终也是出会问题的。尽管线程池(参见第9章)可以用来限制线程的数量,但这
也并不是什么灵丹妙药,它也有自己的问题。当客户端/服务器(C/S)应用在服务器端为每一个链接启动一个独立的线程,对于少量的链接是可以正常工作的,但当同样的技术用于需要处理大量链接的高需求服务器时,也会因为线程太多而耗尽系统资源。在这种场景下,使用线程池可以对性能产生优化(参见第9章)。 - 最后,
运行越多的线程,操作系统就需要越多的上下文切换,每一次切换都需要耗费本可以花在有价值工作上的时间
。所以在某些时候,增加一个额外的线程实际上会降低,而非提高应用程序的整体性能。为此,如果你试图得到系统的最佳性能,可以考虑使用硬件并发(或不用),并调整运行线程的数量。为性能而使用并发就像所有其他优化策略一样:它拥有大幅度提高应用性能的潜力,但它也可能让代码更加复杂,更难以理解,并且更容易出错。因此,应用中只有具有显著增益潜力的性能关键部分,才值得进行并发化。当然,如果性能收益的潜力仅次于设计清晰或关注点分离,可能也值得使用多线程。
既然你已经看到了这里,那无论是为了性能、关注点分离,亦或是因为多线程星期一(multithreading Monday)(译者:可能是学习多线程的意思),你应该确定要在应用中使用并发了。
好!那问题来了,对于C++开发者来说,多线程意味着什么呢?
1.3 C++中的并发和多线程
通过多线程为C++并发提供标准化支持,的确是件新鲜事。使用C++11标准,才能编写不依赖平台扩展的多线程代码。了解C++线程库中的众多规则前,先来了解一下其发展的历史。
1.3.1 C++多线程历史
C++98(1998)标准不承认线程的存在,并且各种语言要素的操作效果,都以顺序抽象的形式编写。不仅如此,内存模型也没有正式定义,所以在C++98标准下,在缺少编译器相关扩展的情况下,没办法编写多线程应用程序。
当然,编译器供应商可以自由地向语言添加扩展,添加C语言中流行的多线程API———POSIX标准中的C标准和Microsoft Windows API中的那些———这就使得很多C++编译器供应商通过各种平台相关扩展来支持多线程。这种编译器支持一般受限于只能使用平台相关的C语言API,并且该C++运行库(例如,异常处理机制的代码)能在多线程情况下正常工作。因为编译器和处理器的实际表现很不错了,所以在少数编译器供应商提供正式的多线程感知内存模型之前,开发者们已经写了很多的C++多线程程序了。
由于不满足于使用平台相关的C语言API来处理多线程,C++程序员们希望使用的类库,能提供面向对象的多线程工具。像MFC这样的应用框架,如同Boost和ACE这样的已积累了多组类的通用C++类库,这些类封装了底层的平台相关API,提供简化任务的高级多线程工具。各种类和库在细节方面差异很大,但在启动新线程的方面,却大同小异。一个为C++类和库共有的设计,同时也是为程序员提供很大便利的设计,也就是使用带锁的获取资源即初始化(RAII,Resource Acquisition Is Initialization)的习惯,即当退出相关作用域时互斥元解锁。编写多线程代码需要坚实的编程基础,当前的很多C++编译器为多线程编程者提供了对应(平台相关)的API;当然,还有一些与平台无关的C++类库(例如:Boost和ACE)。正因为如此,开发者们可以通过这些API来实现多线程。不过,由于缺乏统一标准的支持,以及统一的线程内存模型,从而会导致一些问题,这些问题在跨硬件或跨平台相关的多线程应用上表现得尤为明显
1.3.2 新标准支持并发
所有的这些随着C++11标准的发布而改变,新标准中不仅有了一个全新的线程感知内存模型,C++标准库也扩展了:包含了用于管理线程(参见第2章)、保护共享数据(参见第3章)、线程间同步操作(参见第4章),以及低层原子操作(参见第5章)的各种类。
C++线程库很大程度上,是基于上文提到的C++类库的经验积累。特别是,Boost线程库作为新类库的主要模型,很多类与Boost库中的相关类有着相同名称和结构。随着C++标准的进步,Boost线程库也配合着C++标准在许多方面做出改变,因此之前使用Boost的用户将会发现自己非常熟悉C++11的线程库。
如本章起始提到的那样,支持并发仅是C++标准的变化之一,此外还有很多对于编程语言自身的改善,就是为了让程序员们的工作变得更加轻松。这些内容在本书的论述范围之外,但是其中的一些变化对于线程库本身及其使用方式产生了很大的影响。附录A会对这些特性做一些介绍。
1.3.3 C++14和C++17对并发和并行的更多支持
C++14中为并发和并行添加了一个新的互斥量类型,用于保护共享数据(参见第3章)。不过,在C++17就考虑的更多了:一开始就添加了一整套的并行算法(参见第10章)。两个标准将整个语言的标准库进行了补强,这也就让我们书写多线程代码更加的容易。
之前我们还提到了一个并发技术规范,其描述C++标准对于函数和类的扩展,尤其是对线程同步方面(参见第4章)。
C++新标准直接支持原子操作,允许开发者通过定义语义的方式编写高效的代码,从而无需了解与平台相关的汇编指令。这对于试图编写高效、可移植代码的程序员们来说是一个好消息;编译器不仅可以搞定具体平台,还可以编写优化器来解释操作语义,从而让程序整体得到更好的优化。
1.3.4 C++线程库的效率
通常情况下,这是高性能计算开发者对C++的担忧之一。为了效率,C++类整合了一些底层工具。
这样就需要了解相关使用高级工具和使用低级工具的开销差,这个开销差就是抽象代价(abstraction penalty)
。
C++标准委员会在设计标准库时,特别是标准线程库的时候,就已经注意到了这点;目的就是在实现相同功能的前提下,确保直接使用底层API并不会带来过多的性能收益。因此,该类库在大部分主流平台上都能实现高效(带有非常低的抽象代价)。
C++标准委员会为了达到终极性能,需要确保C++能给那些要与硬件打交道的程序员,提供足够多的的底层工具。为了这个目的,伴随着新的内存模型,形成了一个综合的原子操作库,可用于直接控制单个位、字节、内部线程间同步,以及对所有变化的可见性。原子类型和相应的操作现在可以在很多地方使用,而这些地方以前可能使用的是平台相关的汇编代码。使用了新标准的代码会具有更好的可移植性,而且更容易维护。
C++标准库也提供了更高级别的抽象和工具,使得编写多线程代码更加简单,并且不易出错。有时运用这些工具确实会带来性能开销,因为有额外的代码需要执行。但是,这种性能成本并不一定意味着更高的抽象代价;总体来看,这种性能开销并不比手工编写等效函数高,而且编译器可能会很好地内联大部分额外代码。
某些情况下,高级工具会提供一些额外的功能。大部分情况下这都不是问题,因为你没有为你不使用的那部分买单。在罕见的情况下,这些未使用的功能会影响其他代码的性能。如果你很看重程序的性能,并且高级工具带来的开销过高,你最好是通过较低级别的工具来实现你需要的功能。绝大多数情况下,额外增加的复杂性和出错几率都远大于性能的小幅提升带来的收益。即便是有证据确实表明瓶颈出现在C++标准库的工具中,也可能会归咎于低劣的应用设计,而非类库实现。例如,如果过多的线程竞争一个互斥单元,将会很明显的影响性能。与其在互斥操作上耗费时间,不如重新设计应用,减少互斥元上的竞争来得划算。如何减少应用中的竞争,会在第8章中再次提及。
C++标准库没有提供所需的性能或行为时,就需要使用与平台相关的工具。
1.3.5 平台相关的工具
虽然C++线程库为多线程和并发处理提供了较全面的工具,但在某些平台上提供额外的工具。为了方便地访问那些工具的同时,又使用标准C++线程库,在C++线程库中提供一个 native_handle() 成员函数,允许通过使用平台相关API直接操作底层实现。就其本质而言,任何使用 native_handle() 执行的操作都是完全依赖于平台的,这超出了本书(同时也是标准C++库本身)的范围。
所以,使用平台相关的工具之前,要明白标准库能够做什么,先通过下面一个例子来展示下吧
1.4 开始入门
ok!现在你有一个能与C++11/C++14/C++17标准兼容的编译器。接下来呢?一个C++多线程程序是什么样子呢?其实,它看上去和其他C++程序差不多,通常是变量、类以及函数的组合。唯一的区别在于某些函数可以并发运行,所以需要确保共享数据在并发访问时是安全的,详见第3章。当然,为了并发地运行函数,必须使用特定的函数以及对象来管理各个线程。
1.4.1 你好,并发世界
从一个经典的例子开始:一个打印“Hello World.”的程序。一个非常简单的在单线程中运行的
Hello World程序如下所示,当我们谈到多线程时,它可以作为一个基准。
#include <iostream> int main() { std::cout << "Hello World\n"; }
这个程序所做的就是将“Hello World”写进标准输出流。让我们将它与下面清单所示的简单的“Hello, Concurrent World”程序做个比较,它启动了一个独立的线程来显示这个信息。清单 1.1 一个简单的Hello, Concurrent World程序:
#include <iostream> #include <thread> // 1 void hello() // 2 { std::cout << "Hello Concurrent World\n"; } int main() { std::thread t(hello); // 3 t.join(); // 4 }
- 第一个区别是增加了 #include ①,标准C++库中对多线程支持的声明在新的头文件中:管理线程的函数和类在 中声明,而保护共享数据的函数和类在其他头文件中声明。
- 其次,打印信息的代码被移动到了一个独立的函数中②。因为每个线程都必须具有一个初始函数(initial function),新线程的执行从这里开始。对于应用程序来说,初始线程是main(),但是对于其他线程,可以在 std::thread 对象的构造函数中指定——本例中,被命名为t③的 std::thread 对象拥有新函数hello()作为其初始函数。
- 下一个区别:与直接写入标准输出或是从main()调用hello()不同,该程序启动了一个全新的线程来实现,将线程数量一分为二——初始线程始于main(),而新线程始于hello()。新的线程启动之后③,初始线程继续执行。如果它不等待新线程结束,它就将自顾自地继续运行到main()的结束,从而结束程序——有可能发生在新线程运行之前。这就是为什么在④这里调用 join() 的原因——详见第2章,这会导致调用线程(在main()中)等待与 std::thread 对象相关联的线程,即这个例子中的t。
这看起来仅仅为了将一条信息写入标准输出而做了大量的工作,确实如此——正如上文1.2.3节所描述的,一般来说并不值得为了如此简单的任务而使用多线程,尤其是在这期间初始线程并没做什么。本书后面的内容中,将通过实例来展示在哪些情景下使用多线程可以获得收益
总结
本章总结本章中,提及了并发与多线程的含义,以及在你的应用程序中为什么你会选择使用(或不使用)它。还提及了多线程在C++中的发展历程,从1998标准中完全缺乏支持,经历了各种平台相关的扩展,再到C++11/C++14/C++17标准和并发扩展技术规范对多线程逐渐支持。芯片制造商选择了以多核心的形式,使得更多任务可以同时执行的方式来增加处理能力,而不是增加单个核心的执行速度。在这个趋势下,C++多线程来的正是时候,它使得程序员们可以利用新的CPU,带来的更加强大的硬件并发。
使用1.4节中例子,展示C++标准库中的类和函数有多么的简单。C++中使用多线程并不复杂,复杂的是如何设计代码以实现其预期的行为。
尝试了1.4节的示例后,是时候看看更多实质性的内容了。
第2章中,我们将了解一下用于管理线程的类和函数。