1.2 为什么使用并发

原因有二:分离关注点(SOC)和性能(或者可能是两个都有。当然,除了“我乐意”这样的原因)。

1.2.1 分离关注点

编写软件时,分离关注点是个好办法。通过将相关的代码与无关的代码分离,可以使程序更容易理解和测试,从而减少出错的可能。即使一些操作需要同时进行,依旧可以使用并发,分离不同的功能区域。若不显式地使用并发,就得编写一个任务切换机制,或者在操作中主动地调用一段不相关的代码。

假设有一个用户界面的处理密集型应用——DVD播放程序。这样的应用程序,应具备这两种功能:一,要从光盘中读出数据,对图像和声音进行解码,之后把解码出的信号输出至视频和音频硬件中进行处理,从而实现DVD的播放;二,接收来自用户的输入,当用户单击“暂停”、“返回菜单”或“退出”按键的时候执行对应的操作。当应用是单个线程时,应用需要在回放期间定期检查用户的输入,这就需要把“DVD播放”代码和“用户界面”代码放在一起。如果使用多线程方式来分离这些关注点,“用户界面”代码和“播放DVD”代码不需要放在一起:一个线程可以处理“用户界面”,另一个进行“播放DVD”。它们之间会有交互(用户点击“暂停”),不过任务需要人为的进行关联。

这会带来响应上的错觉,因为用户界面线程通常可以立即响应用户的请求,尽管当请求传递给工作中的线程时,其响应可能只是显示“忙碌中”的光标或“请等待”的消息。同理,独立的线程通常用来执行那些必须在后台持续运行的任务,例如:桌面搜索程序中监视文件系统变化的任务。因为交互清晰可辨,所以会使每个线程变的更加简单。

这种情况下,对线程的划分是基于概念上的设计,所以线程数不再依赖CPU核芯数。

1.2.2 性能

重核系统已经存在了几十年,直至现今,它们也只在超级计算机、大型机和大型服务器系统中才能看到。然而,芯片制造商越来越倾向于芯片的多核设计,即在单芯片上集成2、4、16或更多的处理器,从而获取更好的性能。因此,多核计算机、多核嵌入式设备,现在越来越普遍。它们计算能力的提高不是使单一任务运行的更快,而是并行多个任务。曾今,开发者无需做任何事,可以看着程序随着处理器的更新换代而变得更快。但是现在,如Herb Sutter所说的,“没有免费的午餐了。”[1] 如果想要利用日益增长的计算能力,那就必须设计多任务并发式软件,那些迄今都忽略并发的开发者们要上心了。

有两种利用并发来提高性能的方式:第一,将一个单个任务分成几部分并行运行,从而降低总运行时间,这就是任务并行(task parallelism)。虽然,这听起来很直观,但是一个相当复杂的过程,因为各个部分之间可能存在着依赖。区别可能是在过程方面——一个线程执行算法的一部分,而另一个线程执行算法的另一个部分——或是在处理数据——每个线程在不同的数据块上执行相同的操作(第二种方式)。后一种方法被称为数据并行(data parallelism)。

容易并行的算法称为是“易并行的”(embarrassingly parallel)。易并行算法具有良好的可扩展特性——当可用硬件线程的数量增加时,算法的并行性也会随之增加,这种算法很好的体现了“人多力量大”。如果算法中有不易并行的部分,可以把算法划分成固定(不可扩展)数量的并行任务。

并发提升性能的第二种方式,是利用并行来解决更大的问题:每次只处理一个文件,不如处理2个、10个或20个。虽然,这是数据并行的一种应用(通过对多组数据同时执行相同的操作),但着重点不同。处理等量数据仍然需要同样的时间,但现在在相同的时间内处理了更多的数据。这种方法也有限制,并非所有情况下都是有效的。不过,这种方法所带来的吞吐量提升,可以让某些功能成为可能——如果图片的不同区域能被并行地处理,程序就可以处理更高分辨率的视频。

1.2.3 什么时候不使用并发

知道何时不使用并发与知道何时使用一样重要。

不使用并发的唯一原因就是收益比不上成本。使用并发的代码在很多情况下难以理解,因此编写和维护多线程代码会产生脑力成本,而增加的复杂性也可能会引起更多的错误。除非潜在的性能增益足够大或关注点分离地足够清晰,能抵消为确保正确开发所需的额外时间,以及维护代码的额外成本;否则,勿用并发。

同样地,性能增益可能会小于预期。启动线程时存在固有开销,因为操作系统需要分配内核资源和堆栈空间,才能把新线程加入调度器中。如果在线程上的任务完成得很快,那么实际执行任务的时间要比启动线程的时间小很多,这会导致应用的整体性能不如直接使用单线程。

此外,线程的资源有限。如果太多的线程同时运行,则会消耗很多操作系统资源,从而使得操作系统整体上运行得更加缓慢。不仅如此,因为每个线程都需要一个独立的堆栈,所以运行太多的线程也会耗尽进程的可用内存或地址空间。对于一个可用地址空间为4GB(32bit)的架构来说,这的确是个问题:如果每个线程都有一个1MB的堆栈(很多系统都会这样分配),那么4096个线程将会用尽所有地址空间(不会给代码、静态数据或者堆数据留有任何空间)。即便是64位(或者更大)的系统,不存在这种地址空间限制,但其他资源也是有限的:如果你运行了太多的线程,也会出问题。尽管线程池(参见第9章)可以用来限制线程的数量,但也并不是什么灵丹妙药。

当客户端/服务器(C/S)应用在服务端为每一个链接启动一个独立的线程时,对于少量链接没有问题,但当用于需要处理大量链接的高需求服务器时,就会因为线程太多而耗尽系统资源。这种场景下,使用线程池可以对性能进行优化(参见第9章)。

最后,运行越多的线程,操作系统就需要越多的上下文切换,每一次切换都需要耗费时间。所以在某些时候,增加线程实际上会降低应用的整体性能。如果试图得到系统的最佳性能,可以考虑使用硬件并发(或不用),并调整运行线程的数量。

和所有其他优化策略一样,我们为了性能而使用并发:它可以大幅度提高应用的性能,但也可能让代码更加复杂,难以理解,并且更容易出错。因此,应用中只有性能关键部分,才值得并发化。当然,如果性能收益仅次于设计清晰或分离关注点,也可以使用多线程。

既然已经看到了这里,那无论是为了性能、关注点分离,亦或是因为多线程星期一(multithreading Monday)(译者:可能是学习多线程的意思),你应该确定要在应用中使用并发了。

好!那对于C++开发者来说,多线程意味着什么呢?


[1] “The Free Lunch Is Over: A Fundamental Turn Toward Concurrency in Software,” Herb Sutter, Dr. Dobb’s Journal, 30(3), March 2005. http://www.gotw.ca/publications/concurrency-ddj.htm.