赞
踩
我们先从共同点开始,本节定义了与本书主题相关的术语。在计算机编程中, 某些术语(如并发、并行和多线程)在同一上下文中使用,但具有不同的含义。由 于它们的相似性,将这些术语视为同一事物的倾向是常见的,但这是不正确的。 当对程序的行为进行推理变得很重要时,区分计算机编程术语是至关重要的。例 如,根据定义,并发是多线程的,但多线程不一定是并发的。你可以很容易地使 多核CPU 像单核CPU 一样工作,但不能反过来。
本节旨在就与本书主题相关的定义和术语建立共识。在本节结束时,你将了解以下术语的含义:
顺序编程是按照步骤逐一完成任务的行为。
让我们举一个简单的例子,比如在当地的咖啡店买一杯卡布奇诺。你先排队和单独的咖啡师一起下订单。咖啡师负责接收订单并送饮料,一次只能制作一杯饮料,所以在购买之前你必须耐心地排队等待。制作卡布奇诺需要研磨咖啡,冲煮咖啡,蒸牛奶,使牛奶起泡,把咖啡和牛奶混合,所以在你拿到卡布奇诺咖啡之前需要更多的时间。图1.1 展示了这一过程。
————————————————————————————————————————————————
顺序
一次执行一个任务,按照步骤逐一完成任务,就是一次一条指令线性方式运行,下一个指令必须在上一个指令运行结束后开始。
像简单的For循环,按集合顺序执行。这是我们最常用也最不容易犯错,但容易被阻塞,以同步下载文件为例,就需要等文件下载完成落盘后进行下一步处理。
假设咖啡师更喜欢启动多个步骤并同时执行它们呢?这将使客户队列移动得
更快(并因此增加获得的小费)。例如,一旦咖啡被磨碎,咖啡师就可以开始冲泡
浓缩咖啡。在冲煮过程中,咖啡师可以下新的订单,也可以开始蒸牛奶和发泡牛
奶的过程。在这个例子中,咖啡师给人一种同时进行多个操作(多任务处理)的感
觉,但这只是一种幻觉。有关多任务处理的更多详细信息,请参见第1.2.4 节。事
实上,由于咖啡师只有一台浓缩咖啡机,他们必须停止一项任务才能启动或继续
另一项任务,这意味着咖啡师一次只能执行一项任务,如图1.3 所示。在现代多
核计算机中,这是对宝贵资源的浪费。
并发描述了同时运行多个程序或程序的多个部分的能力。在计算机编程中,在应用程序中使用并发提供了实际的多任务处理,将应用程序分成多个独立的过程,这些过程在不同的线程中同时(并发)运行。如果有多个CPU 内核可用,则可以在单个CPU 内核中进行,也可以并行进行。通过异步或并行执行任务,可以提高程序的吞吐量(CPU 处理计算的速度)和响应能力。例如,流式传输视频内容的应用程序是并发的,因为它同时从网络读取数字数据,对其进行解压缩,并在屏幕上更新其显示。
并发给人的印象是,这些线程是并行运行的,并且程序的不同部分可以同时运行。但是在单核环境中,一个线程的执行会临时暂停并切换到另一个线程,如图1.3 中的咖啡师所示。如果咖啡师希望同时执行多个任务来加速生产,那么必须增加可用资源。在计算机编程中,这个过程称为并行。
————————————————————————————————————————————————
从开发人员的角度看,当我们考虑这些问题时,“我的程序可以同时执行多项操作吗?”或“我的程序如何更快地解决一个问题?”我们会想到并行。
并行是指同时在不同的内核上执行多个任务,以提高应用程序的速度。尽管所有并行程序都是并发的,但我们(在前文)已经看到并非所有并发都是并行的。这是因为并行取决于实际的运行时环境,并且需要硬件支持(多核)。并行只能在多核设备中实现(见图1.4),是提高程序性能和吞吐量的手段。
回到咖啡店的例子,假设你是经理,希望通过加快饮料生产来减少客户的等待时间。一个直观的解决方案是雇用第二名咖啡师建立第二个咖啡站。两名咖啡师同时工作,客户的队列可以独立和并行处理,卡布奇诺的制作(见图1.5)加快了。
生产没有被中断会带来性能上的好处。并行的目标是最大限度地利用所有可用的计算资源;在这种情况下,两名咖啡师在不同的站点并行工作(多核处理)。
当一个任务被拆分为多个独立的子任务,然后使用所有可用的核心来运行时,就可以实现并行。在图1.5 中,多核机器(两个咖啡站)允许并行同时执行不同的任务(两名忙碌的咖啡师)而不会中断。
计时的概念是同时并行执行操作的基础。在图1.6 这样的程序中,如果它们可以一起执行(不管执行时间片是否重叠),那么操作是并发的;如果执行在时间片上重叠(同时执行),那么这些操作是并行的。
并行和并发是相关的编程模型。并行程序也是并发的,但并发程序并不总是
并行的,并行编程是并发编程的子集。并发是指系统的设计,而并行则与硬件运
行环境有关。并发和并行编程模型直接取决于执行它们的本地硬件环境。
————————————————————————————————————————————————
多任务处理是同时在一段时间内执行多个任务的概念。我们对这个概念很熟悉,因为我们在日常生活中一直都是多任务的。例如,在等待咖啡师为我们准备卡布奇诺咖啡的时候,我们使用智能手机查看电子邮件或浏览新闻报道。我们同时做两件事:等待和使用智能手机。
计算机的多任务处理是在计算机只有一个CPU 以共享同一计算资源来同时执行许多任务的时代设计的。最初,将CPU 的时间切片,一次只能执行一个任务。
(时间片涉及协调多个线程之间执行的复杂调度逻辑)。
调度(程序)允许线程在调度不同的线程之前运行的时间量称为线程量子。
CPU 是按时间切片的,在将执行上下文切换到另一个线程之前,每个线程都可以执行一个操作。上下文切换是操作系统处理多任务以优化性能的过程(见图1.7)。但是在单核计算机中,多任务处理可能会因为线程之间的上下文切换而引入额外开销从而降低程序的性能。
多任务处理操作系统有两种类型:
协作式多任务处理系统。调度程序允许每个任务运行一直到完成,或者显式地将执行控制权返回给调度程序(下一个任务被调度的前提是当前任务主动放弃时间片,操作系统没有主动权)。
抢占式多任务系统(如Microsoft Windows)。由操作系统考虑任务的优先级,并根据优先级来执行任务,一旦任务用完分配的时间,底层操作系统将切换执行序列,将控制权交给其他任务。
过去十年中设计的大多数操作系统都提供了抢占式多任务处理。多任务处理对于UI 响应非常有用,有助于避免在长时间后台操作期间冻结UI。
多线程是多任务概念的延伸,旨在通过最大化和优化计算机资源来提高程序的性能。多线程是一种使用多个执行线程的并发形式。多线程意味着并发,但并发并不一定意味着多线程。多线程使应用程序能够将特定任务显式
地细分为在同一进程中并行运行的各个线程。
线程是一个计算单元(一组独立的编程指令,用于实现特定的结果),操作系统调度程序独立地执行和管理这些指令。多线程不同于多任务:与多任务不同,多线程的线程是共享资源的。但是这种“共享资源”设计比多任务带来了更多的编程挑战。我们将在稍后的1.4.1 节中讨论线程之间共享变量的问题。
并行和多线程编程的概念是密切相关的。但是与并行相比,多线程与硬件无关,这意味着无论内核的数量多少,都可以执行多线程处理。并行编程是多线程的超集。例如,可以通过在同一进程中共享资源以使用多线程来并行程序,但也可以通过在多个进程中甚至在不同的计算机中执行计算来并行程序。图1.8 展示了这些术语之间的关系。
————————————————————————————————————————————————
总结:
顺序编程是指在一个CPU 时间片中执行的一组有序指令。
并发编程一次处理多个操作,不需要硬件支持(使用一个或多个内核)。
并行编程在多个CPU 或多个内核上同时执行多个操作。所有并行程序都
是并发的,同时运行的,但并非所有并发都是并行的。原因是并行只能
在多核设备上实现。
多任务同时执行来自不同进程的多个线程。多任务并不一定意味着并行
执行,只有在使用多个CPU 或多个内核时才能实现并行执行。
多线程扩展了多任务处理的思想。它是一种并发形式,它使用来自同一
进程的多个独立执行线程。根据硬件支持的不同,每个线程可以并发或
并行运行。
并发是生活中自然的一部分——就像人类一样,我们习惯于多任务处理。我
们可以一边喝咖啡一边看电子邮件,或者一边听我们最喜欢的歌曲的同时一边打
字。在应用程序中使用并发的主要原因是为了提高性能和响应能力,并实现低延
迟。常识是,如果一个人一个接一个地做两个任务,比两个人同时做同样的这两
个任务要花更长的时间。
应用程序也同样如此。问题是绝大多数应用程序都没有根据可用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]的情况下,计算的确定性将丢失,因为并发任务执行的顺序变得可变。显
而易见的解决方案是支持纯函数来避免副作用。你将在本书中学习这些技巧和
实践。
编写并发程序并不容易,在程序设计时必须考虑许多复杂的元素。在线程池
中创建新线程或将多个作业排队相对简单,但如何确保程序的正确性呢?当许多
线程不断访问共享数据时,你必须考虑如何保护其数据结构以保证其完整性。线
程应该不受其他线程的干扰自动地写入和修改内存位置。现实情况是,用命令式
编程语言编写的程序或具有值可以改变的变量的语言(可变变量)将始终容易受到
数据争用的影响,无论内存同步级别或所使用的并发库如何。
考虑并行运行的两个线程(线程1 和线程2)的情况,两者都试图访问和修改共
享值x,如图1.11 所示。线程1 修改变量需要多个CPU 指令:必须从内存中读取
该值,然后进行修改并最终写回内存。如果线程2 在线程1 写回更新值时尝试从
同一内存位置读取,则x 的值已更改。更确切地说,线程1 和线程2 可能同时读
取值x,然后线程1 修改值x 并将其写回内存,而线程2 也修改值x。结果是数据
损坏。这种现象称为竞态条件。
程序中可变状态和并行性的组合是出问题的同义词。命令式范式的解决方案
是在某一时刻通过锁定对多个线程的访问来保护可变状态。这种技术称为互斥,
因为一个线程对给定内存位置的访问会阻止此时其他线程的访问。由于多个线程
必须同时访问同一数据才能从这一技术中获益,因此计时的概念非常重要。通过
引入锁来同步多个线程对共享资源的访问,解决了数据损坏的问题,但也带来了
更多可能导致死锁的复杂性。见图1.12。
以下是并发危险列表,并附有简要说明。稍后,你将了解每种方法的更多详
细信息,并特别关注如何避免它们:
竞态条件是当多个线程同时访问共享可变资源(例如文件、图像、变量或
集合)时,会留下不一致的状态。数据损坏会导致程序不可靠和不可用。
当多个线程共享需要同步技术的争用状态时,性能下降是一个常见问题。
相互排斥锁(或互斥锁),顾名思义,通过强制并行运行的其他多个线程
暂停工作来防止代码进行通信和同步内存访问。锁的获取和释放会带来
性能损失,从而降低所有进程的速度。随着内核数量的增加,锁争用的
成本可能会增加。随着更多的任务被引入以共享相同的数据,与锁相关
的开销可能会对计算产生负面影响。第1.4.3 节说明了引入锁同步的后果
和开销。
死锁是一个由于使用锁引起的并发问题。当存在一个任务周期,其中每
个任务在等待另一个任务继续时都被阻塞,就会发生这种情况。因为所
有任务都在等待另一个任务执行某些任务,所以它们会无限期地被阻塞。
线程之间共享的资源越多,避免争用条件所需的锁就越多,出现死锁的
风险就越高。
在代码中引入锁会带来一个设计上的问题,组合的缺失。锁不组合。组
合通过将一个复杂的问题分解成更小的更容易解决的部分,然后将它们
粘在一起,来促进问题的解决。组合是FP 的一个基本原则。
现实世界中的程序需要在任务之间进行交互,例如交换信息以协调工作。如
果不共享所有任务都可以访问的数据,则无法实现此功能。处理这种共享状态是
与并行编程相关的大多数问题的根源,除非共享数据是不可变的或者每个任务都
有自己的数据副本。解决方案是保护所有代码不受这些并发问题的影响。没有任
何编译器或工具可以帮助你将这些同步基元锁放到代码中的正确位置,这完全取
决于你作为程序员的技能水平。
由于这些潜在的问题,编程社区已经在呼喊,作为回应,解决这些问题的库
和框架已经被写入和引入主流的面向对象语言(例如C#语言和Java)中,以提供并
发保护,而这些都不是语言最初设计的一部分。这种支持是一种设计修正,以命
令式和面向对象的通用编程环境中共享内存的存在为例。同时,函数式语言不需
要安全措施,因为FP 的概念能很好地映射到并发编程模型。
排序算法通常用于科学计算,并且可能是科学计算中的一个性能瓶颈。这里
让我们讨论一个计算密集型的、对数组元素进行排序的算法(快速排序算法)的并
行版本。此示例旨在演示将顺序算法转换为并行版本时会遇到的陷阱,并指出在
代码中引入并行需要在做出任何决策之前进行额外的思考。否则,性能可能会产
生与预期相反的结果。
快速排序是一种分而治之的算法。它首先将一个大数组分成两个子数组,其
中一个子数组的所有数据都比另外一个子数组的所有数据都要小。然后,快速排
序可以递归地对子数组进行排序,并且易于并行化。它可以在数组上就地操作,
只需要少量额外的内存来执行排序。该算法由三个简单步骤组成,如图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<firstElement) restOfList 标识的数组的每个分区递归调用quicksortSequential。
通过使用TPL 生成新任务,可以重写这部分代码实现并行化。
执行时间显著增加而不是减少。并行快速排序算法从每次运行平均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 个条目的小数组,由于创建和生成新线程的开销,并行排序算法会比串行版本慢。即使你正确编写了一个并行程序,并发构造函数引入的开销也可能会使程序运行时不堪重负,从而降低性能,导致与期望相反的结果。因此,将原始顺序代码基准作为基线进行基准测试,然后继续测量每个更改,以验证并行性是否有益,这一点非常重要。一个完整的策略应该考虑这个因素,并且只有当数组大小大于一个阈值(递归深度),通常与核心数量相匹配,之后默认返回到串行行为时,才采用并行。
麻烦的是,所有有趣的并发应用程序基本上都涉及共享状态可变性的谨慎使用和受控,例如画面实时状态、文件系统或程序的内部数据结构。因此,正确的解决方案是提供允许共享状态部分的安全可变性的机制。
FP 是关于最小化和控制副作用的,通常被称为纯函数编程。FP 使用转换的概念,其中函数创建值x 的副本,然后修改副本,使原始值x 保持不变并且可以由程序的其他部分自由使用。它鼓励在设计程序时考虑是否需要可变性和副作用。
FP 允许可变性和副作用,通过使用方法以策略和显式方式来封装这些区域,将这些区域与代码的其余部分隔离开来。
采用函数式范式的主要原因是为了解决多核时代存在的问题。高度并发的应用程序(如Web 服务器和数据分析数据库)面临着几个体系结构问题。这些系统必须具有可扩展性,以响应大量的并发请求,这将导致处理最大化资源争用和高调度频率的设计挑战。此外,竞态条件和死锁很常见,这使得故障排除和调试代码变得困难。
在本章中,我们讨论了一些特定于在命令式或OOP 中开发并发应用程序的常见问题。在这些编程范式中,我们将对象作为基础构造来进行处理。但是在并发化方面是相反的,处理对象在从单个线程程序传递到大规模并行化工作时是一个具有挑战性且完全不同的场景,需要考虑一些注意事项。
针对这些问题的传统解决方案是同步对资源的访问,避免线程之间的争用。
但是这些解决方案是一把双刃剑,因为使用基元进行同步,如互斥锁,会导致可能死锁或竞态条件。事实上,变量的状态可能会发生变化。在OOP 中,变量通常表示一个容易随时间变化的对象。你永远不能依赖它的状态,因此必须检查它的
当前值以避免意外的行为(见图1.14)。
重要的是要考虑到采用FP 概念的系统组件将不再相互干扰,并且可以在不使用任何锁定策略的情况下在多线程环境中使用它们。
使用共享可变变量和副作用函数来开发安全的并行程序需要程序员大量的努力,他们必须做出关键的决策,通常以锁的形式来实现同步。通过函数式编程消除这些基本问题的同时,还可以消除那些特定于并发性的问题。这就是为什么FP可以成为一个优秀的并发编程模型的原因。在FP 的核心,变量和状态都不可变且不能共享,并且函数可能没有副作用。
FP 是编写并发程序最合适的方式。尝试用命令式语言编写它们不仅困难,而且还会导致难以发现、重现和修复的 bug。
你打算如何利用你可以利用的每一台计算机内核?答案很简单:拥抱函数式范式!
函数式编程的好处
学习FP 有很大的好处,即使你不打算在不久的将来采用这种风格。不过,如果没有立竿见影的好处,就很难说服别人把时间花在新的事情上。这些好处以惯用语言特征的形式出现,这些特性一开始看起来很有颠覆性。然而,FP 是一种范式,它将在短暂的学习曲线之后给你的程序带来巨大的编码能力和积极的影响。
在使用FP 技术的几周内,你将提高应用程序的可读性和正确性。
FP 在并发方面的优点包括:
– 纯函数——它没有副作用,这意味着函数不会更改函数体之外的任何类型的输入或数据。
如果函数对用户是透明的,则称其为纯函数,并且它们的返回值仅取决于输入参数。将相同的参数传递给纯函数,结果不会
改变,并且每个过程将返回相同的值,从而产生一致和预期的行为。
引用透明度——这个函数式的概念是指它的输出依赖它的输入,只映射它的输入。
换句话说,每次函数接收相同的参数时,结果都是相同的。这个概念在并发编程中很有价值,因为表达式的定义可以用它的值替换,并且具有相同的含义。引用透明度保证了一组函数可以以任意顺序并行地进行计算,而不会改变应用程序的行为。
延迟计算——在FP 中是指按需检索函数的结果,或将大数据流的分析推迟直到需要时。
可组合性——用于组合函数并从简单函数中创建更高级的抽象。
可组合性是消除复杂性的最强大工具,可让你定义和构建复杂问题的解决方案。学习函数式编程允许你编写更多模块化、面向表达式和概念上简单的代码。
无论代码执行的线程数是多少,这些FP 资产的组合都可以让你了解你的代码在做什么。
在本书的后面部分,你将学习应用并行化、绕过与可变状态和副作用等相关问题的技术。这些概念的函数式范式方法旨在使用声明式编程风格简化和最大限度地提高编码效率。
有时候,改变是困难的。通常,对自己的领域知识感到满意的开发人员缺乏从不同角度看待编程问题的动力。学习任何新的程序范式都是困难的,需要时间过渡到不同的开发风格。编程视角的改变需要思路和方法的改变,而不仅仅是学习新编程语言的新代码语法。
在整个过程中,你应该会遇到难以理解的概念,并努力克服这些困难。思考如何在实践中使用这些抽象的概念,来解决简单的问题。我的经验表明,你可以通过使用一个真实的例子来找出一个概念的意图,从而突破一个心理障碍。本书将介绍FP 应用于并发和分布式系统的好处。这是一条狭窄难行的道路,但另一方面,你将会发现几个伟大的会在日常编程中使用的基本概念。我相信你会对如何解决复杂问题有新的见解,并利用FP 的强大功能成为优秀的软件工程师。
实际上,作为软件工程师,你应该把编程语言看作工具。理想情况下,一个解决方案应该是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 所采纳。
————————————————————————————————————————————————
● 对于并发和并行编程的挑战和复杂性,不存在银弹。作为一名专业工程师,你需要不同类型的弹药,并且你需要知道如何以及何时使用它们来达到目标。
● 程序的设计必须考虑到并发;程序员不能继续编写顺序代码,而忽视了并行编程的好处。
● 摩尔定律并非不正确。相反,它改变了方向,即每个处理器的内核数量增加,而不是单个CPU 的速度提高。在编写并发代码时,必须牢记并发、多线程、多任务和并行之间的区别。
● 共享可变状态和副作用是在并发环境中要避免的主要问题,因为它们会导致意外的程序行为和bug。
● 为了避免编写并发应用程序的陷阱,你应该使用提高抽象级别的编程模型和工具。
● 函数式范式提供了正确的工具和原则,以便在代码中轻松、正确地处理并发。
● 函数式编程在并行计算中表现出色,因为它默认:值是不可变的,这使得数据共享变得更简单。
————————————————————————————————————————————————
同时运行多个任务,将应用程序分成多个独立的过程,这些过程在不同的线程中同时并发运行。并发和并行比较容易搞混,在单核机器上并发是同一时刻只能有一条指令执行,但多个指令被快速的切换执行,从宏观上看就是同时执行的效果,微观上在单核机上一个线程执行,其他线程就处于挂起状态。
就像一个人啃三个馒头,来回的啃,一口这个馒头,一口那个馒头。
只要切换的够快,你看三个馒头就是同时慢慢变小的。
同时执行多个任务。并行只有在多核机器上才能实现。前面说到并发在单核机器微观上是串行执行的,但在多核中,就有可能是真正的同时执行。在多核中,一个CPU执行一个线程时,另一个CPU可以执行另一个线程,两个线程互不抢占CPU资源,用时进行,这也就是并行。
所以并行一定是并发的,并发就不一定是并行的,并行是并发的子集。
来三个人啃三个馒头,一人一个,谁也别抢谁的吃。
—————————————————————————————————————————————————
并行和并发有什么区别?
并发:多个任务在同一个 CPU 核上,按细分的时间片轮流(交替)执行,从逻辑上来看那些任务是同时执行。
并行:单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的“同时进行”。
串行:有n个任务,由一个线程按顺序执行。由于任务、方法都在一个线程执行所以不存在线程不安全情况,也就不存在临界区的问题。
做一个形象的比喻:
并发 = 两个队列和一台咖啡机。
并行 = 两个队列和两台咖啡机。
串行 = 一个队列和一台咖啡机。
多线程:多线程是指程序中包含多个执行流,即在一个程序中可以同时运行多个不同的线程来执行不同的任务。
多线程的好处:
可以提高 CPU 的利用率。在多线程程序中,一个线程必须等待的时候,CPU 可以运行其它的线程而不是等待,这样就大大提高了程序的效率。也就是说允许单个程序创建多个并行执行的线程来完成各自的任务。
多线程的劣势:
线程也是程序,所以线程需要占用内存,线程越多占用内存也越多;
多线程需要协调和管理,所以需要 CPU 时间跟踪线程;
线程之间对共享资源的访问会相互影响,必须解决竞用共享资源的问题。
—————————————————————————————————————————————————
同时在一段时间内执行多个任务。这里的任务可以理解为进程,在Windows系统中的任务管理器就是查看的各个应用程序进程信息。起初多任务就是设计用来在单核机上同时执行多任务的,所以以前的单核计算机也可以运行多个应用程序。
计算机在多个任务之间多是采用抢占式切换
进程是资源分配的单元,线程是资源调度的单元,一个进程里至少有一个线程,教科书如此定义。多线程使应用程序能够在同一进程内细分为不同的线程任务。
1)基本概念
1. 并发:在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行。其中两种并发关系分别是同步和互斥
2. 互斥:进程间相互排斥的使用临界资源的现象,就叫互斥。
3. 同步:进程之间的关系不是相互排斥临界资源的关系,而是相互依赖的关系。进一步的说明:就是前一个进程的输出作为后一个进程的输入,当第一个进程没有输出时第二个进程必须等待。具有同步关系的一组并发进程相互发送的信息称为消息或事件。
其中并发又有伪并发和真并发,伪并发是指单核处理器的并发,真并发是指多核处理器的并发。
4. 并行:在单处理器中多道程序设计系统中,进程被交替执行,表现出一种并发的外部特种;在多处理器系统中,进程不仅可以交替执行,而且可以重叠执行。在多处理器上的程序才可实现并行处理。从而可知,并行是针对多处理器而言的。并行是同时发生的多个并发事件,具有并发的含义,但并发不一定并行,也亦是说并发事件之间不一定要同一时刻发生。
5. 多线程:多线程是程序设计的逻辑层概念,它是进程中并发运行的一段代码。多线程可以实现线程间的切换执行。
6. 异步:异步和同步是相对的,同步就是顺序执行,执行完一个再执行下一个,需要等待、协调运行。异步就是彼此独立,在等待某事件的过程中继续做自己的事,不需要等待这一事件完成后再工作。线程就是实现异步的一个方式。异步是让调用方法的主线程不需要同步等待另一线程的完成,从而可以让主线程干其它的事情。
异步和多线程并不是一个同等关系,异步是最终目的,多线程只是我们实现异步的一种手段。异步是当一个调用请求发送给被调用者,而调用者不用等待其结果的返回而可以做其它的事情。实现异步可以采用多线程技术或则交给另外的进程来处理。
异步和同步的区别, 在io等待的时候,同步不会切走,浪费了时间。
多线程的好处,比较容易的实现了 异步切换的思想, 因为异步的程序很难写的。多线程本身程还是以同步完成,但是应该说比效率是比不上异步的。 而且多线很容易写, 相对效率也高。
2)深层次理解
多线程和异步操作的异同
多线程和异步操作两者都可以达到避免调用线程阻塞的目的,从而提高软件的可响应性。甚至有些时候我们就认为多线程和异步操作是等同的概念。但是,多线程和异步操作还是有一些区别的。而这些区别造成了使用多线程和异步操作的时机的区别。
异步操作的本质
所有的程序最终都会由计算机硬件来执行,所以为了更好的理解异步操作的本质,我们有必要了解一下它的硬件基础。 熟悉电脑硬件的朋友肯定对DMA这个词不陌生,硬盘、光驱的技术规格中都有明确DMA的模式指标,其实网卡、声卡、显卡也是有DMA功能的。DMA就是直接内存访问的意思,也就是说,拥有DMA功能的硬件在和内存进行数据交换的时候可以不消耗CPU资源。只要CPU在发起数据传输时发送一个指令,硬件就开始自己和内存交换数据,在传输完成之后硬件会触发一个中断来通知操作完成。这些无须消耗CPU时间的I/O操作正是异步操作的硬件基础。所以即使在DOS这样的单进程(而且无线程概念)系统中也同样可以发起异步的DMA操作。
线程的本质
线程不是一个计算机硬件的功能,而是操作系统提供的一种逻辑功能,线程本质上是进程中一段并发运行的代码,所以线程需要操作系统投入CPU资源来运行和调度。
异步操作的优缺点
因为异步操作无须额外的线程负担,并且使用回调的方式进行处理,在设计良好的情况下,处理函数可以不必使用共享变量(即使无法完全不用,最起码可以减少共享变量的数量),减少了死锁的可能。当然异步操作也并非完美无暇。编写异步操作的复杂程度较高,程序主要使用回调方式进行处理,与普通人的思维方式有些初入,而且难以调试。
多线程的优缺点
多线程的优点很明显,线程中的处理程序依然是顺序执行,符合普通人的思维习惯,所以编程简单。但是多线程的缺点也同样明显,线程的使用(滥用)会给系统带来上下文切换的额外负担。并且线程间的共享变量可能造成死锁的出现。
适用范围
在了解了线程与异步操作各自的优缺点之后,我们可以来探讨一下线程和异步的合理用途。我认为:当需要执行I/O操作时,使用异步操作比使用线程+同步I/O操作更合适。I/O操作不仅包括了直接的文件、网络的读写,还包括数据库操作、Web Service、HttpRequest以及.Net Remoting等跨进程的调用。
而线程的适用范围则是那种需要长时间CPU运算的场合,例如耗时较长的图形处理和算法执行。但是往往由于使用线程编程的简单和符合习惯,所以很多朋友往往会使用线程来执行耗时较长的I/O操作。这样在只有少数几个并发操作的时候还无伤大雅,如果需要处理大量的并发操作时就不合适了。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。