本书是一本划时代的著作!在以下领域具有非常深远的意义:
.NET 开发领域——本书再次将.NET 开发人员进行了分层隔代。
高性能/多核/并发编程领域——本书让锁从此变成过去时。
程序员职业生涯领域——本书让普通程序员得以和数学进行接驳。
本书在.NET 开发领域中的意义
作为广州.NET 技术俱乐部主席、中国香港Azure/.NET 技术俱乐部创始人兼
主席、.NET 社区联盟建设者,我对大中华区的.NET 开发人员现状有一定的了解。
目前,虽然.NET 开发人员很多,但因为.NET 至今已存在了十几年且其自身进步
很快,更新换代相当频繁,以致广大.NET 技术人员产生了分层隔代。
在东莞.NET 俱乐部的活动上,有人问我:“活动在场的.NET 开发人员所在
的企业很少使用最新的.NET 技术,那么学习新技术的意义何在呢?”这个问题问
得相当好! 根据过往我所接触的世界500 强企业的经验来看,现状的确如此,相
当多的公司为了求稳而不会使用最新技术。但这些公司也在不断追求进步,比如
在2008 年他们不会使用最新的Visual Studio 2008 而是使用Visual Studio 2005,
但是到了2010 年他们看到Visual Studio 2008 已经稳定了,就会从Visual Studio
2005 升级到Visual Studio 2008。所以相当多的公司会因为求稳而落后时代一步,
但是为了追求进步也仅会落后时代一步。所以总体来讲,程序员学习新技术的需
求和压力依旧时刻存在。
在本书之前,.NET 使用锁和OOP 来解决并发问题,这在该领域是第一代技
术。有了本书之后,将会告别锁(如果要用一句话概括本书,就是坚决不用锁),
尽量使用FP 来实现并发从而提高性能,这就是第二代技术。有些读者可能认为
这代技术的分层隔代并不像前面所说的技术换代那么重要。对此我要提醒大家,
现在前端框架迅猛发展,业界流行前后端分离,无论是Java、Python 还是.NET
都逐渐丧失了在前端技术的话语权,战场转到了后端上,比如.NET Core 现在就
非常重视性能和微服务。所以本书所讲的技术换代将会像当年WPF 接替
WinForm,Angular/React/Vue 接替ASP.NET/WebForm 一样重要。
本书在高性能/多核/并发编程领域的意义
为了能够更好地翻译本书,我参阅了一些其他语言在高性能/多核/并发编程领
域的书籍和相关文章 (包括Java 和JavaScript),经过对比,我发现本书中所讲技
术的先进性十分值得其他语言学习。在实际工作中,大部分程序员还停留在使用
锁解决并发问题。在此我强烈建议这些程序员阅读本书来更新观念。
本书在程序员职业生涯领域的意义
本书在程序员职业生涯领域的意义是: 让普通程序员得以和数学进行接驳。
大多数程序员虽然都知道数学很重要,却没有在日常编程中体会到数学的存在,
导致怀疑数学与编程的关联性。人工智能的浪潮让程序员开始感觉到数学的重要
性。本书有不少的篇幅讲述了数学,而且所讲到的数学都十分基础,比如加法等,
还讲述了我们日常使用的编程知识和数学之间的关联,比如LINQ。
本书是一本划时代的著作,与其他同类书籍相比,本书具
有如下特色:
从实际问题出发,讲述使用对应技术和解决方案的原因。不少同类书籍
只是讲了FP 技术,但并没有讲透为什么要使用FP。
内容十分全面。不但包括FP 技术,还讲述了Dataflow 等非FP 技术。
第13 章专门介绍的实际解决方案和代码配方,可以马上用于实际工作中。
这是我所见到的第一本成规模、成系统地使用C#和F#混合编程的书籍。
这点体现了作者让技术为我所用,而不是我为技术所用的务实精神。
一本书的成功出版是众人辛勤的结晶。在此我要感谢清华大学出版社的编
辑们,感谢他们一直以来的耐心和支持。我要感谢我的父亲,他是这个世界上
永远支持我的人。我要特别感谢我的助手邓永林(一个年轻上进的.NET 和前端
程序员)、姚昂(热心的老程序员/前端架构师/广州架构师活动组织者)、简相辉
(资深软件开发工程师)、戚亚柱(广州.NET 俱乐部常务秘书/MatrixData 数据平
台高级工程师)、郑子铭(广州.NET 俱乐部副秘书长)/贺恩泽(中山大学软件工程
专业本科生)。还有很多朋友也给予我帮助和建议,虽然他们选择匿名,但我
在此一并深表谢意。
虽然在翻译过程中力求“信、达、雅”,但是囿于水平有限,错误和失误在
所难免,如有任何意见和建议,请不吝指正,我们将感激不尽。
叶伟民
函数式并发基础
过去,软件开发人员相信,随着时间的推移(硬件的发展),他们的程序运行
速度将比之前更快。由于硬件的改进,使程序能够随着硬件的每一代更新而提高
运行速度,多年来已被证明是正确的。
在过去这些年,硬件行业不停地发展。在2005 年之前,处理器的发展不断提
供更快的单核CPU,直到最终达到了戈登·摩尔预测的CPU 速度极限。计算机
科学家摩尔在1955 年预测,CPU 速度每两年会翻倍。他的这个预测,即摩尔定
律,被证明是正确的。十年之后,1965 年,晶体管的密度和速度达到了每18 个
月翻倍,直到达到技术无法前进的速度极限。摩尔定律有效持续了很多年(超出了
他所估计的有效持续时间)。
如今,单核处理器 CPU 几乎达到了光速,同时耗能产生了巨大的热量,这
种发热正是进一步改进(CPU 速度)的限制因素。
消失(现代晶体管的密度正在增加,在物理极限速度下提供并行机会)。多核架
构和并行编程模型的结合使摩尔定律得以延续!由于CPU 单核性能改进停滞不
前,开发人员通过采用多核架构和开发支持并发的软件来适应。
处理器革命已经开始。多核处理器设计的新趋势使并行编程成为主流。多核
处理器体系结构提供了更高效计算的可能性,但所有这些功能都需要开发人员做
额外的工作。如果程序员希望在代码中获得更高的性能,那么他们必须适应新的
设计模式,以最大限度地提高硬件利用率,通过并行和并发来利用多个内核。
在本章中,我们将通过研究并发的一些优点和编写传统并发程序的挑战来介
绍有关并发的一般信息。接下来,我们将介绍函数式范式概念,这些概念可以通
过使用简单且可维护的代码来克服传统的限制。在本章末尾,你将了解为什么并
发是一个有价值的编程模型,以及为什么函数式范式是编写并发程序的正确工具。
1.1 你将从本书中学到什么
在本书中,我将介绍在传统编程范式中编写并发多线程应用程序的注意事项
和挑战。我将探讨如何使用函数式范式成功地应对这些挑战和避免并发陷阱。接
下来,我将介绍在函数式编程中使用抽象来创建声明式、易于实现和高性能并发
程序的好处。在本书的整个过程中,我们将研究复杂的并发问题,为深入了解使
用函数式范式在.NET 中构建并发和可扩展程序提供必要的最佳实践。你将熟悉函
数式编程如何通过鼓励在线程之间传递不可变的数据结构来帮助开发人员支持并
发性,而不需要担心共享状态,同时避免副作用。在本书的结尾,你将掌握如何
使用C#和F#语言编写更模块化、更可读和更可维护的代码。在编写性能最佳、
代码行数更少的程序时,你将更高效、更熟练。最终,通过掌握的新技能,你将
拥有成为提供成功高性能解决方案专家所需的知识。
以下是你将学到的:
如何组合异步操作与任务并行库。
如何避免常见问题并对多线程和异步应用程序进行故障排除。
了解采用函数式范式的并发编程模型(函数式、异步、事件驱动、代理和
参与者的消息传递)。
如何使用函数式范式构建高性能并发系统。
如何以声明式风格表达和组合异步计算。
如何通过使用数据并行编程,以(简单)纯粹的方式无缝地加速顺序程序。
如何使用Rx 风格的事件流以声明方式实现反应式和基于事件的程序。
如何使用函数式并发集合构建无锁多线程程序。
如何编写可扩展、高性能和健壮的服务器端应用程序。
如何使用并发编程模式解决问题,例如“分叉/ 联合”(Fork/Join)、并行
聚合和分而治之。
如何使用并行流和并行Map/Reduce 实现来处理海量数据集。
本书假设你具有通用编程知识,但不具备函数式编程的知识。要在编码中应
用函数式并发,只需要函数式编程的概念子集,并且我将解释在此过程中需要了
解的内容。重点放在可以在日常编码体验中立即使用的内容,通过这种方式,你
将在较短的学习曲线中获得函数式并发的许多好处。
1.2 让我们从术语开始
我们先从共同点开始,本节定义了与本书主题相关的术语。在计算机编程中,
某些术语(如并发、并行和多线程)在同一上下文中使用,但具有不同的含义。由
于它们的相似性,将这些术语视为同一事物的倾向是常见的,但这是不正确的。
当对程序的行为进行推理变得很重要时,区分计算机编程术语是至关重要的。例
如,根据定义,并发是多线程的,但多线程不一定是并发的。你可以很容易地使
多核CPU 像单核CPU 一样工作,但不能反过来。
本节旨在就与本书主题相关的定义和术语建立共识。在本节结束时,你将了
解以下术语的含义:
顺序编程
并发编程
并行编程
多任务处理
多线程
1.2.1 顺序编程——一次执行一个任务
顺序编程是按照步骤逐一完成任务的行为。让我们举一个简单的例子,比如
在当地的咖啡店买一杯卡布奇诺。你先排队和单独的咖啡师一起下订单。咖啡师
负责接收订单并送饮料,一次只能制作一杯饮料,所以在购买之前你必须耐心地
排队等待。制作卡布奇诺需要研磨咖啡,冲煮咖啡,蒸牛奶,使牛奶起泡,把咖
啡和牛奶混合,所以在你拿到卡布奇诺咖啡之前需要更多的时间。图1.1 展示了
这一过程。
图1.1 是顺序工作的一个示例,一个任务必须在下一个任务之前完成。
这是一种很方便的方法,有一套明确的系统(逐步)指示,说明该做什么以及
何时做。在这个例子中,咖啡师在准备卡布奇诺时可能不会感到困惑,不会犯任
何错误,因为步骤清晰有序。逐步制作卡布奇诺的缺点是咖啡师必须在制作过程
中等待。要起泡时,咖啡师实际上是不活动的(被阻塞)。同样的概念也适用于顺
序和并发编程模型。如图1.2 所示,顺序编程涉及连续的、逐步有序的过程。过
程的执行是以一次一条指令的线性方式进行。
在命令式编程和面向对象编程(OOP)中,我们倾向于编写顺序行为的代码,
所有注意力和资源都集中在当前正在运行的任务上。我们通过依次执行一组有序
的语句来对程序进行建模和执行。
1.2.2 并发编程——同时运行多个任务
假设咖啡师更喜欢启动多个步骤并同时执行它们呢?这将使客户队列移动得
更快(并因此增加获得的小费)。例如,一旦咖啡被磨碎,咖啡师就可以开始冲泡
浓缩咖啡。在冲煮过程中,咖啡师可以下新的订单,也可以开始蒸牛奶和发泡牛
奶的过程。在这个例子中,咖啡师给人一种同时进行多个操作(多任务处理)的感
觉,但这只是一种幻觉。有关多任务处理的更多详细信息,请参见第1.2.4 节。事
实上,由于咖啡师只有一台浓缩咖啡机,他们必须停止一项任务才能启动或继续
另一项任务,这意味着咖啡师一次只能执行一项任务,如图1.3 所示。在现代多
核计算机中,这是对宝贵资源的浪费。
并发描述了同时运行多个程序或程序的多个部分的能力。在计算机编程中,
在应用程序中使用并发提供了实际的多任务处理,将应用程序分成多个独立的过
程,这些过程在不同的线程中同时(并发)运行。如果有多个CPU 内核可用,则可
以在单个CPU 内核中进行,也可以并行进行。通过异步或并行执行任务,可以提
高程序的吞吐量(CPU 处理计算的速度)和响应能力。例如,流式传输视频内容的
应用程序是并发的,因为它同时从网络读取数字数据,对其进行解压缩,并在屏
幕上更新其显示。
并发给人的印象是,这些线程是并行运行的,并且程序的不同部分可以同时
运行。但是在单核环境中,一个线程的执行会临时暂停并切换到另一个线程,如
图1.3 中的咖啡师所示。如果咖啡师希望同时执行多个任务来加速生产,那么必
须增加可用资源。在计算机编程中,这个过程称为并行。
1.2.3 并行编程——同时执行多个任务
从开发人员的角度看,当我们考虑这些问题时,“我的程序可以同时执行多项
操作吗?”或“我的程序如何更快地解决一个问题?”我们会想到并行。并行是
指同时在不同的内核上执行多个任务,以提高应用程序的速度。尽管所有并行程
序都是并发的,但我们(在前文)已经看到并非所有并发都是并行的。这是因为并
行取决于实际的运行时环境,并且需要硬件支持(多核)。并行只能在多核设备中
实现(见图1.4),是提高程序性能和吞吐量的手段。
回到咖啡店的例子,假设你是经理,希望通过加快饮料生产来减少客户的
等待时间。一个直观的解决方案是雇用第二名咖啡师建立第二个咖啡站。两名
咖啡师同时工作,客户的队列可以独立和并行处理,卡布奇诺的制作(见图1.5)
加快了。
生产没有被中断会带来性能上的好处。并行的目标是最大限度地利用所
有可用的计算资源;在这种情况下,两名咖啡师在不同的站点并行工作(多核
处理)。
当一个任务被拆分为多个独立的子任务,然后使用所有可用的核心来运行时,
就可以实现并行。在图1.5 中,多核机器(两个咖啡站)允许并行同时执行不同的任
务(两名忙碌的咖啡师)而不会中断。
计时的概念是同时并行执行操作的基础。在图1.6 这样的程序中,如果它们
可以一起执行(不管执行时间片是否重叠),那么操作是并发的;如果执行在时间
片上重叠(同时执行),那么这些操作是并行的。
并行和并发是相关的编程模型。并行程序也是并发的,但并发程序并不总是
并行的,并行编程是并发编程的子集。并发是指系统的设计,而并行则与硬件运
行环境有关。并发和并行编程模型直接取决于执行它们的本地硬件环境。
1.2.4 多任务处理——同时在一段时间内执行多个任务
多任务处理是同时在一段时间内执行多个任务的概念。我们对这个概念很熟
悉,因为我们在日常生活中一直都是多任务的。例如,在等待咖啡师为我们准备
卡布奇诺咖啡的时候,我们使用智能手机查看电子邮件或浏览新闻报道。我们同
时做两件事:等待和使用智能手机。
计算机的多任务处理是在计算机只有一个CPU 以共享同一计算资源来同时
执行许多任务的时代设计的。最初,将CPU 的时间切片,一次只能执行一个任务。
(时间片涉及协调多个线程之间执行的复杂调度逻辑)。调度(程序)允许线程在调度
不同的线程之前运行的时间量称为线程量子。CPU 是按时间切片的,在将执行上
下文切换到另一个线程之前,每个线程都可以执行一个操作。上下文切换是操作
系统处理多任务以优化性能的过程(见图1.7)。但是在单核计算机中,多任务处理
可能会因为线程之间的上下文切换而引入额外开销从而降低程序的性能。
多任务处理操作系统有两种类型:
协作式多任务处理系统。调度程序允许每个任务运行一直到完成,或者
显式地将执行控制权返回给调度程序(下一个任务被调度的前提是当前
任务主动放弃时间片,操作系统没有主动权)。
抢占式多任务系统(如Microsoft Windows)。由操作系统考虑任务的优先
级,并根据优先级来执行任务,一旦任务用完分配的时间,底层操作系
统将切换执行序列,将控制权交给其他任务。
过去十年中设计的大多数操作系统都提供了抢占式多任务处理。多任务
处理对于UI 响应非常有用,有助于避免在长时间后台操作期间冻结UI。
1.2.5 多线程性能调优
多线程是多任务概念的延伸,旨在通过最大化和优化计算机资源来提高
程序的性能。多线程是一种使用多个执行线程的并发形式。多线程意味着并
发,但并发并不一定意味着多线程。多线程使应用程序能够将特定任务显式
地细分为在同一进程中并行运行的各个线程。
线程是一个计算单元(一组独立的编程指令,用于实现特定的结果),操作系
统调度程序独立地执行和管理这些指令。多线程不同于多任务:与多任务不同,
多线程的线程是共享资源的。但是这种“共享资源”设计比多任务带来了更多的
编程挑战。我们将在稍后的1.4.1 节中讨论线程之间共享变量的问题。
并行和多线程编程的概念是密切相关的。但是与并行相比,多线程与硬件无
关,这意味着无论内核的数量多少,都可以执行多线程处理。并行编程是多线程
的超集。例如,可以通过在同一进程中共享资源以使用多线程来并行程序,但也
可以通过在多个进程中甚至在不同的计算机中执行计算来并行程序。图1.8 展示
了这些术语之间的关系。
总结:
顺序编程是指在一个CPU 时间片中执行的一组有序指令。
并发编程一次处理多个操作,不需要硬件支持(使用一个或多个内核)。
并行编程在多个CPU 或多个内核上同时执行多个操作。所有并行程序都
是并发的,同时运行的,但并非所有并发都是并行的。原因是并行只能
在多核设备上实现。
多任务同时执行来自不同进程的多个线程。多任务并不一定意味着并行
执行,只有在使用多个CPU 或多个内核时才能实现并行执行。
多线程扩展了多任务处理的思想。它是一种并发形式,它使用来自同一
进程的多个独立执行线程。根据硬件支持的不同,每个线程可以并发或
并行运行。
1.3 为什么需要并发
并发是生活中自然的一部分——就像人类一样,我们习惯于多任务处理。我
们可以一边喝咖啡一边看电子邮件,或者一边听我们最喜欢的歌曲的同时一边打
字。在应用程序中使用并发的主要原因是为了提高性能和响应能力,并实现低延
迟。常识是,如果一个人一个接一个地做两个任务,比两个人同时做同样的这两
个任务要花更长的时间。
应用程序也同样如此。问题是绝大多数应用程序都没有根据可用CPU 去均衡
分割任务来编写。计算机被用于许多不同的领域,如分析、金融、科学和医疗保
健。分析的数据量逐年增加,两个很好的例子就是谷歌和皮克斯。
2012 年,谷歌每分钟收到超过200 万条搜索查询;2014 年,这一数字翻了一
番。1995 年,皮克斯制作了第一部完全由电脑制作的电影《玩具总动员》。在计
算机动画中,必须为每个图像渲染无数的细节和信息,例如阴影和光照。所有这
些信息都以每秒24 帧的速度变化。在3D 电影中,信息变化的需求呈指数级增长。
《玩具总动员》的创作者们用100 台相连的双处理器机器来制作他们的电影,
并行计算的使用是必不可少的。皮克斯为《玩具总动员2》开发的工具使用了1400
台计算机处理器进行数字电影编辑,从而大大提高了数字质量。2000 年初,皮克
斯的计算机功率进一步增加,达到 3500 个处理器。16 年后,用于处理完全动画
电影的计算机功率达到了惊人的24 000 个内核。对并行计算的需求持续呈指数级
增长。
让我们考虑一个运行内核为N(任意数量)的处理器。在单线程应用程序中,只
运行了一个内核。多线程执行的同一应用程序将更快,并且随着对性能的需求增
长,对N 的需求也将增长,使得并行程序成为未来的标准编程模型选择。
如果你在一台多核计算机上运行一个没有考虑到并发的应用程序,那么你就
是在浪费计算机的生产力,因为应用程序在顺序处理过程中只能使用一部分可用
的计算机能力。在这种情况下,如果你打开任务管理器或任何CPU 性能计数器,
你会发现只有一个内核运行得很快,可能为100%,而所有其他内核未充分利用或
空闲。在具有8 个内核的计算机中,运行非并发程序意味着资源的总体使用率可
低至15%(见图1.9)。
这种对计算能力的浪费清楚地说明了顺序代码不是多核处理器的正确编程模
型。为了最大限度地利用可用的计算资源,Microsoft 的.NET 平台通过多线程来
提供代码的并行执行(能力)。通过使用并行,程序可以充分利用可用资源,如图
1.10 中的CPU 性能计数器所示,所有处理器内核都在高速运行,可能为100%。
因此,开发人员别无选择,只能接受这种演变,成为并行程序员。
并发编程的现状和未来
掌握并发来交付可扩展的程序已经成为一项必需的技能。实际上,编写正确
的并行计算程序可以节省时间和金钱。与不断地购买和添加未充分利用的昂贵硬
件来达到相同的性能水平相比,构建使用较少服务器提供的计算资源的可扩展程
序要便宜得多。此外,更多的硬件需要更多的维护和电力运行。
这是学习编写多线程代码的一个激动人心的时代,用函数式编程(Functional
Programming,FP)方法提高程序的性能是值得的。函数式编程是一种编程风格,
它将计算处理成表达式的求值,并避免状态更改和数据可变。由于不可变性是默
认的,并且添加了出色的组合和声明式编程风格,FP 使得编写并发程序变得很容
易。更多细节请见第1.5 节。
虽然在新的范式中思考有点让人不安,但学习并行编程的最初挑战很快就会
减少,对毅力的回报是无限的。你会发现打开Windows 任务管理器时的神奇和壮
观之处,并自豪地注意到,在代码更改后,CPU 使用率将会被最大化,使用率峰
值将为100%。
一旦你熟悉并适应了使用函数式范式编写高度可扩展的系统,就很难回到慢
速的顺序代码风格。
并发是下一个将主导计算机行业的创新,它将改变开发人员编写软件的方式。
业界软件需求的演变以及对通过非阻塞UI 提供出色用户体验的高性能软件的需
求将继续刺激并发的需求。随着硬件的发展,并发和并行显然是编程的未来。
1.4 并发编程的陷阱
并发和并行编程无疑有助于快速响应和快速执行给定的计算,但这种性能和
反应体验的提高是需要付出代价的。使用顺序编程,代码的执行走上了可预测和
确定性的快乐之路。相反,多线程编程需要承诺和努力才能实现正确性。另外,
对于同时运行的多个执行流的推理是困难的,因为我们习惯于按顺序思考。
开发并行程序的过程不仅仅是创建和生成多个线程,编写并行执行的程序要
求和需要深思熟虑的设计。在设计时应考虑以下问题:
● 如何使用并发和并行来达到令人难以置信的计算性能和高度响应的应用
程序?
● 如何充分利用多核计算机提供的性能?
● 如何在确保线程安全的同时协调对同一内存位置在线程之间的通信和访
问?(如果两个或多个线程同时尝试访问和修改数据或状态,而数据和状
态不会被破坏,则称为线程安全)。
● 如何确保程序确定地执行?
● 如何在不影响最终结果质量的情况下并行执行程序?
这些问题都不容易回答。但某些模式和技术可以帮助我们。例如,在存在副
作用[1]的情况下,计算的确定性将丢失,因为并发任务执行的顺序变得可变。显
而易见的解决方案是支持纯函数来避免副作用。你将在本书中学习这些技巧和
实践。
1.4.1 并发的危害
编写并发程序并不容易,在程序设计时必须考虑许多复杂的元素。在线程池
中创建新线程或将多个作业排队相对简单,但如何确保程序的正确性呢?当许多
线程不断访问共享数据时,你必须考虑如何保护其数据结构以保证其完整性。线
程应该不受其他线程的干扰自动地写入和修改内存位置。现实情况是,用命令式
编程语言编写的程序或具有值可以改变的变量的语言(可变变量)将始终容易受到
数据争用的影响,无论内存同步级别或所使用的并发库如何。
考虑并行运行的两个线程(线程1 和线程2)的情况,两者都试图访问和修改共
享值x,如图1.11 所示。线程1 修改变量需要多个CPU 指令:必须从内存中读取
该值,然后进行修改并最终写回内存。如果线程2 在线程1 写回更新值时尝试从
同一内存位置读取,则x 的值已更改。更确切地说,线程1 和线程2 可能同时读
取值x,然后线程1 修改值x 并将其写回内存,而线程2 也修改值x。结果是数据
损坏。这种现象称为竞态条件。
程序中可变状态和并行性的组合是出问题的同义词。命令式范式的解决方案
是在某一时刻通过锁定对多个线程的访问来保护可变状态。这种技术称为互斥,
因为一个线程对给定内存位置的访问会阻止此时其他线程的访问。由于多个线程
必须同时访问同一数据才能从这一技术中获益,因此计时的概念非常重要。通过
引入锁来同步多个线程对共享资源的访问,解决了数据损坏的问题,但也带来了
更多可能导致死锁的复杂性。见图1.12。
以下是并发危险列表,并附有简要说明。稍后,你将了解每种方法的更多详
细信息,并特别关注如何避免它们:
竞态条件是当多个线程同时访问共享可变资源(例如文件、图像、变量或
集合)时,会留下不一致的状态。数据损坏会导致程序不可靠和不可用。
当多个线程共享需要同步技术的争用状态时,性能下降是一个常见问题。
相互排斥锁(或互斥锁),顾名思义,通过强制并行运行的其他多个线程
暂停工作来防止代码进行通信和同步内存访问。锁的获取和释放会带来
性能损失,从而降低所有进程的速度。随着内核数量的增加,锁争用的
成本可能会增加。随着更多的任务被引入以共享相同的数据,与锁相关
的开销可能会对计算产生负面影响。第1.4.3 节说明了引入锁同步的后果
和开销。
死锁是一个由于使用锁引起的并发问题。当存在一个任务周期,其中每
个任务在等待另一个任务继续时都被阻塞,就会发生这种情况。因为所
有任务都在等待另一个任务执行某些任务,所以它们会无限期地被阻塞。
线程之间共享的资源越多,避免争用条件所需的锁就越多,出现死锁的
风险就越高。
在代码中引入锁会带来一个设计上的问题,组合的缺失。锁不组合。组
合通过将一个复杂的问题分解成更小的更容易解决的部分,然后将它们
粘在一起,来促进问题的解决。组合是FP 的一个基本原则。
1.4.2 共享状态的演变
现实世界中的程序需要在任务之间进行交互,例如交换信息以协调工作。如
果不共享所有任务都可以访问的数据,则无法实现此功能。处理这种共享状态是
与并行编程相关的大多数问题的根源,除非共享数据是不可变的或者每个任务都
有自己的数据副本。解决方案是保护所有代码不受这些并发问题的影响。没有任
何编译器或工具可以帮助你将这些同步基元锁放到代码中的正确位置,这完全取
决于你作为程序员的技能水平。
由于这些潜在的问题,编程社区已经在呼喊,作为回应,解决这些问题的库
和框架已经被写入和引入主流的面向对象语言(例如C#语言和Java)中,以提供并
发保护,而这些都不是语言最初设计的一部分。这种支持是一种设计修正,以命
令式和面向对象的通用编程环境中共享内存的存在为例。同时,函数式语言不需
要安全措施,因为FP 的概念能很好地映射到并发编程模型。
1.4.3 一个简单的真实示例:并行快速排序
排序算法通常用于科学计算,并且可能是科学计算中的一个性能瓶颈。这里
让我们讨论一个计算密集型的、对数组元素进行排序的算法(快速排序算法)的并
行版本。此示例旨在演示将顺序算法转换为并行版本时会遇到的陷阱,并指出在
代码中引入并行需要在做出任何决策之前进行额外的思考。否则,性能可能会产
生与预期相反的结果。
快速排序是一种分而治之的算法。它首先将一个大数组分成两个子数组,其
中一个子数组的所有数据都比另外一个子数组的所有数据都要小。然后,快速排
序可以递归地对子数组进行排序,并且易于并行化。它可以在数组上就地操作,
只需要少量额外的内存来执行排序。该算法由三个简单步骤组成,如图1.13 所示:
(1) 选择一个轴元素。
(2) 根据序列相对于轴元素的顺序将序列划分为子序列。
(3) 快速排序子序列。
递归算法,特别是基于分而治之形式的递归算法,是并行和计算密集型的理
想选择。
利用在.NET 4.0 发布之后引入的Microsoft 任务并行库(Task Parallel Library,
TPL)使得此类算法的并行更容易实现。使用TPL,可以划分算法的每个步骤,并
以并行、安全的方式执行每个任务。这是一个简单而直接的实现,但是必须注意
创建线程的深度,以避免添加比需要更多的任务。
要实现快速排序算法,请使用FP 语言F#,这可以使用其原生的递归性质。
这种实现背后的思想也可以应用于C#,通过具有可变状态的命令式风格的for 循
环方法来实现。C#不支持F#的优化的尾递归函数,因此当调用堆栈指针超出堆栈
约束时,存在引发堆栈溢出异常的危险。在第3 章中,我们将详细介绍如何克服
C#这一限制。
代码清单1.1 展示了F#版本的快速排序函数,该函数采用了分而治之策略。
对于每个递归迭代,选择一个轴点并使用它来划分整个数组。使用List.partition
API 围绕轴点对元素进行分区,然后对数据轴点两侧的列表进行递归排序。F#内
置了强大的数据结构操作支持。在这里,使用List.partition API 返回一个包含两个
列表的元组:一个满足断言,另一个不满足断言。
代码清单1.1 简单的快速排序算法
let rec quicksortSequential aList =
match aList with
| [] -> []
| firstElement :: restOfList ->
let smaller, larger =
List.partition (fun number -> number < firstElement) restOfList
quicksortSequential smaller @ (firstElement ::
➥ quicksortSequential larger)
在我的系统(8 个逻辑内核;2.2 GHz 主频)中,针对100 万个随机、未排序整数
的数组运行此快速排序算法平均需要6.5 秒。但当你分析这个算法设计时,并行化
的机会是显而易见的。在quicksortSequential 的末尾,你可以对用(fun number->
number
执行时间显著增加而不是减少。并行快速排序算法从每次运行平均6.5 秒变
成大约12 秒,整体处理时间已经放缓。这里的问题是算法过度并行化,每次对内
部数组进行分区时,都会生成两个新任务来并行化该算法。这种设计生成了太多
与可用内核相关的任务,这产生了并行化开销,在涉及并行递归函数的分而治之
算法中尤其如此。不要添加过多的任务,这一点很重要 。这个令人失望的结果证
明了并行化的一个重要特征:额外线程或额外处理的数量在帮助特定的算法实现
上存在固有的局限性。
为了实现更好的优化,可以在某个点之后停止递归并行化来重构先前的
quicksortParallel 函数。通过这种方式,算法的第一次递归仍将并行执行,直到最
深级别的递归,然后恢复为串行方法。这种设计保证了对内核的充分利用。此外,
并行化所增加的开销也大大降低了。
代码清单1.3 展示了这种新的设计方法,它考虑了递归函数运行的级别。如
果级别低于预定义阈值,则停止并行化。函数quicksortParallelWithDepth 有一个
额外的参数depth,其目的是减少和控制递归函数并行化的次数。depth 参数在每
个递归调用上都会递减,并创建新任务,直到此参数值达到零。在这里,将传递
Math.Log(float System.Enviroment.ProcessorCount, 2.) + 4 得到的值作为max depth。
这样可以确保每一级递归都会产生两个子任务,直到所有可用的内核都被登记
为止。
选择任务数量的一个相关因素是预测的任务运行时间的相似程度。在
quicksortParallelWithDepth 的情况下,任务的持续时间可能会发生很大变化,因为
轴点取决于未排序的数据。它们不一定会产生相同大小的片段。为了补偿任务的
大小不均衡,本示例中的计算depth 参数的公式将生成比内核数量更多的任务。
该公式将任务数量限制为内核数量的16 倍左右,因为任务数量不能超过2 ^ depth。
我们的目标是使快速排序工作负载均衡,并且不会启动超出所需要的任务。在每
次迭代(递归)期间启动Task,直到达到深度级别,从而使处理器饱和工作。
大多数情况下,快速排序会产生不均衡的工作负载,因为生成的片段大小不
相等。概念公式log2(ProcessorCount)+ 4 计算depth 参数,以限制和调整正在运行
的任务的数量,而不考虑任何具体情况。如果替换depth = log2(ProcessorCount)+4
并简化表达式,则会看到任务数是ProcessorCount 的16 倍。通过测量递归深度来
限制子任务的数量是一项非常重要的技术。
例如,在四核机器下,深度被计算如下:
depth = log2(ProcessorCount) + 4
depth = log2(2) + 4
depth = 2 + 4
结果是近似36~64 个并发任务,因为在每个迭代过程中,每个分支都会启动
两个任务,而这每个分支又会在每次迭代中加倍。通过这种方式,线程间分区的
总体工作对于每个内核都有了公平且合适的分布。
1.4.4 F#中的基准测试
你可以使用F# REPL(又称为F#交互式和性能分析器)执行快速排序,这是一个
运行部分目标代码的便利工具,因为它会跳过程序的编译步骤。REPL非常适合原
型设计和数据分析开发,因为它使编程过程更便利。另一个好处是内置的#time功
能,它可以切换性能信息的显示。启用后,F# Interactive会测量解释和执行代码每
个部分的实时、CPU时间和垃圾回收信息。
表1.1 对一个3GB 数组进行排序,启用64 位环境标志以避免(内存)大小限制。
它运行在一台有八个逻辑核心(四个具有超线程的物理内核)的计算机上。平均运
行10 次,表1.1 展示了执行时间 (以秒为单位)。
需要指出的是,对于少于100 个条目的小数组,由于创建和生成新线程的开
销,并行排序算法会比串行版本慢。即使你正确编写了一个并行程序,并发构造
函数引入的开销也可能会使程序运行时不堪重负,从而降低性能,导致与期望相
反的结果。因此,将原始顺序代码基准作为基线进行基准测试,然后继续测量每
个更改,以验证并行性是否有益,这一点非常重要。一个完整的策略应该考虑这
个因素,并且只有当数组大小大于一个阈值(递归深度),通常与核心数量相匹配,
之后默认返回到串行行为时,才采用并行。
1.5 为什么选择函数式编程实现并发
麻烦的是,所有有趣的并发应用程序基本上都涉及共享状态可变性的谨慎使
用和受控,例如画面实时状态、文件系统或程序的内部数据结构。因此,正确的
解决方案是提供允许共享状态部分的安全可变性的机制。
FP 是关于最小化和控制副作用的,通常被称为纯函数编程。FP 使用转换的
概念,其中函数创建值x 的副本,然后修改副本,使原始值x 保持不变并且可以
由程序的其他部分自由使用。它鼓励在设计程序时考虑是否需要可变性和副作用。
FP 允许可变性和副作用,通过使用方法以策略和显式方式来封装这些区域,将这
些区域与代码的其余部分隔离开来。
采用函数式范式的主要原因是为了解决多核时代存在的问题。高度并发的应
用程序(如Web 服务器和数据分析数据库)面临着几个体系结构问题。这些系统必
须具有可扩展性,以响应大量的并发请求,这将导致处理最大化资源争用和高调
度频率的设计挑战。此外,竞态条件和死锁很常见,这使得故障排除和调试代码
变得困难。
在本章中,我们讨论了一些特定于在命令式或OOP 中开发并发应用程序的常
见问题。在这些编程范式中,我们将对象作为基础构造来进行处理。但是在并发
化方面是相反的,处理对象在从单个线程程序传递到大规模并行化工作时是一个
具有挑战性且完全不同的场景,需要考虑一些注意事项。
针对这些问题的传统解决方案是同步对资源的访问,避免线程之间的争用。
但是这些解决方案是一把双刃剑,因为使用基元进行同步,如互斥锁,会导致可
能死锁或竞态条件。事实上,变量的状态可能会发生变化。在OOP 中,变量通常
表示一个容易随时间变化的对象。你永远不能依赖它的状态,因此必须检查它的
当前值以避免意外的行为(见图1.14)。
重要的是要考虑到采用FP 概念的系统组件将不再相互干扰,并且可以在不
使用任何锁定策略的情况下在多线程环境中使用它们。
使用共享可变变量和副作用函数来开发安全的并行程序需要程序员大量的努
力,他们必须做出关键的决策,通常以锁的形式来实现同步。通过函数式编程消
除这些基本问题的同时,还可以消除那些特定于并发性的问题。这就是为什么FP
可以成为一个优秀的并发编程模型的原因。在FP 的核心,变量和状态都不可变
且不能共享,并且函数可能没有副作用。
FP 是编写并发程序最合适的方式。尝试用命令式语言编写它们不仅困难,而
且还会导致难以发现、重现和修复的 bug。
你打算如何利用你可以利用的每一台计算机内核?答案很简单:拥抱函数式
范式!
函数式编程的好处
学习FP 有很大的好处,即使你不打算在不久的将来采用这种风格。不过,
如果没有立竿见影的好处,就很难说服别人把时间花在新的事情上。这些好处以
惯用语言特征的形式出现,这些特性一开始看起来很有颠覆性。然而,FP 是一种
范式,它将在短暂的学习曲线之后给你的程序带来巨大的编码能力和积极的影响。
在使用FP 技术的几周内,你将提高应用程序的可读性和正确性。
FP 在并发方面的优点包括:
不可变性——一种防止在创建后修改对象状态的设计。在FP 中,没有变
量赋值这个概念。一旦一个值与一个标识符相关联,它就不能更改。函
数式代码的定义就是不可变的。不可变对象可以在线程之间安全地传输,
从而带来极大的优化机会。由于没有互斥,不可变性消除了内存损坏(竞
态条件)和死锁的问题。
纯函数——它没有副作用,这意味着函数不会更改函数体之外的任何类
型的输入或数据。如果函数对用户是透明的,则称其为纯函数,并且它
们的返回值仅取决于输入参数。将相同的参数传递给纯函数,结果不会
改变,并且每个过程将返回相同的值,从而产生一致和预期的行为。
引用透明度——这个函数式的概念是指它的输出依赖它的输入,只映射
它的输入。换句话说,每次函数接收相同的参数时,结果都是相同的。
这个概念在并发编程中很有价值,因为表达式的定义可以用它的值替换,
并且具有相同的含义。引用透明度保证了一组函数可以以任意顺序并行
地进行计算,而不会改变应用程序的行为。
延迟计算——在FP 中是指按需检索函数的结果,或将大数据流的分析推
迟直到需要时。
可组合性——用于组合函数并从简单函数中创建更高级的抽象。可组合
性是消除复杂性的最强大工具,可让你定义和构建复杂问题的解决方案。
学习函数式编程允许你编写更多模块化、面向表达式和概念上简单的代码。
无论代码执行的线程数是多少,这些FP 资产的组合都可以让你了解你的代码在
做什么。
在本书的后面部分,你将学习应用并行化、绕过与可变状态和副作用等相关
问题的技术。这些概念的函数式范式方法旨在使用声明式编程风格简化和最大限
度地提高编码效率。
1.6 拥抱函数式范式
有时候,改变是困难的。通常,对自己的领域知识感到满意的开发人员缺乏
从不同角度看待编程问题的动力。学习任何新的程序范式都是困难的,需要时间
过渡到不同的开发风格。编程视角的改变需要思路和方法的改变,而不仅仅是学
习新编程语言的新代码语法。
从Java 语言到C#并不困难。从概念上讲,它们是一样的。从命令式范式转
变为函数式范式是一个困难得多的挑战。核心概念被替换,没有状态了,没有变
量了,没有副作用了。
但是你为改变范式所做的努力将带来巨大的回报。大多数开发人员都同意,
学习一门新语言会让你成为一名更好的开发人员,并把它比作一个医生规定每天
锻炼 30分钟的病人。病人知道锻炼的真正好处,但也知道每天锻炼意味着投入和
牺牲。
同样,学习一个新的范式并不难,但需要付出时间。我鼓励每个想成为更优
秀程序员的人考虑学习FP 范式。学习FP 就像坐过山车一样:学习时你会感到受
刺激,当你相信你理解了一个原则时,你就会急剧下降和尖叫,但这样的乘坐是
值得的。把学习FP 看成一次旅行,一次对你个人和职业生涯有保证回报的投资。
请记住,犯错误并培养技能以避免将来出现这些错误是学习的一部分。
在整个过程中,你应该会遇到难以理解的概念,并努力克服这些困难。思考
如何在实践中使用这些抽象的概念,来解决简单的问题。我的经验表明,你可以
通过使用一个真实的例子来找出一个概念的意图,从而突破一个心理障碍。本书
将介绍FP 应用于并发和分布式系统的好处。这是一条狭窄难行的道路,但另一
方面,你将会发现几个伟大的会在日常编程中使用的基本概念。我相信你会对如
何解决复杂问题有新的见解,并利用FP 的强大功能成为优秀的软件工程师。
1.7 为什么选择F#和C#进行函数式并发编程
本书的重点是开发和设计高度可扩展和高性能的系统,采用函数式范式来编
写正确的并发代码。这并不意味着你必须学习一门新语言,你可以使用你熟悉的
工具来应用函数式范式,例如多用途语言C#和F#。这些年来,这些语言中添加
了一些函数式特性,使你更容易地转向合并这种新范式。
解决问题的方法本质上是不同的,这就是选择这些语言的原因。这两种编程
语言都可以用非常不同的方式来解决同一个问题,这为选择适合该工作的最佳工
具提供了理由。有了全面的工具集,你可以设计一个更好、更简单的解决方案。
实际上,作为软件工程师,你应该把编程语言看作工具。
理想情况下,一个解决方案应该是C#和F#项目的结合,它们可以协同工作。
首先这两种语言都涵盖了不同的编程模型,可以选择各种工具用于开发,这点在
生产力和效率方面提供了巨大的好处。选择这些语言的另一个优点是可以混合使
用它们不同的并发编程模型支持。例如:
● 对于异步计算,F#提供了比C#更简单的模型,称为异步工作流。
● C#和F#都是强类型的多用途编程语言,支持包括函数式、命令式和OOP
技术等多种范式。
● 这两种语言都是.NET 生态系统的一部分,并派生出一组丰富的库,两种
语言都可以同等地使用这些库。
● F#是一种函数优先的编程语言,可以极大地提高工作效率。事实上,用
F#编写的程序往往更简洁,维护的代码更少。
● F#结合了函数式声明式编程风格的优点和命令式面向对象风格的支持。
这使你可以使用现有的面向对象和命令式编程技能来开发应用程序。
● 因为默认的不可变构造函数,F#拥有了一组内置的无锁数据结构。例如,
可区分的联合和记录类型。这些类型具有结构相等性,更容易比较,并且
不允许导致“信任”数据完整性的null。
● F#与C#不同,F#强烈反对使用null 值,也就是所谓的10 亿美元错误,
相反,它鼓励使用不可变的数据结构。null 引用缺失有助于最大限度地减
少编程中bug 的数量。
● F#因为使用不可变的默认类型构造函数,天生就是可并行的。并且由于它
的.NET 基础,它可以在语言实现级别上与C#语言集成最先进的功能。
● C#设计倾向于使用命令式语言,首先完全支持OOP。我喜欢把这定义为
命令式的OO。自从.NET 3.5 发布以后,函数式范式影响了C#语言,增
加了诸如lambda 表达式和 LINQ 之类的列表解析功能。
● C#还拥有出色的并发工具,可以让你轻松编写并行程序并轻松解决棘手
的实际问题。实际上,C#语言中的卓越多核开发支持是通用的,并且能
够对高度并行对称多处理(SMP)应用程序进行快速开发和原型设计。这些
编程语言是编写并发软件的绝佳工具,可用解决方案的功能和选项在共存
使用时聚合。SMP 是一个由共享公共操作系统和内存的多个处理器处理
的程序。
● F#和C#可以互操作。实际上,F#函数可以调用C#库中的方法,反之亦然。
在接下来的章节中,我们将讨论其他并发方法,如数据并行性、异步和消息
传递编程模型。我们将使用这些编程语言所能提供的最佳工具来构建库,并将它
们与其他语言进行比较。我们还将研究诸如TPL 和反应式扩展(RX)之类的工具和
库,这些工具和库通过采用函数式范式成功地设计、启迪和实现,以获得可组合
的抽象。
很明显,业界正在寻找一种可靠而简单的并发编程模型,这一点可以从软件
公司投资于库中看出,软件公司把库中的抽象级别从传统和复杂的内存同步模型
中去除。这些高级库的示例包括Intel 的线程构建块(TBB)和Microsoft 的TPL。
还有一些有趣的开源项目,如OpenMP[它提供了pragma(编译器特定的定义,
可以用来创建新的预处理器功能或将定义实现的信息发送给编译器),你可以将这
些定义插入程序中,令各部分并行]和OpenCL[一种和图形处理单元(GPU)打交道
的低级语言]。GPU 并行编程很有吸引力,并且已被微软的C++ AMP 扩展和
Accelerator .NET 所采纳。
1.8 本章小结
● 对于并发和并行编程的挑战和复杂性,不存在银弹。作为一名专业工程师,
你需要不同类型的弹药,并且你需要知道如何以及何时使用它们来达到目
标。
● 程序的设计必须考虑到并发;程序员不能继续编写顺序代码,而忽视了并
行编程的好处。
● 摩尔定律并非不正确。相反,它改变了方向,即每个处理器的内核数量增
加,而不是单个CPU 的速度提高。在编写并发代码时,必须牢记并发、
多线程、多任务和并行之间的区别。
● 共享可变状态和副作用是在并发环境中要避免的主要问题,因为它们会导
致意外的程序行为和bug。
● 为了避免编写并发应用程序的陷阱,你应该使用提高抽象级别的编程模型
和工具。
● 函数式范式提供了正确的工具和原则,以便在代码中轻松、正确地处理并
发。
● 函数式编程在并行计算中表现出色,因为它默认:值是不可变的,这使得
数据共享变得更简单。
想了解更多关于《.NET并发编程实战》的内容,请点击:https://item.jd.com/12860976.html