原文地址:http://ifeve.com/concurrency-modle-seven-week-1/
并发编程虽不是新的概念,最近却逐渐热门起来。一些编程语言,如Erlang、Haskell、Go、Scala、Clojure,也因对并发编程提供了良好的支持,而受到广泛关注。
并发编程复兴的主要驱动力来自于所谓的“多核危机”。正如摩尔定律①所预言的那样,芯片性能仍在不断提高,但相比加快CPU的速度,计算机正在向多核化方向②发展。正如Herb Sutter所说,“免费午餐的时代已然终结”③。为了让代码运行得更快,单纯依靠更快的硬件已无法满足要求,我们需要利用多核,也就是发掘并行执行的潜力。
本书的主题是“并发”,那么又为何涉及了“并行”呢?虽然两者有所关联又常被混淆,但并发和并行的含义却是不同的。
一字之差也是差。一个并发程序含有多个逻辑上的独立执行块④,它们可以独立地并行执行,也可以串行执行。
一个并行程序解决问题的速度往往比一个串行程序快得多,因为其可以同时执行整个任务的多个部分。并行程序可能有多个独立执行块,也可能仅有一个。
我们还可以从另一种角度来看待并发和并行之间的差异:并发是问题域中的概念——程序需要被设计成能够处理多个同时(或者几乎同时)发生的事件;而并行则是方法域中的概念——通过将问题中的多个部分并行执行,来加速解决问题。
引用Rob Pike的经典描述①:
并发是同一时间应对(dealing with)多件事情的能力;并行是同一时间动手做(doing)多件事情的能力。
那么这本书讲述的是并发还是并行?
我妻子是一位教师。与众多教师一样,她极其善于处理多个任务。她虽然每次只能做一件事,但可以并发地处理多个任务。比如,在听一位学生朗读的时候,她可以暂停学生的朗读,以维持课堂秩序,或者回答学生的问题。这是并发,但并不并行(因为仅有她一个人,某一时刻只能进行一件事)。
但如果还有一位助教,则她们中一位可以聆听朗读,而同时另一位可以回答问题。这种方式既是并发,也是并行。
假设班级设计了自己的贺卡并要批量制作。一种方法是让每位学生制作五枚贺卡。这种方法是并行,而(从整体看)不是并发,因为这个过程整体来说只有一个任务。
并发和并行的共同点就是它们比传统的串行编程模型更优秀。 本书将同时涵盖并发和并行(学究可能会给这本书起名为“七周七并发模型和并行模型”, 不过那样的话本书的封面会变得很难看)。
并发和并行经常被混淆的一个原因是,传统的“线程与锁”模型并没有显式支持并行。如果要用线程与锁模型为多核进行开发,唯一的选择就是写一个并发的程序,让其并行地运行在多核上。
然而,并发程序通常是不确定的,它会随着事件时序的改变而给出不同的结果。对于一个真正的并发程序,不确定性是其与生俱来且伴随始终的属性。与之相反,并行程序可能是确定的——例如,要将数组中的每个数都加倍,一种做法是将数组分为两部分并把它们分别交给一个核处理,这种做法的运行结果是确定的。使用一门直接支持并行的编程语言可以写出并行程序,而不会引入不入不 确定性。
人们通常认为并行等同于多核,但现代计算机在不同层次上都使用了并行技术。比如说,单 核的运行速度现今仍能每年不断提升的原因是:单核包含的晶体管数量,如同摩尔定律预测的那 样变得越来越多,而单核在位级和指令级两个层次上都能够并行地使用这些晶体管资源。
位级(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所示。
通过内存通信比通过网络通信更简单更快速,所以用共享内存编程往往更容易。然而,当处 理器个数逐渐增多,共享内存就会遭遇性能瓶颈——此时不得不转向分布式内存。如果要开发一 个容错系统,就要使用多台计算机以规避硬件故障对系统的影响,此时也必须借助于分布式内存。
使用并发的目的,不仅仅是为了让程序并行运行从而发挥多核的优势。若正确使用并发,程序还将获得以下优点:及时响应、高效、容错、简单。
世界是并发的,为了与其有效地交互,软件也应是并发的。
一部手机可以同时播放音乐、上网浏览、响应触屏动作。我们在IDE中输入代码时,IDE正在后台悄悄检查代码语法。一架飞机上的系统也同时兼顾了好几件事情:监控传感器、在仪表盘上显示信息、执行指令、操纵飞行装置调整飞行姿态。
并发是系统及时响应的关键。比如,当文件下载可以在后台进行时,用户就不必一直盯着鼠标沙漏而烦心了。再比如,Web服务器可以并发地处理多个连接请求,一个慢请求不会影响服务器对其他请求的响应。
有时,我们要解决地理分布型问题。软件在非同步运行的多台计算机上分布式地运行,其本质是并发。
此外,分布式软件还具有容错性。我们可以将服务器一半部署在欧洲,另一半部署在美国,这样如果一个区域停电就不会造成软件整体不可用。下面就介绍容错性1。
1. 作者在此处用到了两个词:”fault-tolerant”和”resilient”,中文都译为”容错性”,但两者略有区别。由于这种微小的区别不会影响对本书的理解,因此之后的译文不再区分两者,统一使用”容错性”以方便读者理解。——译者注
软件有bug,程序会崩溃。即使存在完美的没有bug的程序,运行程序的硬件也可能出现故障。
为了增强软件的容错性,并发代码的关键是独立性和故障检测。独立性是指一个故障不会影响到故障任务以外的其他任务。故障检测是指当一个任务失败时(原因可能是任务崩溃、失去响应或硬件故障),需要通知负责故障处理的其他任务来处理。
串行程序的容错性远不如并发程序。
如果曾经花费数小时纠结在一个难以诊断的多线程bug上,那你可能很难接受这个结论,但在选对编程语言和工具的情况下,比起串行的等价解决方案,一个并发的解决方案会更简洁清晰。
在处理现实世界的并发问题时,这个结论可以得到印证。用串行方案解决一个并发问题往往需要付出额外的代价,而且解决方案会晦涩难懂。如果解决方案有着与问题类似的并发结构,就会简单许多:我们不需要创建一个复杂的线程来处理问题中的多个任务,只需要用多个简单的线程分别处理不同的任务即可。
本书精心挑选了七个模型来介绍并发与并行。
线程与锁:线程与锁模型有很多众所周知的不足,但仍是其他模型的技术基础,也是很多并发软件开发的首选。
函数式编程:函数式编程日渐重要的原因之一,是其对并发编程和并行编程提供了良好的支持。函数式编程消除了可变状态,所以从根本上是线程安全的,而且易于并行执行。
Clojure之道——分离标识与状态:编程语言Clojure是一种指令式编程和函数式编程的混搭方案,在两种编程方式上取得了微妙的平衡来发挥两者的优势。
actor:actor模型是一种适用性很广的并发编程模型,适用于共享内存模型和分布式内存模型,也适合解决地理分布型问题,能提供强大的容错性。
通信顺序进程(Communicating Sequential Processes,CSP):表面上看,CSP模型与actor模型很相似,两者都基于消息传递。不过CSP模型侧重于传递信息的通道,而actor模型侧重于通道两端的实体,使用CSP模型的代码会带有明显不同的风格。
数据级并行:每个笔记本电脑里都藏着一台超级计算机——GPU。GPU利用了数据级并行,不仅可以快速进行图像处理,也可以用于更广阔的领域。如果要进行有限元分析、流体力学计算或其他的大量数字计算,GPU的性能将是不二选择。
Lambda架构:大数据时代的到来离不开并行——现在我们只需要增加计算资源,就能具有处理TB级数据的能力。Lambda架构综合了MapReduce和流式处理的特点,是一种可以处理多种大数据问题的架构。