第 1 章 概述

第 1 章 概述

并发编程的概念并不新,却直到最近才火起来。一些编程语言,如Erlang、Haskell、Go、Scala、Clojure,也因对并发编程提供了良好的支持,而受到广泛关注。

并发编程复兴的主要驱动力来自于所谓的“多核危机”。正如摩尔定律1所预言的那样,芯片性能仍在不断提高,CPU的速度会继续提升,但计算机的发展方向已然转向多核化2

1http://en.wikipedia.org/wiki/Moore%27s_law

2作者在本章不断使用“core”“CPU”“processor”,译者在此尊重原文分别翻译成“核”“CPU”“处理器”。但译者认为此处指的都是广义的处理单元,而不是狭义的硬件。——译者注

Herb Sutter曾经说过:“免费午餐的时代已然终结。”3为了让代码运行得更快,单纯依靠更快的硬件已无法满足要求,我们需要利用多核,也就是发掘并行执行的潜力。

3http://www.gotw.ca/publications/concurrency-ddj.htm

1.1 并发还是并行?

本书的主题是“并发”,那么又为何涉及了“并行”呢?虽然两者有所关联又常被混淆,但并发并行的含义却是不同的。

一字之差也是差

并发程序含有多个逻辑上的独立执行块4,它们可以独立地并行执行,也可以串行执行。

4原文是“logical threads of control”,直译为“控制逻辑线程”,但在此语境下“控制”或“线程”指的并不是我们常见的“控制”和“线程”。为便于理解,在此将其译成“独立执行块”,这个概念来自于Google IO 2012的演讲“Go concurrency patterns”中引用的文档“Concurrency is not Parallelism”(http://tinyurl.com/goconcnotpar),其将这个概念称为“independently executing processes”。——译者注

并行程序解决问题的速度往往比串行程序快得多,因为其可以同时执行整个任务的多个部分。并行程序可能有多个独立执行块,也可能仅有一个。

我们还可以从另一种角度来看待并发和并行之间的差异:并发是问题域中的概念——程序需要被设计成能够处理多个同时(或者几乎同时)发生的事件;而并行则是方法域中的概念——通过将问题中的多个部分并行执行,来加速解决问题。

引用Rob Pike的经典描述5

5http://concur.rspace.googlecode.com/hg/talk/concur.html

并发是同一时间应对(dealing with)多件事情的能力;

并行是同一时间动手做(doing)多件事情的能力。

那么这本书讲述的是并发还是并行?

小乔爱问

并发?并行?

我妻子是一位教师。与众多教师一样,她极其善于处理多个任务。她虽然每次只能做一件事,但可以同时处理多个任务。比如,在听一位学生朗读的时候,她可以暂停学生的朗读,以维持课堂秩序,或者回答学生的问题。这是并发,但并不并行(因为仅有她一个人,某一时刻只能进行一件事)。

但如果还有一位助教,则她们中一位可以聆听朗读,而同时另一位可以回答问题。这种方式既是并发,也是并行。

假设班级设计了自己的贺卡并要批量制作。一种方法是让每位学生制作五枚贺卡。这种方法是并行,而(从整体看)不是并发,因为这个过程整体来说只有一个任务。

超越串行编程模型

并发和并行的共同点就是它们比传统的串行编程模型更优秀。 本书将同时涵盖并发和并行(学究可能会给这本书起名为“七周七并发模型和并行模型”, 不过那样的话,封面会变得很难看)。

并发和并行经常被混淆的原因之一是,传统的“线程与锁”模型并没有显式支持并行。如果要用线程与锁模型为多核进行开发,唯一的选择就是写一个并发的程序,并行地运行在多核上。

然而,并发程序的执行通常是不确定的,它会随着事件时序的改变而给出不同的结果。对于真正的并发程序,不确定性是其与生俱来且伴随始终的属性。与之相反,并行程序可能是确定的——例如,要将数组中的每个数都加倍,一种做法是将数组分为两部分并把它们分别交给一个核处理,这种做法的运行结果是确定的。用支持并行的编程语言可以写出并行程序,而不引入不确定性。

1.2 并行架构

人们通常认为并行等同于多核,但现代计算机在不同层次上都使用了并行技术。比如说,单核的运行速度现今仍能每年不断提升的原因是:单核包含的晶体管数量,如同摩尔定律预测的那样变得越来越多,而单核在位级和指令级两个层次上都能够并行地使用这些晶体管资源。

位级(bit-level)并行

为什么32位计算机的运行速度比8位计算机更快?因为并行。对于两个32位数的加法,8位计算机必须进行多次8位计算,而32位计算机可以一步完成,即并行地处理32位数的4字节。

计算机的发展经历了8位、16位、32位,现在正处于64位时代。然而由位升级带来的性能改善是存在瓶颈的,这也正是短期内我们无法步入128位时代的原因。

指令级(instruction-level)并行

现代CPU的并行度很高,其中使用的技术包括流水线、乱序执行和猜测执行等。

程序员通常可以不关心处理器内部并行的细节,因为尽管处理器内部的并行度很高,但是经过精心设计,从外部看上去所有处理都像是串行的。

而这种“看上去像串行”的设计逐渐变得不适用。处理器的设计者们为单核提升速度变得越来越困难。进入多核时代,我们必须面对的情况是:无论是表面上还是实质上,指令都不再串行执行了。我们将在2.2节的“内存可见性”部分展开讨论。

数据级(data)并行

数据级并行(也称为“单指令多数据”,SIMD)架构,可以并行地在大量数据上施加同一操作。这并不适合解决所有问题,但在适合的场景却可以大展身手。

图像处理就是一种适合进行数据级并行的场景。比如,为了增加图片亮度就需要增加每一个像素的亮度。现代GPU(图形处理器)也因图像处理的特点而演化成了极其强大的数据并行处理器。

任务级(task-level)并行

终于来到了大家所认为的并行形式——多处理器。从程序员的角度来看,多处理器架构最明显的分类特征是其内存模型(共享内存模型或分布式内存模型)。

对于共享内存的多处理器系统,每个处理器都能访问整个内存,处理器之间的通信主要通过内存进行,如图1-1所示。

{%}

图 1-1 共享内存的多处理器系统

对于分布式内存的多处理器系统,每个处理器都有自己的内存,处理器之间的通信主要通过网络进行,如图1-2所示。

{%}

图 1-2 分布式内存的多处理器系统

通过内存通信比通过网络通信更简单更快速,所以用共享内存编程往往更容易。然而,当处理器个数逐渐增多,共享内存就会遭遇性能瓶颈——此时不得不转向分布式内存。如果要开发一个容错系统,就要使用多台计算机以规避硬件故障对系统的影响,此时也必须借助于分布式内存。

1.3 并发:不只是多核

使用并发的目的,不仅仅是为了让程序并行运行从而发挥多核的优势。若正确使用并发,程序还将获得以下优点:及时响应、高效、容错、简单。

并发的世界,并发的软件

世界是并发的,为了与其有效地交互,软件也应是并发的。

手机可以同时播放音乐、上网浏览、响应触屏动作。我们在IDE中输入代码时,IDE正在后台悄悄检查代码语法。飞机上的系统也同时兼顾了好几件事情:监控传感器、在仪表盘上显示信息、执行指令、操纵飞行装置调整飞行姿态。

并发是系统及时响应的关键。比如,当文件下载可以在后台进行时,用户就不必一直盯着鼠标沙漏而烦心了。再比如,Web服务器可以并发地处理多个连接请求,一个慢请求不会影响服务器对其他请求的响应。

分布式的世界,分布式的软件

有时,我们要解决地理分布型问题。软件在非同步运行的多台计算机上分布式地运行,其本质是并发。

此外,分布式软件还具有容错性。我们可以将服务器一半部署在欧洲,另一半部署在美国,这样如果一个区域停电就不会造成软件整体不可用。下面就介绍容错性6

6作者在此处用到了两个词:fault-tolerant和resilient,中文都译为“容错性”,但两者略有区别。由于这种微小的区别不会影响对本书的理解,因此之后的译文不再区分两者,统一使用“容错性”以方便读者理解。——译者注

不可预测的世界,容错性强的软件

软件有bug,程序会崩溃。即使存在完美的没有bug的程序,运行程序的硬件也可能出现故障。

为了增强软件的容错性,并发代码的关键是独立性故障检测。独立性是指一个故障不会影响到故障任务以外的其他任务。故障检测是指当一个任务失败时(原因可能是任务崩溃、失去响应或硬件故障),需要通知负责故障处理的其他任务来处理。

串行程序的容错性远不如并发程序。

复杂的世界,简单的软件

如果曾经花费数小时纠结在一个难以诊断的多线程bug上,那你可能很难接受这个结论,但在选对编程语言和工具的情况下,比起串行的等价解决方案,一个并发的解决方案会更简洁清晰。

在处理现实世界的并发问题时,这个结论可以得到印证。用串行方案解决一个并发问题往往需要付出额外的代价,而且解决方案会晦涩难懂。如果解决方案有着与问题类似的并发结构,就会简单许多:我们不需要创建一个复杂的线程来处理问题中的多个任务,只需要用多个简单的线程分别处理不同的任务即可。

1.4 七个模型

本书精心挑选了七个模型来介绍并发与并行。

线程与锁:线程与锁模型有很多众所周知的不足,但仍是其他模型的技术基础,也是很多并发软件开发的首选。

函数式编程:函数式编程日渐重要的原因之一,是其对并发编程和并行编程提供了良好的支持。函数式编程消除了可变状态,所以从根本上是线程安全的,而且易于并行执行。

Clojure之道——分离标识与状态:编程语言Clojure是一种指令式编程和函数式编程的混搭方案,在两种编程方式上取得了微妙的平衡来发挥两者的优势。

actor:actor模型是一种适用性很广的并发编程模型,适用于共享内存模型和分布式内存模型,也适合解决地理分布型问题,能提供强大的容错性。

通信顺序进程(Communicating Sequential Processes,CSP):表面上看,CSP模型与actor模型很相似,两者都基于消息传递。不过CSP模型侧重于传递信息的通道,而actor模型侧重于通道两端的实体,使用CSP模型的代码会带有明显不同的风格。

数据级并行:每个笔记本电脑里都藏着一台超级计算机——GPU。GPU利用了数据级并行,不仅可以快速进行图像处理,也可以用于更广阔的领域。如果要进行有限元分析、流体力学计算或其他的大量数字计算,GPU的性能将是不二选择。

Lambda架构:大数据时代的到来离不开并行——现在我们只需要增加计算资源,就能具有处理TB级数据的能力。Lambda架构综合了MapReduce和流式处理的特点,是一种可以处理多种大数据问题的架构。

以上每种模型都有各自的甜区7。请带着以下的问题来阅读之后的章节。

7球类运动中球拍上最适合击球的区域。 ——译者注

  • 这个模型适用于解决并发问题、并行问题,还是两者皆可?

  • 这个模型适用于哪种并行架构?

  • 这个模型是否有利于我们写出容错性强的代码,或用于解决分布式问题的代码?

下一章将介绍第一个模型:线程与锁模型。

目录

  • 版权声明
  • 译者序
  • 推荐序
  • 前言
  • 致谢
  • 第 1 章 概述
  • 第 2 章 线程与锁
  • 第 3 章 函数式编程
  • 第 4 章 Clojure之道——分离标识与状态
  • 第 5 章 Actor
  • 第 6 章 通信顺序进程
  • 第 7 章 数据并行
  • 第 8 章 Lambda架构
  • 第 9 章 圆满结束
  • 参考书目