Files
OnJava8/docs/book/24-Concurrent-Programming.md
‘blackwatch cbd5c52268 修改标签
2019-07-16 11:05:53 +08:00

197 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
[TOC]
<!-- Concurrent Programming -->
# 第二十四章 并发编程
>爱丽丝:“但是我不想进入疯狂的人群众”
>
>猫咪“oh你无能为力我们都疯了我疯了你也疯了”
>
>爱丽丝:“你怎么知道我疯了”。
>
>猫咪:“你一定疯了,否则你不会来到这里”——爱丽丝梦游仙境 第6章。
到目前为止我们一直在编程就像文学中的意识流叙事设备一样首先发生一件事然后是下一件事。我们完全控制所有步骤及其发生的顺序。如果我们将值设置为5那么稍后会回来并发现它是47这将是非常令人惊讶的。
我们现在进入了一个奇怪的并发世界,在此这个结果并不令人惊讶。你信赖的一切都不再可靠。它可能有效,也可能没有。很可能它会在某些条件下有效,而不是在其他条件下,你必须知道和了解这些情况以确定哪些有效。
作为类比,你的正常生活是在牛顿力学中发生的。物体具有质量:它们会下降并移动它们的动量。电线具有阻力,过线可以直线传播。但是,如果你进入非常小、热、冷、或者大的世界(我们不能生存),这些事情会发生变化。我们无法判断某物体是粒子还是波,光是否受到重力影响,一些物质变为超导体。
而不是单一的意识流叙事,我们在同时多条故事线进行的间谍小说里。一个间谍在一个特殊的岩石下李璐下微缩胶片,当第二个间谍来取回包裹时,它可能已经被第三个间谍带走了。但是这部特别的小说并没有把事情搞得一团糟;你可以轻松地走到尽头,永远不会弄明白什么。
构建并发应用程序非常类似于游戏[Jenga](https://en.wikipedia.org/wiki/Jenga),每当你拉出一个块并将其放置在塔上时,一切都会崩溃。每个塔楼和每个应用程序都是独一无二的,有自己的作用。您从构建系统中学到的东西可能不适用于下一个系统。
本章是对并发性的一个非常基本的介绍。虽然我使用了最现代的Java 8工具来演示原理但这一章远非对该主题的全面处理。我的目标是为你提供足够的基础知识使你能够解决问题的复杂性和危险性从而安全的通过这些鲨鱼肆虐的困难水域。
对于更多凌乱,低级别的细节,请参阅附录:**Appendix:Low-LevelConcurrency**。要进一步深入这个领域你还必须阅读Brian Goetz等人的Java Concurrency in Practice。虽然在写作时这本书已有十多年的历史但它仍然包含你必须了解和理解的必需品。理想情况下本章和附录是该书的精心准备。另一个有价值的资源是**Bill Venner**的Inside the Java Virtual Machine它详细描述了JVM的最内部工作方式包括线程。
<!-- The Terminology Problem -->
## 术语问题
在编程文献中并发、并行、多任务、多处理、多线程、分布式系统以及可能的其他使用了许多相互冲突的方式并且经常被混淆。Brian Goetz在2016年的演讲中指出了这一点[From Concurrent to Parallel](https://www.youtube.com/watch?v=NsDE7E8sIdQ),他提出了一个合理的解释:
- 并发是关于正确有效地控制对共享资源的访问。
- 并行是使用额外的资源来更快地产生结果。
这些都是很好的定义但有几十年的混乱产生了反对解决问题的历史。一般来说当人们使用“并发”这个词时他们的意思是“一切变得混乱”事实上我可能会在很多地方自己陷入这种想法大多数书籍包括Brian Goetz的Java Concurrency in Practice都在标题中使用这个词。
并发通常意味着“不止一个任务正在执行中”,而并行性几乎总是意味着“不止一个任务同时执行。”你可以立即看到这些定义的区别:并行也有不止一个任务“正在进行”。区别在于细节,究竟是如何“执行”发生的。此外,重叠:为并行编写的程序有时可以在单个处理器上运行,而一些并发编程系统可以利用多个处理器。
这是另一种方法,在减速[原文slowdown]发生的地方写下定义:
_并发_
同时完成多个任务。在开始处理其他任务之前当前任务不需要完成。并发解决了阻塞发生的问题。当任务无法进一步执行直到外部环境发生变化时才会继续执行。最常见的例子是I/O其中任务必须等待一些input在这种情况下会被阻止。这个问题产生在I/O密集型。
_并行_
同时在多个地方完成多个任务。这解决了所谓的计算密集型问题,如果将程序分成多个部分并在不同的处理器上编辑不同的部分,程序可以运行得更快。
术语混淆的原因在上面的定义中显示其中核心是“在同一时间完成多个任务。”并行性通过多个处理器增加分布。更重要的是两者解决了不同类型的问题解决I/O密集型问题并行化可能对你没有任何好处因为问题不是整体速度而是阻塞。并且考虑到计算力限制问题并试图在单个处理器上使用并发来解决它可能会浪费时间。两种方法都试图在更短的时间内完成更多但它们实现加速的方式是不同的并且取决于问题所带来的约束。
这两个概念混合在一起的一个主要原因是包括Java在内的许多编程语言使用相同的机制**线程**来实现并发和并行。
我们甚至可以尝试添加细致的粒度去定义(但是,这不是标准化的术语):
- **纯并发**任务仍然在单个CPU上运行。纯并发系统产生的结果比顺序系统更快但如果有更多的处理器则运行速度不会更快
- **并发-并行**:使用并发技术,结果程序利用更多处理器并更快地生成结果
- **并行-并发**使用并行编程技术编写如果只有一个处理器结果程序仍然可以运行Java 8 **Streams**就是一个很好的例子)。
- **纯并行**:除非有多个处理器,否则不会运行。
在某些情况下,这可能是一个有用的分类法。
对并发性的语言和库支持似乎[Leaky Abstraction](https://en.wikipedia.org/wiki/Leaky_abstraction)是完美候选者。抽象的目标是“抽象出”那些对于手头想法不重要的东西,从不必要的细节中汲取灵感。如果抽象是漏洞,那些碎片和细节会不断重新声明自己是重要的,无论你试图隐藏它们多少
我开始怀疑是否真的有高度抽象。当编写这些类型的程序时你永远不会被底层系统和工具屏蔽甚至关于CPU缓存如何工作的细节。最后如果你非常小心你创作的东西在特定的情况下起作用但它在其他情况下不起作用。有时区别在于两台机器的配置方式或者程序的估计负载。这不是Java特有的-它是并发和并行编程的本质。
您可能会认为[纯函数式](https://en.wikipedia.org/wiki/Purely_functional)语言没有这些限制。实际上,纯函数式语言解决了大量并发问题,所以如果你正在解决一个困难的并发问题,你可以考虑用纯函数语言编写这个部分。但最终,如果你编写一个使用队列的系统,例如,如果它没有正确调整并且输入速率要么没有被正确估计或被限制(并且限制意味着,在不同情况下不同的东西具有不同的影响),该队列将填满并阻塞或溢出。最后,您必须了解所有细节,任何问题都可能会破坏您的系统。这是一种非常不同的编程方式
<!-- A New Definition ofConcurrencyFor -->
### 并发的新定义
几十年来,我一直在努力解决各种形式的并发问题,其中一个最大的挑战一直是简单地定义它。在撰写本章的过程中,我终于有了这样的洞察力,我认为可以定义它:
>**并发性是一系列性能技术,专注于减少等待**
这实际上是一个相当多的声明,所以我将其分解:
- 这是一个集合:有许多不同的方法来解决这个问题。这是使定义并发性如此具有挑战性的问题之一,因为技术差别很大
- 这些是性能技术就是这样。并发的关键点在于让您的程序运行得更快。在Java中并发是非常棘手和困难的所以绝对不要使用它除非你有一个重大的性能问题 - 即使这样,使用最简单的方法产生你需要的性能,因为并发很快变得无法管理。
- “减少等待”部分很重要而且微妙。无论例如你运行多少个处理器你只能在等待某个地方时产生结果。如果您发起I/O请求并立即获得结果没有延迟因此无需改进。如果您在多个处理器上运行多个任务并且每个处理器都以满容量运行并且任何其他任务都没有等待那么尝试提高吞吐量是没有意义的。并发的唯一形式是如果程序的某些部分被迫等待。等待可以以多种形式出现 - 这解释了为什么存在如此不同的并发方法。
值得强调的是,这个定义的有效性取决于等待这个词。如果没有什么可以等待,那就没有机会了。如果有什么东西在等待,那么就会有很多方法可以加快速度,这取决于多种因素,包括系统运行的配置,你要解决的问题类型以及其他许多问题。
<!-- Concurrency Superpowers -->
## 并发的超能力
想象一下,你是一部科幻电影。您必须在高层建筑中搜索一个精心巧妙地隐藏在建筑物的一千万个房间之一中的单个物品。您进入建筑物并移动走廊。走廊分开了。
你自己完成这项任务需要一百个生命周期。
现在假设你有一个奇怪的超级大国。你可以将自己分开,然后在继续前进的同时将另一半送到另一个走廊。每当你在走廊或楼梯上遇到分隔到下一层时,你都会重复这个分裂的技巧。最后,你会有一个人在整个建筑物的每个终点走廊。
每个走廊都有一千个房间。你的超级大国正在变得有点瘦所以你只能让自己50个人同时搜索房间。
一旦克隆体进入房间,它必须搜索房间的所有裂缝和隐藏的口袋。它切换到第二个超级大国。它分成了一百万个纳米机器人,每个机器人都会飞到或爬到房间里一些看不见的地方。你不明白这种力量 - 一旦你启动它就会起作用。在他们自己的控制下,纳米机器人开始行动,搜索房间然后回来重新组装成你,突然,不知何故,你只知道物品是否在房间里
我很想能够说,“你在科幻小说中的超级大国?这就是并发性。“每当你有更多的任务要解决时,它就像分裂两个一样简单。问题是我们用来描述这种现象的任何模型最终都是抽象的
以下是其中一个漏洞:在理想的世界中,每次克隆自己时,您还会复制硬件处理器来运行该克隆。但当然不会发生这种情况 - 您的机器上可能有四个或八个处理器(通常在写入时)。您可能还有更多,并且仍有许多情况只有一个处理器。在抽象的讨论中,物理处理器的分配方式不仅可以泄漏,甚至可以支配您的决策
让我们在科幻电影中改变一些东西。现在当每个克隆搜索者最终到达一扇门时,他们必须敲门并等到有人回答。如果我们每个搜索者有一个处理器,这没有问题 - 处理器只是空闲直到门被回答。但是如果我们只有8个处理器和数千个搜索者那么只是因为搜索者恰好是因为处理器闲置了被锁等待一扇门被接听。相反我们希望将处理器应用于搜索在那里它可以做一些真正的工作因此需要将处理器从一个任务切换到另一个任务的机制。
许多型号能够有效地隐藏处理器的数量,并允许您假装您的数量非常大。但是有些情况会发生故障的时候,你必须知道处理器的数量,以便你可以解决这个问题。
其中一个最大的影响取决于您是单个处理器还是多个处理器。如果你只有一个处理器,那么任务切换的成本也由该处理器承担,将并发技术应用于你的系统会使它运行得更慢。
这可能会让您决定,在单个处理器的情况下,编写并发代码时没有意义。然而,有些情况下,并发模型会产生更简单的代码,实际上值得让它运行得更慢以实现。
在克隆体敲门等待的情况下,即使单处理器系统也能从并发中受益,因为它可以从等待(阻塞)的任务切换到准备好的任务。但是如果所有任务都可以一直运行那么切换的成本会降低一切,在这种情况下,如果你有多个进程,并发通常只会有意义。
在接听电话的客户服务部门,你只有一定数量的人,但是你可以拨打很多电话。那些人(处理器)必须一次拨打一个电话,直到完成电话和额外的电话必须排队。
在“鞋匠和精灵”的童话故事中,鞋匠做了很多工作,当他睡着时,一群精灵来为他制作鞋子。这里的工作是分布式的,但即使使用大量的物理处理器,在制造鞋子的某些部件时会产生限制 - 例如,如果鞋底需要制作鞋子,这会限制制鞋的速度并改变您设计解决方案的方式。
因此,您尝试解决的问题驱动解决方案的设计。打破一个“独立运行”问题的高级[原文lovely ]抽象,然后就是实际发生的现实。物理现实不断侵入和震撼,这种抽象。
这只是问题的一部分。考虑一个制作蛋糕的工厂。我们不知何故在工人中分发了蛋糕制作任务,但是现在是时候让工人把蛋糕放在盒子里了。那里有一个盒子,准备收到蛋糕。但是,在工人将蛋糕放入盒子之前,另一名工人投入并将蛋糕放入盒子中!我们的工人已经把蛋糕放进去了,然后就开始了!这两个蛋糕被砸碎并毁了。这是常见的“共享内存”问题,产生我们称之为竞争条件的问题,其结果取决于哪个工作人员可以首先在框中获取蛋糕(通常使用锁定机制来解决问题,因此一个工作人员可以先抓住框并防止蛋糕砸)。
当“同时”执行的任务相互干扰时,会出现问题。他可以以如此微妙和偶然的方式发生,可能公平地说,并发性“可以说是确定性的,但实际上是非确定性的。”也就是说,你可以假设编写通过维护和代码检查正常工作的并发程序。然而,在实践中,编写仅看起来可行的并发程序更为常见,但是在适当的条件下,将会失败。这些情况可能会发生,或者很少发生,你在测试期间从未看到它们。实际上,编写测试代码通常无法为并发程序生成故障条件。由此产生的失败只会偶尔发生,因此它们以客户投诉的形式出现。
这是推动并发的最强有力的论据之一:如果你忽略它,你可能会被咬。
因此并发似乎充满了危险如果这让你有点害怕这可能是一件好事。尽管Java 8在并发性方面做出了很大改进但仍然没有像编译时验证或检查异常那样的安全网来告诉您何时出现错误。通过并发您可以自己动手只有知识渊博可疑和积极才能用Java编写可靠的并发代码。
<!-- Concurrency is for Speed -->
<!-- 不知道是否可以找到之前翻译的针对速度感觉太直了 -->
## 并发为速度而生
在听说并发编程的问题之后,你可能会想知道它是否值得这么麻烦。答案是“不,除非你的程序运行速度不够快。”并且在决定它没有之前你会想要仔细思考。不要随便跳进并发编程的悲痛之中。如果有一种方法可以在更快的机器上运行您的程序,或者如果您可以对其进行分析并发现瓶颈并在该位置交换更快的算法,那么请执行此操作。只有在显然没有其他选择时才开始使用并发,然后仅在孤立的地方。
速度问题一开始听起来很简单:如果你想要一个程序运行得更快,将其分解成碎片并在一个单独的处理器上运行每个部分。由于我们能够提高时钟速度流(至少对于传统芯片),速度的提高是出现在多核处理器的形式而不是更快的芯片。为了使你的程序运行得更快,你必须学习利用那些超级处理器,这是并发性给你的一个建议。
使用多处理器机器可以在这些处理器之间分配多个任务这可以显着提高吞吐量。强大的多处理器Web服务器通常就是这种情况它可以在程序中为CPU分配大量用户请求每个请求分配一个线程。
但是,并发性通常可以提高在单个处理器上运行的程序的性能。这听起来像是一个双向的。如果考虑一下,由于上下文切换的成本增加(从一个任务更改为另一个任务),在单个处理器上运行的并发程序实际上应该比程序的所有部分顺序运行具有更多的开销。在表面上,将程序的所有部分作为单个任务运行并节省上下文切换的成本似乎更便宜。
可以产生影响的问题是阻塞。如果你的程序中的一个任务由于程序控制之外的某些条件通常是I/O而无法继续我们会说任务或线程阻塞在我们的科幻故事中克隆体已敲门而且是等待它打开。如果没有并发性整个程序就会停止直到外部条件发生变化。但是如果使用并发编写程序则当一个任务被阻止时程序中的其他任务可以继续执行因此程序继续向前移动。实际上从性能的角度来看在单处理器机器上使用并发是没有意义的除非其中一个任务可能阻塞。
单处理器系统中性能改进的一个常见例子是事件驱动编程,特别是用户界面编程。考虑一个程序执行一些长时间运行操作,从而最终忽略用户输入和无响应。如果你有一个“退出”按钮,你不想在你编写的每段代码中轮询它。这会产生笨拙的代码,无法保证程序员不会忘记执行检查。没有并发性,生成响应式用户界面的唯一方法是让所有任务定期检查用户输入。通过创建单独的执行线程来响应用户输入,该程序保证了一定程度的响应。
实现并发的直接方法是在操作系统级别使用与线程不同的进程。进程是一个在自己的地址空间内运行的自包含程序。进程很有吸引力因为操作系统通常将一个进程与另一个进程隔离因此它们不会相互干扰这使得进程编程相对容易。相比之下线程共享内存和I/O等资源因此编写多线程程序时遇到的困难是在不同的线程驱动的任务之间协调这些资源一次不能通过多个任务访问它们。
<!-- 文献引用未加,因为暂时没看到很好的解决办法 -->
有些人甚至提倡将进程作为并发的唯一合理方法[^1],但不幸的是,通常存在数量和开销限制,以防止它们在并发频谱中的适用性(最终你习惯了标准的并发性克制,“这种方法适用于一些情况但不适用于其他情况”)
一些编程语言旨在将并发任务彼此隔离。这些通常被称为_函数式语言_其中每个函数调用不产生其他影响因此不能与其他函数干涉因此可以作为独立的任务来驱动。Erlang就是这样一种语言它包括一个任务与另一个任务进行通信的安全机制。如果您发现程序的一部分必须大量使用并发性并且您在尝试构建该部分时遇到了过多的问题那么您可能会考虑使用专用并发语言创建程序的那一部分。
<!-- 文献标记 -->
Java采用了更传统的方法[^2],即在顺序语言之上添加对线程的支持而不是在多任务操作系统中分配外部进程,线程在执行程序所代表的单个进程中创建任务交换。
并发性会带来成本,包括复杂性成本,但可以通过程序设计,资源平衡和用户便利性的改进来抵消。通常,并发性使您能够创建更加松散耦合的设计;否则,您的代码部分将被迫明确标注通常由并发处理的操作。
<!-- The Four Maxims of Java Concurrency -->
## 四句格言
<!-- The Brutal Truth -->
## 残酷的真相
<!-- The Rest of the Chapter -->
## 本章其余部分
<!-- Parallel Streams -->
## 并行流
<!-- Creating and Running Tasks -->
## 创建和运行任务
<!-- Terminating Long-Running Tasks -->
## 终止耗时任务
<!-- CompletableFutures -->
## CompletableFuture类
<!-- Deadlock -->
## 死锁
<!-- Constructors are not Thread-Safe -->
## 构造函数非线程安全
<!-- Effort, Complexity,Cost -->
## 复杂性和代价
<!-- Summary -->
## 本章小结
[^1]:例如,Eric-Raymond在“VIIX编程艺术”Addison-Wesley2004中提出了一个很好的案例。
[^2]:可以说,试图将并发性用于后续语言是一种注定要失败的方法,但你必须得出自己的结论
<!-- 分页 -->
<div style="page-break-after: always;"></div>