Go-并发编程学习指南-全-

Go 并发编程学习指南(全)

原文:Learn Concurrent Programming with Go

译者:飞龙

协议:CC BY-NC-SA 4.0

前置内容

前言

“你能描述一个执行线程导致死锁的情况吗?”我的面试官问道。在我给出正确答案后,他进一步追问:“在那个情况下,你会怎么做来确保代码避免死锁?”幸运的是,我也知道解决方案。面试官接着给我展示了一些代码,并询问我是否能发现其中的任何问题。代码中存在一个糟糕的竞争条件,我指出了这一点,并提出了解决问题的方法。

这个问题是在我第三次也是最后一次面试伦敦一家国际科技公司核心后端开发职位时提出的。在这个职位上,我接触到了编程中最具挑战性的问题——这些问题要求我提高开发并发、低延迟和高吞吐量服务的技能。那是在 15 年前。

在我 20 多年的技术生涯中,许多情况都发生了变化:开发者现在可以随时随地工作,计算机语言已经发展得能够模拟更复杂的业务,而极客们因为现在运营着大型科技公司而变得酷炫。然而,一些方面保持不变:程序员总是苦于给变量命名,许多问题可以通过关闭系统然后重新启动来解决,而并发编程技能仍然供不应求。

科技行业缺乏擅长并发的程序员,因为并发编程被认为是一项极具挑战性的任务。许多开发者甚至害怕使用并发编程来解决问题。在科技行业中,这种看法是,这是一个高级话题,仅限于硬核计算机极客。这有很多原因。开发者不熟悉用于管理并发的概念和工具,有时他们未能认识到并发如何被程序化地建模。这本书是我试图解决这个问题,并以一种无痛的方式解释并发编程的尝试。

致谢

我对那些在本书写作过程中提供重大贡献的人表示感谢。

首先,我想感谢 Joe Cordina,他在我学习初期就向我介绍了并发编程,激发了我对这个主题的兴趣。

我还想感谢 Miguel David,是他提出了使用 Go 语言发布材料的想法。

我要感谢 Russ Cox 为我提供了关于 Go 语言历史的宝贵指导。

特别感谢开发编辑 Becky Whitney,技术校对员 Nicolas Modrzyk,以及技术编辑 Steven Jenkins,他是一家全球金融服务公司的资深工程师,曾设计、构建并支持从小型初创公司到全球企业的系统。他认为 Go 语言对于高效构建和交付可扩展的解决方案非常有用。他们的专业精神、专业知识和奉献精神确保了这本书达到了最高的质量标准。

致所有审稿人:Aditya Sharma、Alessandro Campeis、Andreas Schroepfer、Arun Saha、Ashish Kumar Pani、Borko Djurkovic、Cameron Singe、Christopher Bailey、Clifford Thurber、David Ong Da Wei、Diego Stamigni、Emmanouil Chardalas、Germano Rizzo、Giuseppe Denora、Gowtham Sadasivam、Gregor Zurowski、Jasmeet Singh、Joel Holmes、Jonathan Reeves、Keith Kim、Kent Spillner、Kévin Etienne、Manuel Rubio、Manzur Mukhitdinov、Martin Czygan、Mattia Di Gangi、Miki Tebeka、Mouhamed Klank、Nathan B. Crocker、Nathan Davies、Nick Rakochy、Nicolas Modrzyk、Nigel V. Thomas、Phani Kumar Yadavilli、Rahul Modpur、Sam Zaydel、Samson Desta、Sanket Naik、Satadru Roy、Serge Simon、Serge Smertin、Sergio Britos Arévalo、Slavomir Furman、Steve Prior 和 Vinicios Henrique Wentz,你们的建议帮助使这本书变得更好。

最后,我想感谢 Vanessa、Luis 和 Oliver 在整个项目中的坚定不移的支持。他们的鼓励和热情让我保持动力,并激励我发挥最佳水平。

关于这本书

《用 Go 学习并发编程》是为了帮助开发者通过更高级的并发编程来提高他们的编程技能。选择 Go 作为展示示例的语言,因为它提供了广泛的工具来完全探索这个并发编程的世界。在 Go 中,这些工具非常直观且易于掌握,让我们能够专注于并发编程的原则和最佳实践。

在阅读这本书之后,你将能够

  • 使用并发编程编写响应迅速、性能高和可扩展的软件

  • 掌握并行计算的优势、局限性和特性

  • 区分内存共享和消息传递

  • 利用 goroutines、互斥锁、读写锁、等待组、通道和条件变量——并且,此外,了解如何构建这些工具

  • 在处理并发执行时识别需要注意的典型错误

  • 通过更高级的多线程主题提高你在 Go 中的编程技能

应该阅读这本书的人

这本书是为那些已经有一定编程经验并希望了解并发编程的读者所写。本书假设读者没有并发编程的先验知识。尽管理想的读者已经有一定使用 Go 或其他 C 语法类似语言的经验,但本书也适合来自任何语言的开发者——如果花些时间学习 Go 的语法。

并发编程为你的编程增加了另一个维度:程序不再是一系列依次执行的指令。这使得它成为一个具有挑战性的主题,需要你以不同的方式思考程序。因此,虽然已经精通 Go 很重要,但拥有好奇心和动力更为重要。

本书不专注于解释 Go 的语法和特性,而是使用 Go 来展示并发原理和技术。这些技术中的大多数可以应用于其他语言。有关 Go 教程和文档,请参阅go.dev/learn

本书组织结构:路线图

本书分为三部分,共 12 章。第一部分介绍了并发编程的基础以及使用内存共享进行通信:

  • 第一章介绍了并发编程,并讨论了并行执行的一些定律。

  • 第二章讨论了我们可以建模并发的方式以及操作系统和 Go 运行时提供的抽象。本章还比较了并发和并行性。

  • 第三章讨论了使用内存共享进行线程间通信,并介绍了竞态条件。

  • 第四章探讨了不同类型的互斥锁作为解决某些竞态条件的方法。它还展示了如何实现基本的读者-写者锁。

  • 第五章展示了如何使用条件变量和信号量来同步并发执行。本章还描述了如何从头开始构建信号量,并改进上一章中开发的读者-写者锁。

  • 第六章演示了如何构建和使用更复杂的同步机制,例如等待组和屏障。

第二部分讨论了多个执行如何通过消息传递而不是内存共享进行通信:

  • 第七章描述了使用 Go 的通道进行消息传递。本章展示了通道可以以各种方式使用,并说明了通道是如何建立在内存共享和同步原语之上的。

  • 第八章解释了如何使用 Go 的select语句组合多个通道。此外,本章还提供了一些在选择开发并发程序时使用内存共享与消息传递的指南。

  • 第九章提供了重用常见消息传递模式的示例和最佳实践。本章还展示了在语言(如 Go)中通道是一等对象时的灵活性。

第三部分探讨了常见的并发模式和一些更高级的主题:

  • 第十章列出了分解问题的技术,以便通过使用并发编程使程序运行得更高效。

  • 第十一章说明了在存在并发的情况下,死锁情况如何发展,并描述了避免这些情况的各种技术。

  • 第十二章处理互斥锁的内部结构。它解释了互斥锁如何在内核和用户空间中实现。

如何阅读本书

没有并发经验的开发者应将本书视为一次旅程,从第一章开始,一直读到结尾。每一章都教授新的技能和技术,这些技能和技术建立在之前章节获得的知识之上。

对于已经有一定并发经验的开发者来说,可以阅读第一章和第二章,作为操作系统如何模拟并发的复习,然后决定是否跳过一些更高级的主题。例如,已经熟悉竞态条件和互斥锁的读者可能会选择在第五章继续学习条件变量。

关于代码

书中列表中的源代码使用固定宽度字体,以区别于文档的其他部分,Go 中的关键字设置为粗体。许多列表旁边都有代码注释,突出重要概念。

要下载本书中的所有源代码,包括练习解答,请访问github.com/cutajarj/ConcurrentProgrammingWithGo。书中示例的完整代码也可以从 Manning 网站www.manning.com/books/learn-concurrent-programming-with-go下载。您还可以从本书的 liveBook(在线)版本中获取可执行的代码片段livebook.manning.com/book/learn-concurrent-programming-with-go

本书中的源代码需要 Go 编译器,可以从go.dev/doc/install下载。请注意,本书中的一些源代码在 Go 的在线沙盒中可能无法正确运行——沙盒被阻止执行某些操作,例如打开网络连接。

liveBook 讨论论坛

购买《用 Go 学习并发编程》包括对 Manning 的在线阅读平台 liveBook 的免费访问。使用 liveBook 的独特讨论功能,您可以在全球范围内或对特定章节或段落附加评论。为自己做笔记、提问和回答技术问题以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/book/learn-concurrent-programming-with-go/discussion。您还可以在livebook.manning.com/discussion了解更多关于 Manning 论坛和行为准则的信息。

Manning 对我们读者的承诺是提供一个场所,让读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要本书仍在印刷中,论坛和先前讨论的存档将从出版社的网站提供。

关于作者

图片

詹姆斯·库塔贾尔是一位对可扩展、高性能计算和分布式算法感兴趣的软件开发者。他在多个行业中从事技术工作已超过 20 年。在其职业生涯中,詹姆斯一直是开源贡献者、博主(www.cutajarjames.com)、技术布道者、Udemy 讲师和作者。当他不编写软件时,他喜欢骑摩托车、冲浪、潜水和驾驶轻型飞机。詹姆斯出生在马耳他,在伦敦生活了近十年,现在在葡萄牙生活和工作。

关于封面插图

《用 Go 学习并发编程》封面上的图像是“Homme Tschoukotske”,或“楚科奇人”,取自雅克·格拉塞·德·圣索沃尔的收藏,1788 年出版。每一幅插图都是手工精细绘制和着色的。

在那些日子里,人们通过他们的着装很容易就能识别出他们住在哪里以及他们的职业或社会地位。曼宁通过基于几个世纪前丰富多样的地区文化的书封面来庆祝计算机行业的创新和主动性,这些文化通过如这一系列图片的图片被重新带回生活。

第一部分:基础

我们如何编写指令,使得某些操作与其他操作同时执行?在本书的第一部分,我们将探讨如何在编程中模拟并发的基础知识。我们将看到模拟和执行并发程序需要硬件、操作系统和编程语言的帮助。

当我们开发并发程序时,会遇到在顺序代码中不存在的编程错误。这些错误被称为竞争条件,可能是最难识别和修复的错误之一。并发编程的大部分工作涉及学习如何防止代码中出现这类错误。在本书的这一部分,我们将了解竞争条件,然后讨论避免这些错误的各种技术。

1 进入并发编程

本章涵盖

  • 介绍并发编程

  • 使用并发执行提高性能

  • 扩展我们的程序

认识 Jane Sutton。Jane 已经在 HSS 国际会计师事务所担任软件开发者工作了三个月。在她的最新项目中,她一直在研究薪资系统中的一个问题。薪资软件模块在业务结束后运行于月底,并为 HSS 客户的员工计算所有薪资支付。Jane 的经理安排了与产品负责人、基础设施团队和一位销售代表的会议,试图找到问题的根源。出乎意料的是,CTO Sarika Kumar 通过视频会议加入了会议室。

产品负责人 Thomas Bock 开始说:“我不明白。我记得薪资模块一直运行得很好。突然,上个月,支付计算没有按时完成,我们收到了大量来自客户的投诉。这让我们在 Block Entertainment 面前显得非常不专业,他们是我们的新客户,也是迄今为止最大的客户,他们威胁要转向我们的竞争对手。”

Jane 的经理 Francesco Varese 插话道:“问题在于计算太慢,耗时过长。由于它们的复杂性质,需要考虑许多因素,如员工缺勤、入职日期、加班和成千上万的其它因素。软件的部分部分是在十多年前用 C++编写的。公司里没有开发者能理解这段代码是如何工作的。”

“我们即将签约我们最大的客户,一家拥有超过 30,000 名员工的公司。他们已经听说我们的薪资问题,并希望在签订合同之前看到问题得到解决。我们尽快解决这个问题非常重要,”来自销售和收购部门的 Rob Gornall 回应道。

“我们尝试在运行模块的服务器上添加更多处理器核心和内存,但这完全没有效果。当我们使用测试数据执行薪资计算时,无论我们分配多少资源,所需时间都是相同的。计算所有客户的薪资需要超过 20 小时,这对我们的客户来说太长了,”来自基础设施的 Frida Norberg 继续说道。

轮到 Jane 终于发言了。作为公司的新员工,她有些犹豫,但设法说道:“如果代码没有以利用额外核心的方式编写,那么分配多个处理器也没有用。代码需要使用并发编程,这样在添加更多处理资源时才能运行得更快。”

似乎每个人都承认简是这个主题最博学的人。短暂的停顿之后,简感觉好像每个人都希望她给出某种答案,于是她继续说:“对,好吧。我一直在用 Go 编写的简单程序进行实验。它将工资单分成更小的员工组,然后对每个组调用工资模块。我已经编程使其使用多个 goroutine 并发调用模块。我还使用 Go 通道来负载均衡工作负载。最后,我还有一个 goroutine 通过另一个通道收集结果。”

简迅速环顾四周,看到每个人的脸上都露出茫然的神情,于是她补充说:“在模拟中,它在相同的多核硬件上至少快了五倍。还有一些测试要运行以确保没有竞争条件,但我相当确信我可以让它运行得更快,特别是如果我能从会计那里得到一些帮助,将一些旧的 C++ 逻辑迁移到干净的 Go 并发代码中。”

简的经理现在脸上带着大大的笑容。会议上的其他人似乎都感到惊讶和无言。CTO 最后开口说:“简,你需要在月底前完成这项工作,你需要什么?”

并发编程是一种越来越受到科技公司青睐的技能。它是一种在几乎每个开发领域都使用的技巧,从网页开发到游戏编程,再到后端业务逻辑、移动应用、加密技术以及许多其他领域。企业希望充分利用硬件资源,因为这可以节省他们的时间和金钱。为了实现这一点,他们明白他们必须雇佣合适的人才——能够编写可扩展并发应用程序的开发者。

1.1 关于并发

在这本书中,我们将关注并发编程的原则和模式。我们如何编写同时发生的指令?我们如何管理并发执行,以确保它们不会相互干扰?我们应该使用什么技术使执行协作解决共同问题?何时以及为什么应该使用一种通信形式而不是另一种?我们将通过使用 Go 编程语言来回答所有这些问题以及更多。Go 为我们提供了一套完整的工具来展示这些概念。

如果你在并发编程方面经验很少或没有经验,但有一些使用 Go 或类似 C 风格语言的经验,这本书非常适合你。本书从操作系统中并发概念的温和介绍开始,描述了 Go 如何使用它们来模拟并发。然后,我们将继续解释竞争条件以及为什么它们在某些并发程序中发生。稍后,我们将讨论我们可以实现执行之间通信的两种主要方式:内存共享和消息传递。在本书的最后几章中,我们将讨论并发模式、死锁以及一些高级主题,如自旋锁。

除了帮助我们作为开发者获得雇佣或晋升外,了解并发编程还给我们提供了一组更广泛的技能,我们可以在新的场景中使用这些技能。例如,我们可以模拟同时发生的复杂商业交互。我们还可以通过迅速处理任务来使用并发编程提高我们软件的响应性。与顺序编程不同,并发编程可以利用多个 CPU 核心,这使我们能够通过加快执行速度来增加程序完成的工作量。即使只有一个 CPU 核心,并发也提供了好处,因为它实现了时间共享,并允许我们在等待 I/O 操作完成时执行任务。现在让我们更详细地看看这些场景中的一些。

1.2 与并发世界交互

我们生活和工作在一个并发世界中。我们编写的软件模拟了并发交互的复杂商业流程。即使是简单的业务通常也有许多这些并发交互。例如,考虑多个人同时在线订购,或者如图 1.1 所示,整合过程将包裹分组在一起,同时协调正在进行的运输。

图片

图 1.1 显示复杂并发交互的整合运输流程

在我们的日常生活中,我们时刻都在处理并发。每次我们开车,我们都会与多个并发参与者互动,例如其他车辆、骑自行车的人和行人。在工作时,我们可能会在等待电子邮件回复时暂停一项任务,然后继续下一项任务。在烹饪时,我们规划我们的步骤,以便最大化我们的生产力并缩短烹饪时间。我们的大脑非常适应管理并发行为。事实上,它一直在这样做,而我们甚至没有注意到。

并发编程是编写代码,以便多个任务和进程可以同时执行和交互。如果两个客户同时下单,而只有一件库存商品,会发生什么?如果每次客户购买机票时机票价格都会上涨,那么当多个机票在同一确切时刻预订时会发生什么?如果我们由于额外需求而突然增加负载,当我们增加处理和内存资源时,我们的软件将如何扩展?这些都是开发者在设计和编程并发软件时需要处理的场景。

1.3 提高吞吐量

对于现代开发者来说,了解如何进行并发编程变得越来越重要。这是因为随着硬件景观的变化,这种编程类型得到了益处。

在多核技术出现之前,处理器性能与时钟频率和晶体管数量成比例增加,大约每两年翻一番。由于过热和功耗,处理器工程师开始遇到物理极限,这与移动硬件的爆炸性增长相吻合,例如笔记本电脑和智能手机。为了减少过度的电池消耗和 CPU 过热,同时增加处理能力,工程师引入了多核处理器。

此外,云计算服务的兴起使得开发者可以轻松访问大量廉价的处理资源,他们可以在这些资源上运行他们的代码。只有当我们的代码以充分利用额外处理单元的方式编写时,这种额外的计算能力才能被有效地利用。

定义 横向扩展是指通过在多个处理资源(如处理器和服务器机器)上分配负载来提高系统性能(见图 1.2)。纵向扩展是指通过获取更快的处理器来提高现有资源。

图片

图 1.2 通过添加更多处理器来提高性能

拥有多个处理资源意味着我们可以进行横向扩展。我们可以使用额外的处理器并行执行任务,从而更快地完成任务。只有当我们以充分利用额外处理资源的方式编写代码时,这才能成为可能。

那么,如果一个系统只有一个处理器,如果我们的系统没有多个处理器,编写并发代码有什么优势吗?结果是,即使在这样的情况下,编写并发程序也有好处。

大多数程序只花费很少的时间在处理器上执行计算。例如,考虑一个等待键盘输入的文字处理器,或者一个文本文件搜索实用程序,它在运行时的大部分时间都在等待文本文件的部分从磁盘加载。我们可以在程序等待 I/O 时执行不同的任务。例如,当用户在思考下一个要输入的内容时,文字处理器可以对文档进行拼写检查。我们可以在将下一个文件读入内存的另一部分时,让文件搜索实用程序查找与我们已加载到内存中的文件匹配的内容。

作为另一个例子,想想烹饪或烘焙一道喜欢的菜肴。如果在菜肴在烤箱或炉子上时,我们做一些其他的事情而不是只是等待(见图 1.3),我们就可以更有效地利用我们的时间。这样,我们就能更有效地利用我们的时间,提高我们的生产力。这与我们的程序在等待网络消息、用户输入或文件写入时在 CPU 上执行其他指令类似。这意味着我们的程序可以在相同的时间内完成更多的工作。

图片

图 1.3 即使只有一个处理器,如果我们利用空闲时间,也可以提高性能。

1.4 提高响应性

并发编程使我们的软件更具响应性,因为我们不需要等待一个任务完成后再响应用户的输入。即使我们只有一个处理器,我们也可以暂停一组指令的执行,响应用户的输入,然后在等待下一个用户输入时继续执行。

如果我们再次考虑文字处理器,在我们输入时,可能会有多个任务在后台运行。有一个任务监听键盘事件并在屏幕上显示每个字符。我们可能还有一个任务在后台检查我们的拼写和语法。另一个任务可能正在运行,为我们提供文档的统计数据(单词计数、页数等)。定期,我们可能还有一个任务自动保存我们的文档。所有这些任务一起运行,给人一种它们似乎同时运行的印象,但实际上,这些任务是由操作系统在 CPU 上快速切换的。图 1.4 展示了这三个任务在一个处理器上执行的简化时间线。这种交错系统是通过结合使用硬件中断和操作系统陷阱来实现的。

图片

图 1.4 文字处理器中的简化任务交错

我们将在下一章中更详细地介绍操作系统和并发。现在,重要的是要意识到,如果没有这种交错系统,我们就必须一个接一个地执行每个任务。我们必须输入一个句子,然后点击拼写检查按钮,等待它完成,然后点击另一个按钮并等待文档统计信息出现。

1.5 Go 语言中的并发编程

Go 语言是学习并发编程的一个非常好的语言,因为它的创造者设计它时考虑了高性能的并发。他们的目标是创建一个在运行时效率高、可读性强且易于使用的语言。

1.5.1 一瞥 goroutines

Go 使用一种轻量级结构,称为goroutine,来模拟并发执行的基本单元。正如我们将在下一章中看到的,goroutines 为我们提供了一个用户级线程系统,这些线程在一组内核级线程上运行,并由 Go 的运行时管理。

由于 goroutines 的轻量级特性,该语言的前提是我们应该主要关注编写正确的并发程序,让 Go 的运行时和硬件机制处理并行性。原则是,如果你需要并发执行某事,创建一个 goroutine 来完成它。如果你需要并发执行许多事情,创建你需要的那么多 goroutine,无需担心资源分配。然后,根据你的程序运行的硬件和环境,你的解决方案将进行扩展。

除了 goroutines 之外,Go 还为我们提供了许多抽象,使我们能够协调在共同任务上的并发执行。其中一种抽象称为通道。通道允许两个或更多 goroutines 相互传递消息。这使得信息交换和多个执行的同步变得简单直观。

1.5.2 使用 CSP 和原语建模并发

1978 年,C.A.R. Hoare 首次将通信顺序进程(CSP)描述为一种用于表达并发交互的正式语言。许多语言,如 Occam 和 Erlang,都受到了 CSP 的影响。Go 试图实现 CSP 的许多想法,例如使用同步通道。

这种具有隔离 goroutines 通过通道进行通信和同步的并发模型(见图 1.5)降低了竞态条件的风险——这类编程错误发生在不良的并发编程中,通常很难调试,并可能导致数据损坏和意外行为。这种类型的并发建模更类似于我们在日常生活中遇到的情况,例如当我们有隔离的执行(人、进程或机器)并发工作时,通过相互发送消息进行通信。

图 1.5 使用 CSP 的并发 Go 应用程序

根据问题,与内存共享一起使用的经典并发原语(如许多其他语言中找到的互斥锁和条件变量)有时会做得更好,并产生更好的性能,比使用 CSP 风格的编程更好。幸运的是,Go 为我们提供了这些工具,除了 CSP 风格的工具之外。当 CSP 不是合适的模型时,我们可以退回到其他经典原语。

在这本书中,我们将故意从使用经典原语进行内存共享和同步开始。目的是当我们讨论 CSP 风格的并发编程时,你将拥有坚实的传统锁定和同步原语的基础。

1.5.3 构建自己的并发工具

在这本书中,你将学习如何使用各种工具来构建并发应用程序。这包括诸如互斥锁、条件变量、通道、信号量等并发构造。

知道如何使用这些并发工具是好的,但了解它们的内部工作原理呢?在这里,我们将更进一步,从零开始构建它们,即使它们在 Go 的库中可用。我们将选择常见的并发工具,看看它们如何使用其他并发原语作为构建块来实现。例如,Go 没有内置的信号量实现,因此除了理解何时以及如何使用信号量之外,我们还将自己实现一个。我们还将为 Go 中可用的某些工具做同样的事情,例如等待组和通道。

这个想法类似于拥有实现知名算法的知识。我们可能不需要知道如何实现排序算法就能使用排序函数;然而,了解算法的工作原理使我们接触到不同的场景和新思维方式,使我们成为更好的程序员。然后我们可以将这些场景应用到不同的问题中。此外,了解并发工具是如何构建的,使我们能够更明智地决定何时以及如何使用它。

1.6 性能扩展

性能 可扩展性是衡量程序在可用资源数量增加时速度提升的指标。为了理解这一点,让我们尝试使用一个简单的类比。

想象一下,我们是一个房地产开发商。我们当前的项目是建造一栋小型多层住宅。我们给建筑工人一份建筑图纸,他们就开始建造小房子。所有工作都在八个月的时间内完成。

那个项目一完成,我们就收到了另一个相同建造请求,但地点不同。为了加快进度,我们雇佣了两个建筑工人而不是一个。这次,建筑工人只用了四个月就完成了房子的建造。

下次我们被要求建造同样的房子时,我们雇佣了更多的帮手,以便更快地完成房子。这次我们雇佣了四个建筑工人,他们花了两个半月的时间才完成。建造这所房子的成本比之前的那所房子要高一些。雇佣四个建筑工人两个半月的花费比雇佣两个建筑工人四个月的花费要多(假设他们的收费率相同)。

我们再次进行了两次实验,一次是雇佣 8 个建筑工人,另一次是雇佣 16 个。无论是 8 个还是 16 个建筑工人,房子都需要两个月才能完成。似乎无论我们投入多少人力,都无法在两个月内完成建造。用技术术语来说,我们达到了可扩展性极限。为什么会这样?为什么我们不能继续加倍我们的资源(人力、资金或处理器)并总是将所需时间减半?

1.6.1 Amdahl 定律

在 1967 年,计算机科学家 Gene Amdahl 在一次会议上提出了一个公式,用来衡量问题并行到顺序比的速度提升。这被称为 Amdahl 定律。

定义 Amdahl 定律 表示,通过优化系统的一个部分所获得的总体性能提升受到该改进部分实际使用时间的比例限制。

在我们的房屋建造场景中,可扩展性受多种因素限制。首先,我们解决问题的方法可能限制了我们的能力。例如,在建造第一层之前不能建造第二层。此外,建造的几个部分只能顺序完成。例如,如果只有一条道路通往建筑工地,任何时候只能有一辆运输工具使用这条道路。换句话说,建筑过程中的某些部分是顺序的(一个接一个),而其他部分可以并行完成(同时进行)。这些因素影响并限制了我们的任务的可扩展性。

阿姆达尔定律告诉我们,执行的不可并行部分充当瓶颈,并限制了并行执行的优势。图 1.6 显示了随着处理器数量的增加而获得的理论速度提升之间的关系。

图 1.6 根据阿姆达尔定律,速度提升与处理器数量的关系

如果我们将这张图应用于我们的构建问题,当我们使用单个构建者并且他们花费 5%的时间在只能顺序完成的部件上时,可扩展性遵循图表中最上面的线条(95%并行)。这部分顺序操作是只能由一个人完成的,例如通过狭窄的道路运输建筑材料。

如您从图表中可以看到,即使有 512 人在建造工作中,我们也只能比只有 1 个人时快大约 19 倍完成工作。在此之后,情况并没有多大改善。我们需要超过 4,096 名建造者才能使项目快 20 倍完成。我们在这个数字附近遇到了硬限制。雇佣更多的工人根本不会有所帮助,我们只是在浪费金钱。

如果可并行化的工作比例更低,情况会更糟。以 90%的比例,我们会在 512 个工作者的标记处达到这个可扩展性限制。以 75%的比例,我们会在 128 个工作者处达到,以 50%的比例在仅 16 个工作者处达到。请注意,不仅仅是这个限制在下降——速度提升也大大减少。当工作量为 90%、75%和 50%可并行化时,我们分别获得最大速度提升为 10、4 和 2。

阿姆达尔定律为并发编程和并行计算描绘了一幅相当黯淡的图景。即使并发代码只有极小比例的串行处理,可扩展性也会大大降低。幸运的是,这并不是全部的图景。

1.6.2 古斯塔夫森定律

在 1988 年,两位计算机科学家约翰·L·古斯塔夫森和爱德华·H·巴里斯重新评估了阿姆达尔定律,并发表了一篇文章,讨论了其一些不足之处(“重新评估阿姆达尔定律”,dl.acm.org/doi/pdf/10.1145/42411.42415)。这篇文章给出了关于并行限制的另一种观点。他们的主要论点是,在实践中,当我们能够访问更多资源时,问题的大小会发生变化。

继续我们的房屋建造类比,如果我们确实有数千名建筑工人可供使用,当我们有未来的项目在管道中时,将他们全部用于建造一个小房子将是浪费的。相反,我们会尝试将最佳数量的建筑工人用于我们的房屋建设,并将剩余的工人分配到其他项目中。

假设我们在开发软件时拥有大量的计算资源。如果我们注意到利用一半的资源就能达到相同的软件性能,我们可以将额外的资源分配去做其他事情,例如在其他区域提高该软件的准确性或质量。

反对阿姆达尔定律的第二个论点是,当你增加问题的大小,问题的非并行部分通常不会与问题大小成比例增长。事实上,古斯塔夫森认为,对于许多问题,这一点保持不变。因此,当你考虑这两个点时,加速可以与可用的并行资源成线性比例。这种关系在图 1.7 中显示。

图 1.7 根据古斯塔夫森定律的加速与处理器数量的关系

古斯塔夫森定律告诉我们,只要我们找到方法让我们的额外资源保持忙碌,加速应该会继续增加,而不会受到问题串行部分的限制。然而,这只在串行部分在我们增加问题大小时保持不变的情况下才成立,根据古斯塔夫森的说法,这在许多类型的程序中是成立的。

为了全面理解阿姆达尔定律和古斯塔夫森定律,让我们以一个电脑游戏为例。假设一个具有丰富图形的电脑游戏被编写来利用多个计算处理器。随着时间的推移,计算机变得越来越强大,拥有更多的并行处理核心,我们可以以更高的帧率运行相同的游戏,从而获得更平滑的体验。最终,我们会达到一个点,即我们添加更多的处理器,但帧率不再进一步增加。这发生在我们达到加速极限时。无论我们添加多少处理器,游戏都不会以更高的帧率运行。这就是阿姆达尔定律告诉我们的——如果一个问题有固定的大小并且有一个非并行部分,那么它有一个加速极限。

然而,随着技术的进步和处理器核心数量的增加,游戏设计师将充分利用这些额外的处理单元。尽管帧率可能不会增加,但由于额外的处理能力,游戏现在可以包含更多的图形细节和更高的分辨率。这就是古斯塔夫森定律在起作用。当我们增加资源时,我们期望系统能力有所增加,开发者将充分利用额外的处理能力。

摘要

  • 并发编程使我们能够构建更响应的软件。

  • 并发程序在多个处理器上运行时也可以提供更高的加速。

  • 即使只有一个处理器,只要我们的并发编程能够有效地利用 I/O 等待时间,我们仍然可以增加吞吐量。

  • Go 为我们提供了 goroutines,这是一种用于建模并发执行的轻量级结构。

  • Go 提供了诸如通道等抽象,这些抽象使得并发执行能够进行通信和同步。

  • Go 允许我们选择使用通信顺序进程(CSP)风格的模型,或者使用经典原语来构建我们的并发应用程序。

  • 使用 CSP 风格的模型,我们减少了某些类型并发错误的可能性;然而,对于某些问题,使用经典原语将给我们带来更好的结果。

  • 阿姆达尔定律告诉我们,固定大小问题的性能可扩展性受到执行非并行部分的限制。

  • 古斯塔夫森定律告诉我们,如果我们不断找到让我们的额外资源保持忙碌的方法,加速应该会继续增加,而不会受到问题串行部分的限制。

2 处理线程

本章涵盖

  • 操作系统中的并发建模

  • 区分进程和线程

  • 创建 goroutines

  • 区分并发和并行

操作系统是我们系统资源的守门人。它决定何时以及哪些进程可以访问各种系统资源,包括处理时间、内存和网络。作为开发者,我们不一定需要成为操作系统内部运作的专家。然而,我们需要对其运作方式和它提供的工具有一个良好的理解,以便使我们的编程生活更加轻松。

我们将从这个章节开始,看看操作系统如何管理和分配资源以并发运行多个作业。在并发编程的背景下,操作系统为我们提供了各种工具来帮助我们管理这种并发。其中两个工具,进程和线程,代表我们代码中的并发参与者。它们可以并行执行或交错并相互交互。我们将详细探讨两者之间的区别。稍后,我们还将讨论 goroutines 及其在这个背景中的位置,然后我们将使用 goroutines 创建我们的第一个并发 Go 程序。

2.1 操作系统中的多进程

操作系统如何提供抽象来构建和支持并发程序?多进程(有时称为多道程序设计)是指操作系统可以同时处理多个任务时的术语。这很重要,因为它使我们能够有效地利用 CPU。每当 CPU 空闲时,例如当前作业等待用户输入时,我们可以让操作系统选择另一个作业在 CPU 上运行。

注意:在多进程方面,现代操作系统有各种程序和组件来管理它们的多个作业。了解这个系统和它如何与我们的编程交互可以帮助我们更有效地编程。

无论我们在系统上执行什么作业,无论是我们的家用笔记本电脑还是云服务器,该执行都会通过各种状态。为了完全理解作业所经历的生命周期,让我们选择一个示例并走过这些状态。假设我们在系统上运行一个命令来在大型文本文件中搜索特定的字符串。假设我们的系统是 UNIX 平台,我们使用以下命令:

grep 'hello' largeReadme.md

图 2.1 显示了此作业的路径示例。

图 2.1 单 CPU 系统中操作系统的作业状态

注意:在某些操作系统(如 Linux)中,就绪队列被称为运行队列

让我们一步一步地看看这些状态:

  1. 用户提交字符串搜索作业以执行。

  2. 操作系统将此作业放入作业队列。当作业尚未准备好运行时,它会进入此队列。

  3. 一旦我们的文本搜索处于就绪可运行状态,它就会移动到就绪队列。

  4. 在某个时刻,当 CPU 空闲时,操作系统从就绪队列中提取任务并在 CPU 上开始执行它。在这个阶段,处理器正在运行任务中包含的指令。

  5. 一旦我们的文本搜索任务请求读取文件的指令,操作系统就会将任务从 CPU 中移除并将其放入 I/O 等待队列。在这里,它等待直到请求的 I/O 操作返回数据。如果就绪队列中还有其他任务可用,操作系统将选择它并在 CPU 上执行,从而保持处理器的忙碌。

  6. 该设备将执行并完成 I/O 操作(从文本文件中读取一些字节)。

  7. 一旦 I/O 操作完成,任务就会回到就绪队列。现在它正在等待操作系统将其选中以便继续执行。这种等待期的原因是 CPU 可能正忙于执行其他任务。

  8. 在某个时刻,CPU 再次空闲,操作系统接管文本搜索任务并继续在 CPU 上执行其指令。在这种情况下,典型的指令是尝试从文件中加载的文本中找到匹配项。

  9. 在任务执行过程中,系统可能会在此时引发中断。中断是一种用于停止当前执行并通知系统特定事件的机制。一个称为中断控制器的硬件设备处理来自多个设备的所有中断。然后,该控制器通知 CPU 停止当前任务并开始另一个任务。通常,这个任务涉及调用设备驱动程序或操作系统调度程序。这种中断可能由许多原因引发,例如

    • 一个 I/O 设备完成了一个操作,例如读取文件或网络,甚至是在键盘上的按键。

    • 另一个程序请求一个软件中断。

    • 一个硬件时钟(或计时器)滴答声发生,中断了当前执行。这确保了就绪队列中的其他任务也有机会执行。

  10. 操作系统暂停当前任务的执行,并将任务放回就绪队列。操作系统还将从就绪队列中提取另一个项目并在 CPU 上执行它。操作系统调度算法的任务是确定从就绪队列中挑选哪个任务来执行。

  11. 在某个时刻,我们的任务再次被操作系统调度程序选中,并在 CPU 上继续执行。步骤 4 到 10 在执行过程中通常会重复多次,具体取决于文本文件的大小以及系统上运行的其他任务数量。

  12. 我们的文本搜索完成编程(完成搜索)并终止。

定义步骤 9 和 10 是上下文切换的例子,这发生在系统中断一个任务并且操作系统介入以调度另一个任务时。

每次上下文切换都会产生一些开销——操作系统需要保存当前作业状态,以便稍后可以从中恢复。操作系统还需要加载下一个要执行的作业的状态。这个状态被称为进程上下文块(PCB)。它是一种数据结构,用于存储有关作业的所有详细信息,例如程序计数器、CPU 寄存器和内存信息。

这种上下文切换给人一种同时进行许多任务的印象,即使我们只有一个 CPU。当我们编写并发代码并在只有一个处理器的系统上执行时,我们的代码会创建一系列作业以这种方式运行,以提供更快的响应。当我们有一个多 CPU 的系统时,我们也可以实现真正的并行性,因为我们的作业在不同的执行单元上同时运行。

在 20 世纪 90 年代,许多系统配备了双处理器主板,尽管这些主板通常价格昂贵。第一个双核处理器于 2005 年(来自英特尔)商业化。在提高处理能力和延长电池寿命的驱动下,大多数设备现在都配备了多个核心。这包括云服务器配置、家用笔记本电脑和移动电话。这些处理器的架构通常是共享主内存和总线接口;然而,每个核心都有自己的 CPU 和至少一个内存缓存。操作系统的角色与单核机器相同,区别在于现在调度器必须在多个 CPU 上调度作业。中断的实现相当复杂,这些系统具有高级中断控制器,可以根据场景中断一个处理器或一组处理器。

多处理和分时

尽管许多系统在 20 世纪 50 年代采用了多处理,但这些通常是专门定制的系统。一个例子是美国军事在 20 世纪 50 年代开发的半自动地面环境(SAGE)系统,用于监控空域。SAGE 由许多通过电话线连接的远程计算机组成。SAGE 系统在当时是超前的,其发展催生了今天仍在使用的许多想法,如实时处理、分布式计算和多处理。

后来,在 20 世纪 60 年代,IBM 推出了 System/360。在各种文献中,这被称为第一个真正的操作系统,尽管之前可用的类似系统有不同的名称和称呼(如批处理系统)。

然而,System/360 是第一个能够执行多处理器的商用系统之一。在此之前,在某些系统中,当作业需要从磁带加载数据或保存数据到磁带时,所有处理都会停止,直到系统访问慢速磁带。这导致了执行大量 I/O 的程序的低效率。在这段时间里,CPU 处于空闲状态,无法进行任何有用的工作。解决这个问题的方法是同时加载多个作业,并为每个作业分配一块固定内存。当一个作业等待其 I/O 时,CPU 会切换到执行另一个作业。

大约在这个时候,还出现了一种解决方案,即时间共享的概念。在此之前,当计算机仍然很大,是共享的主机时,编程涉及提交指令并需要等待数小时才能编译和执行作业。如果提交的程序中存在代码错误,程序员直到过程后期才知道。解决这个问题的方法是有一个 时间共享 系统,即许多程序员通过终端连接。由于编程主要是一个思考过程,只有一小部分连接的用户会编译和执行作业。当这些用户需要时,CPU 资源会交替分配给这一小部分用户,从而减少了漫长的反馈时间。

到目前为止,我们模糊地称由操作系统管理的这些执行单元为系统作业。在下一节中,我们将更详细地探讨操作系统如何为我们提供两种主要抽象来模拟这些执行单元。

2.2 使用进程和线程抽象并发

当我们需要执行我们的代码并管理并发(有作业同时运行或看似同时运行)或在多核系统的情况下启用真正的并行性时,操作系统提供了两种抽象:进程和线程。

一个 进程 代表当前在系统上运行的程序。它是操作系统中的一个基本概念。操作系统的主要目的是高效地在许多正在执行的过程中分配系统的资源(如内存和 CPU)。我们可以使用多个进程,并让它们如前节所述并发运行。

一个 线程 是在进程上下文中执行的一个额外结构,它为我们提供了一种更轻量级、更高效的并发方法。正如我们将看到的,每个进程都是以一个执行线程开始的,有时被称为主线程或主要线程。在本节中,我们将探讨使用多个进程来模拟并发与在单个进程中运行许多线程之间的区别。

2.2.1 使用进程的并发

当多个人在任务上工作时,我们如何完成一项大型工作?为了举一个具体的例子,让我们假设我们是一群著名的艺术家,有人委托我们画一幅大型艺术品。截止日期很紧,所以我们必须作为一个团队高效地工作并按时完成。

让我们的艺术家在相同的画上工作的方法之一是给每个人一张单独的纸张,并指示他们绘制完成画作的不同特征。团队成员中的每个人都会在自己的纸张上绘制他们的特征。当每个人都完成时,我们会合并我们的工作。我们可以将各自的纸张粘贴到空白画布上,覆盖纸张边缘的画,然后认为工作完成了。

在这个类比中,不同的团队成员代表我们的 CPU。我们遵循的指令是我们的程序代码。团队成员执行任务(如纸张上绘画)代表一个进程。我们每个人都有自己的资源(纸张、办公空间等),我们独立工作,最后我们聚集在一起合并我们的工作。在这个例子中,我们分两步完成工作。第一步是并行创建绘画的不同部分。第二步是将不同的部分粘合在一起(见图 2.2)。

图片

图 2.2 在执行任务时拥有自己的空间与使用进程类似。

这与操作系统中的进程发生的情况类似。画家的资源(纸张、铅笔等)代表系统资源,例如内存。每个操作系统进程都有自己的内存空间,与其他进程隔离。通常,进程会独立工作,与其他进程的交互最小。进程通过消耗更多资源来提供隔离。例如,如果一个进程由于错误而崩溃,它不会影响其他进程,因为它有自己的内存空间。这种隔离的缺点是我们最终会消耗更多的内存。此外,启动进程需要更长的时间(与线程相比),因为我们需要分配内存空间和其他系统资源。

由于进程之间不共享内存,它们倾向于与其他进程的通信最小化。就像我们的画家类比一样,使用进程来同步和合并工作最终会带来一些挑战。当进程需要相互通信和同步时,我们编程它们使用操作系统工具和其他应用程序,例如文件、数据库、管道、套接字等。

2.2.2 创建进程

进程是对系统如何执行我们的代码的一种抽象。如果我们想以隔离的方式执行我们的代码,告诉操作系统何时创建进程以及它应该执行哪个代码至关重要。幸运的是,操作系统为我们提供了创建、启动和管理进程的系统调用。

例如,Windows 有一个 CreateProcess() 系统调用。这个调用创建进程,分配所需的资源,加载程序代码,并以进程的形式开始执行程序。

或者,在 UNIX 系统上,有一个 fork() 系统调用。使用这个调用,我们可以创建一个执行的副本。当我们从一个正在执行的进程调用这个系统调用时,操作系统会完全复制内存空间和进程的资源处理器,包括寄存器、堆栈、文件处理器,甚至程序计数器。然后,新进程接管这个新的内存空间,并从那个点继续执行。

定义我们将新进程称为 子进程,创建它的进程称为 父进程。这个子父术语也适用于线程,我们将在第 2.2.4 节中探讨。

fork() 系统调用在父进程中返回进程 ID,在子进程中返回 0。在创建两个进程之后,每个进程都可以根据 fork() 系统调用的返回值来决定要执行哪些指令。子进程可以选择使用复制的资源(例如内存中的数据)或者清除它并重新开始。由于每个进程都有自己的内存空间,如果一个进程更改了其内存内容(例如,更改变量的值),另一个进程将不会看到这个更改。图 2.3 展示了在 UNIX 上 fork() 系统调用的结果。

图 2.3 使用 fork() 系统调用创建新进程

如您所想象的那样,由于每个进程都有自己的内存空间,每次您创建一个新进程时,总内存消耗都会增加。除了消耗更多内存外,复制和分配系统资源还需要时间,并消耗宝贵的 CPU 周期。这意味着创建过多的进程会对系统造成沉重的负担。因此,一个程序同时使用大量进程来处理相同的问题是非常不寻常的。

UNIX 进程的写时复制

写时复制(COW)是引入到 fork() 系统调用中的优化。它通过不复制整个内存空间来减少所需的时间。对于使用这种优化的系统,每当调用 fork() 时,子进程和父进程共享相同的内存页面。然后,如果其中一个进程尝试修改一个内存页的内容,该页就会被复制到一个新位置,以便每个进程都有自己的副本。操作系统只复制被修改的内存页面。这是一种节省内存和时间的好方法,但如果一个进程修改了其内存的大部分内容,操作系统最终仍然会复制大多数页面。

在 Go 中,创建和派生进程的支持仅限于syscall包,并且是操作系统特定的。如果我们查看该包,我们将在 Windows 上找到CreateProcess()函数,在 UNIX 系统上找到ForkExec()StartProcess()。Go 还通过调用exec()函数为我们提供了在新的进程中运行命令的能力,抽象了syscall包中的一些操作系统特定函数。然而,Go 中的并发编程通常不依赖于重量级进程。正如我们将看到的,Go 采用了一种更轻量级的线程和 goroutine 并发模型。

当一个进程执行完其代码或遇到无法处理的错误时,它将终止。一旦进程终止,操作系统将回收其所有资源,以便它们可以被其他进程使用。这包括内存空间、打开的文件句柄、网络连接等。在 UNIX 和 Windows 上,当父进程完成后,它不会自动终止子进程。

2.2.3 使用多进程处理常见任务

你是否考虑过当你运行像这样的 UNIX 命令时幕后发生了什么?

$ curl -s https://www.rfc-editor.org/rfc/rfc1122.txt | wc

当我们在 UNIX 系统上运行此命令时,命令行正在派生两个并发进程。我们可以通过打开另一个终端并运行ps -a来检查这一点:

PID   TTY      TIME     CMD
. . .
26013 pts/49   00:00:00 curl
26014 pts/49   00:00:00 wc
. . .

第一个进程(PID 26013,在此示例中)将运行 curl 程序,该程序将从给定的 URL 下载文本文件。第二个进程(PID 26014)将运行单词计数程序。在此示例中,我们将第一个进程(curl)的输出作为第二个进程(wc)的输入通过一个缓冲区(见图 2.4)。使用管道操作符,我们告诉操作系统分配一个缓冲区,并将 curl 进程的输出和单词计数的输入重定向到该缓冲区。当此缓冲区满时,curl 进程会阻塞,而当单词计数进程消耗它时,它会继续。当缓冲区为空时,单词计数进程会阻塞,直到 curl 积累更多数据。

图 2.4 Curl 和 wc 通过管道并发运行

一旦 curl 从网页中读取所有文本,它将终止并在管道上放置一个标记,表示没有更多数据可用。这个标记作为对单词计数进程的信号,表明它可以终止,因为没有更多数据将到来。

2.2.4 使用线程进行并发

进程是并发问题的重量级解决方案。它们为我们提供了良好的隔离性,但消耗了大量的资源,并且创建需要一段时间。

线程是解决使用进程进行并发时出现的一些问题的答案。我们可以将线程视为多个进程的轻量级替代品。创建一个线程要快得多(有时快 100 倍),并且线程消耗的系统资源比进程少。从概念上讲,线程是进程内的另一个执行上下文(类似于微进程)。

让我们继续我们的简单类比,其中我们用一队人画画。而不是让我们的团队成员各自拥有一张纸并独立绘制,我们可以有一个大型的空白画布,并给每个人分发画笔和铅笔。每个人都会共享空间,并直接在大画布上绘制(见图 2.5)。

图片

图 2.5 同时绘制和共享空间类似于使用线程。

这与使用线程时发生的情况相似。就像我们共享画布一样,多个线程将并发执行并共享相同的内存空间。这更有效率,因为我们不需要为每个执行消耗大量的内存。此外,共享内存空间通常意味着我们不需要在结束时合并我们的工作。根据我们正在解决的问题,我们可能通过与其他线程共享内存来更有效地解决问题。

当我们讨论进程时,我们看到了一个进程如何包含资源(程序和内存中的数据)以及正在运行程序的执行。从概念上讲,我们可以将资源与执行分离,因为这使我们能够创建多个执行并共享它们之间的资源。我们将每个单独的执行称为线程(或执行线程)。当你启动一个进程时,它默认包含一个主线程。当我们在一个进程中拥有多个线程时,我们说该进程是多线程的。多线程编程是指我们在同一应用程序中以不同线程协同工作的方式编写代码。图 2.6 展示了两个线程如何共享同一个进程的内存。

图片

图 2.6 线程共享同一进程内存空间

当我们创建一个新的线程时,操作系统只需要创建足够的管理栈空间、寄存器和程序计数器的资源。新线程在同一个进程的上下文中运行。相比之下,当我们创建一个新的进程时,操作系统需要为它分配一个全新的内存空间。因此,线程比进程轻量得多,在系统开始耗尽资源之前,我们通常可以创建比进程多得多的线程。此外,因为需要分配的新资源很少,启动线程比启动进程要快得多。

栈空间里有什么?

栈空间存储函数内部存在的局部变量。这些通常是短生命周期的变量——当函数结束时,它们就不再被使用了。这个空间不包括在函数之间共享的变量(使用指针),这些变量分配在主内存空间,称为

这种额外的性能是有代价的。在相同的内存空间中工作意味着我们无法获得进程提供的隔离性。这可能导致一个线程覆盖另一个线程的工作。在避免这种情况时,多个线程之间的通信和同步非常重要。在我们的画家团队类比中,情况也大致相同。当我们共同在一个项目上工作并共享相同的资源时,我们需要画家之间有良好的沟通和同步。我们需要不断交流我们在做什么以及何时做。如果没有这种合作,我们可能会覆盖彼此的艺术作品,导致结果不佳。

这与我们在多个线程中管理并发的方式相似。由于多个线程共享相同的内存空间,我们需要注意确保线程不会相互覆盖并引起问题。我们通过使用线程通信和同步来实现这一点。在本书中,我们将检查从共享内存中可能出现的错误类型,并提供解决方案。

由于线程共享内存空间,一个线程对主内存所做的任何更改(例如更改全局变量的值)对同一进程中的其他所有线程都是可见的。这是使用线程的主要优势——多个线程可以一起使用这个共享内存来处理相同的问题。这使得我们能够编写非常高效和响应快速的并发代码。

注意:线程不共享栈空间。尽管线程共享相同的内存空间,但重要的是要认识到每个线程都有自己的私有栈空间(如图 2.6 所示)。

每当我们在一个函数中创建一个局部非共享变量时,我们实际上是在栈空间中放置这个变量。因此,这些局部变量只能被创建它们的线程看到。每个线程都有自己的私有栈空间是很重要的,因为它可能调用与其他线程完全不同的函数,并且需要自己的私有空间来存储这些函数中使用的变量和返回值。

我们还需要每个线程都有自己的程序计数器。程序计数器(也称为指令指针)简单地说是指向 CPU 将要执行的下一个指令的指针。由于线程可能会执行我们程序的不同部分,因此每个线程都需要一个独立的指令指针。

当我们拥有多个线程而只有一个核心处理器时,进程中的每个线程都会获得处理器的时间片。这提高了响应性,在需要同时响应多个请求的应用程序中很有用(例如在 Web 服务器中)。如果系统中存在多个处理器(或处理器核心),线程将能够相互并行执行。这使我们的应用程序速度加快。

在本章的早期部分,我们讨论了操作系统如何管理多进程,并讨论了作业处于不同的状态(例如准备运行、运行中、等待 I/O 等)。在一个处理多线程程序的系统里,这些状态描述了系统上每个执行线程的状态。只有准备运行的线程才能被选中并移动到 CPU 进行执行。如果一个线程请求 I/O,系统会将其移动到等待 I/O 状态,依此类推。

当我们创建一个新的线程时,我们在程序中给它一个指令指针,指示新执行应该从哪里开始。许多编程语言隐藏了这个指针的复杂性,并允许程序指定线程应该开始执行的目标函数(或方法或过程)。操作系统只为新的线程状态分配空间,包括栈、寄存器和程序计数器(指向函数)。然后,子线程将与父线程并发运行,共享主内存和其他资源,例如打开的文件和网络连接。

一旦一个线程完成其执行,它就会终止,操作系统会回收栈内存空间。然而,根据线程的实现方式,线程的终止并不一定意味着整个进程都会终止。在 Go 语言中,当主执行线程终止时,整个进程也会终止,即使其他线程仍在运行。这与某些其他语言不同。例如,在 Java 中,进程只有在进程中的所有线程都完成时才会终止。

操作系统和编程语言以不同的方式实现线程。例如,在 Windows 上,我们可以使用CreateThread()系统调用来创建线程。在 Linux 上,我们可以使用带有CLONE_THREAD选项的clone()系统调用来创建线程。语言表示线程的方式也存在差异。例如,Java 将线程建模为对象,Python 使用全局解释器锁来阻止多个线程并行执行,而在 Go 语言中,正如我们将看到的,有一个更细粒度的 goroutine 概念。

POSIX Threads

IEEE 试图通过一个称为 POSIX Threads(简称 pthreads)的标准来标准化线程实现。这些线程通过使用标准的 POSIX Threads API 来创建、管理和同步。包括 Windows 和 UNIX 系统在内的各种操作系统都提供了这个标准的实现。不幸的是,并非所有语言都支持 POSIX Thread 标准。

尽管线程的创建、建模和销毁方式存在差异,但无论使用什么技术,编码并发程序所涉及的并发概念和技术都将非常相似。因此,了解一种语言中多线程编程的模型、技术和工具将有助于你在任何语言中使用。差异仅在于语言的多线程实现细节。

2.2.5 实际中的多线程应用程序

现在让我们看看一个利用多线程在 Web 服务器应用程序中的示例。假设我们已经开发了一个应用程序,通过服务向用户提供他们最喜欢的体育队伍的信息和比分。这个应用程序运行在服务器上,并通过用户的移动或桌面浏览器处理用户的请求。例如,保罗可能想知道他最喜欢的球队纽约巨人队正在进行的足球比赛的最新比分。这个应用程序的一个架构如图 2.7 所示。它由两个主要部分组成:客户端处理线程和流读取线程。

图片

图 2.7 提供体育比分的 Web 服务器应用程序

流读取线程通过网络连接从体育直播中读取比赛事件。每个接收到的消息都会告诉应用程序特定游戏中的发生情况。例如,得分、犯规、场上的球员等。流读取线程使用这些信息来构建比赛的画面,将每场比赛的比分存储在共享的体育比分数据结构中。

每个客户端处理线程负责处理用户请求。根据用户发出的请求,线程将从体育比分数据结构中查找并读取所需的比赛信息。然后,它将信息返回给用户的设备。我们有一组这样的线程,这样我们就能同时处理多个请求,而不会让用户等待太长时间得到回复。

使用线程来实现这种类型的服务器应用程序有两个好处:

  • 我们消耗的资源更少。我们可以启动多个客户端处理线程,而不会占用太多内存。此外,我们可以动态调整这个池的大小,在预期流量增加时增加线程数,在较不繁忙的时期减少它。我们可以这样做,因为创建和终止线程的成本低且速度快(相对于使用进程)。

  • 我们有选择使用内存来存储和共享体育比分数据结构。当使用线程时,这很容易做到,因为它们共享相同的内存空间。

2.2.6 一起使用多个进程和线程

现在让我们考虑一个混合示例,比如现代浏览器,它可以使用进程和线程。当浏览器渲染网页时,它需要下载下载页面的各种资源:文本、图像、视频等。为了高效地完成这项任务,浏览器可以使用多个线程同时下载并渲染页面的各个元素。线程非常适合这种工作,因为结果页面可以保存在线程的共享内存中,而线程可以在完成任务时填充它。

如果页面包含一些需要大量计算(如图形)的脚本,我们可以分配更多的线程来执行这个计算,可能在多核 CPU 上并行执行。但是,当其中一个脚本行为不当并崩溃时会发生什么?它也会杀死浏览器中所有其他打开的窗口和标签页吗?

这就是进程可能派上用场的地方。我们可以设计浏览器以利用进程的隔离性,也许为每个窗口或标签页使用一个单独的进程。这确保了当某个网页由于错误的脚本而崩溃时,不会导致一切崩溃,确保包含你长篇草稿电子邮件的标签页不会丢失。

现代浏览器出于这个原因采用了混合线程和进程系统。通常,它们对可以创建的进程数量有限制,超过这个限制后,标签页开始共享同一个进程。这样做是为了减少内存消耗。

2.3 goroutine 有什么特别之处?

Go 语言对并发的回答是 goroutine。正如我们将看到的,它并不直接与操作系统线程绑定。相反,goroutine 由 Go 的运行时在更高层次上管理,以给我们提供一个更轻量级的构造,消耗的资源远少于操作系统线程。在本节中,我们将首先探讨如何创建 goroutine,然后再描述 goroutine 在操作系统线程和进程中的位置。

2.3.1 创建 goroutine

现在我们来看看如何在 Go 中创建 goroutine,我们将一个顺序程序转换为并发程序。我们将从以下顺序程序开始。

列表 2.1 模拟执行某些工作的函数

package main

import (
    "fmt"
    "time"
)

func doWork(id int) {
    fmt.Printf("*Work %d started at %s\n*",id,time.Now().Format("*15:04:05*"))
    time.Sleep(1 * time.Second)                                            ❶
    fmt.Printf("*Work %d finished at %s\n*",id,time.Now().Format("*15:04:05*"))
}

❶ 通过睡眠 1 秒来模拟执行计算工作

注意:访问github.com/cutajarj/ConcurrentProgrammingWithGo以查看本书中的所有列表。

如您所见,我们有一个模拟执行一些工作的函数。这项工作可以是任何东西,比如长时间运行的 CPU 计算或从网页上下载某些内容。在函数中,我们传递一个整数作为工作的标识符。然后我们通过将执行暂停 1 秒来模拟执行一些工作。在睡眠期结束后,我们将包含工作标识符的消息打印到控制台,以表示我们已经完成了工作。我们还在开始和结束时打印时间戳,以显示函数执行所需的时间。

让我们连续多次运行这个函数。在列表 2.2 中,我们使用循环调用函数五次,每次传递一个不同的i值,从0开始,到4结束。这个main()函数将在我们的主执行线程中运行,doWork()函数将以相同的执行顺序依次调用。

列表 2.2 main()线程依次调用doWork()函数

func main() {
    for i := 0; i < 5; i++ {
        doWork(i)
    }
}

如您所预期的那样,输出列表按顺序列出工作标识符,每个标识符需要 1 秒:

$ go run main.go
Work 0 started at 19:41:03
Work 0 finished at 19:41:04
Work 1 started at 19:41:04
Work 1 finished at 19:41:05
Work 2 started at 19:41:05
Work 2 finished at 19:41:06
Work 3 started at 19:41:06
Work 3 finished at 19:41:07
Work 4 started at 19:41:07
Work 4 finished at 19:41:08

整个程序大约需要 5 秒钟才能完成。当主线程没有更多的指令可以执行时,它会终止整个进程。

我们如何修改我们的指令,以便我们能够并行执行这项工作而不是顺序执行?我们可以在 goroutine 中放置对doWork()函数的调用,如列表 2.3 所示。与我们的先前顺序程序相比,有两个主要的变化。第一个变化是我们使用关键字go调用doWork()函数。结果是该函数在单独的执行中并行运行。main()函数不会等待它完成就继续执行。相反,它继续执行下一个指令,在这种情况下是创建更多的 goroutine。

列表 2.3 主线程并行调用doWork()函数

func main() {
    for i := 0; i < 5; i++ {
        go doWork(i)              ❶
    }
    time.Sleep(2 * time.Second)   ❷
}

❶ 启动一个新的 goroutine 来调用 doWork()函数

❷ 使用更长的 sleep 等待所有工作完成

我们也可以将这种调用函数的方式称为异步调用,这意味着我们不需要等待函数完成就可以继续执行。我们可以将正常的函数调用称为同步调用,因为我们需要在继续执行其他指令之前等待函数返回。

我们对main()函数的第二个变化是在我们异步调用doWork()函数后,main()函数休眠 2 秒钟。这个 sleep 指令是必要的,因为在 Go 中,当主执行没有更多的指令可以运行时,进程会终止。如果没有这个 sleep,进程就会在没有给 goroutine 运行机会的情况下终止。如果我们尝试省略这个语句,程序在控制台上将不会输出任何内容。列表 2.3 中程序的输出将类似于以下内容:

$ go run main.go
Work 2 started at 20:53:10
Work 1 started at 20:53:10
Work 3 started at 20:53:10
Work 4 started at 20:53:10
Work 0 started at 20:53:10
Work 0 finished at 20:53:11
Work 2 finished at 20:53:11
Work 3 finished at 20:53:11
Work 4 finished at 20:53:11
Work 1 finished at 20:53:11

首先要注意的是,程序大约在 2 秒内完成,而不是顺序版本所需的 5 秒。这仅仅是因为我们现在正在并行执行工作。我们不再是一个接一个地工作,完成一个然后开始另一个,我们一次完成所有的工作。您可以在图 2.8 中看到这种表示。在图的部分a中,我们有这个程序的顺序版本,显示了doWork()函数被多次调用,一次接一次。在部分b中,我们有 goroutine 执行main()函数并产生五个子 goroutine,每个子 goroutine 都并发调用doWork()函数。

图片

图 2.8 (a) 顺序调用doWork()函数 (b) 和并发调用函数

当我们运行 Go 程序时,需要注意的第二件事是函数消息输出的顺序已经改变。程序不再按顺序输出工作标识符。相反,它们似乎随机出现。再次运行程序会给我们不同的顺序:

$ go run main.go
Work 0 started at 20:58:13
Work 3 started at 20:58:13
Work 4 started at 20:58:13
Work 1 started at 20:58:13
Work 2 started at 20:58:13
Work 2 finished at 20:58:14
Work 1 finished at 20:58:14
Work 0 finished at 20:58:14
Work 4 finished at 20:58:14
Work 3 finished at 20:58:14

这是因为当我们并发运行作业时,我们永远无法保证这些作业的执行顺序。当我们的main()函数创建了五个 goroutines 并将它们提交时,操作系统可能会以不同于我们创建它们的顺序来选择执行。

2.3.2 在用户空间中实现 goroutines

在本章前面,我们讨论了操作系统进程和线程,并讨论了它们的不同和角色。goroutine 在这个背景下属于哪里?goroutine 是一个独立的进程还是一个轻量级线程?

事实证明,goroutines 既不是操作系统线程也不是进程。Go 语言的规范并没有严格规定 goroutines 应该如何实现,但当前的 Go 实现将一组 goroutine 执行分组到另一组操作系统线程执行上。为了更好地理解这一点,让我们首先谈谈另一种执行线程的建模方式,称为用户级线程。

在上一节中,我们讨论了存在于进程内部的线程并由操作系统管理。操作系统了解所有关于线程的信息,并决定何时或是否应该执行每个线程。操作系统还存储每个线程的上下文(寄存器、堆栈和状态),并在线程需要执行时使用它。我们将这类线程称为内核级线程,因为操作系统管理它们。每当需要上下文切换时,操作系统就会介入并选择下一个要执行的线程。

我们可以在内核级别之外实现线程,这样线程就可以完全在用户空间中运行,这意味着它是我们应用程序内存空间的一部分,而不是操作系统的空间。使用用户级线程就像在主内核级线程内部运行不同的执行线程,如图 2.9 所示。

图片

图 2.9 用户级线程在单个内核级线程中执行

从操作系统的角度来看,包含用户级线程的进程看起来只有一个执行线程。操作系统对用户级线程一无所知。进程本身负责管理、调度和上下文切换自己的用户级线程。为了执行这个内部上下文切换,需要一个单独的运行时来维护一个包含每个用户级线程所有数据(如状态)的表。我们在进程的主线程内部复制了操作系统在线程调度和管理方面所做的小规模工作。

用户级线程的主要优势是性能。用户级线程的上下文切换比内核级线程的上下文切换要快。这是因为对于内核级上下文切换,操作系统需要介入并选择下一个要执行的线程。当我们可以在不调用任何内核的情况下切换执行时,执行进程可以保持对 CPU 的控制,而无需刷新其缓存并减慢速度。

使用用户级线程的缺点在于当它们执行调用阻塞 I/O 调用的代码时。考虑我们需要从文件中读取的情况。由于操作系统将进程视为只有一个执行线程,如果一个用户级线程执行这个阻塞读取调用,整个进程将被取消调度。如果同一进程中存在其他用户级线程,它们将无法执行,直到读取操作完成。这并不理想,因为拥有多个线程的一个优势是在其他线程等待 I/O 时执行计算。为了克服这种限制,使用用户级线程的应用程序往往使用非阻塞调用来执行它们的 I/O 操作。然而,使用非阻塞 I/O 并不理想,因为并非每个设备都支持非阻塞调用。

用户级线程的另一个缺点是,如果我们有一个多处理器或多核系统,我们将在任何给定时间点只能利用一个处理器。操作系统将包含所有用户级线程的单个内核级线程视为单个执行。因此,操作系统在一个处理器上执行内核级线程,所以包含在该内核级线程中的用户级线程将不会以真正的并行方式执行。

那么,绿色线程又如何呢?

“绿色线程”这个术语是在 Java 编程语言的 1.1 版本中提出的。Java 最初的绿色线程是用户级线程的一种实现。它们只在单个核心上运行,并且完全由 JVM 管理。在 Java 1.3 版本中,绿色线程被内核级线程所取代。从那时起,许多开发者开始使用这个术语来指代其他用户级线程的实现。将 Go 的 goroutines 称为绿色线程可能并不准确,因为正如我们将看到的,Go 的运行时允许其 goroutines 充分利用多个 CPU。

为了进一步混淆命名问题,在 Java 的后续版本中引入了一种类似于 Go 的线程模型。然而,这一次,不是绿色线程,而是使用了“虚拟线程”这个名称。

Go 提供了一个混合系统,它为我们提供了用户级线程的优秀性能,而没有大多数缺点。它是通过使用一组内核级线程来实现的,每个线程管理一个 goroutine 队列。由于我们有多于一个的内核级线程,因此如果可用,我们可以利用多个处理器。

为了说明这种混合技术,假设我们的硬件恰好有两个处理器核心。我们可以有一个运行时系统,该系统创建并使用两个内核级线程——每个处理器核心一个——并且每个内核级线程可以管理一组用户级线程。在某个时刻,操作系统将并行调度这两个内核级线程,每个线程在一个单独的处理器上。然后,我们将有一组用户级线程在每个处理器上运行。

M:N 混合线程

Go 为其 goroutines 使用的系统有时被称为 M:N 线程模型。这是当你有 M 个用户级线程(goroutines)映射到 N 个内核级线程时。这与通常的用户级线程形成对比,用户级线程被称为 N:1 线程模型,意味着 N 个用户级线程对应 1 个内核级线程。实现 M:N 模型的运行时比其他模型复杂得多,因为它需要许多技术来在内核级线程集合上移动和平衡用户级线程。

Go 的运行时根据逻辑处理器的数量确定要使用多少内核级线程。这通过名为 GOMAXPROCS 的环境变量设置。如果没有设置此变量,Go 将通过查询操作系统来确定您的系统有多少个 CPU。您可以通过执行以下代码来检查 Go 看到的处理器数量和 GOMAXPROCS 的值。

列表 2.4 检查可用的 CPU 数量

package main

import (
    "*fmt*"
    "*runtime*"
)

func main() {
    fmt.Println("*Number of CPUs:*", runtime.NumCPU())    ❶

    fmt.Println("*GOMAXPROCS:*", runtime.GOMAXPROCS(0))   ❷
}

❶ Go 将 GOMAXPROCS 的默认值设置为 NumCPU()的值。

❷ 当 n < 1 时,调用 GOMAXPROCS(n)将返回当前值而不更改它。

列表 2.4 的输出将取决于其运行的硬件。以下是在具有八个核心的系统上的输出示例:

$ go run cpucheck.go
Number of CPUs: 8
GOMAXPROCS: 8

Go 的运行时将为每个内核级线程分配一个本地运行队列(LRQ)。每个 LRQ 将包含程序中 goroutines 的一个子集。此外,还有一个全局运行队列(GRQ)用于 Go 尚未分配给内核级线程的 goroutines(参见图 2.10 的左侧)。在处理器上运行的每个内核级线程将负责执行其 LRQ 中存在的 goroutines。

图片

图 2.10(a)内核级线程 A 和 B 正在执行它们各自的 LRQ 中的 goroutines;(b)一个 goroutine 正在等待 I/O 阻塞线程 B,导致创建或重用新的线程 C,从之前的线程中窃取工作。

为了解决阻塞调用的问题,Go 会包装任何阻塞操作,以便它知道何时内核级线程即将被调度。当这种情况发生时,Go 会创建一个新的内核级线程(或从池中重用空闲的一个)并将 goroutine 队列移动到这个新线程,该线程从队列中选取一个 goroutine 并开始执行它。然后,带有等待 I/O 的 goroutine 的旧线程被操作系统取消调度。这个系统确保执行阻塞调用的 goroutine 不会阻塞整个本地 goroutine 运行队列(参见图 2.10 右侧)。

在 Go 中,这种将 goroutine 从一个队列移动到另一个队列的系统被称为 工作窃取。工作窃取不仅仅发生在 goroutine 执行阻塞调用时。当队列中 goroutine 的数量不平衡时,Go 也可以使用这种机制。例如,如果某个特定的 LRQ 为空,且内核级线程没有更多的 goroutine 可执行,它将从另一个线程的队列中窃取工作。这确保了我们的处理器在执行更多工作时有平衡的工作负载,并且当有更多工作要执行时,没有一个是空闲的。

锁定到内核级线程

在 Go 语言中,我们可以通过调用 runtime.LockOSThread() 函数强制一个 goroutine 锁定到操作系统线程。这个调用将 goroutine 独特地绑定到其内核级线程。除非 goroutine 调用 runtime.UnlockOSThread(),否则不会有其他 goroutine 在同一 OS 线程上运行。

这些函数可以在我们需要对内核级线程进行特殊控制时使用——例如,当我们与外部 C 库交互并需要确保 goroutine 不会移动到另一个内核级线程,从而造成访问库的问题。

2.3.3 goroutine 调度

当内核级线程在 CPU 上已经公平地分配了时间后,操作系统调度器会将运行队列中的下一个线程切换到。这被称为 *抢占式调度**。它通过一个时钟中断系统实现,该系统停止正在执行的内核级线程并调用操作系统调度器。由于中断只调用操作系统调度器,运行在用户空间的 Go 调度器需要一个不同的系统。

Go 调度器需要执行以进行上下文切换。因此,Go 调度器需要用户级事件来触发其执行(参见图 2.11)。这些事件包括启动一个新的 goroutine(使用关键字 go)、执行系统调用(例如,从文件中读取)或同步 goroutine。

图片

图 2.11 展示了 Go 中的上下文切换需要用户级事件。

我们也可以在我们的代码中调用 Go 调度器,试图让调度器进行上下文切换到另一个 goroutine。在并发术语中,这通常被称为yield命令。这是当线程决定放弃控制权,以便其他线程在 CPU 上获得其轮次时。在下面的列表中,我们使用命令runtime.Gosched()在我们的main() goroutine 中直接调用调度器。

列表 2.5 调用 Go 调度器

package main

import (
    "*fmt*"
    "*runtime*"
)

func sayHello() {
    fmt.Println("*Hello*")
}

func main() {
    go sayHello()
    runtime.Gosched()        ❶
    fmt.Println("*Finished*")
}

❶ 调用 Go 调度器给其他 goroutine 一个运行的机会。

不直接调用调度器,我们几乎没有机会执行sayHello()函数。main() goroutine 将在调用sayHello()函数的 goroutine 在 CPU 上获得任何运行时间之前终止。由于在 Go 中,我们在main() goroutine 终止时退出进程,所以我们不会看到“Hello”文本被打印出来。

警告:我们无法控制调度器将选择哪个 goroutine 来执行。当我们调用 Go 调度器时,它可能会选择其他 goroutine 并开始执行它,或者它可能会继续执行调用调度器的 goroutine。

在列表 2.5 中,调度器可能会再次选择main() goroutine,我们可能永远看不到“Hello”消息。实际上,通过在列表中调用runtime.Gosched(),我们只是在增加sayHello()被执行的机会。但这并不能保证它一定会被执行。

与操作系统调度器一样,我们无法可预测地确定 Go 调度器接下来会执行什么。作为编写并发程序的程序员,我们绝不能编写依赖于明显调度顺序的代码,因为下次运行程序时,顺序可能会不同。如果你尝试多次执行列表 2.5,你最终会得到一个执行输出Finished而不执行sayHello()函数的执行。如果我们需要控制线程的执行顺序,我们需要在我们的代码中添加同步机制,而不是依赖于调度器。我们将在第四章开始讨论这些技术。

2.4 并发与并行

许多开发者将术语并发并行互换使用,有时将它们视为同一概念。然而,许多教科书在这两者之间做出了明确的区分。

我们可以将并发视为程序代码的属性,将并行视为执行程序的属性。并发编程发生在我们以将指令分组到单独的任务中的方式编写程序时,概述了边界和同步点。以下是一些此类任务的例子:

  • 处理一个用户的请求。

  • 在某个文件中搜索一些文本。

  • 计算矩阵乘法中一行的结果。

  • 渲染视频游戏的一帧。

这些任务可能并行执行,也可能不并行执行。它们是否并行执行将取决于我们执行程序时的硬件和环境。例如,如果我们的并发矩阵乘法程序在一个多核系统上运行,我们可能能够同时执行多个行计算。为了发生并行执行,我们需要多个处理单元。否则,系统可以在任务之间交错,给人一种同时执行多个任务的感觉。例如,两个线程可以轮流共享一个处理器,每个线程占用一定的时间份额。因为操作系统频繁且快速地切换线程,它们似乎同时运行。

注意:并发性是关于规划如何同时执行多个任务。并行性是关于执行多个任务同时进行。

显然,定义是重叠的。实际上,我们可以这样说,并行性是并发性的一个子集。只有并发程序才能并行执行,但并非所有并发程序都会并行执行。

当我们只有一个处理器时,能否实现并行性?你已经看到,并行性需要多个处理单元,但如果我们扩大处理单元的定义,一个等待 I/O 操作完成的线程实际上并不是空闲的。写入磁盘不是程序工作的一部分吗?如果我们有两个线程,一个正在写入磁盘,另一个正在 CPU 上执行指令,我们应该将其视为并行执行吗?其他组件,如磁盘和网络,也可以与 CPU 同时为程序工作。即使在这样的情况下,我们通常将术语并行执行保留用于指代计算,而不是 I/O。然而,许多教科书在此背景下提到了术语伪并行执行。这指的是一个处理器给人一种同时执行多个作业的印象。系统通过频繁地在定时器或执行作业请求阻塞 I/O 操作时进行任务上下文切换来实现这一点。

2.5 练习

注意:访问github.com/cutajarj/ConcurrentProgrammingWithGo以查看所有代码解决方案。

  1. 编写一个类似于列表 2.3 的程序,该程序接受一个文本文件名列表作为参数。对于每个文件名,程序应该启动一个新的 goroutine,该 goroutine 将输出该文件的内容到控制台。你可以使用time.Sleep()函数等待子 goroutine 完成(直到你学会如何更好地做到这一点)。将程序命名为catfiles.go。以下是执行此 Go 程序的方法:

    go run catfiles.go txtfile1 txtfile2 txtfile3
    
  2. 扩展你在第一个练习中编写的程序,使其不再打印文本文件的内容,而是搜索字符串匹配。要搜索的字符串是命令行上的第一个参数。当你启动一个新的 goroutine 时,它应该读取文件并搜索匹配项,而不是打印文件的内容。如果 goroutine 找到匹配项,它应该输出一条消息,说明文件名包含匹配项。将程序命名为 grepfiles.go。以下是如何执行此 Go 程序的示例(在这个例子中,“bubbles”是搜索字符串):

    go run grepfiles.go bubbles txtfile1 txtfile2 txtfile3
    
  3. 修改你在第二个练习中编写的程序,使其不再传递文本文件名列表,而是传递目录路径。程序将查找此目录并列出文件。对于每个文件,你可以启动一个 goroutine 来搜索字符串匹配(与之前相同)。将程序命名为 grepdir.go。以下是如何执行此 Go 程序的示例:

    go run grepdir.go bubbles ../../commonfiles
    
  4. 将第三个练习中的程序修改为在任意子目录中递归地继续搜索。如果你给你的搜索 goroutine 一个文件,它应该在该文件中搜索字符串匹配,就像之前的练习一样。否则,如果你给它一个目录,它应该递归地为每个找到的文件或目录启动一个新的 goroutine。将程序命名为 grepdirrec.go,通过运行以下命令来执行它:

    go run grepdirrec.go bubbles ../../commonfiles
    

摘要

  • 多处理操作系统和现代硬件通过它们的调度和抽象提供并发。

  • 进程是建模并发的重量级方式;然而,它们提供了隔离。

  • 线程是轻量级的,并且共享相同的进程内存空间。

  • 用户级线程更轻量级且性能更好,但它们需要复杂的处理来防止管理所有用户级线程的进程被取消调度。

  • 包含在单个内核级线程中的用户级线程一次只能使用一个处理器,即使系统有多个处理器。

  • Goroutines 采用一种混合线程系统,每个内核级线程包含一组 goroutines。使用此系统,多个处理器可以并行执行 goroutines。

  • Go 的运行时使用一种工作窃取系统,在出现负载不平衡或发生取消调度时,将 goroutines 移动到其他内核级线程。

  • 并发是关于规划如何同时执行许多任务。

  • 并行是关于同时执行许多任务。

3 使用内存共享进行线程通信

本章涵盖

  • 使用我们的硬件架构进行线程间通信

  • 通过内存共享进行通信

  • 识别竞态条件

共同解决一个问题的执行线程需要某种形式的通信。这就是所谓的线程间通信(ITC),或者当提到进程时称为进程间通信(IPC)。这种通信分为两大类:内存共享和消息传递。在本章中,我们将重点关注前者。

内存共享类似于所有我们的执行共享一个大的、空白的画布(进程的内存),每个执行都可以在这个画布上写下自己计算的结果。我们可以协调执行,使它们能够使用这个空白的画布进行协作。相比之下,消息传递正如其名。就像人一样,线程可以通过向彼此发送消息来进行通信。在第八章中,我们将研究使用通道在 Go 语言中的消息传递。

我们在应用程序中使用的线程通信类型将取决于我们试图解决的问题类型。内存共享是 ITC(内部线程通信)的一种常见方法,但正如我们在本章中将要看到的,它带来了一系列挑战。

3.1 内存共享

通过内存共享进行通信就像试图和朋友交谈,但不是通过交换消息,而是使用白板(或一大张纸),我们交换想法、符号和抽象(见图 3.1)。

图片

图 3.1 通过内存共享进行通信

在使用内存共享的并发编程中,我们为进程的内存分配一部分——例如,共享数据结构或变量——并且让不同的 goroutines 在此内存上并发工作。在我们的类比中,白板是各种 goroutines 使用的共享内存。

在 Go 语言中,我们的 goroutines 可能位于多个内核级线程之下。因此,我们运行多线程应用程序的硬件和操作系统架构需要启用同一进程所属线程之间的此类内存共享。如果我们的系统只有一个处理器,架构可以很简单。我们可以将相同的内存访问权限赋予同一进程的所有内核级线程,并且我们可以在线程之间进行上下文切换,让每个线程按自己的意愿读取和写入内存。然而,当我们有一个多处理器(或多核系统)的系统时,情况变得更加复杂,因为计算机架构通常涉及 CPU 和主内存之间各种缓存层。

图 3.2 展示了典型总线架构的简化示例。在这里,处理器在需要从主内存中读取或写入时使用系统总线。在处理器使用总线之前,它会监听以确保总线空闲且未被其他处理器使用。一旦总线空闲,处理器就会向内存位置发出请求,然后返回监听并在总线上等待回复。

图 3.2

图 3.2 具有两个 CPU 和一层缓存的总线架构

随着我们增加系统中处理器的数量,总线变得更加繁忙,并成为我们添加更多处理器的瓶颈。为了减少总线负载,我们可以使用缓存将内存内容更靠近所需位置,从而提高性能。缓存还减少了系统总线的负载,因为 CPU 现在可以从缓存中读取大部分所需数据,而不是查询内存。这防止了总线成为瓶颈。图 3.2 所示的示例是一个包含两个 CPU 和一层缓存的简化架构。通常,现代架构包含更多的处理器和多层缓存。

在图 3.2 中,我们有两个并行运行的线程,它们希望通过内存共享进行通信。假设线程 1 试图从主内存中读取一个变量。系统会将包含该变量的内存块内容加载到更靠近 CPU 的缓存中(通过总线)。然后,当线程 1 需要再次读取或更新该变量时,它将能够通过缓存更快地执行该操作。它不需要再次通过从主内存中读取变量来过载系统总线。这如图 3.3 所示。

图 3.3

图 3.3 从主内存读取内存块并将其存储在处理器缓存中以实现更快的检索

现在假设线程 1 决定更新这个变量的值。这会导致缓存内容更新为这个变化。如果我们不做任何其他事情,线程 2 可能想要读取这个相同的变量,当它从主内存中获取它时,它将有一个过时的值,而没有线程 1 所做的更改。

解决这个问题的方法之一是执行所谓的缓存写通:当线程 1 更新缓存内容时,我们将更新镜像回主内存。然而,如果线程 2 在另一个本地 CPU 缓存中有该内存块的过时副本,这并不能解决问题。为了解决这个问题,我们可以让缓存监听总线内存更新消息。当一个缓存注意到它在其缓存空间中复制的内存更新时,它会应用更新或使包含更新内存的缓存行无效。如果我们使缓存行无效,那么下一次线程需要该变量时,它将不得不从内存中获取它,从而获得一个更新副本。这个系统如图 3.4 所示。

图片

图 3.4 在具有缓存的架构中更新共享变量

在多处理器系统中处理内存和缓存的读写机制称为缓存一致性协议。之前提到的写回并失效是此类协议的一个概述。现代架构通常使用这些协议的混合。

一致性墙

微芯片工程师担心,随着他们扩展处理器核心的数量,缓存一致性将成为限制因素。随着处理器数量的增加,实现缓存一致性将变得更加复杂和昂贵,并可能最终限制性能。这个限制被称为一致性墙

3.2 实践中的内存共享

让我们考察几个示例,展示我们如何在并发 Go 程序中使用共享内存。首先,我们将查看两个 goroutine 之间简单的变量共享,说明内存逃逸分析的概念。然后,我们将查看一个更复杂的应用,其中多个 goroutine 协同工作,并行下载和处理多个网页。

3.2.1 在 goroutine 之间共享变量

我们如何让两个 goroutine 共享内存?在这个第一个例子中,我们将创建一个 goroutine,它将与执行main()函数的main() goroutine 在内存中共享一个变量。这个变量将充当倒计时计时器。一个 goroutine 将每秒减少这个变量的值,另一个 goroutine 将更频繁地读取这个变量并在控制台上输出它。图 3.5 显示了这两个 goroutine 正在执行此操作。

图片

图 3.5 两个 goroutine 共享倒计时计时器变量

在列表 3.1 中,主线程为名为count的整数变量分配空间,然后与一个新创建的 goroutine 共享内存指针引用,称为*seconds,调用countdown()函数。这个函数每秒更新一次共享变量,将其值减少1直到它变为0main() goroutine 每半秒读取这个共享变量并输出它。这样,两个 goroutine 就在指针位置共享内存。

列表 3.1 Goroutine 在内存中共享变量

package main

import (
    "*fmt*"
    "*time*"
)

func main() {
    count := 5                                   ❶
    go countdown(&count)                         ❷
    for count > 0 {                              ❸
        time.Sleep(500 * time.Millisecond)       ❸
        fmt.Println(count)                       ❸
    }
}

func countdown(seconds *int) {
    for *seconds > 0 {
        time.Sleep(1 * time.Second)
        *seconds -= 1                            ❹
    }
}

❶ 为整数变量分配内存空间

❷ 在变量引用处启动 goroutine 并共享内存

main() goroutine 每半秒读取共享变量的值。

❹ goroutine 更新共享变量的值。

注意:您可以访问github.com/cutajarj/ConcurrentProgrammingWithGo以查看本书中的任何列表。

由于我们读取共享变量的频率高于更新它的频率,相同的值在我们的控制台输出中记录了多次:

$ go run countdown.go
5
4
4
3
3
2
2
1
1
0

这里发生的情况是我们有一个非常简单的内存共享并发程序。一个 goroutine 更新特定内存位置的值,另一个 goroutine 读取它的内容。

如果你从列表 3.1 中移除了go关键字,程序将变为顺序执行。它会在主栈上创建变量count,并将它的引用传递给countdown()函数。countdown()函数将花费 5 秒钟返回,在这期间,它每秒通过减 1 来更新main()函数栈上的值。当函数返回时,count变量将具有0的值,而main()函数不会进入循环,而是会终止,因为count的值将是0

3.2.2 逃逸分析

我们应该在哪里为变量count分配内存空间?这是一个 Go 编译器必须为每个新创建的变量做出的决定。它有两个选择:在函数的栈上分配空间或在主进程的内存中,我们称之为 空间

在上一章中,我们讨论了线程共享相同的内存空间,并看到每个线程都有自己的栈空间,但共享进程的主内存。当我们在一个单独的 goroutine 中执行countdown()函数时,count变量不能存在于main()函数的栈上。Go 的运行时允许 goroutine 读取或修改另一个 goroutine 栈的内存内容是没有意义的,因为 goroutines 可能有完全不同的生命周期。一个 goroutine 的栈可能在另一个 goroutine 需要修改它的时候已经不再可用。Go 的编译器足够智能,能够意识到我们在 goroutines 之间共享内存。当它注意到这一点时,它会将内存分配在堆上而不是栈上,即使我们的变量看起来像是属于栈上的局部变量。

定义 在技术术语中,当我们声明一个看起来属于局部函数栈的变量,但实际上是在堆内存中分配时,我们说这个变量已经逃逸到堆上了。逃逸分析包括编译器算法,这些算法决定一个变量是否应该分配在堆上而不是栈上。

有许多情况会导致变量逃逸到堆上。任何时间一个变量在函数栈帧的作用域外被共享,该变量就会在堆上分配。在 goroutines 之间共享变量的引用就是一个例子,如图 3.6 所示。

图片

图 3.6 Goroutines 在堆内存中共享变量

在 Go 中,与使用栈相比,使用堆上的内存会有额外的微小成本。这是因为当我们完成对内存的使用后,堆需要由 Go 的垃圾回收器进行清理。垃圾回收器会遍历堆中不再被任何 goroutine 引用的对象,并将空间标记为空闲,以便可以重用。当我们使用栈上的空间时,当函数结束时,此内存会被回收。

我们可以通过要求编译器显示其优化决策来判断一个变量是否已逃逸到堆内存。我们可以通过使用-m编译时选项来完成此操作:

$ go tool compile -m countdown.go
countdown.go:7:6: can inline countdown
countdown.go:7:16: seconds does not escape
countdown.go:15:5: moved to heap: count

在这里,编译器告诉我们哪些变量正在逃逸到堆内存,哪些变量保持在栈上。在第 7 行,seconds指针变量没有逃逸到堆上,因此保持在countdown()函数的栈上。然而,编译器将count变量放在堆上,因为我们与另一个 goroutine 共享该变量。

如果我们从代码中移除go调用,将其转换为顺序程序,编译器不会将count变量移动到堆上。以下是移除go关键字后的输出:

$ go tool compile -m countdown.go
countdown.go:7:6: can inline countdown
countdown.go:16:14: inlining call to countdown
countdown.go:7:16: seconds does not escape

注意,我们不再收到count变量moved to heap的消息。另一个变化是我们现在收到一条消息,说明编译器正在内联countdown()函数调用。内联是一种优化,在特定条件下,编译器会替换函数调用为函数的内容。编译器这样做是为了提高性能,因为调用函数会有轻微的开销,这来自准备新的函数栈,将输入参数传递到新的栈上,以及使程序跳转到函数上的新指令。当我们并行执行函数时,内联函数没有意义,因为函数是使用单独的栈执行的,可能是在另一个内核级线程上。

通过使用 goroutines,我们放弃了某些编译器优化,例如内联,并且通过将我们的共享变量放在堆上增加了开销。这种权衡是通过并发执行我们的代码,我们可能实现加速。

3.2.3 从多个 goroutines 更新共享变量

现在让我们看看一个涉及多个 goroutines 的例子,其中 goroutines 同时更新相同的变量。对于这个例子,我们将编写一个程序来找出英文字母在常见文本中出现的频率。该程序将通过下载网页并计算字母表中每个字母在网页上出现的频率来处理网页。当程序完成后,它应该给出一个频率表,显示每个字符出现的次数。

让我们先以正常的顺序开发这个程序,然后再修改我们的代码,使其以并发方式运行。开发此类程序所需的步骤和数据结构如图 3.7 所示。我们将使用切片整数数据结构作为我们的字母表,包含每个字母计数的结果。我们的程序将逐个检查网页列表,下载和扫描网页的内容,并读取和更新页面上遇到的每个英语字母的计数。

图片

图 3.7 单个 goroutine 在各个网页上统计字母

我们可以先编写一个简单的函数,从 URL 下载所有文本,然后遍历下载文本中的每个字符,如下一列表所示。在执行此操作的同时,我们可以更新任何英文字母(不包括标点符号、空格等)的字母频率计数表。

列表 3.2 生成网页字母频率计数的函数

package main

import (
    "*fmt*"
    "*io* "
    "*net/http*"
    "*strings*"
)

const allLetters = "*abcdefghijklmnopqrstuvwxyz*"

func countLetters(url string, frequency []int) {
    resp, _ := http.Get(url)                         ❶
    defer resp.Body.Close()                          ❷
    if resp.StatusCode != 200 {
        panic("*Server returning error status code:* " + resp.Status)
    }
    body, _ := io.ReadAll(resp.Body)
    for _, b := range body {                         ❸
        c := strings.ToLower(string(b))
        cIndex := strings.Index(allLetters, c)       ❹
        if cIndex >= 0 {
            frequency[cIndex] += 1                   ❺
        }
    }
    fmt.Println("*Completed:*", url)
}

❶ 从给定的 URL 下载网页

❷ 在函数结束时关闭响应

❸ 遍历每个下载的字符

❹ 找到字符在字母表中的索引

❺ 如果字符是字母表的一部分,则将计数增加 1

注意:为了简洁起见,我们在这些列表中忽略了某些错误处理。

函数首先通过其输入参数下载 URL 的内容。然后使用for循环遍历每个字符,并将其转换为小写。我们这样做是为了将大写和小写字符视为等效。如果我们发现该字符在包含英语字母的字符串中,那么我们就增加该字符在 Go 切片条目中的计数。在这里,我们使用 Go 切片作为我们的字符频率表。在这个表中,元素 0 代表字母a的计数,元素 1 是b,2 是c,依此类推。在函数结束时,在处理完整个下载的文档之后,我们输出一条消息,显示函数完成的 URL。

让我们使用一些网页来运行这个函数。理想情况下,我们希望是永远不会改变的静态页面。如果网页的内容只是文本,没有文档格式、图片、链接等,那就更好了。例如,新闻网页就不够,因为内容经常变化,格式丰富。

www.rfc-editor.org网站包含一个关于互联网的技术文档数据库(称为请求评论,或 RFC),包括规范、标准、政策和备忘录。它是这个练习的好来源,因为文档不会改变,我们可以下载没有格式的纯文本文档。另一个优点是 URL 具有递增的文档 ID,这使得它们可预测。我们可以使用rfc-editor.org/rfc/rfc{ID}.txt的 URL 格式。例如,我们可以通过 URL rfc-editor.org/rfc/rfc1001.txt获取文档 ID 1001。

现在我们只需要一个main()函数来多次运行countLetters()函数,每次使用不同的 URL,传入相同的频率表,并让它更新字符计数。下面的列表显示了此main()函数。

列表 3.3 main()函数调用countLetters()使用不同的 URL

func main() {
    var frequency = make([]int, 26)               ❶
    for i := 1000; i <= 1030; i++ {               ❷
        url := fmt.Sprintf("*https://rfc-editor.org/rfc/rfc%d.txt*", i)
        countLetters(url, frequency)              ❸
    }
    for i, c := range allLetters {
        fmt.Printf("*%c-%d* ", c, frequency[i])     ❹
    }
}

❶ 为频率表初始化切片空间

❷ 从文档 ID 1000 迭代到 1030 以下载 31 个文档

❸ 依次调用 countLetters()函数

❹ 输出每个字母及其频率

main()函数中,我们创建一个新的切片来存储结果,包含字母频率表。然后我们指定从rfc1000.txtrfc1030.txt下载 31 个文档。程序依次调用我们的countLetters()函数来下载和处理每个网页(即一个接一个)。根据我们的互联网连接速度,程序可能需要几秒钟到几分钟的时间。一旦完成,main()函数将输出frequency切片变量的内容:

$ time go run charcountersequential.go
Completed: https://rfc-editor.org/rfc/rfc1000.txt
Completed: https://rfc-editor.org/rfc/rfc1001.txt
. . .
Completed: https://rfc-editor.org/rfc/rfc1028.txt
Completed: https://rfc-editor.org/rfc/rfc1029.txt
Completed: https://rfc-editor.org/rfc/rfc1030.txt
a-103445 b-23074 c-61005 d-51733 e-181360 f-33381 g-24966 h-47722 i-103262 j-3279 k-8839 l-49958 m-40026 n-108275 o-106320 p-41404 q-3410 r-101118 s-101040 t-136812 u-35765 v-13666 w-18259 x-4743 y-18416 z-1404
real    0m17.035s
user    0m0.447s
sys    0m0.308s

程序输出的最后一行(在时间之前)包含所有 31 个文档中每个字母的计数。列表中的第一个条目代表字母a的计数,第二个为b,以此类推。快速浏览告诉我们,字母e是我们文档中最频繁的字母。程序完成大约需要 17 秒。

让我们现在尝试通过使用并发编程来提高我们程序的速度。图 3.8 显示了我们可以如何使用多个 goroutines 并发地下载和处理每个网页,而不是一个接一个。这里的技巧是使用go关键字并发运行我们的countLetters()函数。

图片

图 3.8 Goroutines 协同计数字符

为了实现这一点,我们必须对我们的main()函数进行两项更改,如列表 3.4 所示。第一项是,我们将在countLetters()函数调用中添加go。这意味着我们将创建 31 个 goroutines,每个网页一个。然后,每个 goroutine 将并发(即同时,而不是一个接一个)下载和处理其文档。第二项更改是我们将等待几秒钟,直到所有 goroutines 都完成。我们需要这一步;否则,当main() goroutine 完成时,在完成所有下载之前,进程就会终止。这是因为 Go 中,当main() goroutine 完成时,整个进程都会终止。即使其他 goroutines 仍在执行也是如此。

警告:由于将在下一节讨论的竞态条件,从多个 goroutines 中使用countLetters()函数会产生错误的结果。我们在这里这样做只是为了演示目的。

列表 3.4 main()函数创建 goroutines 并共享频率切片

func main() {
    var frequency = make([]int, 26)
    for i := 1000; i <= 1030; i++ {
        url := fmt.Sprintf("*https://rfc-editor.org/rfc/rfc%d.txt*", i)
        go countLetters(fmt.Sprintf(url), frequency)    ❶
    }
    time.Sleep(10 * time.Second)                        ❷
    for i, c := range allLetters {
        fmt.Printf("*%c-%d* ", c, frequency[i])           ❸
    }
}

❶ 启动一个调用 countLetters()函数的 goroutine

❷ 等待 goroutines 完成

❸ 输出每个字母及其频率

注意:使用Sleep()等待另一个 goroutine 完成并不是一个好方法。实际上,如果你有慢速的互联网连接,你可能需要增加列表 3.4 中的等待时间。在第五章中,我们将讨论如何使用条件变量和信号量来完成这项任务。此外,在第六章中,我们将介绍等待组的概念,它允许我们在某些任务完成之前阻塞 goroutine 的执行。

注意在这个例子中,所有的 goroutines 都在内存中共享相同的数据结构。当我们初始化main()函数中的 Go 切片时,我们在堆上为其分配空间。当我们创建 goroutines 时,我们将包含 Go 切片的内存位置的相同引用传递给它们。然后,31 个 goroutines 并发地读取和写入相同的频率切片。这样,线程正在合作并共同更新相同的内存空间。这就是线程内存共享的全部内容。你有一个数据结构或变量,你正在与其他线程共享。与顺序编程相比,不同之处在于 goroutine 可能会向变量写入值,但当我们再次读取它时,值可能不同,因为另一个 goroutine 可能已经更改了它。

如果你运行了这个程序,你可能已经注意到它的问题。以下是运行后的输出结果:

$ time go run charcounterconcurrent.go
Completed: https://rfc-editor.org/rfc/rfc1022.txt
Completed: https://rfc-editor.org/rfc/rfc1019.txt
. . .
Completed: https://rfc-editor.org/rfc/rfc1012.txt
Completed: https://rfc-editor.org/rfc/rfc1021.txt
Completed: https://rfc-editor.org/rfc/rfc1010.txt
a-103074 b-23054 c-60854 d-51609 e-179936 f-33356 g-24933 h-47637 i-102856 j-3279 k-8835 l-49873 m-39962 n-107840 o-105948 p-41334 q-3408 r-100730 s-100659 t-136100 u-35709 v-13659 w-18240 x-4743 y-18411 z-1404
real    0m11.485s
user    0m0.940s
sys    0m0.430s

首先,你会注意到下载完成得比顺序版本快得多。我们预料到了这一点。一次性完成所有下载应该比一个接一个地下载要快。其次,输出消息不再按顺序排列。由于我们同时开始下载所有文档,由于它们的大小不同,一些文档会比其他文档先完成。先完成的文档会先输出完成消息。在这个应用程序中,我们处理页面的顺序实际上并不重要。

问题出在结果上。当我们将顺序运行和并发运行的字符计数进行比较时,我们注意到一个差异:大多数字符在并发版本中的计数较低。例如,字母 e 在顺序运行中的计数为 181,360,而在并发版本中的计数为 179,936(你的并发结果可能会有所不同)。

我们可以尝试多次运行顺序和并发程序。结果将取决于计算机设置,如互联网连接和处理器速度。然而,当我们比较它们时,我们会看到顺序版本每次都给出相同的结果,但并行版本每次运行都会给出略有不同的值。这是怎么回事?

这是所谓的竞争条件的结果——当我们有多个线程(或进程)共享资源并且它们相互干扰时,会给出意外的结果。让我们更详细地探讨为什么会出现竞争条件。(在下一章中,我们将看到如何通过我们的并发字母频率程序解决这个问题。)

3.3 竞争条件

竞争条件发生在你的程序试图同时做很多事情时,其行为依赖于独立不可预测事件的确切时间。正如我们在上一节中看到的,我们的字母频率程序最终给出了意外的结果,但有时结果甚至更为戏剧化。我们的并发代码可能长时间稳定运行,但有一天可能会崩溃,导致更严重的数据损坏。这可能是因为并发执行缺乏适当的同步,相互干扰。

全系统故障

特纳贝尔福特大厦 24 楼的会议气氛如同最糟糕的情况一样阴郁。这家大型国际投资银行的软件开发人员聚集在一起,讨论在关键核心应用程序失败并导致全系统故障后如何前进。系统故障导致客户账户报告了其持仓中的错误金额。

“各位,我们这里有个严重的问题。我发现这次故障是由我们代码中的竞争条件引起的,这个条件是在一段时间前引入的,昨晚触发的,”高级开发者马克·亚当斯说。

整个房间都安静了下来。在从地板到天花板的大窗户外面,微型汽车在繁忙的城市交通中缓慢而安静地行驶。资深开发者立即意识到情况的严重性,意识到他们现在将全天候工作以修复问题并整理数据存储库中的混乱。经验较少的开发者知道竞态条件很严重,但他们不知道确切的原因,因此闭口不言。

最终,交付经理大卫·霍姆斯打破了沉默,提出了这个问题:“该应用程序已经运行了几个月而没有出现任何问题,我们最近也没有发布任何代码,那么软件究竟是如何突然崩溃的呢?”

每个人都摇了摇头,回到了自己的桌子旁,留下大卫一个人在房间里感到困惑。他拿出手机并搜索了“竞态条件”这个术语。

这种错误不仅适用于计算机程序。有时我们在现实生活中看到这种例子,当我们有并发参与者相互作用时。例如,一对夫妇可能会共享一个家庭购物清单,比如写在冰箱门上的购物清单。在早上,在他们各自去办公室之前,他们独立决定下班后去购物。两人拍下清单的照片,后来去商店购买所有物品。他们各自并不知道对方已经决定做同样的事情。这就是他们如何结束于拥有两份所需的一切(见图 3.9)。

图 3.9 竞态条件在现实生活中也时有发生。

其他竞态条件

软件竞态条件是在并发程序中发生的一种情况。竞态条件也发生在其他环境中,例如在分布式系统、电子电路中,有时甚至在人类互动中。

在字母频率应用中,我们遇到了一个竞态条件,导致程序未能准确报告字母计数。让我们编写一个更简单的并发程序,以突出显示竞态条件,以便我们更好地理解这个问题。在接下来的章节中,我们将讨论避免竞态条件的不同方法,例如使用互斥锁修复字母计数程序(将在下一章讨论)。

3.3.1 吝啬与挥霍:创建竞态条件

吝啬与挥霍是两个独立的 goroutine。吝啬的人努力工作并赚取现金,但从未花过一美元。挥霍的人正好相反,花钱而不赚钱。这两个 goroutine 共享一个共同的银行账户。为了演示竞态条件,我们将让吝啬与挥霍每次各自赚取和花费 10 美元,共 1 百万次。由于挥霍的花费与吝啬的赚取相同,如果我们的编程正确,我们应该以与开始时相同的金额结束(见图 3.10)。

图 3.10 两个 goroutine 的竞态条件

在列表 3.5 中,我们首先创建了 Stingy 和 Spendy 函数。stingy()spendy()函数都迭代一百万次,每次调整共享的 money 变量——stingy()函数每次增加 10 美元,而spendy()函数则减去它。

警告:从多个 goroutines 中使用以下stingy()spendy()函数将产生竞争条件。我们在这里仅为了演示目的。

列表 3.5 Stingy 和 Spendy 函数

func stingy(money *int) {           ❶
    for i := 0; i < 1000000; i++ {
        *money += 10                ❷
    }
    fmt.Println("*Stingy Done*")
}

func spendy(money *int) {           ❶
    for i := 0; i < 1000000; i++ {
        *money -= 10                ❸
    }
    fmt.Println("*Spendy Done*")
}

❶ 函数接受一个指向银行账户中总和变量的指针。

❷ stingy()函数每次增加 10 美元

❸ spendy()函数减去 10 美元

我们现在需要使用单独的 goroutine 调用这两个函数。我们可以编写一个main()函数,初始化共享的money变量,创建 goroutines,并将变量引用传递给新创建的 goroutines。

在列表 3.6 中,我们初始化共同的银行账户为 100 美元。我们还让main() goroutine 在创建 goroutines 后睡眠 2 秒钟,以等待它们终止。(在第六章中,我们将讨论 waitgroups,这将允许我们阻塞直到任务完成,而不是需要睡眠几秒钟。)在主线程重新唤醒后,它打印money变量中的金额。

列表 3.6 Stingy 和 Spendy main()函数

package main

import (
    "*fmt*"
    "*time*"
)
. . .

func main() {
    money := 100                    ❶
    go stingy(&money)               ❷
    go spendy(&money)               ❷
    time.Sleep(2 * time.Second)     ❸
    println("*Money in bank account:* ", money)
}

❶ 将银行账户中的金额初始化为 100 美元

❷ 启动 goroutines 并传递 money 变量的引用

❸ 等待 2 秒钟,直到 goroutines 完成

在这个列表中,我们期望它会输出 100 美元作为结果。毕竟,我们只是在变量上重复加 10 和减 10 一百万次。这模拟了 Stingy 赚取一千万美元和 Spendy 花费相同金额,留下我们初始的 100 美元。然而,这里是程序的输出:

$ go run stingyspendy.go
Spendy Done
Stingy Done
Money in bank account: 4203750

账户中剩余超过 400 万美元!Stingy 对这个结果会非常满意。然而,这个结果是纯粹的偶然。事实上,如果我们再次运行它,我们的账户可能会降到零以下:

$ go run stingyspendy.go
Stingy Done
Spendy Done
Money in bank account: -1127120

海森堡虫

我们可以通过在关键位置设置断点来尝试调试我们的 Stingy 和 Spendy 程序中正在发生的事情。然而,我们不太可能发现这个问题,因为暂停在断点上会减慢执行速度,使得竞争条件发生的可能性降低。

竞争条件是海森堡虫的一个很好的例子。这个名字来源于物理学家维尔纳·海森堡,与他的量子力学不确定性原理有关,海森堡虫是在我们尝试调试和隔离它时消失或改变行为的 bug。由于它们很难调试,处理海森堡虫的最佳方式是根本不出现。因此,了解导致竞争条件的原因并学习防止它们在代码中出现的技巧至关重要。

让我们通过一个场景来尝试理解为什么我们会得到这些奇怪的结果。为了使事情简单起见,现在让我们假设我们只有一个处理器,因此没有并行处理发生。图 3.11 显示了在我们的 Stingy 和 Spendy 程序中发生的一个这样的竞争条件。

图 3.11 Stingy 和 Spendy 之间的竞争条件解释

图 3.11 Stingy 和 Spendy 之间的竞争条件解释

在时间戳 1 到 3 之间,Spendy 正在执行。线程从共享内存中读取100的值并将其放入处理器的寄存器中。然后它减去 10,并将90美元写回共享内存。在时间戳 4 到 6 之间,轮到 Stingy。它读取90的值,加上 10,并将100写回堆上的共享变量。时间戳 7 到 11 是事情开始变坏的时候。在时间戳 7,Spendy 从主内存中读取100的值并将其写入其处理器寄存器。在时间戳 8,发生上下文切换,Stingy 的 goroutine 开始在处理器上执行。由于 Stingy 的线程还没有机会更新它,它开始从共享变量中读取100的值。在时间戳 9 和 10 之间,goroutines 减去和加上 10。然后 Spendy 将值写回90,在时间 11,Stingy 的线程通过将110写入共享变量来覆盖这个值。总的来说,我们花费了$20 并赚回了$20,但我们的账户中多出了$10。

定义单词原子起源于古希腊语,意为“不可分割的”。在计算机科学中,当我们提到原子操作时,我们指的是不能被中断的操作。

我们遇到这个问题是因为操作*money += 10*money -= 10不是原子的;在编译后,它们转换成多个指令。执行中的中断可能发生在这些指令之间。来自另一个 goroutine 的不同指令可能会干扰并导致竞争条件。当这种越界发生时,我们会得到不可预测的结果。

定义在我们的代码中,临界区是一组应该在没有其他执行干扰该部分使用的状态的情况下执行的指令。当允许这种干扰发生时,可能会出现竞争条件。

即使指令是原子的,我们仍然可能会遇到问题。记得在本章开头我们讨论了处理器缓存和寄存器吗?每个处理器核心都有一个局部缓存和寄存器来存储频繁使用的变量。当我们编译我们的代码时,编译器有时会应用优化,以保持变量在 CPU 寄存器或缓存中,在给出指令将其刷新回内存之前。这意味着两个在单独 CPU 上操作的 goroutine 可能直到它们完成周期性的内存刷新才看不到彼此的变化。

当我们在并行环境中执行一个编写糟糕的并发程序时,这些类型的错误出现的可能性更大。并行运行的 Goroutines 增加了这些类型的竞态条件发生的几率,因为现在我们将同时执行一些步骤。在我们的 Stingy 和 Spendy 程序中,当并行运行时,两个 Goroutines 更有可能在写回之前同时读取 money 变量。

当我们使用 Goroutines(或任何用户级线程)并且只在单个处理器上运行时,运行时不太可能在这些指令的中间中断执行。这是因为用户级调度通常是不可抢占的;它只会在特定情况下进行上下文切换,例如 I/O 或当应用程序调用线程释放(在 Go 中为 Gosched())时。这与操作系统调度不同,操作系统调度通常是可抢占的,可以在任何时间中断执行。也不太可能任何 Goroutine 会看到变量的过时版本,因为所有 Goroutines 都将在同一个处理器上运行,使用相同的缓存。实际上,如果你尝试使用 runtime.GOMAXPROCS(1) 列出 3.6,你可能不会看到相同的问题。

显然,这不是一个好的解决方案,主要是因为我们会放弃拥有多个处理器的优势,而且也没有保证它能完全解决问题。Go 的不同版本或未来版本可能会以不同的方式调度,从而破坏我们的程序。无论我们使用哪种调度系统,我们都应该防范竞态条件。这样,无论程序将在哪种环境中运行,我们都能确保安全。

3.3.2 释放执行并不能帮助解决竞态条件

如果我们告诉 Go 的运行时它应该在什么时候运行调度器会怎样?在前一章中,我们看到了如何使用 runtime.Gosched() 调用调度器,以便我们可以将执行权让给另一个 Goroutine。下面的列表显示了我们可以如何修改我们的两个函数并执行这个调用。

列表 3.7 Stingy 和 Spendy 函数调用 Go 的调度器

func stingy(money *int) {
    for i := 0; i < 1000000; i++ {
        *money += 10
        runtime.Gosched()            ❶
    }
    fmt.Println("*Stingy Done*")
}

func spendy(money *int) {
    for i := 0; i < 1000000; i++ {
        *money -= 10
        runtime.Gosched()            ❷
    }
    fmt.Println("*Spendy Done*")
}

❶ 在执行加法操作后调用 Go 调度器

❷ 在执行减法操作后调用 Go 调度器

不幸的是,这并没有解决我们的问题。这个列表的输出将根据系统差异(处理器的数量、Go 版本和实现、操作系统的类型等)而变化。然而,在多核系统中,这是输出结果:

$ go run stingyspendysched.go
Stingy Done
Spendy Done
Money in bank account: 170

再次运行这个程序产生了以下结果:

$ go run stingyspendysched.go
Spendy Done
Stingy Done
Money in bank account: -190

看起来竞态条件发生的频率降低了,但它仍然在发生。在这个片段中,两个 goroutines 在单独的处理器上并行运行。竞态条件发生频率降低可能有各种原因,但不太可能是由于我们指示调度器何时运行。让我们首先通过查看 Go 文档来提醒自己这个调用做了什么,文档地址为pkg.go.dev/runtime#Gosched

func Gosched()

Gosched 让出处理器,允许其他 goroutines 运行。它不会挂起当前 goroutine,因此执行会自动恢复。

我们程序现在在关键部分(加法和减法)上花费的时间比例更小。它在调用 Go 调度器上花费了相当多的时间,因此两个 goroutines 同时读取或写入共享变量的可能性大大降低。

竞态条件发生频率降低的另一个原因可能是,由于我们现在调用了runtime.Gosched(),编译器在循环中优化代码的选项更少。

警告:永远不要依赖告诉运行时何时让处理器让出以解决竞态条件。没有保证另一个并行线程不会干扰。此外,即使系统只有一个处理器,如果我们使用了超过一个内核级线程——例如,通过使用runtime.GOMAXPROCS(n)设置不同的值——操作系统可以在任何时候中断执行。

3.3.3 正确的同步和通信消除了竞态条件

我们如何编写避免竞态条件的并发程序?这里没有灵丹妙药。没有一种单一的技术最适合解决每个案例。

第一步是确保我们使用了正确的工具来完成这项工作。内存共享对于这个问题真的需要吗?我们是否有其他方式可以在 goroutines 之间进行通信?在本书的第七章中,我们将探讨另一种通信方式——使用通道和通信顺序进程。这种方式建模并发消除了许多这类错误。

良好并发编程的第二步是识别何时可能发生竞态条件。当我们与其他 goroutines 共享资源时,我们必须保持警觉。一旦我们知道这些关键代码部分在哪里,我们就可以考虑采用最佳实践来确保资源安全共享。

之前我们讨论了一个涉及两个人共享购物清单的真实竞态条件。这导致他们买了两次杂货,因为他们不知道另一个人也决定去购物。我们可以通过采用更好的同步和通信方式来防止这种情况再次发生,如图 3.12 所示。

图片

图 3.12 正确的同步和通信消除了竞态条件。

例如,我们可以在购物清单上留下便条或标记,以表明有人已经在购物。这将向其他人表明没有必要再次购物。为了在我们的编程中避免竞态条件,我们需要与 goroutine 的其他部分有良好的同步和通信,以确保它们不会相互干扰。良好的并发编程涉及有效地同步你的并发执行,以消除竞态条件,同时提高性能和吞吐量。在本书的后续章节中,我们将使用不同的技术和工具来同步和协调程序中的线程。通过这种方式,我们可以绕过这些竞态条件和同步问题,有时甚至可以完全避免它们。

3.3.4 Go 竞态检测器

Go 给我们提供了一个检测代码中竞态条件的工具:我们可以通过使用 -race 命令行标志来运行 Go 编译器。使用此标志时,编译器会向所有内存访问添加特殊代码以跟踪不同的 goroutine 在何时从内存中读取和写入。当我们使用此标志并且检测到竞态条件时,它会在控制台上输出警告信息。如果我们尝试在 Stingy 和 Spendy 程序(列表 3.5 和 3.6)上使用此标志运行,我们会得到以下结果:

$ go run -race stingyspendy.go
==================
WARNING: DATA RACE
Read at 0x00c00001a0f8 by goroutine 7:
  main.spendy()
      /home/james/go/stingyspendy.go:21 +0x3b
  main.main.func2()
      /home/james/go/stingyspendy.go:29 +0x39

Previous write at 0x00c00001a0f8 by goroutine 6:
  main.stingy()
      /home/james/go/stingyspendy.go:14 +0x4d
  main.main.func1()
      /home/james/go/stingyspendy.go:28 +0x39

Goroutine 7 (running) created at:
  main.main()
      /home/james/go/stingyspendy.go:29 +0x116

Goroutine 6 (running) created at:
  main.main()
      /home/james/go/stingyspendy.go:28 +0xae
==================
Stingy Done
Spendy Done
Money in bank account:  -808630
Found 1 data race(s)
exit status 66

在这个例子中,Go 的竞态检测器找到了我们的一个竞态条件。它指向代码中的关键部分,在第 21 行和第 14 行,这是我们向 money 变量添加和减去的部分。它还提供了关于内存读取和写入的信息。在前面的代码片段中,我们可以看到内存位置 0x00c00001a0f8 首先被 goroutine 6(运行 stingy())写入,然后后来被 goroutine 7(运行 spendy())读取。

警告:Go 的竞态检测器仅在特定的竞态条件被触发时才能找到竞态条件。因此,检测器并非完美无缺。在使用竞态检测器时,你应该使用类似生产环境的场景测试你的代码,但在生产环境中启用它通常是不受欢迎的,因为它会降低性能并使用更多的内存。

随着你编写更多的并发代码,识别竞态条件会变得更加容易。重要的是要记住,无论何时你在代码的关键部分与其他 goroutine 共享资源(如内存),除非你同步对共享资源的访问,否则可能会出现竞态条件。

3.4 练习

备注:访问 github.com/cutajarj/ConcurrentProgrammingWithGo 以查看所有代码解决方案。

  1. 修改我们的顺序字母频率程序,以生成单词频率列表而不是字母频率列表。你可以使用与列表 3.3 中相同的 RFC 网页 URL。一旦完成,程序应该输出一个单词列表,其中包含每个单词在网页中出现的频率。以下是一些示例输出:

    $ go run wordfrequency.go
    the -> 5
    a -> 8
    car -> 1
    program -> 3
    

    当你尝试将顺序程序转换为并发程序,为每一页创建一个 goroutine 时,会发生什么?我们将在下一章中修复这些错误。

  2. 在列表 3.1 上运行 Go 的竞态检测器。结果是否包含竞态条件?如果是,你能解释为什么会发生吗?

  3. 考虑以下列表。你能在不运行竞态检测器的情况下找到这个程序中的竞态条件吗?提示:尝试运行程序几次,看看是否会导致竞态条件。

列表 3.8 寻找竞态条件

package main

import (
    "*fmt*"
    "*time*"
)

func addNextNumber(nextNum *[101]int) {
    i := 0
    for nextNum[i] != 0 { i++ }
    nextNum[i] = nextNum[i-1] + 1
}

func main() {
    nextNum := [101]int{1}
    for i := 0; i < 100; i++ {
        go addNextNumber(&nextNum)
    }
    for nextNum[100] == 0 {
        println("*Waiting for goroutines to complete*")
        time.Sleep(10 * time.Millisecond)
    }
    fmt.Println(nextNum)
}

摘要

  • 内存共享是多个 goroutine 之间进行通信以完成任务的一种方式。

  • 多处理器和多核系统为我们提供了硬件支持和系统,可以在线程之间共享内存。

  • 竞态条件是指由于 goroutine 之间共享资源(如内存)而出现意外结果的情况。

  • 临界区是一组应该在没有其他并发执行干扰的情况下执行的指令。当允许发生干扰时,可能会出现竞态条件。

  • 在临界区外调用 Go 调度器不是解决竞态条件问题的方法。

  • 使用适当的同步和通信可以消除竞态条件。

  • Go 语言为我们提供了一个竞态检测工具,帮助我们检测代码中的竞态条件。

4 使用互斥锁进行同步

本章涵盖

  • 使用互斥锁保护关键部分

  • 使用读写锁提高性能

  • 实现优先读的读写锁

我们可以使用互斥锁来保护代码中的关键部分,这样一次只有一个 goroutine 访问共享资源。通过这种方式,我们消除了竞态条件。互斥锁的变体,有时称为锁,被用于支持并发编程的每种语言中。在本章中,我们将首先查看互斥锁提供的功能。然后我们将查看一种称为 读写互斥锁 的互斥锁变体。

读写互斥锁在需要仅在修改共享资源时阻止并发的情况下提供了性能优化。它们使我们能够在共享资源上执行多个并发读取,同时仍然允许我们独占锁定写访问。我们将看到读写互斥锁的一个示例应用,并了解其内部结构,然后我们自己构建一个。

4.1 使用互斥锁保护关键部分

如果我们有一种方法可以确保只有一个执行线程运行关键部分,这将是什么样子?这正是互斥锁提供的功能。把它们想象成物理锁,它们阻止我们的代码的某些部分在任何时候被多个 goroutine 访问。如果一次只有一个 goroutine 访问关键部分,我们就安全了,因为竞态条件只发生在两个或更多 goroutine 之间有冲突的情况下。

4.1.1 我们如何使用互斥锁?

我们可以使用互斥锁来标记我们关键部分的开始和结束,如图 4.1 所示。当一个 goroutine 到达由互斥锁保护的关键部分代码时,它首先将此互斥锁显式锁定,作为程序代码中的指令。然后,goroutine 开始执行关键部分的代码,完成后,它将解锁互斥锁,以便另一个 goroutine 可以访问关键部分。

图片

图 4.1 只允许一个 goroutine 在互斥锁保护的关键部分中。

如果另一个 goroutine 尝试锁定一个已经被锁定的互斥锁,该 goroutine 将被挂起,直到互斥锁被释放。如果有多个 goroutine 被挂起,等待锁变得可用,则只恢复一个 goroutine,并且它是下一个获得互斥锁锁的。

定义 互斥锁,简称 mutex,是一种并发控制形式,其目的是防止竞态条件。互斥锁允许只有一个执行(如 goroutine 或内核级线程)进入关键部分。如果有两个执行同时请求访问互斥锁,互斥锁的语义保证只有一个 goroutine 将获得对互斥锁的访问。其他执行将不得不等待直到互斥锁再次可用。

在 Go 中,互斥锁功能由 sync 包提供,类型为 Mutex。此类型为我们提供了两个主要操作,Lock()Unlock(),我们可以使用它们分别标记临界代码段的开始和结束。作为一个简单的例子,我们可以修改上一章中的 stingy()spendy() 函数,以保护我们的临界区。在以下列表中,我们将使用互斥锁来保护共享的 money 变量,防止两个 goroutine 同时修改它。

列表 4.1 使用互斥锁的 Stingy 和 Spendy 的函数

package main

import (
    "*fmt*"
    "*sync*"
    "*time*"
)

func stingy(money *int, mutex *sync.Mutex) {    ❶
    for i := 0; i < 1000000; i++ {
        mutex.Lock()                            ❷
        *money += 10
        mutex.Unlock()                          ❸
    }
    fmt.Println("*Stingy Done*")
}

func spendy(money *int, mutex *sync.Mutex) {    ❶
    for i := 0; i < 1000000; i++ {
        mutex.Lock()                            ❷
        *money -= 10
        mutex.Unlock()                          ❸
    }
    fmt.Println("*Spendy Done*")
}

❶ 接受共享互斥结构体的指针

❷ 在进入临界区之前锁定互斥锁

❸ 退出临界区后解锁

注意:本书中的所有列表均可在 github.com/cutajarj/ConcurrentProgrammingWithGo 上找到。

如果 Stingy 和 Spendy 的 goroutine 准确地在同一时间尝试锁定互斥锁,互斥锁将保证只有一个 goroutine 能够锁定它。其他 goroutine 将在互斥锁再次可用之前暂停执行。例如,Stingy 将必须等待 Spendy 减去金额并释放互斥锁。当互斥锁再次可用时,Stingy 的暂停 goroutine 将被恢复,获取临界区的锁。

以下列表显示了修改后的 main() 函数创建一个新的互斥锁并将引用传递给 stingy()spendy()

列表 4.2 创建互斥锁的 main() 函数

func main() {
    money := 100
    mutex := sync.Mutex{}                  ❶
    go stingy(&money, &mutex)              ❷
    go spendy(&money, &mutex)              ❷
    time.Sleep(2 * time.Second)
    mutex.Lock()                           ❸
    fmt.Println("*Money in bank account:* ", money)
    mutex.Unlock()                         ❸
}

❶ 创建一个新的互斥锁

❷ 将互斥锁的引用传递给两个 goroutine

❸ 使用互斥锁保护读取共享变量

注意:当我们创建一个新的互斥锁时,其初始状态总是未锁定。

在我们的 main() 函数中,我们在 goroutine 完成后读取 money 变量时也使用了互斥锁。由于我们睡眠一段时间以确保 goroutine 完成,这里的竞争条件非常不可能。然而,即使你确信不会有冲突,保护共享资源也是良好的实践。使用互斥锁(以及后续章节中介绍的其他同步机制)确保 goroutine 读取变量的更新副本。

注意:我们应该保护所有临界区,包括 goroutine 只读取共享资源的部分。编译器的优化可能会重新排序指令,导致它们以不同的方式执行。使用适当的同步机制,如互斥锁,可以确保我们读取共享资源的最新副本。

如果我们现在一起运行列表 4.1 和 4.2,我们可以看到我们已经消除了竞争条件。在 Stingy 和 Spendy goroutine 完成后,账户余额为 $100。以下是输出:

$ go run stingyspendymutex.go
Stingy Done
Spendy Done
Money in bank account:  100

我们还可以尝试使用 -race 标志运行此代码,以检查是否存在竞争条件。

互斥锁是如何实现的?

互斥锁通常需要操作系统和硬件的帮助来实现。如果我们有一个只有一个处理器的系统,我们可以在一个线程持有锁的时候禁用中断来实现互斥锁。这样,另一个执行将不会中断当前线程,并且没有干扰。然而,这并不是理想的,因为编写不良的代码可能会导致整个系统被所有其他进程和线程阻塞。一个恶意或编写不良的程序在获取互斥锁后可能会进入无限循环并崩溃系统。此外,这种方法在多处理器系统上也不适用,因为其他线程可能在另一个 CPU 上并行执行。

互斥锁的实现涉及到硬件的支持,以提供原子测试和设置操作。通过这个操作,一个执行可以检查一个内存位置,如果值是它所期望的,它将内存更新为锁定标志值。硬件保证这个测试和设置操作是原子的——也就是说,在操作完成之前,没有其他执行可以访问该内存位置。早期的硬件实现通过阻塞整个总线来保证这种原子性,这样就没有其他处理器可以在同一时间使用该内存。如果另一个执行执行了这个操作,并发现它已经设置为锁定标志值,操作系统将阻塞该线程的执行,直到内存位置变回空闲。

我们将在第十二章中探讨如何使用原子操作和操作系统调用来实现互斥锁。在那个章节中,我们还将检查 Go 语言是如何实现其自己的互斥锁的。

4.1.2 互斥锁与顺序处理

当然,当我们有超过两个 goroutine 时,我们也可以使用互斥锁。在前一章中,我们实现了一个字母频率程序,该程序使用了多个 goroutine 来下载并计算英文字母表中字符的出现次数。代码缺乏任何同步,当我们运行程序时,给出了错误的计数。如果我们想使用互斥锁来修复这个竞争条件,我们应该在代码的哪个点锁定和解锁互斥锁(见图 4.2)?

图片

图 4.2 决定放置互斥锁和解锁的位置

注意:使用互斥锁的效果是限制并发性。在锁定和解锁互斥锁之间的代码在任何时候都由一个 goroutine 执行,有效地将这部分代码转换为顺序执行。正如我们在第一章中看到的,根据 Amdahl 定律,顺序到并行的比例将限制我们代码的性能可扩展性,因此我们减少持有互斥锁锁的时间至关重要。

列表 4.3 显示了我们可以如何首先修改main()函数以创建互斥锁并将其引用传递给我们的countLetters()函数。这与我们在 Stingy 和 Spendy 程序中使用的模式相同,即在main() goroutine 中创建互斥锁并与他人共享。我们还在输出结果时保护frequency变量的读取。

列表 4.3 main() 函数创建用于字母频率的互斥锁(省略了导入)

package main

import ( ... )

const AllLetters = "*abcdefghijklmnopqrstuvwxyz*"

func main() {
    mutex := sync.Mutex{}                         ❶
    var frequency = make([]int, 26)
    for i := 1000; i <= 1030; i++ {
        url := fmt.Sprintf("*https://rfc-editor.org/rfc/rfc%d.txt*", i)
        go CountLetters(url, frequency, &mutex)   ❷
    }
    time.Sleep(60 * time.Second)                  ❸
    mutex.Lock()                                  ❹
    for i, c := range AllLetters {                ❹
        fmt.Printf("*%c-%d* ", c, frequency[i])     ❹
    }                                             ❹
    mutex.Unlock()                                ❹
}

❶ 创建新的互斥锁

❷ 将互斥锁的引用传递给 goroutines

❸ 等待 60 秒

❹ 使用互斥锁保护读取共享变量

如果我们在CountLetters()函数的开始锁定互斥锁并在消息输出完毕后释放它会发生什么?您可以在以下列表中看到这一点,其中我们在调用函数后立即锁定互斥锁,并在输出完成消息后释放它。

列表 4.4 错误(慢速)的锁定和解锁互斥锁的方式

func CountLetters(url string, frequency []int, mutex *sync.Mutex) {
    mutex.Lock()                              ❶
    resp, _ := http.Get(url)
    defer resp.Body.Close()
    if resp.StatusCode != 200 {
        panic("*Server returning error status code:* " + resp.Status)
    }
    body, _ := io.ReadAll(resp.Body)
    for _, b := range body {
        c := strings.ToLower(string(b))
        cIndex := strings.Index(AllLetters, c)
        if cIndex >= 0 {
            frequency[cIndex] += 1
        }
    }
    fmt.Println("*Completed:*", url, time.Now().Format("*15:04:05*"))
    mutex.Unlock()                            ❷
}

❶ 锁定互斥锁以进行整个执行过程,使一切按顺序进行

❷ 解锁互斥锁

通过以这种方式使用互斥锁,我们已经将并发程序转换为顺序程序。由于我们无谓地阻塞了整个执行过程,我们将一次下载和处理一个网页。如果我们继续运行这个程序,所需的时间将与程序的非并发版本相同,尽管执行顺序将是随机的:

$ go run charcountermutexslow.go
Completed: https://rfc-editor.org/rfc/rfc1002.txt 08:44:21
Completed: https://rfc-editor.org/rfc/rfc1030.txt 08:44:23
. . .
Completed: https://rfc-editor.org/rfc/rfc1028.txt 08:44:33
Completed: https://rfc-editor.org/rfc/rfc1029.txt 08:44:34
Completed: https://rfc-editor.org/rfc/rfc1001.txt 08:44:34
Completed: https://rfc-editor.org/rfc/rfc1000.txt 08:44:35
a-103445 b-23074 c-61005 d-51733 e-181360 f-33381 g-24966 h-47722 i-103262 j-3279 k-8839 l-49958 m-40026 n-108275 o-106320 p-41404 q-3410 r-101118 s-101040 t-136812 u-35765 v-13666 w-18259 x-4743 y-18416 z-1404

图 4.3 显示了这种锁定方式的简化调度图,仅使用三个 goroutines。该图显示我们的 goroutines 大部分时间在下载文档,而处理文档的时间较短。(在此图中,为了说明目的,我们低估了下载和处理之间的时间比例。实际上,这个差异要大得多。)我们的 goroutines 大部分时间在下载文档,而处理文档的时间只有一秒钟。从性能的角度来看,阻塞整个执行过程是没有意义的。文档下载步骤与其他 goroutines 没有共享内容,因此在那里发生竞争条件的风险很小。

图 4.3 显示过多地锁定代码将我们的字母频率并发程序转换为顺序程序。

TIP 在决定如何以及何时使用互斥锁时,最好专注于我们应该保护哪些资源,并发现关键部分的开始和结束位置。然后我们需要考虑如何最小化Lock()Unlock()调用的次数。

根据互斥锁的实现方式,如果我们频繁调用Lock()Unlock()操作,通常会有性能开销。(在第十二章中,我们将看到原因。)在我们的字母频率程序中,我们可以尝试使用互斥锁仅保护一条语句:

mutex.Lock()
frequency[cIndex] += 1
mutex.Unlock()

然而,这意味着我们将为下载文档中的每个字母调用这两个操作。由于处理整个文档是一个非常快速的操作,因此,在循环之前调用Lock()并在退出循环后调用Unlock()可能更高效。这在上面的列表中显示。

列表 4.5 在处理部分使用互斥锁(省略了导入)

package listing4_5

import (...)

const AllLetters = "*abcdefghijklmnopqrstuvwxyz*"

func CountLetters(url string, frequency []int, mutex *sync.Mutex) {
    resp, _ := http.Get(url)                                        ❶
    defer resp.Body.Close()                                         ❶
    if resp.StatusCode != 200 {                                     ❶
        panic("*Server returning error code:* " + resp.Status)        ❶
    }                                                               ❶
    body, _ := io.ReadAll(resp.Body)                                ❶
    mutex.Lock()                                                    ❷
    for _, b := range body {                                        ❷
        c := strings.ToLower(string(b))                             ❷
        cIndex := strings.Index(AllLetters, c)                      ❷
        if cIndex >= 0 {                                            ❷
            frequency[cIndex] += 1                                  ❷
        }                                                           ❷
    }                                                               ❷
    mutex.Unlock()                                                  ❷
    fmt.Println("*Completed:*", url, time.Now().Format("*15:04:05*"))
}

❶ 并发执行函数的慢速部分(下载)

❷ 仅锁定函数的快速处理部分

在这个版本的代码中,下载部分,这是我们函数中的长部分,将并发执行。然后,快速的字母计数处理将顺序执行。我们基本上通过只在代码部分使用锁来最大化我们程序的扩展性,这些部分相对于其他部分运行得非常快。我们可以运行前面的列表,正如预期的那样,它运行得更快,并给出了持续的正确结果:

$ go run charcountermutex.go
Completed: https://rfc-editor.org/rfc/rfc1026.txt 08:49:52
Completed: https://rfc-editor.org/rfc/rfc1025.txt 08:49:52
. . .
Completed: https://rfc-editor.org/rfc/rfc1008.txt 08:49:53
Completed: https://rfc-editor.org/rfc/rfc1024.txt 08:49:53
a-103445 b-23074 c-61005 d-51733 e-181360 f-33381 g-24966 h-47722 i-103262 j-3279 k-8839 l-49958 m-40026 n-108275 o-106320 p-41404 q-3410 r-101118 s-101040 t-136812 u-35765 v-13666 w-18259 x-4743 y-18416 z-1404

程序的执行在图 4.4 中进行了说明。同样,为了视觉效果,下载和处理部分之间的比例被夸大了。实际上,处理所花费的时间是下载网页所花费时间的极小部分,因此加速效果更为显著。实际上,在我们的main()函数中,我们可以将睡眠时间减少到几秒钟(之前是 60 秒)。

图 4.4 仅锁定countLetters()函数的处理部分

第二种解决方案比我们的第一次尝试更快。如果你比较图 4.3 和图 4.4,你会看到当我们锁定较小的代码部分时,我们完成得更快。这里的教训是要最小化持有互斥锁锁的时间,同时尝试减少互斥锁调用的次数。如果你回想起 Amdahl 定律,它告诉我们如果我们的代码在并行部分花费更多时间,我们可以更快地完成并更好地扩展,这就有意义了。

4.1.3 非阻塞互斥锁

当一个 goroutine 调用Lock()操作时,如果互斥锁已经被另一个执行占用,它将会阻塞。这就是所谓的阻塞函数:goroutine 的执行会停止,直到另一个 goroutine 调用Unlock()。在某些应用中,我们可能不想阻塞 goroutine,而是在尝试再次锁定互斥锁和访问临界区之前执行其他工作。

因此,Go 的互斥锁提供了一个名为TryLock()的另一个函数。当我们调用这个函数时,我们可以预期两种结果之一:

  • 锁可用,在这种情况下我们获取它,函数返回布尔值true

  • 锁不可用,因为另一个 goroutine 正在使用互斥锁,函数将立即返回(而不是阻塞)并返回一个布尔值false

非阻塞的使用

Go 在版本 1.18 中为互斥锁添加了TryLock()函数。这种非阻塞调用的有用示例很难找到。这是因为与在其他语言中使用内核级线程相比,在 Go 中创建 goroutine 的成本非常低。如果互斥锁不可用,让 goroutine 做其他事情没有太多意义,因为在 Go 中,等待锁释放时,创建另一个 goroutine 来完成工作更容易。事实上,Go 的互斥锁文档提到了这一点(来自pkg.go.dev/sync#Mutex.TryLock):

请注意,虽然存在正确的TryLock使用方式,但它们很少见,TryLock的使用通常是一个特定互斥锁使用中更深层次问题的标志。

使用TryLock()的一个例子是监控 goroutine,它检查某个任务的进度,而不想打扰任务的进度。如果我们使用正常的Lock()操作,并且应用程序正忙于许多其他 goroutine 想要获取锁,那么我们只是在监控目的上对互斥锁增加了额外的竞争。当我们使用TryLock()时,如果另一个 goroutine 正忙于持有互斥锁的锁,监控 goroutine 可以决定在系统不那么繁忙时稍后再尝试。想想看,当你看到入口处的大长队时,去邮局办一个非重要的事情,并决定改天再尝试(见图 4.5)。

图 4.5 尝试获取互斥锁,如果忙碌,稍后再尝试。

我们可以将我们的字母频率程序修改为在主 goroutine 中周期性地监控频率表,同时我们使用其他 goroutine 进行下载和文档扫描。列表 4.6 显示了main()函数每 100 毫秒打印frequency切片的内容。为了做到这一点,它必须获取互斥锁;否则,我们可能会读取错误的数据。然而,如果我们不想无谓地打扰忙碌的CountLetters() goroutine,我们就不希望这样做。因此,我们使用TryLock()操作,它尝试获取锁,但如果锁不可用,它将在下一个 100 毫秒周期再次尝试。

列表 4.6 使用TryLock()监控频率表

package main

import (
    "*fmt*"
    "*github.com/cutajarj/ConcurrentProgrammingWithGo/chapter4/listing4.5*"
    "*sync*"
    "*time*"
)

func main() {
    mutex := sync.Mutex{}
    var frequency = make([]int, 26)
    for i := 2000; i <= 2200; i++ {
        url := fmt.Sprintf("*https://rfc-editor.org/rfc/rfc%d.txt*", i)
        go listing4_5.CountLetters(url, frequency, &mutex)
    }
    for i := 0; i < 100; i++ {
        time.Sleep(100 * time.Millisecond)                 ❶
        if mutex.TryLock() {                               ❷
            for i, c := range listing4_5.AllLetters {      ❸
                fmt.Printf("*%c-%d* ", c, frequency[i])      ❸
            }                                              ❸
            mutex.Unlock()                                 ❸
        } else {
            fmt.Println("*Mutex already being used*")        ❹
        }
    }
}

❶ 睡眠 100 毫秒

❷ 尝试获取互斥锁

❸ 如果互斥锁可用,它会输出频率计数并释放互斥锁。

❹ 如果互斥锁不可用,它会输出一条消息并在稍后再次尝试。

当我们运行列表 4.6 时,我们可以在输出中看到main()goroutine 试图获取锁以打印频率表。有时它会成功;在其他时候,当它不成功时,它会等待下一个 100 毫秒再次尝试:

$ go run nonblockingmutex.go
a-0 b-0 c-0 d-0 e-0 f-0 g-0 h-0 i-0 j-0 k-0 l-0 m-0 n-0 o-0 p-0 q-0 r-0 s-0 t-0 u-0 v-0 w-0 x-0 y-0 z-0
. . .
Completed: https://rfc-editor.org/rfc/rfc2005.txt 11:18:39
a-2367 b-334 c-1270 d-1196 e-3685 f-1069 g-599 h-957 i-2537 j-22 k-112 l-1218 m-927 n-2131 o-2321 p-722 q-64 r-1673 s-2188 t-2609 u-628 v-204 w-510 x-65 y-364 z-15
Completed: https://rfc-editor.org/rfc/rfc2122.txt 11:18:39
. . .
Completed: https://rfc-editor.org/rfc/rfc2027.txt 11:18:41
Mutex already being used
Completed: https://rfc-editor.org/rfc/rfc2006.txt 11:18:41
a-462539 b-90971 c-258306 d-235639 e-766999 f-142655 g-106497 h-212728 i-460748 j-10833 k-32495 l-213285 m-170227 n-433419 o-426131 p-174817 q-12578 r-419110 s-441282 t-597287 u-160276 v-60274 w-63028 x-28231 y-80664 z-6908
Completed: https://rfc-editor.org/rfc/rfc2178.txt 11:18:41
. . .

4.2 使用读者-写者互斥锁提高性能

有时,互斥锁可能过于限制性。我们可以将互斥锁视为一种简单的工具,通过阻塞并发来解决并发问题。一次只能有一个 goroutine 执行我们的互斥锁保护的临界区。这对于保证我们不遭受竞态条件非常有用,但这也可能无谓地限制了某些应用程序的性能和可扩展性。读者-写者互斥锁为我们提供了一种标准互斥锁的变体,它只在需要更新共享资源时才阻塞并发。使用读者-写者互斥锁,我们可以提高那些在共享数据上执行大量读操作的应用程序的性能,与更新操作相比。

4.2.1 Go 的读者-写者互斥锁

如果我们有一个主要服务于许多并发客户端的静态数据应用程序呢?我们在第二章中概述了这样一个应用程序,当时我们有一个提供体育信息的 Web 服务器应用程序。让我们以一个类似的应用程序为例,该应用程序为用户提供关于篮球比赛的更新。图 4.6 展示了这样一个应用程序。

图 4.6 读取密集型服务器应用程序示例

在这个应用程序中,用户通过他们的设备检查实时篮球比赛的更新。运行在我们服务器上的 Go 应用程序提供这些更新。在这个应用程序中,我们有一个 match-recorder goroutine,每次游戏发生事件时都会更改共享数据的内容。事件可以是得分、犯规、传球等。

篮球是一项快节奏的运动,所以平均每秒我们会有几个这样的事件。在另一端,我们有一组大量的 goroutine 将整个游戏事件列表提供给大量连接的用户。用户出于各种原因使用这些数据:显示比赛统计数据、理解比赛策略,或者只是查看比分和比赛时间。这场比赛很受欢迎,所以我们应该构建能够处理尽可能多的每秒用户请求的东西。我们预计每秒会有数千个针对我们的比赛事件数据的请求。

让我们编写两种不同类型的 goroutine,从 match-recorder 函数开始,该函数在列表 4.7 中展示。运行此函数的 goroutine 会监听游戏期间发生的事件,例如得分、犯规等,然后将它们追加到一个共享数据结构中。在这种情况下,共享数据结构是一个string类型的 Go 切片。在我们的代码中,我们通过添加包含"Match Event i"的字符串来模拟每 200 毫秒发生一个事件。在现实世界中,goroutine 会监听体育直播或定期轮询 API,事件类型为"3 pointer from Team A"。

列表 4.7 模拟周期性游戏事件的 match recorder 函数

package main

import (
    "*fmt*"
    "*strconv*"
    "*sync*"
    "*time*"
)

func matchRecorder(matchEvents *[]string, mutex *sync.Mutex) {
    for i := 0; ; i++ {
        mutex.Lock()                           ❶
        *matchEvents = append(*matchEvents,    ❷
            "*Match event* " + strconv.Itoa(i))  ❷
        mutex.Unlock()                         ❸
        time.Sleep(200 * time.Millisecond)
        fmt.Println("*Appended match event*")
    }
}

❶ 使用互斥锁保护对 matchEvents 的访问

❷ 每 200 毫秒添加一个包含比赛事件的模拟字符串

❸ 解锁互斥锁

列表 4.8 显示了一个客户端处理器函数以及一个复制共享切片中所有事件的函数。我们可以将 clientHandler() 函数作为一个 goroutine 运行,每个处理一个已连接的用户。该函数锁定包含游戏事件的共享切片,并复制切片中的每个元素。这个函数模拟构建发送给用户的响应。在现实世界中,我们可以将这个响应格式化为类似 JSON 的格式。clientHandler() 函数有一个循环,重复 100 次,以模拟同一个用户多次请求。

列表 4.8 使用独占访问共享列表的客户端处理器

func clientHandler(mEvents *[]string, mutex *sync.Mutex, st time.Time) {
    for i := 0; i < 100; i ++ {
        mutex.Lock()                                                ❶
        allEvents := copyAllEvents(mEvents)                         ❷
        mutex.Unlock()                                              ❸

        timeTaken := time.Since(st)                                 ❹
        fmt.Println(len(allEvents), "*events copied in*", timeTaken)  ❺
    }
}

func copyAllEvents(matchEvents *[]string) []string {
    allEvents := make([]string, 0, len(*matchEvents))
    for _, e := range *matchEvents {
        allEvents = append(allEvents, e)
    }
    return allEvents
}

❶ 使用互斥锁保护对比赛事件列表的访问

❷ 复制比赛切片的全部内容,模拟构建发送给客户端的响应

❸ 解锁互斥锁

❹ 计算自开始以来经过的时间

❺ 将服务客户端所需的时间输出到控制台

在列表 4.9 中,我们将所有内容连接起来,并在 main() 函数中启动我们的 goroutines。在这个 main() 函数中,我们在创建一个普通互斥锁之后,预先填充比赛事件切片中的许多比赛事件。这模拟了一个已经进行了一段时间的游戏。我们这样做是为了测量当切片包含一些事件时,我们代码的性能。

列表 4.9 main() 函数预填充事件并启动 goroutines

func main() {
    mutex := sync.Mutex{}                                   ❶
    var matchEvents = make([]string, 0, 10000)
    for j := 0; j < 10000; j++ {                            ❷
        matchEvents = append(matchEvents, "*Match event*")    ❷
    }
    go matchRecorder(&matchEvents, &mutex)                  ❸
    start := time.Now()                                     ❹
    for j := 0; j < 5000; j++ {
        go clientHandler(&matchEvents, &mutex, start)       ❺
    }
    time.Sleep(100 * time.Second)
}

❶ 初始化一个新的互斥锁

❷ 在事件切片中预填充许多事件,模拟正在进行的游戏

❸ 启动比赛记录器 Go 协程

❹ 在启动客户端处理器 goroutines 之前记录开始时间

❺ 启动大量客户端处理器 goroutines

main() 函数中,然后启动一个比赛记录器 goroutine 和 5,000 个客户端处理器 goroutine。基本上,我们正在模拟一个正在进行的游戏,并且有大量用户同时请求获取游戏更新。我们还记录在启动客户端处理器 goroutines 之前的时间,以便我们可以测量处理所有请求所需的时间。最后,我们的 main() 函数休眠一段时间,等待客户端处理器 goroutines 完成。

与读取查询的数量相比,数据变化非常缓慢。当我们使用正常的互斥锁锁时,每次一个 goroutine 读取共享的篮球数据时,它会阻塞所有其他正在服务的 goroutines,直到它完成。即使客户端处理器只是读取共享的切片而不进行任何修改,我们仍然给每个处理器提供对切片的独占访问。请注意,如果有多个 goroutine 只是读取共享数据而不更新它,就没有必要这种独占访问;并发读取共享数据不会引起任何干扰。

注意:只有当我们没有适当同步地更改共享状态时,才会发生竞态条件。如果我们不修改共享数据,就不会有竞态条件的风险。

如果所有客户端处理 goroutine 都可以非独占地访问切片,那么在需要时它们可以同时读取列表。这将提高性能,因为它将允许多个仅读取共享数据的 goroutine 同时访问它。我们只有在需要更新共享数据时才会阻止对共享数据的访问。在这个例子中,我们更新数据的频率非常低(每秒几次),与我们的读取次数(每秒数千次)相比。因此,我们将从允许多个并发读取但独占写入的系统中获得好处。

这正是 读者-写者锁 给我们的。当我们只需要读取共享资源而不更新它时,读者-写者锁允许多个并发 goroutine 执行只读关键部分。当我们需要更新共享资源时,执行写关键部分的 goroutine 会请求写锁以获取独占访问。这一概念在图 4.7 中有所描述。在图的左侧,读锁允许并发读取访问,同时阻止任何写访问。在右侧,获取写锁阻止所有其他访问,无论是读取还是写入,就像正常的互斥锁一样。

图 4.7 使用读者-写者锁的 goroutine

Go 自带读者-写者锁的实现。除了提供常规的独占锁定和解锁功能外,Go 的 sync.RWMutex 还提供了额外的方法来使用互斥锁的读者端。以下是我们可以使用的函数列表:

type RWMutex
  //Locks mutex
  func (rw *RWMutex) Lock()
  //Locks read part of mutex
  func (rw *RWMutex) RLock()
  //Returns read part locker of mutex
  func (rw *RWMutex) RLocker() Locker
  //Unlocks read part of mutex
  func (rw *RWMutex) RUnlock()
  //Tries to lock mutex
  func (rw *RWMutex) TryLock() bool
  //Tries to lock read part of mutex
  func (rw *RWMutex) TryRLock() bool
  //Unlock mutex
  func (rw *RWMutex) Unlock()

函数名中带有 R 的锁定和解锁功能,例如 RLock() 函数,为我们提供了 RWMutex 的读者端。其他所有功能,如 Lock(),则让我们可以操作写者部分。现在,我们可以修改我们的篮球更新应用程序以使用这些新功能。在下面的列表中,我们将初始化这些读者-写者互斥锁之一,并将其传递给 main() 函数中的其他 goroutine。

列表 4.10 main() 函数创建 RWMutex

func main() {
    mutex := sync.RWMutex{}                             ❶
    var matchEvents = make([]string, 0, 10000)
    for j := 0; j < 10000; j++ {
        matchEvents = append(matchEvents, "*Match event*")
    }
    go matchRecorder(&matchEvents, &mutex)              ❷
    start := time.Now()
    for j := 0; j < 5000; j++ {
        go clientHandler(&matchEvents, &mutex, start)   ❸
    }
    time.Sleep(100 * time.Second)
}

❶ 初始化一个新的读者-写者互斥锁

❷ 将读者-写者互斥锁传递给匹配记录器

❸ 将读者-写者互斥锁传递给客户端处理 goroutine

接下来,我们的两个函数,matchRecorder()clientHandler(),需要更新,以便它们分别调用写锁和读锁互斥锁函数。在列表 4.11 中,matchRecorder() 调用 Lock()UnLock(),因为它需要更新共享数据结构。clientHandler() goroutine 使用 RLock()RUnlock(),因为它们只读取共享数据结构。这里使用的读锁是必要的,因为我们不希望在遍历期间修改切片数据结构。例如,在另一个 goroutine 遍历时修改切片的指针和内容可能会导致我们跟随无效的指针引用。

列表 4.11 匹配记录器和客户端处理函数调用读写互斥锁

func matchRecorder(matchEvents *[]string, mutex *sync.RWMutex) {
    for i := 0; ; i++ {
        mutex.Lock()                           ❶
        *matchEvents = append(*matchEvents,    ❶
            "*Match event* " + strconv.Itoa(i))  ❶
        mutex.Unlock()                         ❶
        time.Sleep(200 * time.Millisecond)
        fmt.Println("*Appended match event*")
    }
}

func clientHandler(mEvents *[]string, mutex *sync.RWMutex, st time.Time) {
    for i := 0; i < 100; i ++ {
        mutex.RLock()                          ❷
        allEvents := copyAllEvents(mEvents)    ❷
        mutex.RUnlock()                        ❷
        timeTaken := time.Since(st)
        fmt.Println(len(allEvents), "*events copied in*", timeTaken)
    }
}

❶ 使用写互斥锁保护关键部分

❷ 使用读互斥锁保护关键部分

在我们的 clientHandler() 函数中,执行 RLock()RUnlock() 之间的关键代码部分的 goroutine 会阻止另一个 goroutine 在我们的 matchRecorder() 函数中获取写锁。然而,它不会阻止另一个 goroutine 也获取对关键部分的读者锁。这意味着我们可以有并发执行 clientHandler() 的 goroutine,而没有任何读 goroutine 会相互阻塞。

当有游戏更新时,matchRecorder() 函数中的 goroutine 通过在互斥锁上调用 Lock() 函数来获取写锁。只有在任何活动的 matchRecorder() goroutine 释放其读锁时,才会获取写锁。当获取写锁后,它将阻止任何其他 goroutine 在我们 clientHandler() 函数中的关键部分访问,直到我们通过调用 UnLock() 释放写锁。

如果我们有一个运行多个核心的系统,这个例子应该会给我们比单核系统更高的速度提升。这是因为我们会并行运行多个客户端处理 goroutine,因为它们可以同时访问共享数据。在一次测试运行中,我使用读者-写者互斥锁实现了吞吐量性能的三倍增长:

$ go run matchmonitor.go
. . .
10064 events copied in 33.033974291s
Appended match event
Appended match event
. . .
$ go run matchmonitor.go
. . .
10033 events copied in 10.228970583s
Appended match event
Appended match event
. . .

图 4.8 将前面的结果转换为每秒请求数,并显示了在 10 核机器上运行此简单应用程序时使用读者-写者互斥锁的优势。该图表假设应用程序处理了总共 500,000 个请求(来自 5,000 个客户端的 100 个请求)。

图片

图 4.8 在多核处理器上运行的以读为主的服务器应用程序的性能差异

注意:在不同的硬件上运行此应用程序会产生不同的结果。在较慢的机器上运行可能需要更改末尾的睡眠周期或减少客户端处理 goroutine 的数量。

4.2.2 构建自己的读优先读者-写者互斥锁

现在我们已经看到了如何使用读者-写者互斥锁,那么了解它们在内部是如何工作的会很好。在本节中,我们将尝试构建自己的读者-写者互斥锁,类似于 Go 的 sync 包中捆绑的互斥锁。为了使事情简单,我们只构建四个重要的函数:ReadLock()ReadUnlock()WriteLock()WriteUnlock()。我们将它们的命名与 sync 版本略有不同,以便我们可以区分我们的实现与 Go 库中的实现。

为了实现我们的读者-写入者互斥锁,我们需要一个系统,当 goroutine 调用ReadLock()时,阻止对写入部分的任何访问,同时允许其他 goroutine 仍然可以调用ReadLock()而不被阻塞。我们将通过确保调用WriteLock()的 goroutine 挂起执行来阻止写入部分。只有当所有读取 goroutine 都调用ReadUnlock()后,我们才允许另一个 goroutine 从WriteLock()中解除阻塞。

为了帮助我们可视化这个系统,我们可以将 goroutines 想象成试图通过两个入口进入房间的实体。这个房间象征着对共享资源的访问。读者 goroutines 使用一个特定的入口,而写入者使用另一个入口。入口一次只允许一个 goroutine 进入,尽管同时可以有多个 goroutine 在房间里。我们保持一个计数器,读者 goroutine 通过读者入口进入时将其增加1,离开房间时将其减少1。写入者的入口可以通过我们称之为全局锁的东西从内部锁定。这个概念在图 4.9 的左侧显示。

图片

图 4.9 锁定读者-写入者互斥锁的读取部分

程序是这样的,当第一个读者 goroutine 进入房间时,它必须锁定写入者的入口,如图 4.9 的右侧所示。这确保了写入者 goroutine 将无法进入,阻止了 goroutine 的执行。然而,其他读者 goroutine 仍然可以通过它们自己的入口进入。读者 goroutine 知道它是第一个进入房间的原因是计数器的值为1

写入者的进入这里只是一个我们称之为全局锁的互斥锁。写入者需要获取这个互斥锁以持有读者-写入者锁的写入者部分。当第一个读者锁定这个互斥锁时,它会阻止任何请求写入者部分锁的 goroutine。

我们需要确保在任何时候只有一个 goroutine 使用读者入口,因为我们不希望两个同时的读取 goroutine 同时进入并认为他们是第一个进入房间的人。这会导致他们都尝试锁定全局锁,而只有一个是成功的。因此,为了同步访问,确保一次只有一个 goroutine 可以使用读者入口,我们可以使用另一个互斥锁。在下面的列表中,我们将称这个互斥锁为readersLock。读者计数器由readersCounter变量表示,我们将写入者的锁称为globalLock

列表 4.12 读者-写入者互斥锁的类型结构

package listing4_12

import "*sync*"

type ReadWriteMutex struct {
    readersCounter int           ❶

    readersLock    sync.Mutex    ❷

    globalLock     sync.Mutex    ❸
}

❶ 计数整数变量,用于统计当前在临界区内的读者 goroutine 数量

❷ 用于同步读者访问的互斥锁

❸ 用于阻止任何写入者访问的互斥锁

下面的列表展示了我们概述的锁定机制的实现。在读者方面,ReadLock() 函数通过使用 readersLock 互斥锁来同步访问,确保一次只有一个 goroutine 使用该函数。

列表 4.13 ReadLock()函数的实现

func (rw *ReadWriteMutex) ReadLock() {
    rw.readersLock.Lock()               ❶
    rw.readersCounter++                 ❷
    if rw.readersCounter == 1 {
        rw.globalLock.Lock()            ❸
    }
    rw.readersLock.Unlock()             ❹
}

func (rw *ReadWriteMutex) WriteLock() {
    rw.globalLock.Lock()                ❺
}

❶ 同步访问,以确保在任何时候只允许一个 goroutine 访问

❷ 读者 goroutine 将readersCounter增加1

❸ 如果一个读者 goroutine 是第一个进入的,它将尝试锁定全局锁。

❹ 同步访问,以确保在任何时候只允许一个 goroutine 访问

❺ 任何写者访问都需要锁定全局锁。

一旦调用者获取了readersLock,它将读者的计数器增加1,表示另一个 goroutine 即将获得对共享资源的读取访问。如果 goroutine 意识到它是第一个获得读取访问的,它将尝试锁定globalLock以阻止任何写者 goroutine 的访问。(当WriteLock()函数需要获取互斥锁的写者部分时,它使用globalLock。)如果globalLock是空闲的,这意味着当前没有写者正在执行其关键部分。在这种情况下,第一个读者获得globalLock,释放readersLock,然后继续执行其读者的关键部分。

当一个读者 goroutine 完成其关键部分的执行时,我们可以将其视为通过相同的通道退出。在它离开的过程中,它会将计数器减1。使用相同的通道意味着在更新计数器时需要获取readersLock。最后离开房间的人(当计数器为0时),解锁全局锁,以便写者 goroutine 最终可以访问共享资源。这显示在图 4.10 的左侧。

图 4.10 读者-写者互斥锁中写部分的读取解锁和锁定

当一个写者 goroutine 正在执行其关键部分,即访问我们类比中的房间时,它持有globalLock的锁。这有两个效果。首先,它阻止了其他写者 goroutine 的访问,因为写者需要在获得访问权之前获取这个锁。其次,它也阻止了第一个读者 goroutine 在尝试获取globalLock时。第一个读者 goroutine 将会阻塞并等待直到globalLock变得可用。由于第一个读者 goroutine 也持有readersLock,它也会在等待期间阻止任何其他读者 goroutine 的访问。这就像第一个读者 goroutine 没有移动并因此阻止了读者的进入,不让任何其他 goroutine 进入。

一旦写者 goroutine 完成其关键部分的执行,它将释放globalLock。这会阻止第一个读者 goroutine,并允许任何其他阻塞的读者进入。

我们可以在我们的两个解锁函数中实现这个释放逻辑。列表 4.14 展示了 ReadUnlock()WriteUnlock() 函数。ReadUnlock() 再次使用 readersLock 来确保一次只有一个 goroutine 执行此函数,保护共享的 readersCounter 变量。一旦读者获取了锁,它将 readersCounter 的计数减 1,如果计数达到 0,它也会释放 globalLock。这允许写者获得访问权限。在写者的方面,WriteUnlock() 简单地释放 globalLock,允许读者或单个写者访问。

列表 4.14 ReadUnlock() 函数的实现

func (rw *ReadWriteMutex) ReadUnlock() {
    rw.readersLock.Lock()                  ❶
    rw.readersCounter--                    ❷
    if rw.readersCounter == 0 {
        rw.globalLock.Unlock()             ❸
    }
    rw.readersLock.Unlock()                ❹
}

func (rw *ReadWriteMutex) WriteUnlock() {
    rw.globalLock.Unlock()                 ❺
}

❶ 同步访问,以确保任何时间只允许一个 goroutine 访问

❷ 读者 goroutine 将 readersCounter1

❸ 如果读者 goroutine 是最后一个离开的,它将解锁全局锁。

❹ 同步访问,以确保任何时间只允许一个 goroutine 访问

❺ 写者 goroutine 完成其关键部分后,释放全局锁。

注意:这个读者-写者锁的实现是优先读取的。这意味着如果我们有固定数量的读者 goroutine 占据互斥锁的读取部分,写者 goroutine 将无法获得互斥锁。从技术角度来说,我们说读者 goroutine 正在饿死写者 goroutine,不允许它们访问共享资源。在下一章中,我们将通过讨论条件变量来改进这一点。

4.3 练习

注意:访问 github.com/cutajarj/ConcurrentProgrammingWithGo 以查看所有代码解决方案。

  1. 列表 4.15(最初来自第三章)没有使用任何互斥锁来保护对共享变量 seconds 的访问。这是不好的做法。改变这个程序,以便通过互斥锁保护对共享 seconds 变量的访问。提示:你可能需要复制一个变量。

    列表 4.15 Goroutines 共享变量而不进行同步

    package main
    
    import (
        "*fmt*"
        "*time*"
    )
    
    func countdown(seconds *int) {
        for *seconds > 0 {
            time.Sleep(1 * time.Second)
            *seconds -= 1
        }
    }
    
    func main() {
        count := 5
        go countdown(&count)
        for count > 0 {
            time.Sleep(500 * time.Millisecond)
            fmt.Println(count)
        }
    }
    
  2. 在读者-写者互斥锁的实现中添加一个非阻塞的 TryLock() 函数。该函数应尝试锁定写者的锁部分。如果获得锁,则应返回 true 值;否则,函数应立即返回,不阻塞,并返回 false 值。

  3. 在读者-写者锁的实现中添加一个非阻塞的 TryReadLock() 函数。该函数应尝试锁定读者的锁部分。就像在练习 2 中一样,如果它成功获得锁,则立即返回 true,否则返回 false

  4. 在上一章的练习 3.1 中,我们开发了一个程序来输出从下载的网页中获取的单词频率。如果你使用共享内存映射来存储单词频率,则需要保护对共享映射的访问。你能使用互斥锁来保证对映射的独占访问吗?

摘要

  • 互斥锁可以用来保护我们的代码中的关键部分免受并发执行的影响。

  • 我们可以通过在关键部分开始和结束时分别调用Lock()UnLock()函数来使用互斥锁保护关键部分。

  • 锁定互斥锁时间过长可能会将我们的并发代码转换为顺序执行,从而降低性能。

  • 我们可以通过调用TryLock()来测试互斥锁是否已经被锁定。

  • 读者-写者互斥锁可以为读取密集型应用提供性能提升。

  • 读者-写者互斥锁允许多个读者 goroutine 并发执行关键部分,并为单个写者 goroutine 提供独占访问。

  • 我们可以使用一个计数器和两个普通互斥锁来构建一个优先读取的读者-写者互斥锁。

5 条件变量和信号量

本章涵盖

  • 使用条件变量等待条件

  • 实现偏向写者的读者-写者锁

  • 使用计数信号量存储信号

在上一章中,我们看到了如何使用互斥锁来保护代码的关键部分,并防止多个 goroutine 同时执行。互斥锁并不是我们拥有的唯一同步工具:条件变量为我们提供了额外的控制,以补充独占锁定。它们使我们能够在解除阻塞执行之前等待某个条件的发生。信号量在互斥锁的基础上更进一步,因为它们允许我们控制多少个并发 goroutine 可以同时执行某个部分。此外,信号量还可以用来存储一个信号(发生事件的信号),以便稍后由执行过程访问。

除了在并发应用程序中很有用之外,条件变量和信号量还是我们可以用来构建更复杂工具和抽象的额外原始构建块。在本章中,我们还将重新审视上一章中开发的读者-写者锁,并使用条件变量对其进行改进。

5.1 条件变量

条件变量在互斥锁的基础上提供了额外的功能。我们可以在 goroutine 需要阻塞并等待特定条件发生的情况下使用它们。让我们通过一个例子来了解它们是如何使用的。

5.1.1 将互斥锁与条件变量结合

在前面的章节中,我们看到了两个 goroutine(Stingy 和 Spendy)共享同一个银行账户的例子。Stingy 和 Spendy 的 goroutine 会反复赚取和花费 10 美元。如果我们尝试创建一个不平衡的情况,其中 Spendy 的花费速度比 Stingy 的赚取速度快呢?之前我们的总收入和总支出平衡在 1000 万美元。在这个例子中,我们将保持相同的总金额平衡在 1000 万美元,但我们将花费率提高到 50,并将总迭代次数减少到 20 万次。这样,银行账户会很快出现负数(见图 5.1),因为我们现在花费的速度比赚取的速度快。当我们的账户出现负数时,银行也可能会有额外的成本。理想情况下,我们需要一种方法来减缓花费,以防止余额低于零。

图片

图 5.1 花费者 goroutine 花费的金额与 stingy 赚到的金额相同,但速度更快。

列表 5.1 展示了修改后的spendy()函数以展示这种场景。在这个列表中,当银行账户变成负数时,我们打印一条消息并退出程序。注意,在这两个函数中,赚取和花费的金额是相同的。只是在开始时,Spendy 的花费速度比 Stingy 的赚取速度快。如果我们省略os.Exit()spendy()函数将更早完成,然后 Stingy 函数最终会将银行账户填满到原始值。

列表 5.1:以更快的速度花费(省略了main()函数以节省篇幅)

package main

import (
    "*fmt*"
    "*os*"
    "*sync*"
    "*time*"
)

func stingy(money *int, mutex *sync.Mutex) {
    for i := 0; i < 1000000; i++ {
        mutex.Lock()
        *money += 10                            ❶
        mutex.Unlock()
    }
    fmt.Println("*Stingy Done*")
}

func spendy(money *int, mutex *sync.Mutex) {
    for i := 0; i < 200000; i++ {
        mutex.Lock()
        *money -= 50                            ❶
        if *money < 0 {                         ❷
            fmt.Println("*Money is negative!*")   ❷
            os.Exit(1)                          ❷
        }
        mutex.Unlock()
    }
    fmt.Println("*Spendy Done*")
}

❶ 花费 50 元,而收入只有 10 元

❷ 当金钱变量变成负数时,输出消息并终止程序

当我们使用第四章中的main()方法运行列表 5.1 时,余额迅速变成负数,程序终止:

$ go run stingyspendynegative.go
Money is negative!
exit status 1

我们能做些什么来阻止余额变成负数吗?理想情况下,我们希望有一个系统不会花费我们没有的钱。我们可以尝试让spendy()函数在继续花费之前检查是否有足够的钱。如果没有足够的钱,我们可以让 goroutine 休眠一段时间,然后再次检查。spendy()函数的这种处理方式在下一列表中展示。

列表 5.2:当金钱用尽时spendy()函数的重试

func spendy(money *int, mutex *sync.Mutex) {
    for i := 0; i < 200000; i++ {
        mutex.Lock()
        for *money < 50 {                        ❶
            mutex.Unlock()                       ❷
            time.Sleep(10 * time.Millisecond)    ❸
            mutex.Lock()                         ❹
        }
        *money -= 50
        if *money < 0 {
            fmt.Println("*Money is negative!*")
            os.Exit(1)
        }
        mutex.Unlock()
    }
    fmt.Println("*Spendy Done*")
}

❶ 如果没有足够的钱,会持续尝试

❷ 解锁互斥锁,允许其他 goroutine 访问金钱变量

❸ 短暂休眠

❹ 再次锁定互斥锁以确保我们访问最新的金钱值

这个解决方案将适用于我们的用例,但并不理想。在我们的例子中,我们选择了任意的休眠值为 10 毫秒,但我们应该选择什么才是最佳数值呢?在一种极端情况下,我们可以选择完全不休眠。这会导致 CPU 资源浪费,因为 CPU 会无谓地循环,检查money变量即使它没有变化。在另一种极端情况下,如果 goroutine 休眠时间过长,我们可能会浪费时间等待已经发生的money变量变化。

这就是条件变量发挥作用的地方。条件变量与互斥锁协同工作,使我们能够挂起当前执行,直到我们收到特定条件已更改的信号。图 5.2 展示了使用条件变量与互斥锁的常见模式。

图 5.2:使用条件变量与互斥锁的常见模式

让我们深入到图 5.2 中每个步骤的细节,以了解使用条件变量的这种常见模式:

  1. 当持有互斥锁时,goroutine A 检查共享状态上的特定条件。在我们的例子中,条件将是“共享银行账户变量中是否有足够的钱?”

  2. 如果条件不满足,goroutine A 会在条件变量上调用Wait()函数。

  3. Wait()函数执行两个操作原子性地(定义在列表之后):

    1. 它释放互斥锁。

    2. 它阻塞当前执行,实际上是将 Go 程置于睡眠状态。

  4. 由于互斥锁现在可用,另一个 Go 程(Go 程 B)获取它以更新共享状态。例如,Go 程 B 增加共享银行账户变量中可用的资金量。

  5. 在更新共享状态后,Go 程 B 在条件变量上调用 Signal()Broadcast(),然后解锁互斥锁。

  6. 当接收到 Signal()Broadcast() 时,Go 程 A 会醒来并自动重新获取互斥锁。Go 程 A 可以重新检查共享状态上的条件,例如在花费之前检查共享银行账户中是否有足够的资金。步骤 2 到 6 可能会重复,直到条件得到满足。

  7. 条件最终会被满足。

  8. Go 程继续执行其逻辑,例如通过使用银行账户中现在可用的资金。

注意:理解条件变量的关键是理解 Wait() 函数以原子方式释放互斥锁并暂停执行。这意味着在这两个操作之间,另一个执行不能进来获取锁并调用 Signal() 函数,在调用 Wait() 的执行被暂停之前。

Go 中的条件变量实现可以在 sync.Cond 类型中找到。如果我们查看此类型上可用的函数,我们会发现以下内容:

type Cond
  func NewCond(l Locker) *Cond
  func (c *Cond) Broadcast()
  func (c *Cond) Signal()
  func (c *Cond) Wait()

创建一个新的 Go 条件变量需要一个 Locker,它定义了两个函数:

type Locker interface {
    Lock()
    Unlock()
}

要使用 Go 的条件变量,我们需要实现这两个函数的东西,而互斥锁就是这样一种类型。以下列表显示了一个 main() 函数,它创建了一个互斥锁,然后将其用于条件变量。稍后,它将条件变量传递给 stingy()spendy() Go 程中。

列表 5.3 main() 函数使用互斥锁创建条件变量

package main

import (
    "*fmt*"
    "*os*"
    "*sync*"
    "*time*"
)

func main() {
    money := 100
    mutex := sync.Mutex{}              ❶
    cond := sync.NewCond(&mutex)       ❷
    go stingy(&money, cond)            ❸
    go spendy(&money, cond)            ❸
    time.Sleep(2 * time.Second)
    mutex.Lock()
    fmt.Println("*Money in bank account:* ", money)
    mutex.Unlock()
}

❶ 创建一个新的互斥锁

❷ 使用互斥锁创建一个新的条件变量

❸ 将条件变量传递给两个 Go 程中

我们可以通过使用 Go 的 sync.Cond 类型提供的函数,在我们的 stingy()spendy() 函数中应用之前在图 5.2 中概述的模式。图 5.3 展示了使用此模式的两个 Go 程的运行时间。如果我们让 Spendy Go 程在减去 50 美元之前检查条件,我们就可以保护余额不会变成负数。如果没有足够的资金,Go 程会等待,暂停其执行,直到有更多的资金可用。当 Stingy 添加资金时,它会向等待更多资金的任何执行发送信号以恢复。

图片

图 5.3 显示了 Stingy 和 Spendy 使用条件变量防止余额变为负数

修改节俭 goroutine 更简单,因为我们只需要发出信号。列表 5.4 显示了我们对此 goroutine 的修改。每次我们向共享的 money 变量添加资金时,我们通过在条件变量上调用 Signal() 函数来发送信号。另一个变化是我们正在使用条件变量上存在的互斥锁来保护对临界区的访问。

列表 5.4 节俭函数发出更多资金可用的信号

func stingy(money *int, cond *sync.Cond) {
    for i := 0; i < 1000000; i++ {
        cond.L.Lock()       ❶
        *money += 10
        cond.Signal()       ❷
        cond.L.Unlock()     ❶
    }
    fmt.Println("*Stingy Done*")
}

❶ 使用条件变量上的互斥锁

❷ 每次向共享资金变量添加资金时,在条件变量上发出信号

接下来,我们可以修改我们的 spendy() 函数,使其等待直到我们的 money 变量中有足够的资金。我们可以通过一个循环来实现这个条件检查,每次资金金额低于 50 美元时,就调用 Wait()。在列表 5.5 中,我们使用了一个 for 循环,只要 *money 小于 50 美元,它就会持续迭代。在每次迭代中,它会调用 Wait()。该函数现在还利用了条件变量类型中包含的互斥锁。

列表 5.5 节俭等待更多资金可用

func spendy(money *int, cond *sync.Cond) {
    for i := 0; i < 200000; i++ {
        cond.L.Lock()               ❶
        for *money < 50 {           ❷
            cond.Wait()             ❷
        }
        *money -= 50                ❸
        if *money < 0 {
            fmt.Println("*Money is negative!*")
            os.Exit(1)
        }
        cond.L.Unlock() 
    }
    fmt.Println("*Spendy Done*")
}

❶ 使用条件变量上的互斥锁

❷ 当我们没有足够的资金时等待,释放互斥锁并挂起执行

❸ 从 Wait() 返回时,一旦有足够的资金,就重新获取互斥锁并减去资金

注意:每当一个等待的 goroutine 收到信号或广播时,它都会尝试重新获取互斥锁。如果另一个执行正在持有互斥锁,那么 goroutine 将保持挂起状态,直到互斥锁变得可用。

当我们执行列表 5.3、5.4 和 5.5 时,程序不会因为负余额而退出。相反,我们得到以下输出:

$ go run stingyspendycond.go
Stingy Done
Spendy Done
Money in bank account:  100

监视器

有时我们在条件变量和互斥锁的上下文中听到 monitor 这个术语。monitor 是一种同步模式,它有一个与相关条件变量关联的互斥锁。我们可以使用这些来等待或向正在等待条件的其他线程发出信号,就像我们在本节中所做的那样。一些语言,如 Java,在每个对象实例上都有一个 monitor 构造。在 Go 中,每次我们使用带有条件变量的互斥锁时,都使用 monitor 模式。

5.1.2 丢失信号

如果一个 goroutine 调用 Signal()Broadcast() 而没有等待执行的执行,会发生什么?它会被丢失还是存储以供下一个 goroutine 调用 Wait()?答案如图 5.4 所示。如果没有处于等待状态的 goroutine,Signal()Broadcast() 调用将会丢失。让我们通过使用条件变量来解决另一个问题——等待我们的 goroutine 完成它们的任务。

图 5.4 显示,在没有 Wait() 的情况下调用 Signal() 将导致信号丢失。

到目前为止,我们一直在main()函数中使用time.Sleep()来等待我们的 goroutine 完成。这并不好,因为我们只是在估计 goroutine 将花费多长时间。如果我们在一个较慢的计算机上运行我们的代码,我们将不得不增加我们睡眠的时间量。

我们可以修改doWork()函数,使其在准备好后通过条件变量让main()函数等待,然后子 goroutine 发送信号。以下列表显示了这种做法的不正确方式。

列表 5.6 信号的不正确方式

package main

import (
    "*fmt*"
    "*sync*"
)

func doWork(cond *sync.Cond) {
    fmt.Println("*Work started*")
    fmt.Println("*Work finished*")
    cond.Signal()                                     ❶
}

func main() {
    cond := sync.NewCond(&sync.Mutex{})
    cond.L.Lock()
    for i := 0; i < 50000; i++ {                      ❷
        go doWork(cond)                               ❸
        fmt.Println("*Waiting for child goroutine* ")
        cond.Wait()                                   ❹
        fmt.Println("*Child goroutine finished*")
    }
    cond.L.Unlock()
}

❶ Goroutine 发出信号,表明它已完成工作。

❷ 重复 50,000 次

❸ 启动一个 goroutine,模拟做一些工作

❹ 等待 goroutine 发送完成信号

当我们运行列表 5.6 时,我们得到以下输出:

$ go run signalbeforewait.go
Waiting for child goroutine
Work started
Work finished
Child goroutine finished
Waiting for child goroutine
Work started
Work finished
Child goroutine finished
. . .
Work started
Work finished
Waiting for child goroutine
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [sync.Cond.Wait]:
sync.runtime_notifyListWait(0xc000024090, 0x9a9)
        sema.go:517 +0x152
sync.(*Cond).Wait(0xe4e1c4?)
        cond.go:70 +0x8c
main.main()
        signalbeforewait.go:19 +0xaf
exit status 2

提示:列表 5.6 的行为可能取决于我们运行它的硬件和操作系统。为了增加前一个错误发生的可能性,我们可以在main()函数中的cond.Wait()之前插入一个runtime.Gosched()调用。这给了子 goroutine 在main() goroutine 进入等待状态之前执行更多机会。

前一个输出中的问题是,我们可能在main() goroutine 没有在条件变量上等待时发出信号。当这种情况发生时,我们会错过信号。Go 的运行时会检测到一个 goroutine 在徒劳地等待,因为没有其他 goroutine 可能调用信号函数,并且它会抛出一个致命错误。

注意:我们需要确保在调用信号或广播函数时,有另一个 goroutine 正在等待它;否则,信号或广播不会被任何 goroutine 接收,并且会错过。

为了确保我们不错过任何信号和广播,我们需要将它们与互斥锁结合使用。也就是说,我们应该只在持有相关互斥锁时调用这些函数。这样,我们可以确信main() goroutine 处于等待状态,因为互斥锁只有在 goroutine 调用Wait()时才会释放。图 5.5 显示了两种场景:错过信号和使用互斥锁发出信号。

图 5.5 (a) 没有 goroutine 等待时错过信号;(b) 在doWork() goroutine 中使用互斥锁并在持有互斥锁时调用信号

我们可以修改列表 5.6 中的doWork()函数,使其在调用signal()之前锁定互斥锁,如图 5.5 的右侧所示。这确保了main() goroutine 处于等待状态,如下一个列表所示。

列表 5.7 在信号时持有互斥锁

func doWork(cond *sync.Cond) {
    fmt.Println("*Work started*")
    fmt.Println("*Work finished*")
    cond.L.Lock()                ❶
    cond.Signal()                ❷
    cond.L.Unlock()              ❸
}

❶ 在信号前锁定互斥锁

❷ 在条件变量上发出信号

❸ 信号后解锁互斥锁

提示:始终在持有互斥锁时使用Signal()Broadcast()Wait(),以避免同步问题。

5.1.3 使用等待和广播同步多个 goroutine

我们到目前为止只看了使用Signal()而不是Broadcast()的示例。当我们有多个 goroutine 在条件变量的Wait()上挂起时,Signal()将任意唤醒这些 goroutine 中的一个。另一方面,Broadcast()调用将唤醒所有挂起在Wait()上的 goroutine。

注意:当一个 goroutine 组在Wait()上挂起时,我们调用Signal(),我们只会唤醒其中一个 goroutine。我们无法控制系统将恢复哪个 goroutine,我们应该假设它可以是条件变量的Wait()上挂起的任何goroutine。使用Broadcast(),我们确保所有挂起的 goroutine 都会被恢复。

让我们现在用一个例子来演示Broadcast()功能。图 5.6 显示了一个游戏,玩家在游戏开始前等待所有人加入。这在在线多人游戏和游戏机中都是一个常见的场景。让我们想象我们的程序有一个 goroutine 处理与每个玩家的交互。我们如何编写代码来挂起执行直到所有玩家都加入游戏?

图片

图 5.6 服务器在开始游戏之前等待四个玩家加入

为了模拟处理四个玩家的 goroutine,每个玩家在游戏中的连接时间不同,我们可以在main()函数中以时间间隔创建每个 goroutine(参见列表 5.8)。在我们的main()函数中,我们还在共享一个playersInGame变量。这告诉 goroutine 有多少玩家正在参加游戏。每个 goroutine 执行一个playerHandler()函数,我们将在稍后实现。

列表 5.8 main()函数以时间间隔启动玩家处理器

package main

import (
    "*fmt*"
    "*sync*"
    "*time*"
)

func main() {
    cond := sync.NewCond(&sync.Mutex{})                     ❶
    playersInGame := 4                                      ❷
    for playerId := 0; playerId < 4; playerId++ {
        go playerHandler(cond, &playersInGame, playerId)    ❸
        time.Sleep(1 * time.Second)                         ❹
    }
}

❶ 创建一个新的条件变量

❷ 初始化玩家总数为 4

❸ 开始一个共享条件变量、游戏玩家和玩家 ID 的 goroutine

❹ 在下一个玩家连接之前暂停 1 秒

我们可以通过让多个 goroutine 等待同一个条件来使用条件变量。由于我们有一个处理每个玩家的 goroutine,我们可以让每个 goroutine 等待一个条件,告诉我们所有玩家都已连接。然后我们可以使用相同的条件变量来检查所有玩家是否已连接,如果没有,我们调用Wait()。每次一个新的 goroutine 连接到新的玩家时,我们通过1减少这个共享变量的计数。当它达到0的计数时,我们可以通过调用Broadcast()唤醒所有挂起的线程。

图 5.7 显示了四个不同的 goroutine 检查playersRemaining变量,并等待最后一个玩家连接并且其 goroutine 调用Broadcast()。最后一个 goroutine 知道它是最后一个,因为共享变量playersRemaining的值为0

图片

图 5.7 使用Wait()Broadcast()模式等待四个玩家连接

玩家处理 goroutine 显示在列表 5.9 中。每个 goroutine 都遵循相同的条件变量模式。我们在从 playersRemaining 变量中减去计数并检查是否还有更多玩家需要连接时持有互斥锁。我们还在调用 Wait() 时原子性地释放这个互斥锁。这里的区别是,如果 goroutine 发现没有更多玩家剩余要连接,它将调用 Broadcast()。goroutine 知道没有更多玩家要连接,因为 playersRemaining 变量是 0

当所有其他 goroutine 由于 Broadcast() 而从 Wait() 中解除阻塞时,它们退出条件检查循环并释放互斥锁。从这一点开始,如果这是一个真正的多人游戏,我们将有处理游戏玩法的代码。

列表 5.9 玩家处理函数

func playerHandler(cond *sync.Cond, playersRemaining *int, playerId int) {
    cond.L.Lock()                        ❶
    fmt.Println(playerId, "*: Connected*")
    *playersRemaining--                  ❷
    if *playersRemaining == 0 {
        cond.Broadcast()                 ❸
    }
    for *playersRemaining > 0 {
        fmt.Println(playerId, "*: Waiting for more players*")
        cond.Wait()                      ❹
    }
    cond.L.Unlock()                      ❺
    fmt.Println("*All players connected. Ready player*", playerId)
    *//Game started*
}

❶ 锁定条件变量的互斥锁以避免竞争条件

❷ 从共享剩余玩家变量中减去 1

❸ 当所有玩家都连接后发送广播

❹ 只要还有玩家要连接,就在条件变量上等待

❺ 解锁互斥锁,以便所有 goroutine 可以继续执行并开始游戏

当我们同时运行列表 5.8 和 5.9 中的代码时,每个 goroutine 都会等待所有玩家加入,直到最后一个 goroutine 发送广播并解除所有 goroutine 的阻塞。以下是输出:

$ go run gamesync.go
0 : Connected
0 : Waiting for more players
1 : Connected
1 : Waiting for more players
2 : Connected
2 : Waiting for more players
3 : Connected
All players connected. Ready player 3
All players connected. Ready player 2
All players connected. Ready player 1
All players connected. Ready player 0

5.1.4 使用条件变量重新审视读者-写者锁

在上一章中,我们使用互斥锁开发了自己的读者-写者锁实现。该实现是偏好读的,这意味着只要至少有一个读者 goroutine 持有锁,写者 goroutine 就无法在其关键部分访问资源。写者 goroutine 只有在所有读者都释放了它们的锁之后才能获取锁。如果没有读者空闲窗口,写者将被排除在外。图 5.8 展示了一个场景,其中两个 goroutine 交替持有读者锁,阻止写者获取锁。

图 5.8 由于读者占用资源访问,写者 goroutine 无法无限期地访问资源。

在技术术语中,我们称这种情况为 写饥饿——我们无法更新我们的共享数据结构,因为执行中的读者部分持续访问它们,阻止了写者的访问。以下列表模拟了这种情况。

列表 5.10 读者 goroutine 占用读者锁,阻止写访问

package main

import (
    "*fmt*"
    "*github.com/cutajarj/ConcurrentProgrammingWithGo/chapter4/listing4.12*"
    "*time*"
)

func main() {
    rwMutex := listing4_12.ReadWriteMutex{}     ❶
    for i := 0; i < 2; i++ {
        go func() {                             ❷
            for {                               ❸
                rwMutex.ReadLock()
                time.Sleep(1 * time.Second)     ❹
                fmt.Println("*Read done*")
                rwMutex.ReadUnlock()
            }
        }()
    }
    time.Sleep(1 * time.Second)
    rwMutex.WriteLock()                         ❺
    fmt.Println("*Write finished*")               ❻
}

❶ 使用第四章中开发的读者-写者互斥锁

❷ 启动两个 goroutine

❸ 无限重复

❹ 在持有读者锁的同时睡眠 1 秒

❺ 尝试从 main() goroutine 获取写者锁

❻ 在获取写者锁后,输出消息并终止

尽管我们的 goroutine 中有一个无限循环,但我们期望最终main() goroutine 会获取写者锁,输出消息Write finished,并终止。这应该会发生,因为在 Go 中,每当main() goroutine 终止时,整个进程都会退出。然而,当我们运行列表 5.10 时,这是发生的情况:

$ go run writestarvation.go
Read done
Read done
Read done
Read done
Read done
Read done
Read done
Read done
. . . continues indefinitely

我们的两个 goroutine 持续持有互斥锁的读者部分,这阻止了main() goroutine 获取锁的写部分。如果我们很幸运,读者可能会同时释放读者锁,使写者 goroutine 能够获取它。然而,在实践中,两个读者线程同时释放锁的可能性不大。这导致我们的main() goroutine 发生写者饥饿。

定义饥饿是一种情况,其中执行被阻止访问共享资源,因为资源由于其他贪婪执行长时间(或无限期)不可用。

我们需要一个不同的设计来处理一个不是优先读取的读者-写者锁,一个不会饿死写者 goroutine 的锁。我们可以在写者调用WriteLock()函数时立即阻止新读者获取读锁。为了实现这一点,我们可以在条件变量上挂起 goroutine,而不是让它们在互斥锁上阻塞。使用条件变量,我们可以设置不同的条件来决定何时阻塞读者和写者。要设计一个优先写锁,我们需要一些属性:

  • 读者计数器—初始设置为0,这告诉我们有多少读者 goroutine 正在积极访问共享资源。

  • 写者等待计数器—初始设置为0,这告诉我们有多少写者 goroutine 正在挂起等待访问共享资源。

  • 写者活跃指示器—初始设置为false,这个标志告诉我们资源当前是否正在被写者 goroutine 更新。

  • 带有互斥锁的条件变量—这允许我们在前面的属性上设置各种条件,当条件不满足时挂起执行。

Go 的RWMutex

Go 附带RWMutex优先写。这在 Go 的文档中有突出显示(从pkg.go.dev/sync#RWMutex调用Lock()获取互斥锁的写部分):

如果一个 goroutine 持有 RWMutex 进行读取,而另一个 goroutine 可能会调用 Lock,那么没有任何 goroutine 应该期望能够获取读锁,直到最初的读锁被释放。特别是,这禁止了递归读锁定。这是为了确保锁最终可用;阻塞的 Lock 调用排除了新读者获取锁的可能性。

让我们看看不同的场景,以帮助我们理解实现。第一个场景是当没有访问临界区且没有 goroutine 请求写访问时。在这种情况下,我们允许读者 goroutine 获取读锁的部分并访问共享资源。这个场景在图 5.9 的左侧显示。

图 5.9 (a) 当没有写者活动或等待时,读者可以访问共享资源。(b) 当读者或写者正在使用时,我们阻止写者访问共享资源。当写者等待时,我们也阻止新的读者。

我们知道没有写者正在使用资源,因为写者活动指示器是关闭的。我们可以将写者活动指示器实现为一个布尔标志,当写者获取对锁的访问时设置为 true。我们还知道没有写者正在等待获取锁,因为写者等待计数器设置为 0。这个等待计数器可以作为一个整型数据类型实现。

图 5.9 的右侧显示的第二个场景是当读者获取锁时。当发生这种情况时,他们必须增加读者计数器。这向任何想要获取写锁的写者表明资源正在被读取。如果写者在此时尝试获取锁,它必须等待条件变量,直到读者使用资源。它还必须通过增加它来更新写者等待计数器。

写者等待计数器确保任何新来的读者都知道有等待的写者。然后读者将通过阻塞直到写者等待计数器回到 0 来优先考虑写者。这就是我们的读者-写者互斥锁优先考虑写者的原因。

要实现这两个场景,我们首先需要创建我们概述的属性。在下面的列表中,我们设置了一个新的结构体,其中包含所需的属性和一个初始化条件变量和互斥锁的函数。

列表 5.11 优先写读者的读写互斥锁类型

package main

import (
    "*sync*"
)

type ReadWriteMutex struct {
    readersCounter int                                         ❶
    writersWaiting int                                         ❷
    writerActive   bool                                        ❸
    cond           *sync.Cond
}

func NewReadWriteMutex() *ReadWriteMutex {
    return &ReadWriteMutex{cond: sync.NewCond(&sync.Mutex{})}  ❹
}

❶ 存储当前持有读锁的读者数量

❷ 存储当前等待的写者数量

❸ 指示是否有写者持有写锁

❹ 使用新的条件变量和相关的互斥锁初始化一个新的 ReadWriteMutex

列表 5.12 展示了读锁定函数的实现。在获取读者锁时,ReadLock() 函数使用条件变量的互斥锁,然后在有写者等待或活动的情况下条件性地等待。等待 writersWaiting 计数达到 0 确保我们优先给予写者 goroutine。一旦读者检查这两个条件,readersCounter 就会增加,并且互斥锁被释放。

列表 5.12 读者锁函数

func (rw *ReadWriteMutex) ReadLock() {
    rw.cond.L.Lock()                                  ❶
    for rw.writersWaiting > 0 || rw.writerActive {    ❷
        rw.cond.Wait()                                ❷
    }
    rw.readersCounter++                               ❸
    rw.cond.L.Unlock()                                ❹
}

❶ 获取互斥锁

❷ 当写者等待或活动时等待条件变量

❸ 增加读者计数器

❹ 释放互斥锁

WriteLock()函数中,如列表 5.13 所示,我们使用相同的互斥锁和条件变量等待,直到有读者或写入者活跃。此外,该函数增加写入者等待计数器变量以指示它在等待锁变得可用。一旦我们可以获取写入者的锁,我们就将写入者等待计数器减1并将writeActive标志设置为true

列表 5.13 写入者的锁定函数

func (rw *ReadWriteMutex) WriteLock() {
    rw.cond.L.Lock()                                  ❶

    rw.writersWaiting++                               ❷
    for rw.readersCounter > 0 || rw.writerActive {    ❸
        rw.cond.Wait()                                ❸
    }
    rw.writersWaiting--                               ❹
    rw.writerActive = true                            ❺

    rw.cond.L.Unlock()                                ❻
}

❶ 获取互斥锁

❷ 增加写入者等待计数器

❸ 只要存在读者或活跃的写入者,就在条件变量上等待

❹ 等待结束后,递减写入者等待计数器

❺ 等待结束后,标记写入者活跃标志

❻ 释放互斥锁

调用WriteLock()函数的 goroutine 将writeActive标志设置为true,这样就没有其他 goroutine 会尝试同时访问锁。设置为truewriteActive标志将阻止读者和写入者的 goroutine 获取锁。这种情况在图 5.10 的左侧显示。

图 5.10 (a) 当写入者有访问权限时,读者和写入者被阻塞;(b) 最后一个读者广播以恢复任何写入者,使其能够访问

最后一种情况是我们 goroutines 释放锁时的情况。当最后一个读者释放锁时,我们可以通过在条件变量上广播来通知任何挂起的写入者。goroutine 知道它是最后一个读者,因为读者计数器在它递减后会变成0。这种情况在图 5.10 的右侧显示。ReadUnlock()函数在下面的列表中展示。

列表 5.14 读者的解锁函数

func (rw *ReadWriteMutex) ReadUnlock() {
    rw.cond.L.Lock()              ❶
    rw.readersCounter--           ❷
    if rw.readersCounter == 0 {
        rw.cond.Broadcast()       ❸
    }
    rw.cond.L.Unlock()            ❹
}

❶ 获取互斥锁

❷ 递减读者计数器 1

❸ 如果 goroutine 是最后一个剩余的读者,则发送广播

❹ 释放互斥锁

写入者的解锁函数更简单。由于在任何时候都只能有一个活跃的写入者,因此我们每次解锁时都可以发送广播。这将唤醒任何正在等待条件变量的写入者或读者。如果有读者和写入者都在等待,由于读者会在写入者等待计数器大于0时重新进入挂起状态,因此将优先选择写入者。WriteUnlock()函数在下面的列表中展示。

列表 5.15 写入者的解锁函数

func (rw *ReadWriteMutex) WriteUnlock() {
    rw.cond.L.Lock()             ❶
    rw.writerActive = false      ❷
    rw.cond.Broadcast()          ❸
    rw.cond.L.Unlock()           ❹
}

❶ 获取互斥锁

❷ 取消标记写入者活跃标志

❸ 发送广播

❹ 释放互斥锁

使用这种新的写入者优先实现,我们可以重新运行列表 5.10 中的代码以确认我们没有写入者饥饿。正如预期的那样,一旦有 goroutine 请求写入访问,读者 goroutine 就会等待并让出空间给写入者。然后我们的main() goroutine 完成,进程终止:

$ go run readwritewpref.go
Read done
Read done
Write finished
$

5.2 计数信号量

在上一章中,我们看到了互斥锁(mutexes)如何允许只有一个 goroutine 访问共享资源,而读写互斥锁(readers–writer mutex)则允许我们指定多个并发读取但独占写入。信号量(semaphores)为我们提供了不同类型的并发控制,因为我们能够指定允许的并发执行的数量。信号量还可以作为构建更复杂并发工具的基石,正如我们将在接下来的章节中看到的。

5.2.1 什么是信号量?

互斥锁(mutexes)为我们提供了一种允许一次只发生一个执行的方法。如果我们需要允许可变数量的执行并发发生呢?是否存在一种机制可以让我们指定可以访问我们资源的 goroutine 数量?一种允许我们限制并发的机制将使我们能够限制系统负载。例如,考虑一个慢速数据库,它只接受一定数量的并发连接。我们可以通过允许固定数量的 goroutine 访问数据库来限制交互次数。一旦达到限制,我们既可以让 goroutine 等待,也可以向客户端返回一个错误消息,说明系统已满负荷运行。

这正是信号量发挥作用的地方。它们允许固定数量的许可,使得并发执行能够访问共享资源。一旦所有许可都被使用,进一步的访问请求将不得不等待,直到有许可被释放(见图 5.11)。

图片

图 5.11 允许固定数量的 goroutine 访问。

为了更好地理解信号量,让我们将它们与互斥锁进行比较。互斥锁确保只有一个 goroutine 具有独占访问权,而信号量确保最多有N个 goroutine 可以访问。实际上,互斥锁提供了与信号量相同的功能,其中N的值为 1。计数信号量允许我们选择N的任何值。

定义 只有一个许可的信号量有时被称为二进制信号量

注意 虽然互斥锁是只有一个许可的信号量的特殊情况,但它们的使用预期存在细微差别。当使用互斥锁时,持有互斥锁的执行也应该负责释放它。当使用信号量时,情况并不总是如此。

为了理解我们如何使用信号量,让我们首先看看它提供的三个函数:

  • 新的信号量函数—创建一个具有X个许可的新的信号量。

  • 获取许可 函数—goroutine 将从信号量中获取一个许可。如果没有可用的许可,goroutine 将挂起并等待,直到有许可变得可用。

  • 释放许可 函数—释放一个许可,以便 goroutine 可以使用它再次通过获取函数。

5.2.2 构建信号量

在本节中,我们将实现自己的信号量,以便我们更好地理解它们是如何工作的。Go 的捆绑库中没有信号量类型,但有一个扩展的sync包在pkg.go.dev/golang.org/x/sync,其中包含信号量的实现。这个包是 Go 项目的一部分,但它是在比核心包更宽松的兼容性要求下开发的。

要构建一个信号量,我们需要记录我们还有多少许可,我们还可以使用一个条件变量来帮助我们等待,当我们没有足够的许可时。以下列表显示了我们的信号量的类型结构,包含许可计数器和条件变量。还有一个创建信号量的函数,它接受信号量上包含的初始许可数。

列表 5.16 Semaphore类型

package listing5_16

import (
    "*sync*"
)

type Semaphore struct {
    permits int                               ❶
    cond *sync.Cond                           ❷
}

func NewSemaphore(n int) *Semaphore {
    return &Semaphore{
        permits: n,                           ❸
        cond: sync.NewCond(&sync.Mutex{}),    ❹
    }
}

❶ 信号量上剩余的许可

❷ 当没有足够的许可时使用的条件变量

❸ 新信号量上的初始许可数

❹ 在新的信号量上初始化一个新的条件变量和相关的互斥锁

要实现Acquire()函数,我们需要在许可数为0(或更少)时在条件变量上调用wait()。如果有足够的许可,我们只需从许可计数中减去1Release()函数做相反的操作:将许可计数增加1并发出一个新许可可用的信号。我们使用Signal()函数而不是Broadcast(),因为只释放了一个许可,我们只想让一个 goroutine 被解除阻塞。

列表 5.17 Acquire()Release()函数

\func (rw *Semaphore) Acquire() {
    rw.cond.L.Lock()               ❶
    for rw.permits <= 0 {
        rw.cond.Wait()             ❷
    }
    rw.permits--                   ❸
    rw.cond.L.Unlock()             ❹
}

func (rw *Semaphore) Release() {
    rw.cond.L.Lock()               ❺

    rw.permits++                   ❻
    rw.cond.Signal()               ❼

    rw.cond.L.Unlock()             ❽
}

❶ 获取互斥锁以保护许可变量

❷ 等待直到有可用的许可

❸ 将可用许可的数量减少 1

❹ 释放互斥锁

❺ 获取互斥锁以保护许可变量

❻ 将可用许可的数量增加 1

❼ 信号条件变量表示还有一个许可可用

❽ 释放互斥锁

5.2.3 使用信号量永不错过信号

从另一个角度来看信号量,它们提供了与条件变量的等待和信号类似的功能,并且还有一个附加的好处,即即使没有 goroutine 在等待,也能记录一个信号。

名字中有什么?

信号量是由荷兰计算机科学家 Edsger Dijkstra 在他的未发表 1962 年论文“Over Seinpalen”(“关于信号量”)中发明的。这个名字的灵感来源于一个早期的铁路信号系统,该系统使用一个枢轴臂来向火车司机发出信号。信号的含义取决于枢轴臂的倾斜角度。

在列表 5.6 中,我们看到了一个使用条件变量等待 goroutine 完成任务示例。我们遇到的问题是,我们可能会在main() goroutine 调用Wait()之前调用Signal()函数,导致信号丢失。

我们可以通过使用初始化为 0 许可证的信号量来解决这个问题。这给我们一个系统,其中调用 Release() 函数作为我们的工作完成信号。然后 Acquire() 函数作为我们的 Wait()。在这个系统中,我们调用 Acquire() 的时间(在任务完成之前或之后)并不重要,因为信号量通过许可计数记录了 Release() 被调用的次数。如果我们先调用它,goroutine 将阻塞并等待 Release() 信号。如果我们之后调用它,由于有可用的许可,goroutine 将立即返回。

图 5.12 展示了使用信号量等待并发任务完成的示例。它显示了一个执行 doWork() 函数的 goroutine,在完成其任务后调用 Release()。我们的执行 main() 的 goroutine 想要知道这个任务是否完成,但它仍然忙碌,还没有停下来等待并检查。由于我们使用信号量,这个释放调用被记录为一个许可。稍后,当 main() goroutine 调用 Acquire() 时,函数将立即返回,表示 doWork() goroutine 已经完成了其分配的工作。

图 5.12 使用信号量知道 goroutine 是否完成

列表 5.18 展示了这一实现的代码。当我们启动 doWork() goroutine 时,我们传递一个对信号量的引用,正如图 5.11 所示。在这个函数中,我们模拟 goroutine 执行一些并发快速任务。当 goroutine 完成其任务时,它调用 Release() 来表示已完成。在 main() 函数中,我们创建了许多这样的 goroutine,并在每个创建后,通过在信号量上调用 Acquire() 来等待其完成。

列表 5.18 使用信号量来指示任务完成

package main

import (
    "*fmt*"
    "*github.com/cutajarj/ConcurrentProgrammingWithGo/chapter5/listing5.16*"
)

func main() {
    semaphore := listing5_16.NewSemaphore(0)       ❶
    for i := 0; i < 50000; i++ {                   ❷
        go doWork(semaphore)                       ❸
        fmt.Println("*Waiting for child goroutine* ")
        semaphore.Acquire()                        ❹
        fmt.Println("*Child goroutine finished*")
    }
}

func doWork(semaphore *listing5_16.Semaphore) {
    fmt.Println("*Work started*")
    fmt.Println("*Work finished*")
    semaphore.Release()                            ❺
}

❶ 使用之前的实现创建一个新的信号量

❷ 重复 50,000 次

❸ 通过传递信号量的引用来启动 goroutine

❹ 等待信号量上的可用许可,表示任务已完成

❺ 当 goroutine 完成,它释放一个许可来通知 main() goroutine

如果首先调用 Release(),信号量将存储这个释放许可,当 main() goroutine 调用 Acquire() 函数时,它将立即返回而不阻塞。如果我们使用没有互斥锁定的条件变量,这会导致我们的 main() goroutine 错过信号。

5.3 练习

注意:您可以在 github.com/cutajarj/ConcurrentProgrammingWithGo 上看到所有代码解决方案。

  1. 在列表 5.4 中,Stingy 的 goroutine 每次向银行账户添加钱时都会在条件变量上发出信号。你能修改这个函数,使其仅在账户中有 50 美元或更多时发出信号吗?

  2. 将游戏同步列表 5.8 和 5.9 进行修改,使得,仍然使用条件变量,玩家需要等待固定的时间数。如果玩家没有在规定时间内全部加入,协程应停止等待,并允许游戏在没有所有玩家的情况下开始。提示:尝试使用另一个带有过期计时器的协程。

  3. 加权信号量是信号量的一个变体,允许你同时获取和释放多个许可证。加权信号量的函数签名如下:

    func (rw *WeightedSemaphore) Acquire(permits int)
    func (rw *WeightedSemaphore) Release(permits int)
    

    使用这些函数签名来实现一个具有类似计数信号量功能的加权信号量。它应该允许你获取或释放多个许可证。

摘要

  • 通过使用条件变量和互斥锁,一个执行可以被挂起,等待直到满足某个条件。

  • 在条件变量上调用 Wait()原子性地解锁互斥锁并挂起当前执行。

  • 调用 Signal() 会恢复一个已调用 Wait() 的挂起协程的执行。

  • 调用 Broadcast() 会恢复所有已调用 Wait() 的挂起协程的执行。

  • 如果我们调用 Signal()Broadcast(),但没有协程在 Wait() 调用上挂起,那么信号或广播就会被错过。

  • 我们可以使用条件变量和互斥锁作为构建块来构建更复杂的并发工具,例如信号量和优先写入的读写锁。

  • 当一个执行因为共享资源长时间不可用而被阻塞时,就会发生饥饿。

  • 优先写入的读写互斥锁解决了写饥饿问题。

  • 信号量让我们能够限制对共享资源的并发访问,使其固定数量的并发执行。

  • 与条件变量一样,信号量可以用来向另一个执行发送信号。

  • 当用作信号时,信号量具有额外的优势,即如果执行尚未等待该信号,则信号会被存储。

6 使用 waitgroups 和屏障同步

本章涵盖了

  • 使用 waitgroups 等待已完成的任务

  • 使用信号量构建 waitgroups

  • 使用条件变量实现 waitgroups

  • 使用屏障同步并发工作

Waitgroups 和屏障是两种在执行组(如 goroutines)上工作的同步抽象。我们通常使用waitgroups来等待一组任务完成。我们使用barriers在共同点上同步多个执行。

我们将首先通过几个应用程序来检查 Go 的内置 waitgroups。稍后,我们将研究两种 waitgroups 的实现:一种使用信号量构建,另一种使用条件变量构建,功能更完整。

Go 的库中没有捆绑屏障,因此我们将构建自己的屏障类型。然后,我们将在这个简单的并发矩阵乘法算法中使用这个屏障类型。

6.1 Go 中的 waitgroups

使用 waitgroups,我们可以让一个 goroutine 等待一组并发任务完成。我们可以将 waitgroup 视为一个项目经理,管理分配给不同工人的任务集。一旦所有任务都完成,项目经理会通知我们。

6.1.1 使用 waitgroups 等待任务完成

在前面的章节中,我们看到了一个并发模式,其中主 goroutine 将问题分解成多个任务,并将每个任务传递给一个单独的 goroutine。goroutines 随后并发地完成这些任务。例如,在第三章中,当我们开发字母频率程序时,我们看到了这种模式。主 goroutine 创建了多个 goroutine,每个 goroutine 下载并处理一个单独的网页。在我们的第一个实现中,我们使用sleep()函数等待几秒钟,直到所有 goroutine 完成下载。使用 waitgroup 将使等待所有 goroutine 完成任务变得更加容易。

图 6.1 显示了使用 waitgroup 的典型模式。我们设置 waitgroup 的大小,然后使用两个操作Wait()Done()。在这个模式中,我们通常有多个 goroutine 需要并发完成一些任务。我们可以创建一个 waitgroup,并将其大小设置为分配的任务数。主 goroutine 将任务交给新创建的 goroutines,并在调用Wait()操作后暂停执行。一旦一个 goroutine 完成其任务,它将在 waitgroup 上调用Done()操作(见图 6.1 的左侧)。当所有 goroutine 都对其分配的所有任务调用了Done()操作后,主 goroutine 将解除阻塞。此时,主 goroutine 知道所有任务都已完成(见图 6.1 的右侧)。

图 6.1 waitgroup 的典型使用

Go 的sync包中内置了WaitGroup实现。它包含三个函数,允许我们使用图 6.1 中描述的模式:

  • Done()—将 waitgroup 的大小计数器减1

  • Wait()—阻塞直到 waitgroup 的大小计数器为0

  • Add(delta int)—将 waitgroup 的大小计数器增加 delta

列表 6.1 展示了我们如何使用这三个操作的简单示例。我们有一个doWork()函数,通过睡眠随机长度的时间来模拟完成任务。一旦完成,它打印一条消息并在 waitgroup 上调用Done()函数。main()函数调用Add(4)函数,创建四个这样的doWork()协程,并在 waitgroup 上调用Wait()

一旦所有协程都表示已完成,Wait()解除阻塞,main()函数继续执行。

列表 6.1 简单使用 waitgroup

package main

import (
    "*fmt*"
    "*math/rand*"
    "*sync*"
    "*time*"
)

func main() {
    wg := sync.WaitGroup{}                                 ❶
    wg.Add(4)                                              ❷
    for i := 1; i <= 4; i++ {
        go doWork(i, &wg)                                  ❸
    }
    wg.Wait()                                              ❹
    fmt.Println("*All complete*")
}

func doWork(id int, wg *sync.WaitGroup) {
    i := rand.Intn(5)
    time.Sleep(time.Duration(i) * time.Second)             ❺
    fmt.println(id, "*Done working after*", i, "*seconds*")
    wg.Done()                                              ❻
}

❶ 创建一个新的 waitgroup

❷ 由于我们有四项工作,将 4 添加到 waitgroup 中

❸ 创建四个协程,传递 waitgroup 的引用

❹ 等待工作完成

❺ 随机睡眠一段时间(最多 5 秒)

❻ 信号表示协程已完成其任务

当我们运行列表 6.1 时,所有协程在睡眠了略微不同的时间后完成。它们在 waitgroup 上调用Done(),然后main()协程解除阻塞,给出以下输出:

$ go run waitforgroup.go
1 Done working after 1 seconds
4 Done working after 2 seconds
2 Done working after 2 seconds
3 Done working after 4 seconds
All complete

现在我们有了这个额外的工具,让我们修复字母频率程序(来自列表 4.5),使其使用 waitgroups。在main()协程中,我们不再调用sleep()函数等待 10 秒,而是可以创建一个协程,调用我们现有的CountLetters()函数,然后在该 waitgroup 上调用Done(),如下所示。注意,我们不需要修改CountLetters()函数来调用Done();相反,我们使用一个在单独的协程中运行的匿名函数,调用这两个函数。

列表 6.2 使用 waitgroup 计算频率

package main

import (
    "*fmt*"
    "*github.com/cutajarj/ConcurrentProgrammingWithGo/chapter4/listing4.5*"
    "*sync*"
)

func main() {
    wg := sync.WaitGroup{}                                        ❶
    wg.Add(31)                                                    ❷
    mutex := sync.Mutex{}
    var frequency = make([]int, 26)
    for i := 1000; i <= 1030; i++ {
        url := fmt.Sprintf("*https://rfc-editor.org/rfc/rfc%d.txt*", i)
        go func() {                                               ❸
            listing4_5.CountLetters(url, frequency, &mutex)
            wg.Done()                                             ❹
        }()
    }
    wg.Wait()                                                     ❺
    mutex.Lock()
    for i, c := range listing4_5.AllLetters {
        fmt.Printf("*%c-%d* ", c, frequency[i])
    }
    mutex.Unlock()
}

❶ 创建一个新的 waitgroup

❷ 添加一个 delta 为 31——为每个要并发下载的网页添加一个

❸ 创建一个带有匿名函数的协程

❹ 在完成字母计数后调用 Done()

❺ 等待所有协程完成

当我们运行列表 6.2 时,我们不需要等待所有协程完成固定时间,main()函数将在 waitgroup 解除阻塞后立即输出结果。

6.1.2 使用信号量创建 waitgroup 类型

现在,让我们看看我们如何自己实现 waitgroup,而不是使用 Go 捆绑的实现。我们可以在上一章中开发的信号量类型的基础上创建 waitgroup 的简单版本。

我们可以在 Wait() 函数中包含逻辑来调用信号量的 Acquire() 函数。在信号量中,如果可用的许可数是 0 或更少,Acquire() 调用将挂起 goroutine 的执行。我们可以使用一个技巧,用一个等于 1 – n 的许可数初始化信号量,以作为大小为 n 的 waitgroup。这意味着我们的 Wait() 函数将阻塞,直到许可数增加到 n 次,从 1 – n1。图 6.2 展示了一个大小为 3 的 waitgroup 的示例。对于大小为 3 的组,我们可以使用大小为 -2 的信号量。

图 6.2 使用负数许可数初始化信号量以用作 waitgroup

每次一个 goroutine 在 waitgroup 上调用 Done() 时,我们都可以在信号量上调用 Release() 操作。这将每次将信号量上可用的许可数增加 1。一旦所有 goroutine 完成它们的任务并都调用了 Done(),信号量中的许可数最终将变成 1。这个过程在图 6.3 中展示。

图 6.3 当一个 goroutine 完成,它会导致一个 Acquire() 调用,许可数增加 1,最终留下 1 个许可。

当许可数大于 0 时,Acquire() 调用将解除阻塞,释放我们挂起的 goroutine。在图 6.4 中,许可被 main() goroutine 获取,许可数回到 0。这样,main() goroutine 就会恢复,并且知道所有 goroutine 都完成了它们分配的任务。

图 6.4 一旦有许可可用,Acquire() 将解除 main() goroutine 的阻塞。

列表 6.3 展示了使用信号量实现 waitgroup 的一个示例。在这个列表中,我们使用了第五章中关于信号量的实现。正如之前所讨论的,当我们创建 waitgroup 时,我们初始化一个具有 1 – size 许可的信号量。当我们调用 Wait() 函数时,我们尝试获取一个许可,当我们调用 Done() 函数时,我们释放一个许可。

列表 6.3 使用信号量实现的 Waitgroup

package listing6_3

import (
    "*github.com/cutajarj/ConcurrentProgrammingWithGo/chapter5/listing5.16*"
)

type WaitGrp struct {
    sema *listing5_16.Semaphore                                 ❶
}

func NewWaitGrp(size int) *WaitGrp {
    return &WaitGrp{sema: listing5_16.NewSemaphore(1 - size)}   ❷
}

func (wg *WaitGrp) Wait() {
    wg.sema.Acquire()                                           ❸
}

func (wg *WaitGrp) Done() {
    wg.sema.Release()                                           ❹
}

❶ 在 WaitGrp 类型上存储信号量引用(在上一章中开发)

❷ 使用 1 – size 许可初始化一个新的信号量

❸ 在 Wait() 函数中调用信号量的 Acquire()

❹ 完成后,在信号量上调用 Release()

列表 6.4 展示了我们对信号量 waitgroup 的简单使用。Go 的内置 waitgroup 和我们的实现之间的主要区别是我们需要在开始使用它之前指定 waitgroup 的大小。在 Go 的 sync 包中的 waitgroup,我们可以在任何时间点增加组的大小——即使有 goroutine 正在等待工作完成。

列表 6.4 简单使用信号量 waitgroup

package main

import (
    "*fmt*"
    "*github.com/cutajarj/ConcurrentProgrammingWithGo/chapter6/listing6.3*"
)

func doWork(id int, wg *listing6_3.WaitGrp) {
    fmt.Println(id, "*Done working* ")
    wg.Done()                         ❶
}

func main() {
    wg := listing6_3.NewWaitGrp(4)    ❷
    for i := 1; i <= 4; i++ {
        go doWork(i, wg)              ❸
    }
    wg.Wait()                         ❹
    fmt.Println("*All complete*")
}

❶ 当 goroutine 完成,它会在 waitgroup 上调用 Done()

❷ 创建一个大小为 4 的新 waitgroup

❸ 创建一个 goroutine,传递对 waitgroup 的引用

❹ 等待 waitgroup 以完成工作

6.1.3 在等待时更改我们的 waitgroup 大小

我们使用信号量实现的 waitgroup 实现有限制,因为我们必须在开始时指定 waitgroup 的大小。这意味着我们无法在创建 waitgroup 后更改其大小。为了更好地理解这种限制,让我们看看一个在创建后需要调整 waitgroup 大小的应用场景。

想象我们正在编写一个使用多个 goroutine 的文件名搜索程序。程序将从输入目录递归地搜索文件名字符串。我们希望程序接受输入目录和文件名字符串作为两个输入参数。它应该输出一个包含完整路径的匹配列表:

$ go run filesearch.go /home cat
/home/photos/holiday/cat.jpg
/home/art/cat.png
/home/sketches/cat.svg
. . .

使用多个 goroutine 可以帮助我们更快地找到文件,尤其是在跨多个驱动器搜索时。我们可以为我们在搜索中遇到的每个目录创建一个单独的 goroutine。图 6.5 显示了这一概念。

图片

图 6.5 递归并发文件名搜索

这里的想法是让一个 goroutine 找到与输入字符串匹配的文件。如果这个 goroutine 遇到一个目录,它将1添加到一个全局 waitgroup,并启动一个新的 goroutine,该 goroutine 为该目录运行相同的逻辑。搜索在每一个 goroutine 在 waitgroup 上调用Done()后结束。这意味着我们已经探索了我们第一个输入目录的每一个子目录。下面的列表实现了这个递归搜索函数。

列表 6.5 递归搜索函数(为简洁起见省略了错误处理)

package main

import (
    "*fmt*"
    "*os*"
    "*path/filepath*"
    "*strings*"
    "*sync*"
)

func fileSearch(dir string, filename string, wg *sync.WaitGroup) {
    files, _ := os.ReadDir(dir)                          ❶
    for _, file := range files {
        fpath := filepath.Join(dir, file.Name())         ❷
        if strings.Contains(file.Name(), filename) {
            fmt.Println(fpath)                           ❸
        }
        if file.IsDir() {
            wg.Add(1)                                    ❹
            go fileSearch(fpath, filename, wg)           ❺
        }
    }
    wg.Done()                                            ❻
}

❶ 读取函数提供的目录中的所有文件

❷ 将每个文件连接到目录:’cat.jpg’变为’/home/pics/cat.jpg’

❸ 如果有匹配,将在控制台打印路径

❹ 如果它是一个目录,在启动新 goroutine 之前将 1 添加到 waitgroup

❺ 递归地创建 goroutine,在新目录中进行搜索

❻ 在处理完所有文件后在 waitgroup 上标记 Done()

现在我们只需要一个main()函数,该函数创建一个 waitgroup,将其增加 1,然后启动一个调用我们的fileSearch()函数的 goroutine。main()函数可以简单地等待 waitgroup 以完成搜索,如下面的列表所示。在这个列表中,我们使用命令行参数来读取搜索目录和要匹配的文件名字符串。

列表 6.6 main()函数调用文件搜索函数并等待 waitgroup

func main() {
    wg := sync.WaitGroup{}                        ❶
    wg.Add(1)                                     ❷
    go fileSearch(os.Args[1], os.Args[2], &wg)    ❸
    wg.Wait()                                     ❹
}

❶ 创建一个新的、空的 waitgroup

❷ 将 1 的增量添加到 waitgroup

❸ 创建一个新的 goroutine,执行文件搜索并传递 waitgroup 的引用

❹ 等待搜索完成

6.1.4 构建一个更灵活的 waitgroup

文件搜索程序展示了使用 Go 的内置 waitgroup 而不是我们自己的信号量 waitgroup 实现的优点。不知道我们将在开始时创建多少 goroutine,这迫使我们随着进程的进行而调整 waitgroup 的大小。此外,我们的信号量 waitgroup 实现有一个限制,即只有一个 goroutine 可以等待在 waitgroup 上。如果我们有多个 goroutine 调用Wait()函数,只有一个会被恢复,因为我们只将信号量的许可计数增加到了1。我们能否改变我们的实现以匹配 Go 内置 waitgroup 的功能?

我们可以使用条件变量来实现一个更完整的 waitgroup。图 6.6 展示了我们如何使用条件变量实现Add(delta)Wait()函数。Add()函数简单地增加 waitgroup 的大小变量。我们可以使用互斥锁保护这个变量,这样我们就不与其他 goroutine 同时修改它(见图 6.6 的左侧)。为了实现Wait()操作,我们可以有一个条件变量,当 waitgroup 的大小大于0时等待(见图 6.6 的右侧)。

图片

图 6.6 (a) 在 waitgroup 上的Add()操作; (b) Wait()操作导致在条件变量上等待。

下一个列表实现了一个包含此 waitgroup 大小变量和条件变量的WaitGrp类型。Go 默认将组大小初始化为0。列表还显示了一个初始化条件变量及其互斥锁的函数。

列表 6.7 使用条件变量初始化 waitgroup

package listing6_7

import (
    "*sync*"
)

type WaitGrp struct {
    groupSize int                           ❶
    cond      *sync.Cond                    ❷
}

func NewWaitGrp() *WaitGrp {
    return &WaitGrp{
        cond: sync.NewCond(&sync.Mutex{}),  ❸
    }
}

❶ waitgroup 大小属性,默认初始化为 0

❷ 在 waitgroup 中使用的条件变量

❸ 使用新的互斥锁初始化条件变量

要编写我们的Add(delta)函数,我们需要获取条件变量的互斥锁,将 delta 加到groupSize变量上,然后最后释放互斥锁。在Done()操作中,我们再次需要使用Lock()Unlock()互斥锁保护groupSize变量。我们还执行条件等待,当组大小大于0时。这个逻辑在下面的列表中展示。

列表 6.8 waitgroup 的Add(delta)Wait()操作

func (wg *WaitGrp) Add(delta int) {
    wg.cond.L.Lock()                 ❶
    wg.groupSize += delta            ❷
    wg.cond.L.Unlock()               ❶
}

func (wg *WaitGrp) Wait() {
    wg.cond.L.Lock()                 ❸
    for wg.groupSize > 0 {
        wg.cond.Wait()               ❹
    }
    wg.cond.L.Unlock()               ❸
}

❶ 使用条件变量的互斥锁保护对 groupSize 更新的操作

❷ 通过 delta 增加 groupSize

❸ 使用条件变量的互斥锁保护对 groupSize 变量的读取

❹ 当 groupSize 大于 0 时等待并原子性地释放互斥锁

当 goroutine 想要表示它已经完成其任务时,它会调用Done()函数。当这种情况发生时,在等待组的Done()函数内部,我们可以通过1减少组的大小。我们还需要添加逻辑,以便等待组中最后一个调用Done()函数的 goroutine 向当前挂起在Wait()操作上的任何其他 goroutine 广播。goroutine 知道它是最后一个,因为减少组大小后组的大小将是0

图 6.7(a)Done()操作减少组大小;(b)最后一个Done()操作导致广播。

图 6.7 的左侧显示了 goroutine 如何获取互斥锁(mutex lock),减少组的大小值,然后释放互斥锁。图 6.7 的右侧显示,当组的大小达到0时,goroutine 知道它是最后一个,它会在条件变量上广播,以便任何挂起的 goroutine 都能继续执行。这样,我们就表明所有由等待组完成的工作都已经完成。我们使用广播调用而不是信号调用,因为可能有多个 goroutine 在Wait()操作上挂起。

列表 6.9 实现了等待组的Done()操作。像往常一样,我们使用互斥锁来保护groupSize变量。之后,我们减少这个变量的值。最后,我们检查是否是等待组中最后一个调用Done()函数的 goroutine,通过检查值是否为0。如果是0,我们在条件变量上调用Broadcast()操作以恢复任何挂起的 goroutine。

列表 6.9 Done()操作使用条件变量实现等待组

func (wg *WaitGrp) Done() {
    wg.cond.L.Lock()           ❶
    wg.groupSize--             ❷
    if wg.groupSize == 0 {
        wg.cond.Broadcast()    ❸
    }
    wg.cond.L.Unlock()         ❶
}

❶ 使用互斥锁保护对 groupSize 变量的更新

❷ 通过 1 减少 groupSize

❸ 如果它是等待组中最后一个完成的 goroutine,它将在条件变量上广播。

这种新的实现满足了我们的初始要求。我们可以在创建等待组之后更改等待组的大小,并且我们可以解除在Wait()操作上挂起的多个 goroutine 的阻塞。

6.2 屏障

Waitgroups(等待组)在任务完成后进行同步非常出色。但如果我们需要在开始任务之前协调我们的 goroutines(协程)怎么办?我们可能还需要在不同时间点对不同的执行进行对齐。屏障(Barriers)赋予我们在代码的特定点同步 goroutines 组的能力。

让我们用一个简单的类比来帮助我们比较等待组和屏障。一架私人飞机只有在所有乘客到达出发终端时才会起飞。这代表了一个屏障。每个人都必须等待直到每个乘客到达这个屏障(机场终端)。当所有人都最终到达后,乘客可以继续并登机。

对于同一航班,飞行员必须在起飞前等待一系列任务完成,例如加油、存放行李和装载乘客。在我们的类比中,这代表着 waitgroup。飞行员正在等待这些并发任务完成,然后飞机才能起飞。

6.2.1 什么是屏障?

要理解程序屏障,可以想象一组 goroutines,它们共同在不同的计算部分工作。在 goroutines 开始之前,它们都需要等待它们的输入数据。一旦完成,它们又需要等待另一个执行来收集和合并它们计算的结果。这个周期可能会重复多次,只要还有需要计算更多的输入数据。图 6.8 说明了这个概念。

图片

图 6.8 屏障挂起执行,直到所有 goroutines 赶上。

当思考屏障时,我们可以将我们的 goroutines 想象成处于两种可能的状态之一:要么正在执行它们的任务,要么暂停并等待其他人赶上。例如,一个 goroutine 可能执行一些计算,然后等待(通过调用一个Wait()函数)其他 goroutines 完成它们的计算。这个Wait()函数将挂起 goroutine 的执行,直到所有参与这个屏障组的其他 goroutines 也通过调用Wait()来赶上。此时,屏障将一起释放所有挂起的 goroutines(见图 6.9),以便它们可以继续或重新开始它们的执行。

图片

图 6.9 在所有 goroutines 都调用了Wait()操作后,goroutines 将恢复执行。

与 waitgroups 不同,屏障将 waitgroup 的Done()Wait()操作合并成一个原子的调用。另一个区别是,根据实现方式,屏障可以被重复使用多次。

定义 可以重复使用的屏障有时被称为循环屏障

6.2.2 在 Go 中实现屏障

很不幸,Go 语言并没有自带屏障的实现,所以如果我们想使用它,就需要自己实现。就像使用 waitgroups 一样,我们可以使用一个条件变量来实现我们的屏障。

首先,我们需要知道将要使用这个屏障的执行组的大小。在实现中,我们将称之为*屏障大小**。我们可以使用这个大小来知道何时足够的 goroutines 到达屏障。

在屏障实现中,我们只需担心 Wait() 操作。图 6.10 展示了调用此函数的两个场景。第一个场景是当 goroutine 调用此函数而并非所有执行都在屏障上(如图 6.10 的左侧所示)。在这种情况下,调用 Wait() 函数会导致等待计数器的增加,这告诉我们有多少 goroutine 正在等待屏障释放。当等待的 goroutine 数量少于屏障大小时,我们通过在条件变量上等待来挂起 goroutine。

图 6.10 当并非所有 goroutine 都在屏障上时的等待,以及当所有 goroutine 都在屏障上时的广播和恢复屏障

当等待计数器达到屏障的大小(如图 6.10 的右侧所示)时,我们需要将计数器重置为 0 并在条件变量上广播以唤醒任何挂起的 goroutine。这样,任何在屏障上等待的 goroutine 将被解除阻塞并可以继续执行。

在列表 6.10 中,我们实现了屏障的结构类型和 NewBarrier(size) 构造函数。Go 结构包含屏障的大小、一个等待计数器和条件变量的引用。在构造函数中,我们初始化等待计数器为 0,创建一个新的条件变量,并将屏障大小设置为与函数输入参数相同的值。

列表 6.10 屏障的 Type structNewBarrier() 函数

package listing6_10

import "*sync*"

type Barrier struct {
    size      int                            ❶
    waitCount int                            ❷
    cond      *sync.Cond                     ❸
}

func NewBarrier(size int) *Barrier {
    condVar := sync.NewCond(&sync.Mutex{})   ❹
    return &Barrier{size, 0, condVar}        ❺
}

❶ 屏障的参与者总数

❷ 表示当前挂起执行数量的计数器变量

❸ 屏障中使用的条件变量

❹ 创建新的条件变量

❺ 创建并返回新屏障的引用

列表 6.11 实现了 Wait() 函数及其两种场景。在函数中,我们立即在条件变量上获取互斥锁,然后增加等待计数。如果等待计数器尚未达到屏障的大小,我们通过在条件变量上调用 Wait() 函数来挂起 goroutine 的执行。这个 if 语句的第二部分代表了图 6.10 的左侧,其中计数器达到屏障的大小。在这种情况下,我们只需将计数器重置为 0 并在条件变量上广播。这将唤醒所有在屏障上等待的挂起 goroutine。

列表 6.11 屏障的 Wait() 函数

func (b *Barrier) Wait() {
    b.cond.L.Lock()              ❶
    b.waitCount += 1             ❷

    if b.waitCount == b.size {
        b.waitCount = 0          ❸
        b.cond.Broadcast()       ❸
    } else {
        b.cond.Wait()            ❹
    }

    b.cond.L.Unlock()            ❺
}

❶ 使用互斥锁保护对 waitCount 变量的访问

❷ 将计数变量增加 1

❸ 如果 waitCount 达到屏障大小,重置 waitCount 并在条件变量上广播

❹ 如果 waitCount 未达到屏障大小,则在条件变量上等待

❺ 使用互斥锁保护对 waitCount 变量的访问

我们可以通过让两个 goroutine 模拟执行不同时间段来测试我们的屏障。在列表 6.12 中,我们有一个 workAndWait() 函数,它模拟了一段时间的工作,然后等待在屏障上。像往常一样,我们通过使用 time.Sleep() 函数来模拟工作。goroutine 从屏障中解除阻塞后,它会用相同的时间继续工作。在每一个阶段,该函数都会打印从 goroutine 开始以来的秒数。

列表 6.12 简单使用屏障

package main

import (
    "*fmt*"
    "*github.com/cutajarj/ConcurrentProgrammingWithGo/chapter6/listing6.10*"
    "*time*"
)

func workAndWait(name string, timeToWork int, barrier *listing6_10.Barrier) {
    start := time.Now()
    for {
        fmt.Println(time.Since(start), name,"*is running*")
        time.Sleep(time.Duration(timeToWork) * time.Second)           ❶
        fmt.Println(time.Since(start), name,"*is waiting on barrier*")
        barrier.Wait()                                                ❷
    }
}

❶ 模拟工作若干秒

❷ 等待其他 goroutine 赶上

我们现在可以启动两个使用 workAndWait() 函数的 goroutine,每个 goroutine 有不同的 timeToWork。这样,先完成工作的 goroutine 将被屏障挂起,并在开始工作之前等待较慢的 goroutine。在下一个列表中,我们创建了一个屏障并启动了两个 goroutine,将它们的引用传递过去。我们将这两个 goroutine 命名为 RedBlue,分别给它们 4 秒和 10 秒的工作时间。

列表 6.13 启动快慢不同的 goroutine 并共享一个屏障

func main() {
    barrier := listing6_10.NewBarrier(2)   ❶

    go workAndWait("*Red*", 4, barrier)      ❷

    go workAndWait("*Blue*", 10, barrier)    ❸

    time.Sleep(100 * time.Second)          ❹
}

❶ 使用列表 6.10 中的实现创建一个新的屏障,包含两个参与者

❷ 使用名为 Red 的 goroutine 并设置工作时间为 4 秒

❸ 使用名为 Blue 的 goroutine 并设置工作时间为 10 秒

❹ 等待 100 秒

当我们同时运行列表 6.12 和 6.13 时,程序运行了 100 秒,之后 main() goroutine 终止。正如预期的那样,快速 4 秒的 goroutine,称为 Red,提前完成并等待较慢的,称为 Blue 的 goroutine,它需要 10 秒。我们可以从输出时间戳中看到这一点:

$ go run simplebarrierexample.go
0s Blue is running
0s Red is running
4.0104152s Red is waiting on barrier
10.0071386s Blue is waiting on barrier
10.0076689s Blue is running
10.0076689s Red is running
14.0145434s Red is waiting on barrier
20.0096403s Blue is waiting on barrier
20.010348s Blue is running
20.010348s Red is running
. . .

现在我们来看一个使用屏障来同步多个执行的实际应用。

6.2.3 使用屏障进行并发矩阵乘法

矩阵乘法是线性代数中的一个基本操作,它在计算机科学的各个领域中都有应用。图论、人工智能和计算机图形学中的许多算法都采用了矩阵乘法。不幸的是,计算这个线性代数操作是一个耗时的过程。

使用简单的迭代方法将两个 n × n 矩阵相乘,其运行时间复杂度为 O(n³)。这意味着计算结果所需的时间将与矩阵大小 n 的立方成正比。例如,如果我们用 10 秒来计算两个 100 × 100 矩阵的乘法,那么当我们将矩阵的大小加倍到 200 × 200 时,计算结果将需要 80 秒。输入大小的加倍会导致所需时间按 2³ 的比例扩展。

更快的矩阵乘法算法

存在一些矩阵乘法算法的运行时间复杂度比 O(n³)更好。1969 年,德国数学家 Volker Strassen 设计了一个运行时间复杂度为 O(n^(2.807))的更快算法。尽管这比简单方法有很大的改进,但只有在矩阵的大小非常大时,这种加速才是显著的。对于较小的矩阵大小,简单方法似乎效果最好。

其他更近期的算法甚至有更好的运行时间复杂度。然而,这些算法在实际应用中并不使用,因为它们只有在矩阵的输入大小极端巨大时才会更快——实际上,如此之大以至于它们无法适应今天计算机的内存。这些解决方案属于一类称为银河算法的算法,其中算法对于太大以至于无法在实际中使用的输入优于其他算法。

我们如何使用并行计算并构建矩阵乘法算法的并发版本来加速这个操作?让我们首先回顾一下矩阵乘法是如何工作的。为了使实现简单,我们将在本节中仅考虑方阵(n × n)。例如,当计算矩阵 A 与矩阵 B 的乘积时,第一个单元格(行 0,列 0)的结果是 A 的行 0 与 B 的列 0 相乘的结果。一个 3 × 3 矩阵乘法的示例如图 6.11 所示。要计算第二个单元格(行 0,列 1),我们需要将 A 的行 0 与 B 的列 1 相乘,依此类推。

图 6.11 并行矩阵乘法使用每个结果行的单独 goroutine

图 6.11 使用每个结果行的单独 goroutine 进行并行矩阵乘法

以下列表显示了一个在单个 goroutine 中执行此乘法的函数。该函数使用三个嵌套循环,首先遍历行,然后遍历列,并在最后的循环中相乘和相加。

列表 6.14 一个简单的矩阵乘法函数

package main

const matrixSize = 3

func matrixMultiply(matrixA, matrixB, result *[matrixSize][matrixSize]int) {
    for row := 0; row < matrixSize; row++ {                ❶
        for col := 0; col < matrixSize; col++ {            ❷
            sum := 0
            for i := 0; i < matrixSize; i++ {
                sum += matrixA[row][i] * matrixB[i][col]   ❸
            }
            result[row][col] = sum                         ❹
        }
    }
}

❶ 遍历每一行

❷ 遍历每一列

❸ 将矩阵 A 的每一行的值与矩阵 B 的每一列的值相乘后的总和

❹ 使用总和更新结果矩阵

将我们的算法转换为可由多个处理器并行执行的一种方法是将矩阵乘法分解成不同的部分,并让每个部分由一个 goroutine 计算。图 6.11 展示了我们可以如何使用每个行对应的 goroutine 分别计算每行的结果。对于一个 n × n 的结果矩阵,我们可以创建 n 个 goroutine,并将一个 goroutine 分配给每一行。然后,每个 goroutine 将负责计算其行的结果。

为了使我们的矩阵乘法应用程序更真实,我们可以让它经过三个步骤,然后重复这三个步骤,模拟长时间的计算:

  1. 加载矩阵 A 和 B 的输入。

  2. 使用每个行一个 goroutine 的方式并发计算 A × B 的结果。

  3. 在控制台上输出结果。

对于步骤 1,加载输入矩阵,我们可以简单地使用随机整数生成它们。在实际应用中,我们会从源读取这些输入,例如网络连接或文件。下面的列表显示了我们可以使用的函数,用于用随机整数填充矩阵。

列表 6.15 使用随机整数生成矩阵

package main

import (
    "*math/rand*"
)

const matrixSize = 3

func generateRandMatrix(matrix *[matrixSize][matrixSize]int) {
    for row := 0; row < matrixSize; row++ {
        for col := 0; col < matrixSize; col++ {
            matrix[row][col] = rand.Intn(10) – 5    ❶
        }
    }
}

❶ 对于每一行和每一列,分配一个介于 -5 和 4 之间的随机数

为了计算并发乘法(步骤 2),我们需要一个函数来评估结果矩阵中单行的乘法。想法是,我们从多个协程中运行这个函数,每个协程对应一行。一旦协程计算出结果矩阵的所有行,我们就可以在控制台上输出结果矩阵(步骤 3)。

如果我们要多次执行步骤 1 到 3,我们还需要一个机制来协调这些步骤。例如,在加载输入矩阵之前我们不能执行乘法。同样,在协程完成计算所有行之前,我们也不应该输出结果。

这就是我们在上一节中开发的屏障实用程序发挥作用的地方。我们可以通过使用我们的屏障来确保各个步骤之间的适当同步,这样我们就不在完成其他步骤之前开始一个步骤。图 6.12 显示了我们可以如何做到这一点。该图显示,对于一个 3 × 3 矩阵,我们可以使用一个大小为 4 的屏障(总行数 + 1)。这是包括 main() 协程在内的我们 Go 程序中的总协程数。

图片

图 6.12 使用屏障在矩阵乘法中进行同步

让我们逐步分析并发矩阵乘法程序的各种步骤,如图 6.12 所示:

  1. 初始时,main() 协程加载输入矩阵,而行协程则在屏障上等待。在我们的应用程序中,我们将使用第 6.15 列表中开发的函数随机生成矩阵。

  2. 一旦加载完成,main() 协程调用最后的 Wait() 操作,释放所有协程。

  3. 现在轮到 main() 协程在屏障上等待,直到协程完成行乘法。

  4. 一旦协程在其行上计算出结果,它将在屏障上调用另一个 Wait()

  5. 一旦所有协程完成并在屏障上调用 Wait(),所有协程将解除阻塞,main() 协程将输出结果并加载下一个输入矩阵。

  6. 每个行协程将通过在屏障上调用 Wait() 等待,直到 main() 协程的加载完成。

  7. 只要我们还有更多的矩阵要乘,就重复步骤 2。

列表 6.16 展示了我们可以如何实现单行乘法。该函数接受两个输入矩阵、一个可以放置结果的空格、一个屏障和一个表示它应该计算哪一行的行号。它不会迭代每一行,而只会处理作为参数传入的行号。它的实现与列表 6.14 相同,但缺少外层行循环。在并行性的方面,根据我们有多少空闲处理器,Go 的运行时应该能够平衡行计算在可用的 CPU 资源上。在理想情况下,我们会有一个 CPU 可用于每个执行每行计算的 goroutine。

列表 6.16 为单独的 goroutines 实现的矩阵单行乘法函数

package main

import (
    "*fmt*"
    "*github.com/cutajarj/ConcurrentProgrammingWithGo/chapter6/listing6.10*"
)

const matrixSize = 3

func rowMultiply(matrixA, matrixB, result *[matrixSize][matrixSize]int,
    row int, barrier *listing6_10.Barrier) {
    for {                                                   ❶
        barrier.Wait()                                      ❷
        for col := 0; col < matrixSize; col++ {
            sum := 0
            for i := 0; i < matrixSize; i++ {
                sum += matrixA[row][i] * matrixB[i][col]    ❸
            }
            result[row][col] = sum                          ❹
        }
        barrier.Wait()                                      ❺
    }
}

❶ 启动一个无限循环

❷ 在屏障上等待,直到 main() goroutine 加载矩阵

❸ 在这个 goroutine 中计算行的结果

❹ 将结果分配给正确的行和列

❺ 在屏障上等待,直到其他每一行都被计算

列表 6.16 中的 rowMultiply() 函数使用了屏障两次。第一次是在等待 main() goroutine 加载两个输入矩阵。第二次,在循环的末尾,它等待所有其他 goroutine 完成它们各自行的计算。这样,它可以与 main 和其他 goroutine 保持同步。

现在我们可以编写我们的 main() 函数,该函数将执行矩阵的加载、在屏障上等待以及输出结果。main() 函数还初始化大小为 matrixSize + 1 的屏障,并在开始时启动 goroutines,如下列所示。

列表 6.17 矩阵乘法的 main() 函数

func main() {
    var matrixA, matrixB, result [matrixSize][matrixSize]int
    barrier := listing6_10.NewBarrier(matrixSize + 1)              ❶
    for row := 0; row < matrixSize; row++ {
        go rowMultiply(&matrixA, &matrixB, &result, row, barrier)  ❷
    }

    for i := 0; i < 4; i++ {
        generateRandMatrix(&matrixA)                               ❸
        generateRandMatrix(&matrixB)                               ❸

        barrier.Wait()                                             ❹

        barrier.Wait()                                             ❺

        for i := 0; i < matrixSize; i++ {
            fmt.Println(matrixA[i], matrixB[i], result[i])         ❻
        }
        fmt.Println()
    }
}

❶ 创建一个大小为行 goroutine + main() goroutine 的新屏障

❷ 为每一行创建一个 goroutine,并分配正确的行号

❸ 通过随机生成来加载两个矩阵

❹ 释放屏障,以便 goroutines 可以开始它们的计算

❺ 等待 goroutines 完成它们的计算

❻ 将结果输出到控制台

将列表 6.15、6.16 和 6.17 一起运行,我们在控制台上得到以下结果:

$ go run matrixmultiplysimple.go
[-4 2 2] [-5 -1 -4] [12 4 22]
[4 -4 3] [-3 4 3] [-11 -32 -28]
[0 -5 1] [-1 -4 0] [14 -24 -15]
. . .
[-5 0 3] [1 1 3] [-2 -11 -12]
[3 -2 0] [-4 -3 -2] [11 9 13]
[-4 -5 0] [1 -2 1] [16 11 -2]

使用屏障还是不使用屏障?

屏障是有用的并发工具,它允许我们在代码的某些点上同步执行,正如我们在矩阵乘法应用中看到的那样。这种加载工作、等待其完成以及收集结果的模式是屏障的典型应用。然而,当创建新的执行是一个相当昂贵的操作时,例如当我们使用内核级线程时,它主要是有用的。使用这种模式,你可以在每次加载周期上节省创建新线程的时间。

在 Go 中,创建 goroutines 既便宜又快,因此使用障碍(barriers)来提高这种模式的性能提升并不大。通常,直接加载工作,创建你的工作 goroutines,使用等待组等待它们的完成,然后收集结果会更简单。尽管如此,在需要同步大量 goroutines 的场景中,障碍(barriers)仍然可能带来性能上的好处。

6.3 练习

  1. 在列表 6.5 和 6.6 中,我们开发了一个递归并发文件搜索。当一个 goroutine 找到文件匹配时,它会在控制台上输出。你能修改这个文件搜索的实现,使其在搜索完成后按字母顺序打印所有文件匹配项吗?提示:尝试在共享数据结构中收集结果,而不是从 goroutine 在控制台上打印它们。

  2. 在前面的章节中,我们看到了互斥锁上的 TryLock() 操作。这是一个非阻塞调用,会立即返回而不等待。如果锁不可用,函数返回 false;否则,它会锁定互斥锁并返回 true。你能为我们列表 6.8 中的等待组实现一个类似的非阻塞函数,称为 TryWait() 吗?如果等待组尚未完成,此函数会立即返回 false;否则,它返回 true

  3. 在列表 6.14 和 6.15 以及 6.16 和 6.17 中,我们实现了单线程和多线程的矩阵乘法程序。你能测量计算大小为 1000 × 1000 或更大的大型矩阵乘法所需的时间吗?为了使时间测量准确,你应该移除 Println() 调用,因为大型矩阵在控制台上打印将花费很长时间。你可能只有在系统有多个核心的情况下才会注意到差异。

  4. 在列表 6.16 和 6.17 中,在并发矩阵乘法中,我们使用障碍(barriers)在 goroutines 需要开始处理新行时重用 goroutines。由于在 Go 中创建新线程既便宜又快,你能修改这个实现,使其不使用障碍(barriers)吗?相反,你可以在每次生成新矩阵时创建一组 goroutines(每个 goroutine 对应一行)。提示:你仍然需要一种方法来通知 main() goroutine 所有行都已计算完成。

摘要

  • 等待组(Waitgroups)允许我们等待一组 goroutines 完成其工作。

  • 当使用等待组时,goroutine 在完成一个任务后会调用 Done()

  • 要使用等待组等待所有任务完成,我们调用 Wait() 函数。

  • 我们可以使用初始化为负许可数的信号量来实现固定大小的等待组。

  • Go 的内置等待组允许我们通过使用 Add(``) 函数在创建等待组后动态调整组的大小。

  • 我们可以使用条件变量来实现动态大小的等待组。

  • 障碍(Barriers)允许我们在 goroutines 执行的特定点进行同步。

  • 当一个 goroutine 调用Wait()时,屏障会暂停执行,直到所有参与屏障的 goroutine 也调用Wait()

  • 当所有参与屏障的 goroutine 调用Wait()时,屏障上所有暂停的执行都会恢复。

  • 屏障可以被多次重用。

  • 我们还可以使用条件变量来实现屏障。

第二部分. 消息传递

在本书的第一部分,我们讨论了如何使用内存共享来启用执行线程之间的通信。在本书的第二部分,我们将探讨消息传递,这是执行之间通信的另一种方式。在消息传递中,执行线程在需要通信时相互传递消息的副本。由于这些执行没有共享内存,我们消除了许多类型竞态条件的风险。

Go 从一种称为通信顺序进程(CSP)的并发模型中汲取灵感,这是一种描述并发程序交互的正式语言。在这个模型中,进程通过同步消息传递相互连接。同样地,Go 为我们提供了通道的概念,它使 goroutines 能够相互连接、同步和共享消息。

在本书的这一部分,我们将探讨消息传递以及我们可以使用的各种工具和编程模式来管理这种通信形式。

7 使用消息传递进行通信

本章涵盖

  • 用于线程通信的消息交换

  • 采用 Go 的通道进行消息传递

  • 使用通道收集异步结果

  • 构建我们自己的通道

到目前为止,我们讨论了让我们的 goroutines 通过共享内存和使用同步控制来防止它们相互跨越来解决问题。消息传递是另一种实现 线程间通信(ITC)的方式,即 goroutines 向其他 goroutines 发送消息或等待来自其他 goroutines 的消息。

在本章中,我们将探讨使用 Go 的通道在我们的 goroutines 之间发送和接收消息。本章将作为使用来自形式化语言通信顺序进程(CSP)的抽象来编程并发的介绍。我们将在接下来的章节中更详细地介绍 CSP。

7.1 传递消息

无论我们与朋友、家人或同事交谈或沟通,我们都是通过相互传递消息来进行的。在言语中,我们说些什么,通常期望从我们说话的人那里得到回复或反应。当我们通过信件、电子邮件或电话进行沟通时,这种期望也是有效的。goroutines 之间的消息传递与此类似。在 Go 中,我们可以在两个或更多 goroutines 之间打开一个通道,然后编程 goroutines 在它们之间发送和接收消息(见图 7.1)。

图片

图 7.1 Goroutines 之间传递消息

消息传递和分布式系统

当我们在多台机器上运行分布式应用程序时,消息传递是它们之间通信的主要方式。由于应用程序运行在不同的机器上,并且不共享任何内存,它们通过发送消息通过常见的协议(如 HTTP)来共享信息。

使用消息传递的优势在于,我们大大降低了因编程错误而引起竞态条件的风险。由于我们不是在修改任何共享内存的内容,goroutines 在内存中不能相互跨越。使用消息传递,每个 goroutine 只与自己的隔离内存进行工作。

7.1.1 使用通道传递消息

Go 通道允许两个或更多 goroutines 交换消息。从概念上讲,我们可以将通道视为我们的 goroutines 之间的直接线路,如图 7.2 所示。goroutines 可以使用通道的两端来发送或接收消息。

图片

图 7.2 通道是 goroutines 之间的直接线路。

要使用通道,我们首先使用make()内置函数创建一个通道。然后,每次我们创建协程时都可以将其作为参数传递。要发送消息,我们使用<-操作符。在列表 7.1 中,我们初始化了一个类型为string的通道。通道指定的类型允许我们发送相同类型的消息。正如这个例子所示,我们只能通过这个通道发送字符串。在创建这个通道后,我们将其传递给一个新创建的名为receiver()的协程。然后我们通过通道发送三个字符串消息。

列表 7.1 创建和使用通道

package main

import "*fmt*"

func main() {
    msgChannel := make(chan string)    ❶
    go receiver(msgChannel)            ❷
    fmt.Println("*Sending HELLO...*")
    msgChannel <- "*HELLO*"              ❸
    fmt.Println("*Sending THERE...*")    ❸
    msgChannel <- "*THERE*"              ❸
    fmt.Println("*Sending STOP...*")     ❸
    msgChannel <- "*STOP*"               ❸
}

❶ 创建一个新的字符串类型通道

❷ 使用通道引用启动一个新的协程

❸ 通过通道发送三个字符串消息

要从通道消费消息,我们使用相同的<-操作符。然而,我们将通道放在操作符的右边而不是左边。这在上面的receiver()协程实现中显示,它从通道读取消息,直到接收到消息STOP

列表 7.2 从通道读取消息

func receiver(messages chan string) {
    msg := ""
    for msg != "*STOP*" {                  ❶
        msg = <-messages                 ❷
        fmt.Println("*Received:*", msg)    ❸
    }
}

❶ 在接收到消息不是 STOP 时继续

❷ 从通道读取下一个消息

❸ 在控制台上输出消息

将列表 7.1 和 7.2 合并会导致main()协程在公共通道上推送消息,而receiver协程消费它们。一旦main()协程发送停止消息,接收器将退出for循环并终止。以下是输出:

$ go run messagepassing.go
Sending HELLO...
Sending THERE...
Received: HELLO
Received: THERE
Sending STOP...

注意在输出中,我们缺少接收器的最终STOP消息。这是因为main()协程发送停止消息然后终止。一旦main协程终止,整个进程就会退出,我们永远不会看到在控制台上打印的停止消息。

如果一个协程在没有任何其他协程读取该消息的情况下向通道推送消息会发生什么?Go 的通道默认是同步的,这意味着发送者协程将阻塞,直到有接收者协程准备好消费消息。图 7.3 显示了没有接收者的协程发送者被阻塞。

图 7.3 在无接收者的情况下向通道发送消息

我们可以通过将列表 7.2 中的接收器更改为以下内容来尝试这一点。在这个接收器中,我们在终止之前等待 5 秒钟,而不是从通道中消费任何消息。

列表 7.3 接收器不消费任何消息

func receiver(messages chan string) {
    time.Sleep(5 * time.Second)                  ❶
    fmt.Println("*Receiver slept for 5 seconds*")
}

❶ 等待 5 秒钟而不是从通道读取消息

当我们运行列表 7.1 中的main()函数和列表 7.3 时,main()协程会阻塞 5 秒钟。这是因为没有东西可以消费main()协程试图放置在通道上的消息:

$ go run noreceiver.go
Sending HELLO...
Receiver slept for 5 seconds
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
        /chapter7/listing7.3/noreceiver.go:12 +0xb9
exit status 2

由于我们的receiver ()协程在 5 秒后终止,没有其他协程可用于从通道中消费消息。Go 的运行时意识到这一点,并引发致命错误。如果没有这个错误,我们的程序将保持阻塞状态,直到我们手动终止它。错误信息提到我们遇到了死锁——我们将在第十一章探讨如何处理死锁。

如果有一个等待消息的接收者而没有发送者,也会出现相同的情况。接收者的协程将暂停,直到有消息可用(见图 7.4)。

图 7.4

图 7.4 接收者被阻塞,直到有消息可用。

在下面的列表中,我们有一个sender()协程,它不是将消息写入通道,而是睡眠 5 秒。main()协程试图从同一个通道中消费一个消息,但它将被阻塞,因为没有发送者发送消息。

列表 7.4 接收者被阻塞,因为发送者没有发送任何消息

package main

import (
    "*fmt*"
    "*time*"
)

func main() {
    msgChannel := make(chan string)                ❶
    go sender(msgChannel)
    fmt.Println("*Reading message from channel**...*")
    msg := <-msgChannel                            ❷
    fmt.Println("*Received:*", msg)
}

func sender(messages chan string) {
    time.Sleep(5 * time.Second)                    ❸
    fmt.Println("*Sender slept for 5 seconds*")
}

❶ 创建一个新的字符串类型通道

❷ 从通道读取消息

❸ 替代发送消息,睡眠 5 秒

运行列表 7.4 会产生与列表 7.3 类似的结果。我们得到一个等待消息的接收者,当sender()协程终止时,Go 的运行时会输出一个错误。以下是控制台输出:

$ go run nosender.go
Reading message from channel...
Sender slept for 5 seconds
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
        /chapter7/listing7.4/nosender.go:12 +0xbd
exit status 2

这里的关键思想是,默认情况下,Go 的通道是同步的。如果没有协程消费其消息,发送者将阻塞,同样,如果没有协程发送消息,接收者也会阻塞。

7.1.2 使用通道缓冲消息

虽然通道是同步的,但我们可以配置它们,在它们阻塞之前存储一定数量的消息(见图 7.5)。当我们使用带缓冲的通道时,只要缓冲区有空间,发送者协程就不会阻塞。

图 7.5

图 7.5 在协程之间使用带缓冲的通道

当我们创建一个通道时,可以指定其缓冲区容量。然后,每当发送者协程写入消息而没有接收者消费该消息时,通道将存储该消息(如图 7.6 所示)。这意味着只要缓冲区有空间,我们的发送者就不会阻塞,我们也不必等待接收者读取消息。

图 7.6

图 7.6 当没有接收者消费消息时,消息存储在缓冲区中。

只要缓冲区中还有容量,通道就会继续存储消息。一旦缓冲区被填满,发送者将再次阻塞,如图 7.7 所示。如果接收端响应慢,没有足够快地消费消息以跟上发送者,也可能发生这种消息缓冲区积累。

图 7.7

图 7.7 一个满缓冲区阻止了发送者。

一旦有接收器 goroutine 可以消费消息,消息将以与发送时相同的顺序被发送到接收器。即使发送器 goroutine 不再发送任何新消息(如图 7.8 所示),也是如此。只要缓冲区中有消息,接收器 goroutine 就不会阻塞。

图 7.8 接收器即使在没有发送者的情况下也会从缓冲区中消费存储的消息。

一旦接收器 goroutine 消耗了所有消息并且缓冲区为空,接收器 goroutine 将再次阻塞。当缓冲区为空时,如果没有发送者或者发送者产生消息的速度比接收者读取它们的速度快,接收者将阻塞。这如图 7.9 所示。

图 7.9 没有发送者的空缓冲区将阻塞接收器。

让我们现在尝试一下。列表 7.5 显示了一个慢速消息接收器,它以每秒一个消息的速率从整数通道中消费消息。我们使用time.Sleep()来减慢 goroutine 的速度。一旦receiver() goroutine 接收到-1值,它就停止接收消息,并在 waitgroup 上调用Done()

列表 7.5 每秒读取一个消息的慢速接收器

package main

import (
    "*fmt*"
    "*sync*"
    "*time*"
)

func receiver(messages chan int, wGroup *sync.WaitGroup) {
    msg := 0
    for msg != -1 {                    ❶
        time.Sleep(1 * time.Second)    ❷
        msg = <-messages               ❸
        fmt.Println("*Received:*", msg)
    }
    wGroup.Done()                      ❹
}

❶ 从通道中读取消息,直到接收到-1

❷ 等待 1 秒

❸ 从通道读取下一个消息

❹ 在读取所有消息后在 waitgroup 上调用 Done()

我们现在可以编写一个main()函数,该函数创建一个带缓冲的通道,并以比我们的读者消费它们更快的速度将消息喂入通道。在列表 7.6 中,我们创建了一个容量为三个消息的带缓冲通道。然后我们使用这个通道快速发送六个消息,每个消息都包含从16的序列中的下一个数字。之后,我们发送一个包含-1值的最终消息。最后,我们通过等待 waitgroup 来等待receiver() goroutine 完成。

列表 7.6 main()函数在带缓冲的通道上发送消息

func main() {
    msgChannel := make(chan int, 3)              ❶
    wGroup := sync.WaitGroup{}                   ❷
    wGroup.Add(1)                                ❷
    go receiver(msgChannel, &wGroup)             ❸
    for i := 1; i <= 6; i++ {
        size := len(msgChannel)                  ❹
        fmt.Printf("%s Sending: %d. Buffer Size: %d\n",
            time.Now().Format("15:04:05"), i, size)
        msgChannel <- i                          ❺
    }
    msgChannel <- -1                             ❻
    wGroup.Wait()                                ❼
}

❶ 创建一个容量为 3 个消息的新通道

❷ 创建一个大小为 1 的 waitgroup

❸ 使用带缓冲的通道和 waitgroup 启动接收器 goroutine

❹ 读取带缓冲通道中的消息数量

❺ 发送从 1 到 6 的六个整数消息

❻ 发送包含-1 的消息

❼ 等待 waitgroup 直到接收器完成

注意:我们可以通过使用len(buffer)函数来检查缓冲区中有多少消息。

结合列表 7.5 和 7.6,我们得到一个快速发送者,它试图发送六个消息。由于我们的接收者速度要慢得多,main() goroutine 将用三个消息填满通道缓冲区,然后阻塞。接收者将每秒消费一个消息,为发送者快速填充缓冲区腾出空间。以下是显示每个发送和接收操作时间戳的输出:

11:09:15 Sending: 1\. Buffer Size: 0
11:09:15 Sending: 2\. Buffer Size: 1
11:09:15 Sending: 3\. Buffer Size: 2
11:09:15 Sending: 4\. Buffer Size: 3
11:09:16 Received: 1
11:09:16 Sending: 5\. Buffer Size: 3
11:09:17 Received: 2
11:09:17 Sending: 6\. Buffer Size: 3
11:09:18 Received: 3
11:09:19 Received: 4
11:09:20 Received: 5
11:09:21 Received: 6
11:09:22 Received: -1

7.1.3 为通道分配方向

Go 的通道默认是 双向的。这意味着一个 goroutine 可以同时作为消息的接收者和发送者。然而,我们可以给通道指定一个方向,这样使用该通道的 goroutine 就只能发送或接收消息。

例如,当我们声明一个函数的参数时,我们可以指定通道的方向。列表 7.7 声明了接收者和发送者函数,允许消息只能单向流动。在接收者中,当我们声明通道为 messages <-chan int 时,我们是在说这个通道是一个只接收通道。发送者函数中 messages chan<- int 的声明则表示相反的意思——通道只能用来发送消息。

列表 7.7 声明具有方向的通道

package main

import (
    "*fmt*"
    "*time*"
)

func receiver(messages <-chan int) {     ❶
    for {
        msg := <-messages                ❷
        fmt.Println(time.Now().Format("*15:04:05*"), "*Received:*", msg)
    }
}

func sender(messages chan<- int) {       ❸
    for i := 1; ; i++ {
        fmt.Println(time.Now().Format("*15:04:05*"), "*Sending:*", i)
        messages <- i                    ❹
        time.Sleep(1 * time.Second)      ❹
    }
}

func main() {
    msgChannel := make(chan int)
    go receiver(msgChannel)
    go sender(msgChannel)
    time.Sleep(5 * time.Second)
}

❶ 声明一个只接收通道

❷ 从通道接收消息

❸ 声明一个只发送通道

❹ 每秒在通道上发送一条消息

在列表 7.7 中,如果我们尝试使用接收者的通道来发送消息,我们会得到一个编译错误。例如,如果在 receiver() 函数中我们这样做

messages <- 99

在编译时我们会得到一个错误信息:

$ go build directional.go
# command-line-arguments
.\directional.go:11:9: invalid operation: cannot send to receive-only channel 
➥ messages (variable of type <-chan int)

7.1.4 关闭通道

我们一直使用特殊值消息来表示通道上没有更多数据可用。例如,在列表 7.6 中,接收者正在等待通道上出现 –1 值。这向接收者发出信号,表明它可以停止消费消息。这个消息包含了一个被称为 哨兵值 的内容。

定义 在软件开发中,哨兵值 是一个预定义的值,它向执行、进程或算法发出信号,表明它应该终止。在多线程和分布式系统的上下文中,这有时被称为 毒药丸 消息。

而不是使用这个 哨兵值 消息,Go 允许我们关闭一个通道。我们可以通过调用 close(channel) 函数来实现。一旦我们关闭了通道,就不应该再向其发送任何消息,因为这样做会引发错误。如果我们尝试从已关闭的通道接收消息,我们会收到包含通道数据类型默认值的消息。例如,如果我们的通道是整型,从已关闭的通道读取将导致读取操作返回 0 值。这如图 7.10 所示。

图 7.10 关闭通道并继续消费消息

我们可以通过实现一个在关闭通道后仍然持续消费消息的接收者来展示这一点。下面的列表显示了一个 receiver() 函数,其中包含一个循环,从通道读取消息并在每秒将其输出到控制台。

列表 7.8 无限通道接收器

package main

import (
    "*fmt*"
    "*time*"
)

func receiver(messages <-chan int) {        ❶
    for {
        msg := <-messages                   ❷
        fmt.Println(time.Now().Format("*15:04:05*"), "*Received:*", msg)
        time.Sleep(1 * time.Second)         ❸
    }
}

❶ 声明一个只接收通道

❷ 从通道读取一条消息

❸ 等待 1 秒

接下来,我们可以实现一个main()函数,该函数在通道上发送几条消息,然后关闭通道。在下面的列表中,我们发送了三条消息,每秒一条,然后关闭了通道。我们还添加了 3 秒的休眠时间,以显示receiver()协程从关闭的通道中读取的内容。

列表 7.9 main()函数发送消息并关闭通道

func main() {
    msgChannel := make(chan int)
    go receiver(msgChannel)
    for i := 1; i <= 3 ; i++ {
        fmt.Println(time.Now().Format("*15:04:05*"), "*Sending:*", i)
        msgChannel <- i
        time.Sleep(1 * time.Second)
    }
    close(msgChannel)
    time.Sleep(3 * time.Second)
}

将列表 7.8 和 7.9 一起运行,我们得到接收器首先输出从13的消息,然后读取 3 秒的0

$ go run closing.go
17:19:50 Sending: 1
17:19:50 Received: 1
17:19:51 Sending: 2
17:19:51 Received: 2
17:19:52 Sending: 3
17:19:52 Received: 3
17:19:53 Received: 0
17:19:54 Received: 0
17:19:55 Received: 0

我们能否使用这个默认值让接收器知道通道已被关闭?使用默认值并不理想,因为默认值可能对我们用例是有效的值。想象一下,例如,一个通过通道发送温度的天气预报应用程序。在这种情况下,当温度降到 0 时,接收器会认为通道已被关闭。

幸运的是,Go 提供了几种处理关闭通道的方法。每次我们从通道中消费时,都会返回一个额外的标志,告诉我们通道的状态。这个标志仅在通道被关闭时设置为false。下面的列表显示了如何修改列表 7.8 中的接收器函数来读取这个标志。通过使用这个标志,我们可以决定是否停止从通道读取。

列表 7.10 当通道指示已关闭时,接收器停止

func receiver(messages <-chan int) {
    for {
        msg, more := <-messages                  ❶
        fmt.Println(time.Now().Format("*15:04:05*"), "*Received:*", msg, more)
        time.Sleep(1 * time.Second)
        if !more {                               ❷
            return                               ❷
        }
    }
}

❶ 读取消息和一个打开的通道标志,当通道关闭时该标志设置为 false

❷ 当没有更多消息时,它停止从通道中消费。

如预期,当我们运行列表 7.10 与列表 7.9 中的main()函数时,我们会消费消息直到通道关闭。我们还可以看到,当通道关闭时,打开通道标志被设置为false

$ go run closingFlag.go
08:07:41 Sending: 1
08:07:41 Received: 1 true
08:07:42 Sending: 2
08:07:42 Received: 2 true
08:07:43 Sending: 3
08:07:43 Received: 3 true
08:07:44 Received: 0 false

正如我们在下一章中将要看到的,这种语法在特定情况下很有用,例如当它与select语句结合使用时。然而,我们可以使用更简洁的语法来停止接收器在关闭的通道上读取。如果我们想读取所有消息直到关闭通道,我们可以使用以下for循环语法:

for msg := range messages

在这里,messages变量是我们的通道。这样,我们可以继续迭代,直到发送者最终关闭通道。下面的列表显示了如何将列表 7.9 中的receiver()函数更改为使用这种新语法。

列表 7.11 接收器迭代通道中的消息

func receiver(messages <-chan int) {
    for msg := range messages {                  ❶
        fmt.Println(time.Now().Format("*15:04:05*"), "*Received:*", msg)
        time.Sleep(1 * time.Second)
    }
    fmt.Println("*Receiver finished.*")
}

❶ 从通道中消费直到它关闭,将消息分配给 msg 变量

将列表 7.11 与列表 7.9 中的相同main()函数一起运行,我们最终会消费来自main()协程的所有发送的消息。该列表输出以下内容:

$ go run forchannel.go
09:52:11 Sending: 1
09:52:11 Received: 1
09:52:12 Sending: 2
09:52:12 Received: 2
09:52:13 Sending: 3
09:52:13 Received: 3
Receiver finished.

7.1.5 使用通道接收函数结果

我们可以在后台并发执行函数,一旦完成,就可以通过通道收集它们的结果。通常,在正常的顺序编程中,我们调用一个函数并期望它返回一个结果。在并发编程中,我们可以在不同的 goroutine 中调用函数,稍后从输出通道中获取它们的返回值。

让我们用一个简单的例子来探讨这个问题。以下列表显示了一个寻找输入数字因子的函数。例如,如果我们调用findFactors(6),它将返回值[1 2 3 6]

列表 7.12 寻找数字所有因子的函数

package main

import (
    "*fmt*"
)

func findFactors(number int) []int {    ❶
    result := make([]int, 0)
    for i := 1; i <= number; i++ {
        if number%i == 0 {
            result = append(result, i)
        }
    }
    return result
}

❶ 寻找输入数字的所有因子

如果我们为两个不同的数字两次调用findFactors()函数,在顺序编程中,我们将有两个调用,一个接一个。例如:

fmt.Println(findFactors(3419110721))
fmt.Println(findFactors(4033836233))

但如果我们想用第一个数字调用函数,同时它在计算这些因子时,我们再次用第二个数字调用该函数呢?如果我们有多个可用核心,将第一个findFactors()调用与第二个并行执行将加快我们的程序。寻找大数的因子可能是一个耗时的操作,因此将工作分配到多个处理核心上会更好。

我们当然可以启动一个 goroutine 来处理第一次调用,然后进行第二次调用:

go findFactors(3419110721)
fmt.Println(findFactors(4033836233))

然而,我们如何轻松地等待并收集第一次调用的结果呢?我们可以使用共享变量和 waitgroup,但有一个更简单的方法:使用通道。在下一个列表中,我们使用一个匿名函数,作为一个 goroutine 运行,并执行第一次findFactors()调用。

列表 7.13 使用通道收集结果

func main() {
    resultCh := make(chan []int)              ❶
    go func() {
        resultCh <- findFactors(3419110721)   ❷
    }()
    fmt.Println(findFactors(4033836233))
    fmt.Println(<- resultCh)                  ❸
}

❶ 创建一个新的整数切片类型的通道

❷ 在匿名 goroutine 中调用函数并将结果放置到通道中

❸ 从通道中收集结果

我们使用这个匿名 goroutine 来收集findFactors()函数的结果并将它们写入通道。在我们main() goroutine 中完成第二次调用之后,我们可以从通道中读取这些结果。如果第一次findFactors()调用尚未完成,从通道中读取将阻塞main() goroutine,直到我们得到结果。以下是显示所有因子的输出:

$ go run collectresults.go
[1 7 131 917 4398949 30792643 576262319 4033836233]
[1 13 113 1469 2327509 30257617 263008517 3419110721]

7.2 实现通道

通道的内部逻辑是什么样的?在其基本形式中,一个带缓冲的通道类似于固定大小的队列数据结构。区别在于它可以安全地从多个并发 goroutine 中使用。此外,当缓冲区为空时,通道需要阻塞接收 goroutine;当缓冲区满时,需要阻塞发送 goroutine。在本节中,我们将使用之前章节中构建的并发原语来构建通道的发送和接收函数,以便我们更好地理解其内部工作原理。

7.2.1 使用信号量创建通道

我们需要一些元素来构建我们频道的功能:

  • 一个共享队列数据结构,它像一个缓冲区一样存储发送者和接收者之间的消息

  • 对共享数据结构的并发访问保护,以确保多个发送者和接收者不会相互干扰

  • 当缓冲区为空时阻止接收器执行的访问控制

  • 当缓冲区满时阻止发送器执行的访问控制

我们有几种实现我们的共享数据结构的方法,我们将在这里存储我们的消息。例如,我们可以在数组上构建一个队列结构,并使用 Go 切片或链表。无论我们选择哪种工具,它都需要给我们提供队列语义,即先进先出。

为了保护我们的共享数据结构免受并发访问的影响,我们可以使用一个简单的互斥锁。当我们从队列中添加或删除消息时,我们需要确保对队列的并发修改不会相互干扰。

为了控制访问,以便在队列满或空时阻塞执行,我们可以使用信号量。在这种情况下,信号量是一个很好的基本原语,因为它们允许对特定数量的并发执行进行并发访问。从接收者的角度来看,我们可以将使用信号量视为共享队列中消息数量的自由许可证。一旦队列空了,信号量将阻止下一个请求消费消息,因为信号量上的自由许可证数量将是 0。我们可以在发送者的一侧使用同样的技巧——我们可以使用另一个信号量,当队列满时它将下降到 0。一旦发生这种情况,信号量将阻止下一个发送请求。

这四个元素构成了图 7.11 中的 10 个缓冲区大小的通道。我们使用两个信号量,容量和缓冲区大小信号量,分别用于在容量达到或缓冲区为空时阻塞 goroutines。在图中,缓冲区中有三个消息,因此缓冲区大小信号量显示为 3。这意味着我们还有七个空间直到缓冲区满,容量信号量被设置为这个值。

图片

图 7.11 构建通道所需的结构和工具

我们可以通过创建一个包含这四个元素的 channel struct 类型来将此转换为代码,如列表 7.14 所示。对于我们的缓冲区,我们将使用 container 包中的链表实现。链表是实现队列的理想结构,因为我们总是在链表的头部或尾部添加和删除消息。在 channel struct 类型中,我们还使用了 Go 的泛型,这使得我们的通道实现更容易与各种数据类型一起使用。

列表 7.14 用于自定义通道实现的类型 struct

package listing7_14

import (
    "*container/list*"
    "*github.com/cutajarj/ConcurrentProgrammingWithGo/chapter5/listing5.16*"
    "*sync*"
)

type Channel[M any] struct {
    capacitySema *listing5_16.Semaphore    ❶

    sizeSema     *listing5_16.Semaphore    ❷

    mutex        sync.Mutex                ❸

    buffer       *list.List                ❹
}

❶ 容量信号量,当缓冲区满时阻止发送器

❷ 缓冲区大小信号量,当缓冲区为空时阻止接收器

❸ 互斥锁保护我们的共享列表数据结构

❹ 将用作队列数据结构的链表

接下来,我们需要一个函数来初始化struct类型中的元素,使用默认空值。当我们创建一个新的通道时,我们需要缓冲区为空,缓冲区大小信号量具有0个许可,容量信号量的许可计数等于输入容量。这将确保我们允许发送者添加消息,但接收者因为缓冲区当前为空而被阻塞。以下列表中的NewChannel()函数执行此初始化。

列表 7.15:创建新通道的函数

func NewChannelM any *Channel[M] {
    return &Channel[M]{
        capacitySema: listing5_16.NewSemaphore(capacity),  ❶

        sizeSema:     listing5_16.NewSemaphore(0),         ❷

        buffer:       list.New(),                          ❸
    }
}

❶ 创建一个许可数等于输入容量的新信号量

❷ 创建一个许可数为 0 的新信号量

❸ 创建一个新的、空的链表

7.2.2 在我们的通道中实现 Send()函数

让我们探索一下信号量、缓冲区和互斥锁如何协同工作,以给我们提供通道的发送功能。Send(message)函数需要满足以下三个要求:

  • 如果缓冲区已满,阻止 goroutine。

  • 否则,安全地将message添加到缓冲区。

  • 如果有任何接收者 goroutine 被阻塞,等待消息,恢复其中一个。

图 7.12 在通道上发送消息

我们可以通过执行图 7.12 中概述的三个步骤来满足所有这些要求:

  1. 发送者从容量信号量中获取一个许可,许可计数减少1。这将满足第一个要求;如果缓冲区已满,goroutine 将因为不再有可用的许可而阻塞。

  2. 发送者将消息推送到缓冲区数据结构中。在我们的实现中,这个数据结构是链表队列。为了保护队列免受并发更新,我们可以使用互斥锁来同步访问。

  3. 发送者 goroutine 通过在信号量上调用Release()函数来释放缓冲区大小信号量上的许可。这满足了最终要求;如果有阻塞的 goroutine 正在等待消息,它将被恢复。

下一个列表显示了发送者的实现。Send(message)函数包含三个步骤:减少容量信号量的许可数,将消息推送到队列中,并增加缓冲区大小信号量的许可数。

列表 7.16:通道实现的Send()函数

func (c *Channel[M]) Send(message M) {
    c.capacitySema.Acquire()            ❶

    c.mutex.Lock()                      ❷
    c.buffer.PushBack(message)          ❷
    c.mutex.Unlock()                    ❷

    c.sizeSema.Release()                ❸
}

❶ 从容量信号量中获取一个许可

❷ 在使用互斥锁保护以防止竞争条件的情况下,将消息添加到缓冲区队列中

❸ 从缓冲区大小信号量中释放一个许可

如果缓冲区已满,我们的容量信号量将没有剩余的许可,因此发送者 goroutine 将在第一步(见图 7.13)上被阻塞。如果使用初始容量为0的通道且没有接收者,发送者也会阻塞,这给我们提供了 Go 中默认通道的相同同步功能。

图 7.13 当缓冲区已满且我们具有0容量时阻止发送者

7.2.3 在我们的通道中实现 Receive()函数

现在我们来看看我们通道实现的接收端。Receive()函数需要满足以下要求:

  • 解除等待容量空间的发送器的阻塞。

  • 如果缓冲区为空,则阻塞接收器。

  • 否则,安全地消费缓冲区中的下一条消息。

图 7.14 从通道接收消息

满足所有这些要求所需的步骤如图 7.14 所示:

  1. 接收器在容量信号量上释放一个许可证。这将解除等待容量以放置其消息的发送器的阻塞。

  2. 接收器尝试从缓冲区大小信号量中获取一个许可证。如果缓冲区中没有消息,这将导致接收器 goroutine 阻塞,满足第二个要求。

  3. 一旦信号量解除接收器的阻塞,goroutine 将读取并从缓冲区中移除下一条消息。在这里,我们应该使用与发送器函数中相同的互斥锁,以确保我们保护共享缓冲区免受并发执行的干扰。

注意:首先释放容量信号量许可证的原因是我们希望实现也能在零缓冲区通道的情况下工作。这就是发送器和接收器都等待直到两者同时可用的情况。

列表 7.17 显示了Receive()函数的实现,执行图 7.14 中概述的三个步骤。它释放容量信号量,获取缓冲区信号量,并从实现队列缓冲区的链表中提取第一条消息。该函数使用与Send()函数相同的互斥锁来保护链表免受并发干扰。

列表 7.17 通道实现的Receive()函数

 func (c *Channel[M]) Receive() M {
    c.capacitySema.Release()                     ❶

    c.sizeSema.Acquire()                         ❷

    c.mutex.Lock()                               ❸
    v := c.buffer.Remove(c.buffer.Front()).(M)   ❸
    c.mutex.Unlock()                             ❸

    return v                                     ❹
}

❶ 从容量信号量中释放一个许可证

❷ 从缓冲区大小信号量中获取一个许可证

❸ 在使用互斥锁保护以防止竞态条件的同时,从缓冲区中移除一条消息

❹ 返回消息的值

如果我们的缓冲区为空,缓冲区大小信号量将没有可用的许可证。在这种情况下,当接收器 goroutine 尝试获取许可证时,缓冲区大小信号量将阻塞,直到发送器推送一条消息并调用同一信号量的Release()。图 7.15 显示了接收器 goroutine 在具有0个许可证的缓冲区大小信号量上阻塞的情况。

图 7.15 当缓冲区为空且我们在缓冲区大小信号量上有0个许可证时,接收器阻塞

使用信号量的这种阻塞逻辑,当通道容量设置为 0 时也会工作,如图 7.16 所示。这是 Go 通道的默认行为。在这种情况下,接收者会增加容量信号量上的许可,并阻塞在获取缓冲区大小信号量上。一旦有发送者到来,它将从容量信号量获取许可,将一条消息推入缓冲区,并释放缓冲区大小信号量。这将导致接收者 goroutine 被解除阻塞。然后接收者将从缓冲区中拉取消息。

图片

图 7.16 零容量通道阻塞接收者,直到发送者推送一条消息

如果在零容量通道中发送者先于接收者到达,当发送者尝试获取容量信号量时,它将被阻塞,直到有接收者到来并释放同一信号量上的许可。

Go 通道是如何实现的?

实际的 Go 语言通道实现与运行时调度器集成,以提高性能。与我们的实现不同,它不使用双信号量系统来挂起 goroutines。相反,它使用两个链表来存储挂起的接收者和发送者 goroutines 的引用。

实现还有一个缓冲区来存储任何挂起的消息。当这个缓冲区满了或者通道是同步的,任何新的发送者 goroutine 会被挂起并排队在发送者列表中。相反,当缓冲区为空时,任何新的接收者 goroutine 会被挂起并排队在接收者列表中。

当需要恢复 goroutine 时,这些列表会被使用。当有消息可用时,接收者列表中的第一个 goroutine 被选中并恢复。当有新的接收者可用时,发送者列表中的第一个 goroutine 被恢复(如果有的话)。与我们的实现不同,这个系统确保了挂起 goroutines 之间的公平性;第一个挂起的 goroutine 也将是第一个被恢复的。

通道的源代码可以在 Go 的 GitHub 项目中找到,位于 runtime 包下,具体位置为 github.com/golang/go/blob/master/src/runtime/chan.go

7.3 练习

注意:访问 github.com/cutajarj/ConcurrentProgrammingWithGo 查看所有代码解决方案。

  1. 在列表 7.1 和 7.2 中,接收者没有输出最后的消息 STOP。这是因为 main() goroutine 在 receiver() goroutine 有机会打印最后一条消息之前就终止了。你能否在不使用额外的并发工具和不使用 sleep 函数的情况下改变逻辑,以便打印最后一条消息?

  2. 在列表 7.8 中,当通道关闭时,接收者读取到 0。你能否尝试使用不同的数据类型?如果通道是字符串类型会发生什么?如果是切片类型呢?

  3. 在列表 7.13 中,我们使用子 goroutine 来计算一个数的因子,而main() goroutine 来计算另一个数的因子。修改这个列表,以便使用多个 goroutine 收集 10 个随机数的因子。

  4. 将列表 7.14 至 7.17 修改为使用条件变量而不是信号量来实现通道。实现还需要支持零大小缓冲区的通道。

概述

  • 消息传递是并发执行之间通信的另一种方式。

  • 消息传递类似于我们日常通过传递消息并期待行动或回复的方式进行沟通。

  • 在 Go 中,我们可以使用通道在 goroutine 之间传递消息。

  • Go 中的通道是同步的。默认情况下,如果没有接收者,发送者将会阻塞,如果没有发送者,接收者也会阻塞。

  • 如果我们想允许发送者在阻塞接收者之前发送N条消息,我们可以配置通道上的缓冲区来存储消息。

  • 使用缓冲通道时,如果缓冲区有足够容量,发送者即使没有接收者也可以继续向通道中写入消息。一旦缓冲区填满,发送者将会阻塞。

  • 使用缓冲通道时,如果缓冲区不为空,接收者可以继续从通道中读取消息。一旦缓冲区清空,接收者将会阻塞。

  • 我们可以为通道声明分配方向,以便我们可以从通道接收或向通道发送,但不能同时进行。

  • 可以通过使用close()函数来关闭通道。

  • 通道上的读操作会返回一个标志,告诉我们通道是否仍然打开。

  • 我们可以使用for range循环继续从通道中消费消息,直到通道关闭。

  • 我们可以使用通道收集并发 goroutine 执行的结果。

  • 我们可以通过使用队列、两个信号量和互斥锁来实现通道功能。

8 选择 channel

本章涵盖

  • 从多个 channel 中选择

  • 禁用 select 情况

  • 在消息传递和内存共享之间选择

在上一章中,我们使用了 channel 在两个 goroutine 之间实现消息传递。在这一章中,我们将看到如何使用 Go 的select语句在多个 channel 上读取和写入消息,以及实现超时和非阻塞 channel。我们还将检查一种排除已关闭 channel 并仅从剩余的开放 channel 中消费的技术。最后,我们将讨论内存共享与消息传递之间的区别,以及何时应该选择一种技术而不是另一种。

8.1 结合多个 channel

我们如何让一个 goroutine 响应来自多个 goroutine 通过多个 channel 的消息?Go 的select语句允许我们指定多个 channel 操作作为单独的情况,然后根据哪个 channel 准备好来执行一个情况。

8.1.1 从多个 channel 读取

让我们考虑一个简单的场景,其中 goroutine 期望从不同的 channel 接收消息,但我们不知道下一个消息将在哪个 channel 上接收。select语句允许我们将多个 channel 上的读取操作组合在一起,阻塞 goroutine 直到任何一个 channel 上到达消息(见图 8.1)。

图 8.1 Select 语句会阻塞,直到某个 channel 可用。

一旦任何 channel 上到达消息,goroutine 将被解除阻塞,并运行该 channel 的代码处理器,如图 8.2 所示。然后我们可以决定接下来做什么——要么继续执行,要么再次使用select语句等待下一个消息。

图 8.2 一旦 channel 可用,select 解除阻塞。

让我们看看这如何转化为代码。在列表 8.1 中,我们有一个创建匿名 goroutine 的函数,该 goroutine 周期性地在 channel 上发送消息。周期由seconds输入变量指定。正如我们将在本章后面看到的那样,使用函数返回输出-only channel 的模式使我们能够将这些函数作为构建更复杂行为的构建块重用。我们可以这样做,因为 Go channels 是一等对象。

列表 8.1 函数周期性地在 channel 上输出消息

package main

import (
    "*fmt*"
    "*time*"
)

func writeEvery(msg string, seconds time.Duration) <-chan string {
    messages := make(chan string)    ❶
    go func() {                      ❷
        for {
            time.Sleep(seconds)      ❸
            messages <- msg          ❹
        }
    }()
    return messages                  ❺
 }

❶ 创建一个新的字符串类型 channel

❷ 创建一个新的匿名 goroutine

❸ 睡眠指定的时间段

❹ 在 channel 上发送指定的消息

❺ 返回新创建的消息 channel

定义 Channels 是一等对象,这意味着我们可以将它们存储为变量,从函数中传递或返回,甚至通过 channel 发送。

我们可以通过两次调用 writeEvery() 函数(如前一个列表所示)来演示 select 语句。如果我们指定不同的消息和睡眠周期,我们将得到两个通道和两个在不同时间发送消息的 goroutine。以下列表在 select 语句中读取这些通道,每个通道作为一个单独的 select 情况。

列表 8.2 使用 select 从多个通道读取

func main() {
    messagesFromA := writeEvery("*Tick*", 1 * time.Second)    ❶
    messagesFromB := writeEvery("*Tock*", 3 * time.Second)    ❷

    for {                                                   ❸
        select {
        case msg1 := <-messagesFromA:                       ❹
            fmt.Println(msg1)                               ❹
        case msg2 := <-messagesFromB:                       ❺
            fmt.Println(msg2)                               ❺
        }
    }
}

❶ 在通道 A 上创建一个每秒发送消息的 goroutine

❷ 在通道 B 上创建一个每 3 秒发送消息的 goroutine

❸ 无限循环

❹ 如果通道 A 有可用的消息,则输出该消息

❺ 如果通道 B 有可用的消息,则输出该消息

当我们同时运行列表 8.1 和 8.2 时,我们得到 main() goroutine 在每次收到来自任一通道的消息之前循环并阻塞。当我们收到消息时,main() goroutine 执行 case 语句下的代码。在这个例子中,代码只是将消息输出到控制台:

$ go run selectmanychannels.go
Tick
Tick
Tock
Tick
Tick
Tick
Tock
Tick
Tick
. . .

注意:当使用 select 时,如果多个情况都准备好了,则会随机选择一个情况。你的代码不应该依赖于情况指定的顺序。

select 语句的起源

UNIX 操作系统包含一个名为 select() 的系统调用,它接受一组文件描述符(如文件或网络套接字),并在一个或多个描述符准备好 I/O 操作时阻塞。当您想从单个内核级线程监控多个文件或套接字时,该系统调用非常有用。

Go 的 select 语句的名字来源于 Newsqueak 编程语言的 select 命令。Newsqueak(不要与乔治·奥威尔虚构的语言 Newspeak 混淆)是一种语言,就像 Go 一样,从 C.A.R. Hoare 的 CSP 形式语言中获取其并发模型。Newsqueak 的 select 语句可能得名于 1983 年为 Blit 图形终端构建的多路复用 I/O 的 select 系统调用。

不清楚 Go 的 select 语句的命名是否受到了 UNIX 系统调用的启发;然而,我们可以说 UNIX 的 select() 系统调用与 Go 的 select 语句类似,因为它将多个阻塞操作多路复用到单个执行中。

8.1.2 使用 select 进行非阻塞通道操作

select 的另一个用例是我们需要以非阻塞方式使用通道。回想一下,当我们讨论互斥锁时,我们看到 Go 提供了一个非阻塞的 tryLock() 操作。这个函数调用尝试获取锁,但如果锁正在被使用,它将立即返回一个 false 的返回值。我们能否采用这种模式进行通道操作?例如,我们能否尝试从通道读取消息?然后,如果没有消息可用,而不是阻塞,我们能否让当前执行在默认指令集上工作(参见图 8.3)?

图片

图 8.3 当没有通道可用时,执行默认情况的指令。

select语句为我们提供了恰好这种场景的默认情况。如果没有其他情况可用,将执行默认情况下的指令。这让我们可以尝试访问一个或多个通道,但如果没有任何通道准备好,我们可以做其他事情。

在下面的列表中,我们有一个带有默认情况的select语句。在这个列表中,我们正在尝试从通道读取消息,但由于消息到达较晚,我们执行了默认情况的代码。

列表 8.3 从通道进行非阻塞读取

package main

import (
    "*fmt*"
    "*time*"
)

func sendMsgAfter(seconds time.Duration) <-chan string {
    messages := make(chan string)
    go func() {
        time.Sleep(seconds)
        messages <- "*Hello*"
    }()
    return messages
}

func main() {
    messages := sendMsgAfter(3 * time.Second)         ❶
    for {
        select {
        case msg := <-messages:                       ❷
            fmt.Println("*Message received:*", msg)
            return                                    ❸
        default:                                      ❹
            fmt.Println("*No messages waiting*")
            time.Sleep(1 * time.Second)
        }
    }
}

❶ 在 3 秒后发送通道消息

❷ 如果有消息,则从通道读取消息

❸ 当有消息可用时,终止执行

❹ 当没有消息可用时,执行默认情况。

在前面的列表中,由于我们在循环中有select语句,默认情况会反复执行,直到我们收到消息。当发生这种情况时,我们会打印消息并在main()函数中返回,终止程序。以下是输出:

$ go run nonblocking.go
No messages waiting
No messages waiting
No messages waiting
Message received: Hello

8.1.3 在默认情况下执行并发计算

一个有用的场景是使用默认的 select 情况来执行并发计算,然后使用通道来表示我们需要停止。为了说明这个概念,假设我们有一个示例应用程序,它将通过暴力破解来发现遗忘的密码。为了使事情简单,让我们假设我们有一个受密码保护的文件,我们知道它的密码是六位或更少,只使用小写字母az和空格。

从"a"到"zzzzzz",包括空格,可能的字符串数量是 27 的 6 次方减 1(387,420,488)。下面的列表中的函数为我们提供了一种将 1 到 387,420,488 的整数转换为字符串的方法。例如,调用toBase27(1)会得到"a",调用它时使用2会得到"b",28会得到"aa",依此类推。

列表 8.4 枚举字符串的所有可能组合

package main

import (
    "*fmt*"
    "*time*"
)

const (
    passwordToGuess = "*go far*"                      ❶
    alphabet = " *abcdefghijklmnopqrstuvwxyz*"        ❷
)

func toBase27(n int) string {
    result := ""
    for n > 0 {                                     ❸
        result = string(alphabet[n%27]) + result    ❸
        n /= 27                                     ❸
    }
    return result
}

❶ 设置我们需要猜测的密码

❷ 定义密码可能由的所有可能字符

❸ 算法使用字母常量将十进制整数转换为基 27 的字符串

如果我们不得不在顺序程序中使用暴力方法,我们就会创建一个循环,枚举从"a"到"zzzzzz"的所有字符串,并且每次都会检查它是否与变量passwordToGuess匹配。在现实生活中,我们不会有密码的值;相反,我们会尝试使用每个字符串枚举作为密码来访问我们的资源(例如文件)。

为了更快地找到我们的密码,我们可以将猜测的范围分配给几个 goroutine。例如,goroutine A 将尝试从字符串枚举 1 到 1000 万,goroutine B 将尝试从 1000 万到 2000 万,依此类推(见图 8.4)。这样,我们可以有多个 goroutine,每个 goroutine 都在我们问题空间的不同部分工作。

图片

图 8.4 在执行之间分配工作并关闭通道以停止它们

为了避免不必要的计算,我们希望在任何一个 goroutine 做出正确猜测时停止每个 goroutine 的执行。为了实现这一点,我们可以使用一个通道来通知所有其他 goroutine 当一个执行发现密码时,如图 8.4 所示。一旦一个 goroutine 找到匹配的密码,它就关闭一个公共通道。这会中断所有参与 goroutine 并停止处理。

注意 我们可以在通道上使用close()操作来向所有消费者广播信号。

我们如何实现关闭公共通道后停止所有 goroutine 处理的逻辑?一个解决方案是在select语句的默认情况下执行必要的计算,然后有一个等待公共通道的另一个情况。在我们的例子中,我们可以调用我们的toBase27()函数并在默认情况下尝试猜测密码,每次只猜测一个密码。我们可以将停止生成和尝试密码的逻辑放在一个单独的select情况中,这将触发当公共通道关闭时。

列表 8.5 显示了一个接受这个公共通道,称为stop的函数。在函数中,我们生成给定范围的密码猜测,这些范围由fromupto整数变量表示。每次我们生成下一个密码猜测时,我们尝试将其与passwordToGuess常量匹配。这模拟了程序尝试访问受密码保护的资源。一旦密码匹配,函数关闭通道,导致所有 goroutine 在自己的select情况下收到关闭消息,并由于return语句而停止处理。

列表 8.5 强制力密码发现 goroutine

func guessPassword(from int, upto int, stop chan int, result chan string) {
    for guessN := from; guessN < upto; guessN += 1 {                       ❶

        select {

        case <-stop:                                                       ❷
            fmt.Printf("*Stopped at %d %d,%d)\n*", guessN, from, upto)
            return

        default:
            if toBase27(guessN) == passwordToGuess {                       ❸
                result <- toBase27(guessN)                                 ❹
                close(stop)                                                ❺
                return
            }
        }
    }
    fmt.Printf("*Not found between [%d,%d)\n*", from, upto)
}

❶ 使用 from 和 upto 作为起始点和结束点循环遍历所有密码组合

❷ 在收到停止通道的消息后,输出一条消息并停止处理

❸ 检查密码是否匹配(在现实生活中的系统中,我们会尝试访问受保护的资源)

❹ 在结果通道上发送匹配的密码

❺ 关闭通道,以便其他 goroutine 停止检查密码

我们现在可以创建几个执行前面列表的 goroutine。每个 goroutine 将尝试在特定范围内找到正确的密码。在下面的列表中,main()函数创建了必要的通道,并以每步 1000 万为单位启动所有 goroutine。

列表 8.6 main() 函数创建具有各种密码范围的多个 goroutine

func main() {
    finished := make(chan int)                                          ❶

    passwordFound := make(chan string)                                  ❷

    for i := 1; i <= 387_420_488; i += 10_000_000 {                     ❸
        go guessPassword(i, i+ 10_000_000, finished, passwordFound)     ❸
    }                                                                   ❸

    fmt.Println("*password found:*", <-passwordFound)                     ❹
    close(passwordFound)
    time.Sleep(5 * time.Second)                                         ❺
}

❶ 创建一个在 goroutine 中使用,用于在找到密码时发出信号的公共通道

❷ 创建一个通道,在找到密码后它将包含找到的密码

❸ 创建一个输入范围为[1, 10M), [10M, 20M), ... [380M, 390M)的 goroutine

❹ 等待找到密码

❺ 使用密码模拟程序访问资源

在启动所有 goroutine 后,main()函数等待passwordFound通道上的输出消息。一旦某个 goroutine 发现正确的密码,它将向main()函数发送密码到其result通道。当我们一起运行所有列表时,我们得到以下输出:

Not found between [1,10000001)
Stopped at 277339743 [270000001,280000001)
Stopped at 267741962 [260000001,270000001)
Stopped at 147629035 [140000001,150000001)
. . .
password found: go far
Stopped at 378056611 [370000001,380000001)
Stopped at 217938567 [210000001,220000001)
Stopped at 357806660 [350000001,360000001)
Stopped at 287976025 [280000001,290000001)
. . .

8.1.4 通道超时

另一个有用的场景是仅阻塞指定的时间,等待通道上的操作。就像在前两个例子中一样,我们想要检查通道上是否收到了消息,但我们想等待几秒钟看看是否收到消息,而不是立即解除阻塞并做其他事情。这在许多通道操作对时间敏感的情况下很有用。例如,考虑一个金融交易应用程序,如果我们没有在时间窗口内收到股票价格更新,我们需要发出警报。

我们可以通过使用一个单独的 goroutine,在指定超时后向额外的通道发送消息来实现这种行为。然后我们可以使用这个额外的通道在我们的select语句中,与其他通道一起使用。这将给我们阻塞在select语句上的效果,直到任何通道变得可用或超时发生(见图 8.5)。

![

图 8.5 使用定时器在通道上发送消息以实现带超时的阻塞

幸运的是,Go 中的time.Timer类型为我们提供了这个功能,我们不需要实现自己的定时器 goroutine。我们可以通过调用time.After(duration)来创建这样的定时器。这将返回一个在持续时间过后发送消息的通道。以下列表展示了我们如何使用select语句与这个通道结合来实现带超时的通道阻塞。

列表 8.7 带超时的阻塞

package main

import (
    "*fmt*"
    "*os*"
    "*strconv*"
    "*time*"
)

func sendMsgAfter(seconds time.Duration) <-chan string {     ❶
    messages := make(chan string)
    go func() {
        time.Sleep(seconds)
        messages <- "*Hello*"
    }()
    return messages
}

func main() {
    t, _ := strconv.Atoi(os.Args[1])                         ❷
    messages := sendMsgAfter(3 * time.Second)                ❸
    timeoutDuration := time.Duration(t) * time.Second
    fmt.Printf("Waiting for message for %d seconds...\n", t)
    select {
    case msg := <-messages:                                  ❹
        fmt.Println("*Message received:*", msg)
    case tNow := <-time.After(timeoutDuration):              ❺
        fmt.Println("*Timed out. Waited until:*", tNow.Format("*15:04:05*"))
    }
}

❶ 在指定秒数后,在返回的通道上发送“Hello”消息

❷ 从程序参数中读取超时值

❸ 启动一个 goroutine,在 3 秒后向返回的通道发送消息

❹ 如果有消息,则从messages通道读取消息

❺ 创建一个通道和定时器,在指定持续时间后接收消息

列表 8.7 接受一个超时值作为程序参数。我们使用这个超时来等待messages通道上的消息到达,该消息在 3 秒后到达。以下是当我们指定小于 3 秒的超时时,这个程序的输出:

$ go run selecttimer.go 2
Waiting for message for 2 seconds...
Timed out. Waited until: 16:31:50

当我们指定大于 3 秒的超时时,消息如预期那样到达:

$ go run selecttimer.go 4
Waiting for message for 4 seconds...
Message received: Hello

当我们使用time.After(duration)调用时,返回的通道将接收到一个包含消息发送时间的消息。在列表 8.7 中,我们只是简单地输出了它。

8.1.5 使用 select 向通道写入

我们也可以在需要向通道写入消息时使用select语句,而不仅仅是当我们从通道读取消息时。Select语句可以组合读取或写入阻塞通道操作,选择首先解锁的情况。与先前的场景一样,我们可以使用select来实现非阻塞通道发送或带有超时的通道发送。让我们演示一个在单个select语句中结合写入和读取通道的场景。

假设我们必须生成 100 个随机质数。在现实生活中,我们可以从一个包含大量数字的袋子中随机抽取一个数字,然后只有当它是质数时才保留该数字(见图 8.6)。

图 8.6 从随机数中过滤质数

在编程中,我们可以有一个质数过滤器,给定一个随机数流,从中挑选出任何找到的质数,并将其输出到另一个流。在列表 8.8 中,primesOnly()函数正是这样做的:它接受一个包含输入数字的通道,并过滤出质数。质数输出在返回的通道上。

为了证明一个数,C,是非质数,我们只需要在 2 到C的平方根范围内找到一个质数,它是C的因子。一个因子是除另一个数时没有余数的数。如果不存在这样的因子,那么C就是质数。为了使我们的primesOnly()函数实现简单,我们将检查这个范围内的每一个整数,而不是检查每一个质数。

列表 8.8 质数 Goroutine 过滤

package main

import (
    "*fmt*"
    "*math*"
    "*math/rand*"
)

func primesOnly(inputs <-chan int) <-chan int {                   ❶
    results := make(chan int)
    go func() {                                                   ❷
        for c := range inputs {
            isPrime := c != 1                                     ❸
            for i := 2; i <= int(math.Sqrt(float64(c))); i++ {    ❹
                if c%i == 0 {                                     ❹
                    isPrime = false                               ❹
                    break
                }
            }
            if isPrime {                                          ❺
                results <- c                                      ❺
            }
        }
    }()
    return results
}

❶ 接受输入通道中的数字并返回只包含质数的通道

❷ 创建一个只过滤质数的匿名 goroutine

❸ 检查以确保 c 不是 1,因为 1 不是质数

❹ 检查 c 在 2 到 c 的平方根范围内是否有因子

❺ 如果 c 是质数,则在结果通道上输出 c

注意,在列表 8.8 中,我们的 goroutine 输出它在输入通道上接收到的数字的一个子集。通常,goroutine 会接收到一个非质数,然后被丢弃,这意味着没有数字被输出。我们如何在单个 goroutine 中同时读取另一个通道上返回的质数并输入一个随机数流?答案是使用select语句来同时输入随机数和读取质数。这在下述列表中显示,其中main() goroutine 使用了两个select情况:一个用于输入随机数,另一个用于读取质数。

列表 8.9 输入随机数并收集 100 个质数

func main() {
    numbersChannel := make(chan int)
    primes := primesOnly(numbersChannel)
    for i := 0; i < 100; {                                 ❶
        select {
        case numbersChannel <- rand.Intn(1000000000) + 1:  ❷
        case p := <-primes:                                ❸
            fmt.Println("*Found prime:*", p)
            i++
        }
    }
}

❶ 重复直到我们收集到 100 个质数

❷ 将 1 亿之间的随机数输入到 isPrimeChannel 输入通道

❸ 读取输出质数

在列表 8.9 中,我们继续执行,直到收集到 100 个质数。运行此代码后,我们得到以下输出:

$ go run selectsender.go
Found prime: 646203301
Found prime: 288845803
Found prime: 265690541
Found prime: 263958077
Found prime: 280061603
Found prime: 214167823
. . .

8.1.6 使用 nil 通道禁用 select 情况

在 Go 中,我们可以将nil值赋给通道。这会阻止通道发送或接收任何内容,如下面的列表所示。main() goroutine 尝试在一个 nil 通道上发送一个字符串,操作会阻塞,阻止任何进一步语句的执行。

列表 8.10 在 nil 通道上阻塞

package main

import "*fmt*"

func main() {
    var ch chan string = nil              ❶
    ch <- "*message*"                       ❷
    fmt.Println("*This is never printed*")
}

❶ 创建一个 nil 通道

❷ 尝试在 nil 通道上发送消息时阻塞执行

当我们运行列表 8.10 时,Println()命令永远不会被执行,因为执行在消息发送上阻塞。Go 有死锁检测,所以当 Go 注意到程序陷入僵局且没有恢复的希望时,它会给我们以下信息:

$ go run blockingnils.go
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send (nil chan)]:
main.main()
  /ConcurrentProgrammingWithGo/chapter8/listing8.10/blockingnils.go:7 +0x28
exit status 2

同样的逻辑也适用于select语句。在select语句中尝试向 nil 通道发送或从 nil 通道接收会导致使用该通道的 case 阻塞(见图 8.7)。

图片

图 8.7 在 nil 通道上阻塞

使用只有一个 nil 通道的select并不那么有用,但我们可以使用将nil赋给通道的模式来在select语句中禁用一个 case。考虑一个场景,我们正在从两个不同的 goroutine 和两个不同的通道中消费消息,这些 goroutine 在不同的时间关闭它们的通道。

例如,我们可能正在开发会计软件,该软件从各种来源接收销售和费用金额。在业务结束时,我们希望输出当天的总利润或亏损。我们可以通过让一个 goroutine 在一个通道上输出销售详情,另一个 goroutine 在另一个通道上输出费用详情来模拟这种情况。然后我们可以在另一个 goroutine 中汇总这两个来源,一旦两个通道都关闭,就可以将日终余额输出给用户(见图 8.8)。

图片

图 8.8 一个从两个来源读取销售和费用的会计应用程序

列表 8.11 模拟我们的费用和销售应用程序。generateAmounts()函数将创建 n 个随机交易金额并将它们发送到输出通道。然后我们可以调用这个函数两次,一次用于销售,再次用于费用,我们的主 goroutine 可以合并这两个通道。

在循环中有一个小的睡眠,这样我们就可以交错销售和费用 goroutine。

列表 8.11 生成销售和费用的generateAmounts()函数

package main

import (
    "*fmt*"
    "*math/rand*"
    "*time*"
)

func generateAmounts(n int) <-chan int {
    amounts := make(chan int)                    ❶
    go func() {
        defer close(amounts)                     ❷
        for i := 0; i < n; i++ {                 ❸
            amounts <- rand.Intn(100) + 1        ❸
            time.Sleep(100 * time.Millisecond)   ❸
        }
    }()
    return amounts                               ❹
}

❶ 创建一个输出通道

❷ 完成后关闭输出通道

❸ 每 100 毫秒将 n 个随机金额写入[1, 100]范围内的输出通道

❹ 返回输出通道

如果我们使用正常的select语句从销售和费用 goroutine 中消费,其中一个 goroutine 比另一个 goroutine 先关闭其通道,我们最终总是在关闭通道的 case 上执行。每次我们从关闭的通道中消费时,它都会返回默认数据类型而不会阻塞。这也适用于 select cases。在我们的简单会计应用程序中,如果我们使用select语句从两个来源中消费,我们最终会在关闭通道的 select case 上无谓地循环,每次都接收到0(见图 8.9)。

警告:当我们在一个关闭的通道上使用 select case 时,该 case 将始终执行。

图片

图 8.9 使用带有关闭通道的 select case 将导致该 select case 始终执行。

解决此问题的一种方法是将销售和费用 goroutine 的输出都发送到同一个通道,然后在两个 goroutine 都完成后关闭通道。然而,这不一定总是可行的,因为这需要我们更改 goroutine 函数的签名,以便我们可以将相同的输出通道传递给两个来源。有时,例如在使用第三方库时,更改函数的签名是不可能的。

另一种解决方案是在通道关闭时将其更改为nil通道。从通道读取总是返回两个值:消息和一个标志告诉我们通道是否仍然开放。我们可以读取标志,如果标志指示通道已关闭,我们可以将通道引用设置为nil(见图 8.10)。

图片

图 8.10 当通道关闭时分配一个 nil 通道以禁用 select case

接收器检测到通道已关闭后,将通道变量赋值为nil,这将禁用该case语句。这允许接收 goroutine 从剩余的开放通道中读取。

列表 8.12 展示了我们如何在我们的会计应用程序中使用这种nil通道模式。在main() goroutine 中,我们初始化销售和费用来源,然后使用select语句从两者中消费。如果任一通道返回一个标志表示通道已关闭,我们将通道设置为nil以禁用 select case。只要有一个非空通道,我们就继续从通道中选择。

列表 8.12 使用nil select 模式的main() goroutine

func main() {
    sales := generateAmounts(50)                ❶
    expenses := generateAmounts(40)             ❷
    endOfDayAmount := 0
    for sales != nil || expenses != nil {       ❸
        select {
        case sale, moreData := <-sales:         ❹
            if moreData {
                fmt.Println("*Sale of:*", sale)
                endOfDayAmount += sale          ❺
            } else {
                sales = nil                     ❻
            }
        case expense, moreData := <-expenses:   ❼
            if moreData {
                fmt.Println("*Expense of:*", expense)
                endOfDayAmount -= expense       ❽
            } else {
                expenses = nil                  ❾
            }
        }
    }
    fmt.Println("*End of day profit and loss:*", endOfDayAmount)
}

❶ 在销售通道上生成 50 个金额

❷ 在费用通道上生成 40 个金额

❸ 当存在非空通道时继续循环

❹ 从销售通道中消费下一个金额和通道开放标志

❺ 将销售金额添加到每日结束时的总余额中

❻ 如果通道已关闭,将通道标记为nil,禁用此 select case

❽ 从费用通道中消费下一个金额和通道开放标志

❽ 从每日结束时的总余额中减去费用金额

❾ 如果通道已被关闭,则将通道标记为 nil,禁用此 select 情况

在列表 8.12 中,一旦两个通道都已关闭并设置为 nil,我们就退出 select 循环并输出日终余额。将列表 8.11 和 8.12 一起运行,我们得到销售和费用金额交替出现,直到我们消耗完所有费用并且通道已关闭。在此阶段,select 语句清空销售通道,然后退出循环,打印总余额:

$ go run selectwithnil.go
Expense of: 82
Sale of: 88
Sale of: 48
Expense of: 60
Sale of: 82
. . .
Sale of: 34
Sale of: 44
Sale of: 92
Sale of: 3
End of day profit and loss: 387

注意:将通道数据合并到一个流中的这种模式被称为 扇入 模式。使用 select 语句合并不同的来源仅在我们有固定数量的来源时才有效。在下一章中,我们将看到一种扇入模式,该模式可以合并动态数量的来源。

8.2 在消息传递和内存共享之间进行选择

我们可以根据我们试图实现的解决方案类型来决定是否使用内存共享或消息传递来构建我们的并发应用程序。在本节中,我们将检查我们在决定使用两种方法中的哪一种时应考虑的因素和影响。

8.2.1 平衡代码的简洁性

在当今复杂的企业需求和大型开发团队中,生产简单、可读且易于维护的软件代码变得越来越重要。使用消息传递进行并发编程往往会产生包含定义良好的模块的代码,每个模块运行自己的并发执行,并将消息传递给其他执行。这使得代码更简单,更容易理解。此外,具有清晰的输入和输出通道到并发执行意味着我们的程序数据流更容易掌握,如果需要,也更容易修改。

相比之下,内存共享意味着我们需要使用更原始的方式来管理并发。就像阅读低级语言一样,使用并发原语(如互斥锁和信号量)的代码往往更难跟踪。代码通常更冗长,并充满了受保护的临界区。与消息传递不同,确定数据如何通过应用程序流动更困难(参见图 8.11)。

图片

图 8.11 在代码简洁性和性能之间取得正确的平衡

8.2.2 设计紧密耦合与松散耦合的系统

术语紧密耦合和松散耦合软件指的是不同模块之间相互依赖的程度。紧密耦合软件意味着当我们更改一个组件时,它将对软件的许多其他部分产生连锁反应,通常这些部分也需要进行更改。在松散耦合软件中,组件往往具有清晰的边界,对其他模块的依赖性很少。在松散耦合软件中,对某个组件的更改只需要对其他组件进行少量或没有更改(见图 8.12)。松散耦合通常是软件设计目标和一个理想的代码属性。这意味着我们的软件更容易测试和更易于维护,在引入新功能时所需的工作量更少。

图 8.12 紧密耦合和松散代码耦合的区别

使用内存共享进行并发编程通常会产生更紧密耦合的软件。线程间的通信使用一个共同的内存块,每个执行的边界并不明确。任何执行都可以读取和写入同一位置。在使用内存共享的同时编写松散耦合的软件比使用消息传递更困难,因为更改一个执行更新共享内存的方式将对整个应用程序产生重大影响。

相比之下,使用消息传递,执行可以具有明确定义的输入和输出契约,这意味着我们知道一个执行中的更改将如何影响另一个执行。例如,如果我们通过通道维护输入和输出契约,我们可以轻松地更改 goroutine 的内部逻辑。这使我们更容易构建松散耦合的系统,并且在一个模块中重构逻辑不会对应用程序的其他部分产生大的连锁反应。

注意:这并不是说所有使用消息传递的代码都是松散耦合的。也不是所有使用内存共享的软件都是紧密耦合的。只是使用消息传递来设计松散耦合的方案更容易,因为我们可以为每个并发执行定义简单的边界,并具有清晰的输入和输出通道。

8.2.3 优化内存消耗

使用消息传递时,每个 goroutine 都有其自己的隔离状态,存储在内存中。当我们从一个 goroutine 向另一个 goroutine 传递消息时,每个 goroutine 都会在其内存中组织数据以计算其任务。通常,在多个 goroutine 之间会有相同数据的复制。

例如,考虑我们在第三章中实现的字母频率应用。在我们的实现中,我们使用了在 goroutines 之间共享的 Go 切片。程序使用并发 goroutines 下载网页,并使用这个共享切片来存储下载文档中每个英文字母出现的次数(见图 8.13 的左侧)。我们可以将程序修改为使用消息传递,让每个 goroutine 在下载其网页时构建一个包含遇到频率的本地切片实例。在计算字母频率后,每个 goroutine 会在输出通道上发送一个包含结果的切片消息。然后,在我们的main()函数中,我们可以收集这些结果并将它们合并(见图 8.13 的右侧)。

图片

图 8.13 消息传递可能导致内存消耗增加。

列表 8.13 显示了如何实现一个 goroutine,该 goroutine 下载网页并计算字母表中每个字母的出现次数。它有自己的本地切片数据结构,而不是共享的。

一旦完成,它将结果发送到其输出通道。

列表 8.13 使用消息传递的字母频率函数(省略了导入)

package main

import (...)

const allLetters = "*abcdefghijklmnopqrstuvwxyz*"

func countLetters(url string) <-chan []int {
    result := make(chan []int)                    ❶
    go func() {
        defer close(result)
        frequency := make([]int, 26)              ❷
        resp, _ := http.Get(url)
        defer resp.Body.Close()
        if resp.StatusCode != 200 {
            panic("*Server returning error code:* " + resp.Status)
        }
        body, _ := io.ReadAll(resp.Body)
        for _, b := range body {
            c := strings.ToLower(string(b))
            cIndex := strings.Index(allLetters, c)
            if cIndex >= 0 {
                frequency[cIndex] += 1            ❸
            }
        }
        fmt.Println("*Completed:*", url)
        result <- frequency                       ❹
    }()
    return result
}

❶ 创建一个包含 int 切片类型的输出通道

❷ 创建一个本地频率切片

❸ 更新本地频率切片中的每个字符计数

❹ 一旦完成,频率切片将通过通道发送。

我们现在可以添加一个main()函数,为每个网页启动一个 goroutine,并等待每个输出通道的消息。一旦我们开始接收包含切片的消息,我们就可以将它们合并到一个最终的切片中。下面的列表显示了我们可以如何做到这一点,将每个切片累加到totalFrequencies切片中。

列表 8.14 消息传递字母频率程序的主函数

func main() {
    results := make([]<-chan []int, 0)                                ❶
    totalFrequencies := make([]int, 26)                               ❷
    for i := 1000; i <= 1030; i++ {
        url := fmt.Sprintf("*https://rfc-editor.org/rfc/rfc%d.txt*", i)
        results = append(results, countLetters(url))                  ❸
    }
    for _, c := range results {                                       ❹
        frequencyResult := <-c                                        ❺
        for i := 0; i < 26; i++ {                                     ❻
            totalFrequencies[i] += frequencyResult[i]                 ❻
        }
    }
    for i, c := range allLetters {
        fmt.Printf("*%c-%d* ", c, totalFrequencies[i])
    }
}

❶ 创建一个包含所有输出通道的切片

❷ 创建一个切片来存储英文字母的频率

❸ 为每个网页创建一个 goroutine 并将输出通道存储在结果切片中

❹ 遍历每个输出通道

❺ 从每个输出通道接收包含一个网页频率的消息

❻ 将频率计数添加到每个字母的总频率中

在将我们的程序转换为使用消息传递时,我们避免了使用互斥锁来控制对共享内存的访问,因为每个 goroutine 现在只在自己的数据上工作。然而,这样做增加了内存使用,因为我们为每个网页分配了一个切片。对于这个简单的应用,内存增加是微不足道的,因为我们只使用了一个大小为 26 的小切片。对于传递包含大量数据的结构的应用,我们可能更倾向于使用内存共享来减少内存消耗。

8.2.4 高效通信

如果我们在传递消息上花费太多时间,消息传递将降低我们应用程序的性能。由于我们从 goroutine 到 goroutine 传递消息的副本,我们承受了复制消息中数据的时间性能损失。如果消息很大或数量很多,这种额外的性能成本是明显的。

一种情况是当消息大小太大时。例如,考虑一个图像或视频处理应用程序,它对图像进行并发处理,应用各种过滤器。仅仅为了通过通道传递,就复制包含图像或视频的大量内存块,可能会大大降低我们的性能。如果共享的数据量很大,并且我们有性能限制,我们可能更倾向于使用内存共享。

另一种情况是我们的执行非常频繁——当并发执行需要相互发送大量消息时。例如,我们可以想象一个使用并发编程来加速其天气预报的天气预报应用程序。图 8.14 显示了我们可以如何将天气预报区域分割成网格,并将预测每个网格方块天气的计算工作分配给单独的 goroutine。

图片

图 8.14 使用并发执行加速天气预报

要计算每个网格方块中的天气预报,goroutine 可能需要从所有其他网格的计算中获取信息。每个 goroutine 可能需要从所有其他 goroutine 发送和接收部分计算结果,这个过程可能需要重复多次,直到预测计算收敛。我们编写的算法,在每个 goroutine 中运行,可能看起来像这样:

  1. 为 goroutine 的网格方块计算部分结果。

  2. 将部分结果发送给所有其他 goroutine,每个 goroutine 都在其自己的网格方块上工作。

  3. 从每个其他 goroutine 接收部分结果,并将它们包含在下一个计算中。

  4. 1开始重复,直到计算完全完成。

在这种情况下使用消息传递意味着我们会在每次迭代中发送大量的消息。每个 goroutine 都必须将其部分结果发送给所有其他 goroutine,然后从每个 goroutine 接收其他网格的结果。在这种情况下,我们的应用程序最终会花费大量时间和内存来复制和传递值。

在这种情况下,我们可能更倾向于使用内存共享。例如,我们可以分配一个共享的二维数组空间,并让 goroutines 读取彼此的网格结果,使用适当的同步工具,如读者-写者锁。

8.3 练习

注意:访问github.com/cutajarj/ConcurrentProgrammingWithGo以查看所有代码解决方案。

  1. 在列表 8.15 中,我们有两个 goroutine。generateTemp()函数模拟每 200 毫秒在通道上读取和发送温度。outputTemp()函数简单地每 2 秒在通道上输出一条消息。你能写一个main()函数,使用select语句读取来自generateTemp() goroutine 的消息,并将最新的温度发送到outputTemp()通道吗?由于generateTemp()函数输出值的速度比outputTemp()函数快,你需要丢弃一些值,以确保只显示最新的温度。

    列表 8.15 最新温度练习

    package main
    
    import (
        "*fmt*"
        "*math/rand*"
        "*time*"
    )
    
    func generateTemp() chan int {
        output := make(chan int)
        go func() {
            temp := 50 *//fahrenheit*
            for {
                output <- temp
                temp += rand.Intn(3) - 1
                time.Sleep(200 * time.Millisecond)
            }
        }()
        return output
    }
    
    func outputTemp(input chan int) {
        go func() {
            for {
                fmt.Println("*Current temp:*", <-input)
                time.Sleep(2 * time.Second)
            }
        }()
    }
    
  2. 在列表 8.16 中,generateNumbers()函数中有一个 goroutine 输出随机数。你能写一个使用select语句的main()函数,持续从输出通道中消费,直到程序开始后的 5 秒内打印到控制台上的输出吗?5 秒后,函数应停止从输出通道中消费,程序应终止。

    列表 8.16 停止读取 5 秒练习

    package main
    
    import (
        "*math/rand*"
        "*time*"
    )
    
    func generateNumbers() chan int {
        output := make(chan int)
        go func() {
            for {
                output <- rand.Intn(10)
                time.Sleep(200 * time.Millisecond)
            }
        }()
        return output
    }
    
  3. 考虑包含player()函数的列表 8.17。此函数创建一个 goroutine 模拟在二维平面上移动的游戏玩家。goroutine 通过在输出通道上写入UPDOWNLEFTRIGHT在随机时间返回移动。创建一个main()函数,创建四个玩家 goroutine,并在控制台上输出四个玩家的所有移动。main()函数应在游戏中只剩下一个玩家时终止。以下是一个输出示例:

    Player 1: DOWN
    Player 0: LEFT
    Player 3: DOWN
    Player 2 left the game. Remaining players: 3
    Player 1: UP
    . . .
    Player 0: LEFT
    Player 3 left the game. Remaining players: 2
    Player 1: RIGHT
    . . .
    Player 1: RIGHT
    Player 0 left the game. Remaining players: 1
    Game finished
    

    列表 8.17 模拟游戏玩家

    package main
    
    import (
        "*fmt*"
        "*math/rand*"
        "*time*"
    )
    
    func player() chan string {
        output := make(chan string)
        count := rand.Intn(100)
        move := []string{"*UP*", "*DOWN*", "*LEFT*", "*RIGHT*"}
        go func() {
            defer close(output)
            for i := 0; i < count; i++ {
                output <- move[rand.Intn(4)]
                d := time.Duration(rand.Intn(200))
                time.Sleep(d * time.Millisecond)
            }
        }()
        return output
    }
    

摘要

  • 当使用select语句组合多个通道操作时,首先解除阻塞的操作将被执行。

  • 我们可以通过在select语句中使用默认情况,在阻塞通道上实现非阻塞行为。

  • select语句中将发送或接收通道操作与Timer通道结合会导致在指定超时时间内阻塞通道。

  • select语句不仅可以用于接收消息,还可以用于发送。

  • 尝试向或从 nil 通道发送或接收消息会导致执行阻塞。

  • 当我们使用 nil 通道时,可以禁用select情况。

  • 消息传递产生更简单、更容易理解的代码。

  • 紧密耦合的代码会导致难以添加新功能的应用程序。

  • 以松散耦合方式编写的代码更容易维护。

  • 使用消息传递的松散耦合软件通常比使用内存共享更简单、更易读。

  • 使用消息传递的并发应用程序可能会消耗更多内存,因为每个执行都有自己的独立状态,而不是共享状态。

  • 需要交换大量数据的同时运行的应用程序可能更适合使用内存共享,因为为消息传递而复制这些数据可能会极大地降低性能。

  • 内存共享更适合那些如果使用消息传递将会交换大量消息的应用程序。

9 使用通道编程

本章涵盖

  • 介绍通信顺序进程

  • 重复使用常见的通道模式

  • 利用通道作为一等对象的优势

使用通道需要与使用内存共享不同的编程方式。想法是有一组 goroutines,每个 goroutine 都有自己的内部状态,通过在 Go 的通道上传递消息与其他 goroutines 交换信息。这样,每个 goroutine 的状态就与来自其他执行的直接干扰隔离,从而降低了竞态条件的风险。

Go 的自身格言不是通过共享内存进行通信,而是通过通信来共享内存。由于内存共享更容易出现竞态条件并需要复杂的同步技术,因此我们应该尽可能避免它,而改用消息传递。

在本章中,我们将首先讨论通信顺序进程(CSP),然后转向查看在使用通道进行消息传递时使用的常见模式。我们将通过展示将通道视为一等对象的价值来结束本章,这意味着我们可以将通道作为函数参数传递,并将它们作为函数返回类型接收。

9.1 通信顺序进程

在前面的章节中,我们讨论了使用 goroutines、共享内存和互斥锁、条件变量、信号量等原语来建模并发性的模型。这是建模并发的经典方式。对这个模型的主要批评是,对于许多应用程序来说,它太低级了。

SRC 模型

使用共享内存与并发原语,如互斥锁,有时被称为SRC 模型。该名称来源于 Andrew D. Birrell 的一篇论文,标题为“使用线程进行编程的介绍”(系统研究中心,1989 年)。这篇论文是关于并发编程的流行介绍,使用共享内存的线程,并使用并发原语进行同步。

使用低级并发模型进行编程意味着,作为程序员,我们需要更努力地管理复杂性并减少软件中的错误。我们不知道操作系统何时会调度执行线程,这创造了一个非确定性的环境——指令在没有事先知道执行顺序的情况下交错。这种非确定性,加上内存共享,创造了竞态条件的可能性。为了避免这些,我们必须跟踪哪些执行正在同时访问内存,并且我们需要使用互斥锁或信号量等同步原语来限制这种访问。

使用这种低级工具进行并发编程,当与现代软件开发团队和不断增长的业务复杂性相结合时,会导致存在错误、复杂且维护成本高的代码。包含竞态条件的软件难以调试,因为竞态条件难以重现和测试。在某些行业和应用中,如健康和基础设施软件,代码可靠性至关重要(见图 9.1)。对于这些应用,由于其非确定性,很难证明以这种方式编写的并发代码是正确的。

图 9.1 对于关键应用来说,证明软件的正确性非常重要。

9.1.1 避免与不可变性冲突

一种大大降低竞态条件风险的方法是不允许我们的程序从多个并发执行中修改相同的内存。我们可以通过在共享内存时使用不可变概念来限制这一点。

不可变性的定义字面意思是 不可改变。在计算机编程中,我们在初始化结构时使用不可变性,不提供任何修改它们的方式。当编程需要更改这些结构时,我们创建一个新的结构副本,包含所需的更改,而保留旧副本不变。

如果我们的执行线程只共享包含永不更新的数据的内存,我们可以确信没有数据竞态条件。毕竟,大多数竞态条件发生是因为多个执行同时写入相同的内存位置。如果一个执行需要修改共享数据,例如一个变量,它可以创建一个包含所需更新的单独、局部副本,而保留旧副本不变。

当我们需要更新共享数据时创建副本,这会给我们留下一个问题:我们如何共享现在位于内存中不同位置的新、更新后的数据?我们需要一个模型来管理和共享这种新、修改后的数据。这就是消息传递和 CSP 发挥作用的地方。

9.1.2 使用 CSP 进行并发编程

C.A.R Hoare 在其 1978 年的文章“Communicating Sequential Processes”(www.cs.cmu.edu/~crary/819-f09/Hoare78.pdf)中提出了一个不同层次、更高级的并发模型。CSP,即 communicating sequential processes 的缩写,是一种用于描述并发系统的形式化语言。它不是通过共享内存,而是基于通过通道的消息传递来实现。CSP 的思想和概念已被用于 Erlang、Occam、Go、Scala 的 Akka 框架、Clojure 的 core.async 以及许多其他编程语言和框架中的并发模型。

在 CSP(Communicating Sequential Processes)中,进程通过交换值的副本相互通信。通信是通过命名的不带缓冲的通道完成的。CSP 进程不应与操作系统进程(我们在第二章中讨论过的)混淆;相反,CSP 进程是一种顺序执行,它具有自己的独立状态,如图 9.2 所示。

图 9.2 一个顺序进程与其他进程通信

使用 CSP 模型时的关键区别在于执行不会共享内存。相反,它们相互传递数据的副本。就像使用不可变性一样,如果每个执行都没有修改共享数据,就没有干扰的风险,因此我们避免了大多数竞态条件。如果每个执行都有自己的独立状态,我们可以消除数据竞态条件,而无需使用涉及互斥锁、信号量或条件变量的复杂同步逻辑。

Go 通过 goroutines 和通道实现此模型。就像在 CSP 模型中一样,Go 的通道默认是同步且不带缓冲的。CSP 模型与 Go 实现之间的一个关键区别是,在 Go 中,通道是一等对象,这意味着我们可以在函数或甚至在其他通道中传递它们。这为我们提供了更多的编程灵活性。我们不是创建连接的顺序进程的静态拓扑,而是可以根据我们的逻辑需求在运行时创建和删除通道。

其他语言中的 CSP

许多其他语言实现了 CSP 模型的一些方面。例如,在 Erlang 中,进程通过发送消息相互通信。然而,在 Erlang 中,没有通道的概念,发送的消息也不是同步的。

在 Java 和 Scala 中,Akka 框架使用 Actor 模型。这是一个执行单元被称为actors的消息传递框架。Actors 有自己的独立内存空间,并相互传递消息。与 CSP 不同,没有通道的概念,消息传递也不是同步的。

9.2 使用通道重用常见模式

当我们在 Go 中使用通道进行消息传递时,有两个主要指南需要遵循:

  • 尽量只在通道上传递数据的副本。这暗示了在大多数情况下,你不应该在通道上传递直接指针。传递指针可能导致多个 goroutines 共享内存,从而产生竞态条件。如果你必须传递指针引用,以不可变的方式使用数据结构——一次性创建它们,并且不要更新它们。或者,通过通道传递一个引用,然后从发送者那里不再使用它。

  • 尽可能不要混合消息传递 模式与内存共享。将内存共享与消息传递结合使用可能会在解决方案采用的方法上造成混淆。

让我们现在看看一些常见的并发模式、最佳实践和可重用组件的例子,以了解我们如何将一些 CSP 思想应用到我们的应用程序中。

9.2.1 关闭通道

我们将要检查的第一个模式是有一个公共通道,指示 goroutine 停止处理消息。在前一章中,我们看到了如何使用 Go 的 close(channel) 调用来通知 goroutine 没有更多的消息到来。然后 goroutine 可以终止其执行。但是,如果我们的 goroutine 从多个通道中消费数据,我们应该在收到第一个 close() 调用还是所有通道都关闭时终止执行?

一种解决方案是使用一个 quit 通道与 select 语句一起使用。图 9.3 展示了一个示例,其中 goroutine 生成数字,直到它被指示在另一个 quit 通道上停止。右侧的 goroutine 接收这 10 个数字,然后在 quit 通道上调用 close(channel),指示数字生成停止。

图片

图 9.3 使用 quit 通道停止 goroutine 的执行

让我们先实现一个 goroutine,它接收并打印数字。列表 9.1 展示了一个接受输入 numbers 通道和 quit 通道的函数。该函数简单地从 numbers 通道中取出 10 个项目,然后关闭 quit 通道。我们使用的 quit 通道的数据类型并不重要,因为除了关闭信号外,永远不会在该通道上发送数据。

列表 9.1 打印 10 个数字然后关闭 quit 通道

package main

import "*fmt*"

func printNumbers(numbers <-chan int, quit chan int) {
    go func() {
        for i := 0; i < 10; i++ {   ❶
            fmt.Println(<-numbers)  ❶
        }
        close(quit)                 ❷
    }()
}

❶ 从数字通道消费 10 个项目

❷ 关闭 quit 通道

接下来,让我们看看在通道上生成一个数字流,以便由我们之前的功能消费。在我们的数字流中,我们可以写入图 9.4 所示的三角数序列。

图片

图 9.4 生成三角数序列

在列表 9.2 中,main() goroutine 创建了 numbersquit 通道,并调用了 printNumbers() 函数。然后我们可以继续生成数字并将它们发送到 numbers 通道,直到 select 语句告诉我们 quit 通道已解除阻塞。一旦 quit 通道解除阻塞,我们可以终止 main() goroutine。

列表 9.2 生成数字直到 quit 通道关闭

func main() {
    numbers := make(chan int)                            ❶
    quit := make(chan int)                               ❶
    printNumbers(numbers, quit)                          ❷
    next := 0
    for i := 1; ; i++ {
        next += i                                        ❸
        select {
        case numbers <- next:                            ❹
        case <-quit:                                     ❺
            fmt.Println("*Quitting number generation*")    ❺
            return                                       ❺
        }
    }
}

❶ 创建数字和 quit 通道

❷ 调用 printNumbers() 函数,传递通道

❸ 生成下一个三角数

❹ 在数字通道上发送数字

❺ 当 quit 通道解除阻塞时,输出一条消息并终止执行

注意:我们在通道上传递数字的副本。我们没有共享任何内存,因为 goroutine 有它自己的独立内存空间。

在 goroutine 中使用的所有变量都没有被共享。例如,在列表 9.2 中,next 变量保持在 main() 函数的栈上本地。运行列表 9.1 和 9.2,我们得到以下结果:

$ go run closingchannel.go
1
3
6
10
15
21
28
36
45
55
Quitting number generation

9.2.2 使用通道和 goroutine 进行管道化

现在我们来看一下连接 goroutine 以形成一个执行管道的模式。我们可以通过一个处理网页文本内容的应用程序来演示这一点。在第三章和第四章中,我们使用了一个并发内存共享应用程序,从互联网下载文本文档并计算字符频率。在下一节中,我们将开发一个类似的应用程序,该应用程序使用通过通道的消息传递而不是内存共享。

我们应用程序的第一步是生成我们可以稍后下载的网页 URL。我们可以让一个 goroutine 生成几个 URL,并将它们发送到通道以供消费(见图 9.5)。一开始,我们可以在main() goroutine 中简单地从控制台打印出 URL。一旦我们完成,生成 URL 的 goroutine 将关闭输出通道以通知main() goroutine 没有更多的网页需要处理。

图 9.5 生成 URL 并打印它们

列表 9.3 展示了generateUrls()函数的实现,该函数创建一个 goroutine,在输出通道上生成 URL 字符串。该函数返回输出通道。该函数还接受一个退出通道,它监听该通道以防止需要提前停止生成 URL。我们将采用一个常见的模式,将输入通道作为函数参数传递,并返回输出通道(generateUrls()函数没有输入通道)。这使我们能够轻松地将这些 goroutine 以管道的形式连接起来。在我们的实现中,就像在第三章中一样,我们使用从rfc-editor.org获得的文档。这为我们提供了具有可预测 Web 地址的静态在线文本文档。

列表 9.3 从 goroutine 生成 URL

package main

import "*fmt*"

func generateUrls(quit <-chan int) <-chan string {   ❶
    urls := make(chan string)                        ❷
    go func() {
        defer close(urls)                            ❸
        for i := 100; i <= 130; i++ {
            url := fmt.Sprintf("*https://rfc-editor.org/rfc/rfc%d.txt*", i)
            select {
            case urls <- url:                        ❹
            case <-quit:
                return
            }
        }
    }()
    return urls                                      ❺
}

❶ 接受退出通道并返回输出通道

❷ 创建输出通道

❸ 完成后,关闭输出通道

❹ 将 50 个 URL 写入输出通道

❺ 返回输出通道

接下来,让我们通过编写main()函数来完成我们的简单应用程序,该函数如列表 9.4 所示。在main()函数中,我们创建quit通道,然后调用generateUrls(),该函数返回 goroutine 的输出通道(在这个例子中称为results)。然后我们监听输出和quit通道。我们继续从输出通道向控制台写入消息,直到quit通道关闭,此时我们通过在main()函数中返回来终止应用程序。

列表 9.4 打印输出的main()函数

func main() {
    quit := make(chan int)            ❶
    defer close(quit)
    results := generateUrls(quit)     ❷
    for result := range results {     ❸
        fmt.Println(result)           ❹
    }
}

❶ 创建退出通道

❷ 调用函数以启动返回 URL 的 goroutine,这些 URL 在结果通道上

❸ 读取结果通道上的所有消息

❹ 打印结果

将列表 8.7 和 8.8 一起运行,我们得到以下输出:

$ go run generateurls.go
https://rfc-editor.org/rfc/rfc100.txt
https://rfc-editor.org/rfc/rfc101.txt
https://rfc-editor.org/rfc/rfc102.txt
https://rfc-editor.org/rfc/rfc103.txt
https://rfc-editor.org/rfc/rfc104.txt
. . .

接下来,让我们编写下载这些页面内容的逻辑。为此任务,我们只需要一个接受 URL 流并输出文本内容的另一个输出流的 goroutine。这个 goroutine 可以连接到generateUrls()goroutine 的输出和main()goroutine 的输入,如图 9.6 所示。

图片

图 9.6 向我们的管道添加下载网页的 goroutine

列表 9.5 展示了downloadPages()函数的实现。它接受quiturls通道,并返回一个包含下载页面的输出通道。该函数创建一个 goroutine,使用select语句下载每个页面,直到urls通道或quit通道关闭。goroutine 通过读取读取下一个消息时返回的moreData布尔标志来检查输入通道是否仍然打开。当它返回false,意味着通道已被关闭时,我们停止在select语句上迭代。

列表 9.5 下载页面的 Goroutine(省略了导入以节省篇幅)

func downloadPages(quit <-chan int, urls <-chan string) <-chan string {
    pages := make(chan string)                                   ❶
    go func() {
        defer close(pages)                                       ❷
        moreData, url := true, ""
        for moreData {                                           ❸
            select {
            case url, moreData = <-urls:                         ❹
                if moreData {                                    ❺
                    resp, _ := http.Get(url)                     ❺
                    if resp.StatusCode != 200 {                  ❺
                        panic("*Server’s error:* " + resp.Status)  ❺
                    }                                            ❺
                    body, _ := io.ReadAll(resp.Body)             ❺
                    pages <- string(body)                        ❺
                    resp.Body.Close()                            ❺
                }
            case <-quit:                                         ❻
                return
            }
        }
    }()
    return pages                                                 ❼
}

❶ 创建输出通道,该通道将包含下载的网页

❷ 完成后关闭输出通道

❸ 如果输入通道上有更多数据,则继续选择

❹ 使用新消息和标志更新变量以显示是否有更多数据

❺ 当收到新的 URL 消息时,下载页面并将页面上的文本发送到页面通道

❻ 当退出通道上收到消息时,终止 goroutine

❼ 返回输出通道

警告 在列表 9.5 中,我们在通道上传递了网页文档的副本。我们可以这样做,因为网页的大小只有几 KB。以这种方式使用消息传递来处理大型对象,如图片或视频,可能会对性能产生不利影响。使用内存共享架构可能更适合需要高性能且共享大量数据的应用程序。

由于它接受与generateUrls()函数输出相同的通道数据类型,我们可以轻松地将这个新的 goroutine 连接到我们的管道中。它还返回与我们的main()goroutine 可以使用的相同输出通道数据类型。在下面的列表中,我们将main()函数修改为也调用downloadPages()函数。

列表 9.6 修改后的main()函数以调用downloadPages()

func main() {
    quit := make(chan int)
    defer close(quit)
    results := downloadPages(quit, generateUrls(quit))   ❶
    for result := range results {
        fmt.Println(result)
    }
}

❶ 将新的下载页面 goroutine 添加到我们现有的管道中

当我们运行前面的main()函数时,我们从网页中获取文本,并将它们打印在控制台上。打印我们的文本页面并不很有用,因此我们可以向我们的管道添加另一个 goroutine 来从下载的文本中提取单词。

通过接受输入通道作为函数输入参数并返回输出通道的模式,构建管道变得容易。我们只需要创建一个新的 goroutine 来提取单词,然后将其连接到我们的管道中,如图 9.7 所示。

图片

图 9.7 向页面添加 goroutine 以提取单词

列表 9.7 显示了extractWords()函数的实现。与downloadPages()相同的模式被使用。该函数接受包含文本的输入通道,并返回包含在接收到的文本中找到的所有单词的输出通道。它通过使用正则表达式(regex)从文档中提取单词。

就像在列表 9.6 中一样,我们继续从输入通道中读取,直到从输入或quit通道接收到关闭信号。我们通过使用select语句和读取输入通道上的moreData标志来实现这一点。

列表 9.7 从文本页面中提取单词(为了简洁,省略了导入)

func extractWords(quit <-chan int, pages <-chan string) <-chan string {
    words := make(chan string)                               ❶
    go func() {
        defer close(words)
        wordRegex := regexp.MustCompile(*`[a-zA-Z]+`*)         ❷
        moreData, pg := true, ""
        for moreData {
            select {
            case pg, moreData = <-pages:                     ❸
                if moreData {
                    for _, word := range wordRegex.FindAllString(pg, -1) {
                        words <- strings.ToLower(word)       ❹
                    }
                }
            case <-quit:                                     ❺
                return
            }
        }
    }()
    return words                                             ❻
}

❶ 创建输出通道,其中将包含提取的单词

❷ 创建正则表达式以提取单词

❸ 使用新的消息和标志更新变量以显示是否有更多数据

❹ 当接收到新的文本页面时,使用正则表达式提取所有单词并发送到输出通道

❺ 当 quit 通道上到达消息时,终止 goroutine

❻ 返回输出通道

再次,我们可以修改我们的main()函数,将这个新的 goroutine 包含到我们的管道中,如列表 9.8 所示。管道中的每个函数都是一个 goroutine,它接受quit通道和一个输入通道,并返回一个输出通道,结果将发送到该通道。使用quit通道将允许我们控制管道不同部分的流程。

列表 9.8 将extractWords()添加到管道中

func main() {
    quit := make(chan int)
    defer close(quit)
    results := extractWords(quit, downloadPages(quit, generateUrls(quit)))
    for result := range results {
        fmt.Println(result)
    }
}

在我们的管道中使用新的extractWords()运行前面的列表,我们得到包含在文本中的单词列表:

$ go run extractwords.go
network
working
group
p
karp
request
for
comments
. . .

注意:这种管道模式使我们能够轻松地将执行连接起来。每个执行都由一个函数表示,该函数启动一个接受输入通道作为参数并返回输出通道作为返回值的 goroutine。

当运行列表 9.8 时,网页是顺序下载的,一个接一个,使得执行速度相当慢。理想情况下,我们希望加快这个速度并实现并发下载。这正是下一个模式(扇入和扇出)发挥作用的地方。

9.2.3 扇入和扇出

在我们的示例应用程序中,如果我们想加快速度,可以通过负载均衡 URL 到多个 goroutine 来实现并发下载。我们可以创建一定数量的 goroutine,每个 goroutine 从相同的 URL 输入通道中读取。每个 goroutine 将从generateUrls()goroutine 接收一个单独的 URL,并且它们可以并发地执行下载。然后,下载的文本页面可以写入每个 goroutine 自己的输出通道。

定义 在 Go 中,扇出并发模式是指多个 goroutine 从同一个通道读取。通过这种方式,我们可以在一组 goroutine 之间分配工作。

图 9.8 展示了我们如何将 URL 扇出到多个downloadPage() goroutine,每个 goroutine 执行不同的下载。在这个例子中,并发 goroutine 正在通过generateUrls() goroutine 发送的 URL 进行负载均衡;当一个downloadPage() goroutine 空闲时,它将从共享输入通道读取下一个 URL。这类似于在你当地的咖啡馆中,多个咖啡师从同一个队列服务客户。

图 9.8 使用扇出模式进行负载均衡请求,但与extractWords()的连接缺失

注意 由于并发处理是非确定性的,一些消息的处理速度会比其他消息快,导致消息以不可预测的顺序处理。只有当我们不关心传入消息的顺序时,扇出模式才有意义。

在我们的代码中,我们可以通过创建一组downloadPages() goroutine 并将相同的通道作为输入通道参数来实现这个简单的扇出模式。如下所示。

列表 9.9 扇出到多个downloadPages() goroutine

const downloaders = 20

func main() {
    quit := make(chan int)
    defer close(quit)
    urls := generateUrls(quit)
    pages := make([]<-chan string, downloaders)   ❶
    for i := 0; i < downloaders; i++ {            ❷
        pages[i] = downloadPages(quit, urls)      ❷
    }                                             ❷
    . . .

❶ 创建一个切片以存储从下载 goroutines 输出的通道

❷ 创建 20 个 goroutine 下载网页并存储输出通道

我们应用程序中的扇出模式创建了一个问题:我们的下载 goroutine 的输出在单独的通道中。我们如何将它们连接到下一阶段的单个输入通道:extractWords() goroutine?

一个解决方案是更改downloadPages() goroutine,并让它们都输出到同一个通道。为此,我们必须将相同的输出通道传递给每个下载器。这将破坏我们易于插入单元的模式,其中每个单元接受输入通道作为参数,并返回输出通道作为返回值。

为了保持这种模式,我们需要一个机制将不同通道的输出消息合并到单个输出通道中。然后我们可以将单个输出通道连接到extractWords() goroutine。这就是所谓的扇入模式。

定义 在 Go 中,当我们将多个通道的内容合并到一个通道时,发生扇入并发模式。

由于 goroutine 非常轻量级,我们可以通过创建一组 goroutine(每个输出通道一个)并将每个 goroutine 连接到一个公共通道来实现这种扇入模式,如图 9.9 所示。每个 goroutine 监听输出通道的消息,当消息到达时,它只需将其转发到公共通道。

图 9.9 通过使用扇入合并通道

当多个 goroutines 都向单个公共通道提供数据时,会引发问题。当我们有一个一对一的输入到输出通道的 goroutine 时,通道关闭策略很简单:在输入通道关闭后关闭输出通道。当我们有一个多对一的扇入场景时,我们必须决定何时关闭公共通道。如果我们继续在 goroutine 注意到它消费的通道已被关闭时关闭通道的方法,我们可能会过早地关闭通道。另一个 goroutine 可能仍在输出消息。

解决方案是仅在所有goroutines 都注意到它们消费的通道已被关闭时才关闭公共通道。如图 9.9 所示,我们可以使用一个 waitgroup 来实现这一点。扇入组中的每个 goroutine 在发送其最后一条消息后将其标记为完成。我们有一个单独的 goroutine,它会在这个 waitgroup 上调用wait(),这将导致其执行暂停,直到所有扇入 goroutine 都完成。一旦这个 goroutine 恢复,它将关闭输出通道。这种技术在下述列表中展示。

列表 9.10 实现扇入函数

package listing9_10

import (
    "*sync*"
)

func FanInK any chan K {
    wg := sync.WaitGroup{}              ❶
    wg.Add(len(allChannels))            ❶
    output := make(chan K)              ❷
    for _, c := range allChannels {
        go func(channel <-chan K) {     ❸
            defer wg.Done()             ❹
            for i := range channel {
                select {
                case output <- i:       ❺
                case <-quit:            ❻
                    return              ❻
                }
            }
        }(c)                            ❼
    }
    go func() {                         ❽
        wg.Wait()                       ❽
        close(output)                   ❽
    }()
    return output                       ❾
}

❶ 创建一个 waitgroup,设置大小等于输入通道的数量

❷ 创建输出通道

❸ 为每个输入通道启动一个 goroutine

❹ 一旦 goroutine 终止,将 waitgroup 标记为完成

❺ 将接收到的每条消息转发到共享输出通道

❻ 如果退出通道被关闭,终止 goroutine

❼ 将一个输入通道传递给 goroutine

❽ 等待所有 goroutine 完成,然后关闭输出通道

❾ 返回输出通道

我们现在可以将我们的扇入模式连接到我们的应用程序,并将其包含在管道中。列表 9.11 修改了我们的main()函数,以包含列表 9.10 中的fanIn()函数。fanIn()函数接受包含网页的通道列表,并返回一个公共聚合通道,然后我们将其输入到我们的extractWords()函数中。

列表 9.11 向管道添加fanIn()函数

const downloaders = 20

func main() {
    quit := make(chan int)
    defer close(quit)
    urls := generateUrls(quit)
    pages := make([]<-chan string, downloaders)
    for i := 0; i < downloaders; i++ {
        pages[i] = downloadPages(quit, urls)
    }
    results := extractWords(quit, listing9_10.FanIn(quit, pages...))   ❶
    for result := range results {
        fmt.Println(result)
    }
}

❶ 使用扇入模式将所有页面通道合并为一个通道

当我们运行新的实现时,它运行得更快,因为下载是并发执行的。作为一起执行下载的副作用,每次运行程序时提取单词的顺序都不同。

9.2.4 关闭时刷新结果

除了提取单词之外,我们的 URL 下载应用程序并没有真正做任何有趣的事情。如果我们用下载的网页做些有用的事情会怎样?比如尝试找出这些文本文档中的 10 个最长单词?

如果我们继续遵循我们的管道构建模式,这个任务就很简单了。我们只需要添加一个新的 goroutine,它接受一个输入通道并返回一个输出通道。在图 9.10 中,这个新 goroutine,称为 longestWords(),被插入到我们的 extractWords() goroutine 之后。

图 9.10 向我们的文本添加 longestWords() goroutine 以找到 10 个最长单词

这个新的 longestWords() goroutine 与我们在管道中开发的其它 goroutine 略有不同。它在内存中累积一组唯一单词。一旦它从网页中读取了所有单词并收到关闭消息,它将审查这个集合并输出最长的 10 个单词。然后,我们的 main() goroutine 将在控制台上打印出来。

longestWords() 函数的实现展示在列表 9.12 中。在这个函数中,我们使用一个映射来存储唯一单词的集合。由于这个映射与我们的并发执行是隔离的,并且只有我们的 longestWords() goroutine 访问它,所以我们不需要担心数据竞争条件。我们还将单词存储在单独的切片中,以便于后续排序。

列表 9.12 输出最长单词的 goroutine(为了简洁省略了导入)

func longestWords(quit <-chan int, words <-chan string) <-chan string {
    longWords := make(chan string)
    go func() {
        defer close(longWords)
        uniqueWordsMap := make(map[string]bool)                 ❶
        uniqueWords := make([]string, 0)                        ❷
        moreData, word := true, ""
        for moreData {
            select {
            case word, moreData = <-words:
                if moreData && !uniqueWordsMap[word] {          ❸
                    uniqueWordsMap[word] = true                 ❸
                    uniqueWords = append(uniqueWords, word)     ❸
                }
            case <-quit:
                return
            }
        }
        sort.Slice(uniqueWords, func(a, b int) bool {           ❹
            return len(uniqueWords[a]) > len(uniqueWords[b])    ❹
        })
        longWords <- strings.Join(uniqueWords[:10], "*,* ")       ❺
    }()
    return longWords
}

❶ 创建一个映射来存储唯一单词

❷ 创建切片来存储唯一单词列表,以便于后续排序

❸ 如果通道没有关闭且单词是新的,将新单词添加到映射和列表中

❹ 一旦输入通道关闭,就按单词长度对唯一单词列表进行排序

❺ 一旦输入通道关闭,就在输出通道上发送包含 10 个最长单词的字符串

在列表 9.12 中,goroutine 将所有唯一单词存储在映射和列表中。一旦输入通道关闭,意味着没有更多消息,goroutine 将按长度对唯一单词列表进行排序。然后,在输出通道上,它发送列表上的前 10 项,即最长的 10 个单词。这样,我们在收集所有数据后刷新结果。

现在,我们可以在 main() 函数中将这个新组件连接到我们的管道中。在下面的列表中,longestWords() goroutine 从 extractWords() 的输出通道中消费数据。

列表 9.13 向我们的管道添加 longestWords()

func main() {
    quit := make(chan int)
    defer close(quit)
    urls := generateUrls(quit)
    pages := make([]<-chan string, downloaders)
    for i := 0; i < downloaders; i++ {
        pages[i] = downloadPages(quit, urls)
    }
    results := longestWords(quit,                                 ❶
        extractWords(quit, listing8_14.FanIn(quit, pages...)))    ❶
    fmt.Println("*Longest Words:*", <-results)                      ❷
}

❶ 在 extractWords() goroutine 之后将 longestWords() goroutine 连接到管道

❷ 打印包含最长单词的单条消息

当我们运行这些列表时,管道将在下载的文档中找到最长的单词,并在控制台上输出。以下是输出:

$ go run longestwords.go
Longest Words: interrelationships, misunderstandings, telecommunication, administratively, implementability, characteristics, insufficiencies, implementations, synchronization, representatives

9.2.5 向多个 goroutine 广播

如果我们想从下载的网页中获取更多统计数据呢?对于这种情况,假设除了找到最长的单词外,我们还想找出出现频率最高的单词。

对于这种场景,我们将extractWords()的输出传递给两个 goroutine:现有的longestWords()和一个额外的名为frequentWords()的 goroutine。新函数的模式将与longestWords()相同。它将存储每个唯一单词的频率,当输入通道关闭时,它将输出出现频率最高的前 10 个单词。

在上一节中,当我们需要将一个计算的输出传递给多个并发 goroutine 时,我们使用了扇出模式。我们平衡了消息负载,每个 goroutine 接收输出数据的不同子集。这种模式在这里不适用,因为我们希望将每个输出消息的副本发送给longestWords()frequentWords() goroutine。

而不是扇出模式,我们可以使用广播模式——一种将消息复制到一组输出通道的模式。图 9.11 显示了我们可以如何使用一个单独的 goroutine 来广播到多个通道。在我们的管道中,我们可以将广播的输出连接到frequentWords()longestWords() goroutine 的输入。

图片

图 9.11 将并发frequentWords() goroutine 连接到我们的管道

为了实现这个广播工具,我们只需要创建一个输出通道列表,然后使用一个 goroutine 将每个接收到的消息写入每个通道。在列表 9.14 中,广播函数接受输入通道和一个整数n,指定所需的输出数量。然后函数返回这些n个输出通道的切片。在这个实现中,我们使用了泛型,以便广播可以使用任何通道数据类型。

列表 9.14 向多个输出通道广播

package listing9_14

func BroadcastK any []chan K {
    outputs := CreateAllK                           ❶
    go func() {
        defer CloseAll(outputs...)                       ❷
        var msg K
        moreData := true
        for moreData{
            select {
            case msg, moreData = <-input:                ❸
                if moreData {                            ❹
                    for _, output := range outputs {     ❹
                        output <- msg                    ❹
                    }
                }
            case <-quit:
                return
            }
        }
    }()
    return outputs                                       ❺
}

❶ 创建 n 个类型为 K 的输出通道(请参阅下一列表以获取实现方法)

❷ 完成后,关闭所有输出通道(请参阅下一列表以获取实现方法)

❸ 从输入通道读取下一个消息

❹ 如果输入通道尚未关闭,则将消息写入每个输出通道

❺ 返回输出通道集合

注意:在列表 9.14 中的广播实现中,我们只在当前消息被发送到所有通道之后才读取下一个消息。从这个广播实现中来的慢消费者会减慢所有消费者的速度。

上一列表使用了两个函数,CreateAll()CloseAll(),分别用于创建和关闭一组通道。以下列表显示了它们的实现。

列表 9.15 CreateAll()CloseAll()函数

func CreateAllK any []chan K {       ❶
    channels := make([]chan K, n)
    for i, _ := range channels {
        channels[i] = make(chan K)
    }
    return channels
}

func CloseAllK any {    ❷
    for _, output := range channels {
        close(output)
    }
}

❶ 创建 n 个类型为 K 的通道

❷ 关闭所有通道

我们现在可以编写我们的frequentWords()函数,该函数将识别下载页面中频率最高的前 10 个单词。以下列表中的实现与longestWords()函数类似。这次,我们使用一个名为mostFrequentWords的映射来计数每个单词的出现次数。在输入通道关闭后,我们根据映射中的出现次数对单词列表进行排序。

列表 9.16 寻找最频繁的单词(省略了导入以节省空间)

func frequentWords(quit <-chan int, words <-chan string) <-chan string {
    mostFrequentWords := make(chan string)
    go func() {
        defer close(mostFrequentWords)
        freqMap := make(map[string]int)                           ❶
        freqList := make([]string, 0)                             ❷
        moreData, word := true, ""
        for moreData {
            select {
            case word, moreData = <-words:                        ❸
                if moreData {
                    if freqMap[word] == 0 {                       ❹
                        freqList = append(freqList, word)         ❹
                    }
                    freqMap[word] += 1                            ❺
                }
            case <-quit:
                return
            }
        }
        sort.Slice(freqList, func(a, b int) bool {                ❻
            return freqMap[freqList[a]] > freqMap[freqList[b]]    ❻
        })
        mostFrequentWords <- strings.Join(freqList[:10], "*,* ")    ❼
    }()
    return mostFrequentWords
}

❶ 创建一个映射来存储每个唯一单词的频率出现次数

❷ 创建一个切片来存储唯一单词列表

❸ 消费输入通道上的下一个消息

❹ 如果消息包含一个新单词,则将其添加到唯一单词的切片中

❺ 增加单词的计数

❻ 一旦消耗完所有输入消息,就按出现次数对单词列表进行排序

❼ 将 10 个最频繁的单词写入输出通道

现在我们可以使用我们之前开发的广播实用程序将frequentWords()单元连接起来。在以下列表中,我们调用Broadcast()函数创建两个输出通道,并使其从extractWords()中消费。然后我们使用广播的两个输出通道作为longestWords()frequentWords() goroutine 的输入。

列表 9.17 将广播模式连接到查找最频繁和最长的单词

const downloaders = 20

func main() {
    quit := make(chan int)
    defer close(quit)
    urls := generateUrls(quit)
    pages := make([]<-chan string, downloaders)
    for i := 0; i < downloaders; i++ {
        pages[i] = downloadPages(quit, urls)
    }
    words := extractWords(quit, listing9_10.FanIn(quit, pages...))
    wordsMulti := listing9_14.Broadcast(quit, words, 2)               ❶
    longestResults := longestWords(quit, wordsMulti[0])               ❷
    frequentResults := frequentWords(quit, wordsMulti[1])             ❸
    fmt.Println("*Longest Words:*", <-longestResults)                   ❹
    fmt.Println("*Most frequent Words:*", <-frequentResults)            ❺
}

❶ 创建一个 goroutine,该 goroutine 将向两个输出通道广播单词通道的内容

❷ 创建 goroutine 以从输入通道中查找最长的单词

❸ 创建 goroutine 以从输入通道中查找最常用的单词

❹ 从longestWords() goroutine 读取结果并打印

❺ 从mostFrequentWords() goroutine 读取结果并打印

由于longestWords()frequentWords() goroutine 只输出包含结果的一个消息,因此我们的main()函数只需从每个 goroutine 中消费一个消息,并在控制台上打印它。以下片段包含运行完整管道时的输出。不出所料,the是最频繁的单词:

$ go run wordstats.go
Longest Words: interrelationships, telecommunication, misunderstandings, implementability, administratively, transformations, reconfiguration, representatives, experimentation, interpretations
Most frequent Words: the, to, a, of, is, and, in, be, for, rfc

9.2.6 在满足条件后关闭通道

到目前为止,我们还没有真正使用我们在应用程序中的每个 goroutine 中连接的退出通道。这些退出通道可以在某些条件下停止管道的部分。

在我们的应用程序中,我们正在读取固定数量的网页并处理它们,但如果我们只想处理下载的前 10,000 个单词怎么办?解决方案是在我们的管道消耗了指定数量的消息后停止其一部分的执行。如果我们在这个新的 goroutine(称为 Take(n))之后插入这个新的 goroutine,我们就可以指示它在接收指定数量的消息后关闭 quit 通道(见图 9.12)。Take(n) goroutine 将通过在 quit 通道上调用 close() 来仅终止管道的一部分。我们可以通过将管道的左侧(在 take(n) goroutine 之前)与一个单独的 quit 通道连接来实现这一点。

图片

图 9.12 将 Take(n) goroutine 添加到我们的管道中

要实现 Take(n),我们需要一个 goroutine,它简单地转发从输入接收到的消息到输出通道,同时进行倒计时,每次转发的消息都会使倒计时减少 1。一旦倒计时达到 0,goroutine 将关闭 quit 和输出通道。列表 9.18 展示了 Take(n) 的一个实现,其中倒计时由变量 n 表示。只要还有更多数据,倒计时大于 0,并且 quit 通道未被关闭,goroutine 就会继续转发消息。它只有在倒计时达到 0 时才会关闭 quit 通道。

列表 9.18 实现 Take(n) 函数

package listing9_18

func TakeK any <-chan K {
    output := make(chan K)
    go func() {
        defer close(output)
        moreData := true
        var msg K
        for n > 0 && moreData {            ❶
            select {
            case msg, moreData = <-input:  ❷
                if moreData {
                    output <- msg          ❸
                    n--                    ❹
                }
            case <-quit:
                return
            }
        }
        if n == 0 {                        ❺
            close(quit)                    ❺
        }
    }()
    return output
}

❶ 只要还有更多数据且倒计时 n 大于 0,就继续转发消息

❷ 从输入读取下一个消息

❸ 将消息转发到输出

❹ 将倒计时变量 n 减少到 1

❺ 如果倒计时达到 0,则关闭 quit 通道

我们现在可以将这个新组件添加到我们的管道中,并在达到特定的单词计数时停止处理。以下列表显示了如何修改我们的 main() 函数以包括配置为在达到 10,000 个单词计数时停止处理的 Take(n) goroutine。

列表 9.19 将 Take(n) 连接到我们的管道

const downloaders = 20

func main() {
    quitWords := make(chan int)                               ❶
    quit := make(chan int)
    defer close(quit)
    urls := generateUrls(quitWords)
    pages := make([]<-chan string, downloaders)
    for i := 0; i < downloaders; i++ {
        pages[i] = downloadPages(quitWords, urls)
    }
    words := listing9_18.Take(quitWords, 10000,               ❷
        extractWords(quitWords, listing9_10.FanIn(quitWords, pages...)))
    wordsMulti := listing9_14.Broadcast(quit, words, 2)       ❸
    longestResults := longestWords(quit, wordsMulti[0])       ❸
    frequentResults := frequentWords(quit, wordsMulti[1])     ❸

    fmt.Println("*Longest Words:*", <-longestResults)
    fmt.Println("*Most frequent Words:*", <-frequentResults)
}

❶ 在 Take(n) 函数之前创建一个单独的 quit 通道

❷ 创建一个带有 10,000 个倒计时的 Take(n) goroutine,从 extractWords() 输出中获取数据

❸ 为管道的其余部分使用单独的 quit 通道

运行列表 9.19 结果只处理下载的前 10,000 个单词。由于下载是并行进行的,下载的页面顺序无法预测,并且每次运行应用程序时可能都不同。因此,遇到的第一个 10,000 个单词将根据首先下载的页面而变化。以下是此类运行的一个输出示例:

$ go run wordstatsearlyquit.go
Longest Words: implementations, characteristics, recommendations, considerations, implementation, effiectiveness, simultaneously, specifications, irrecoverable, informational
Most frequent Words: the, to, of, is, a, and, be, for, in, not

9.2.7 采用通道作为一等对象

在他的 CSP 语言论文中,C.A.R. Hoare 使用了一个例子,即使用通信顺序进程列表生成 1 万以内的素数。该算法基于埃拉托斯特尼筛法,这是一种检查一个数是否为素数的简单方法。CSP 论文中的方法使用了一个静态线性管道,其中管道中的每个进程都会过滤掉一个素数的倍数,并将其传递给下一个进程。因为管道是静态的(它不会随着问题规模的增长而增长),它只能生成到固定数量的素数。

Go 语言相较于原始论文中定义的 CSP 语言可用的改进是,通道是一等对象。这意味着通道可以被存储为变量并传递给其他函数。在 Go 中,一个通道也可以传递给另一个通道。这允许我们通过使用动态线性管道来改进原始解决方案,这种管道随着问题规模的增长而增长,并且允许我们生成多达n个素数,而不是生成到固定数量的素数。

素数管道算法的起源

虽然使用管道生成素数的解决方案在 CSP 论文中提到了,但原始的想法被归功于数学家和程序员 Douglas McIlroy。

图 9.13 展示了我们如何使用并发管道生成素数。一个数c是素数,如果c不是小于c的所有素数的倍数。例如,要检查 7 是否为素数,我们需要确保 7 不能被 2、3 或 5 整除。由于 7 不能被这些数中的任何一个整除,所以 7 是一个素数。然而,数字 9 可以被 3 整除,所以 9 不是一个素数。

图 9.13

图 9.13 通过管道检查一个数是否为素数

检查一个数是否为素数

要检查数字c是否为素数,我们只需要检查c不能被小于c的平方根的所有素数整除。然而,在本节中,我们简化了要求以保持列表更简单、更短。

对于我们的素数检查管道,我们可以让一个 goroutine 从 2 开始生成候选的连续数字。这个 goroutine 的输出将输入到一个由 goroutine 链组成的管道中,每个 goroutine 都会过滤掉一个素数的倍数。这个链中的 goroutine 被分配一个素数p,它将丢弃所有是p的倍数的数字。如果一个数字没有被丢弃,它将被传递到链的右侧。如果它一路存活到链的末端,这意味着我们找到了一个新的素数,并且会创建一个新的 goroutine,其p等于新的素数。这个过程在图 9.14 中展示。

图 9.14

图 9.14 当找到一个新素数时,我们启动一个新的 goroutine 来过滤该新素数的倍数。

在我们的管道中,当一个数字通过所有现有的 goroutine 并且没有被丢弃时,这意味着我们找到了一个新的素数。管道中的最后一个 goroutine 将在管道尾部初始化一个新的 goroutine 并将其连接。这个新的 goroutine 将成为管道的新尾部,并且它将过滤掉新找到的素数的倍数。这样,管道会随着素数的数量动态增长。

让这个管道随着素数的数量动态增长显示了将 channel 视为一等对象的优势,与 C.A.R. Hoare 在 CSP 论文中的原始 channel 相比。Go 给了我们像普通变量一样处理 channel 的能力。

列表 9.20 实现了这个素数过滤 goroutine。创建时,goroutine 在其 channel 上接收第一个消息,其中包含将要用于倍数过滤的素数p。然后它在其输入 channel 上监听新的数字,并检查接收到的任何数字是否是p的倍数。如果是,goroutine 简单地丢弃它;否则,它将数字传递到其右边的 channel。如果 goroutine 恰好是管道的尾部,它将创建一个新的右边的 channel 并将 channel 传递给一个新创建的 goroutine。

列表 9.20 primeMultipleFilter() goroutine

package main

import "*fmt*"

func primeMultipleFilter(numbers <-chan int, quit chan<- int) {
    var right chan int
    p := <-numbers                                     ❶
    fmt.Println(p)                                     ❶
    for n := range numbers {                           ❷
        if n%p != 0 {                                  ❸
            if right == nil {                          ❹
                right = make(chan int)                 ❹
                go primeMultipleFilter(right, quit)    ❹
            }
            right <- n                                 ❺
        }
    }
    if right == nil {
        close(quit)                                    ❻
    } else {
        close(right)                                   ❼
    }
}

❶ 在输入 channel 上接收包含素数 p 的第一个消息并打印它

❷ 从输入 channel 读取下一个数字

❸ 丢弃任何接收到的 p 的倍数

❹ 如果当前 goroutine 没有权限,它将启动一个新的 goroutine 并通过一个 channel 连接到它。

❺ 将过滤后的数字传递到右边的 channel

❻ 如果没有更多的数字要过滤并且 goroutine 没有右边的 channel,关闭退出 channel

❼ 否则,关闭右边的 channel

现在我们只需要将我们的素数倍数过滤器连接到一个连续数字生成器。我们可以使用main() goroutine 来完成这个任务。在列表 9.21 中,我们的main()函数使用输入 channel 启动我们的第一个素数倍数过滤器 goroutine,然后从 2 到 100,000 连续地给它喂数字。之后,它关闭输入 channel 并等待退出 channel 关闭。这样,我们确保在终止main() goroutine 之前打印出最后一个素数。

列表 9.21 main() 函数将连续数字喂给素数过滤器

func main() {
    numbers := make(chan int)               ❶
    quit := make(chan int)                  ❷
    go primeMultipleFilter(numbers, quit)   ❸
    for i := 2; i < 100000; i++ {           ❹
        numbers <- i                        ❹
    }
    close(numbers)                          ❺
    <-quit                                  ❻
}

❶ 创建一个将喂给素数倍数过滤器的输入 channel

❷ 创建一个公共的退出 channel

❸ 启动管道中的第一个 goroutine,传递数字和退出 channel

❹ 从 2 开始,将连续数字喂到输入 channel,直到 100,000

❺ 关闭输入 channel,表示将没有更多的数字

❻ 等待退出 channel 关闭

将列表 9.20 和 9.21 一起运行,我们可以得到小于 100,000 的所有素数:

$ go run primesieve.go
2
3
5
7
11
13
. . .
99989
99991

9.3 练习

注意:访问github.com/cutajarj/ConcurrentProgrammingWithGo以查看所有代码解决方案。

  1. 编写一个类似于列表 9.2 的生成器 goroutine,它不是生成 URL 字符串,而是在输出通道上生成一个无限的平方数流(1, 4, 9, 16, 25 ...)。以下是函数签名:

    func GenerateSquares(quit <-chan int) <-chan int
    
  2. 在列表 9.18 中,我们开发了一个take(n) goroutine。扩展此 goroutine 的功能以实现TakeUntil(f),其中f是一个返回布尔值的函数。当f的返回值为true时,goroutine 需要继续消费和转发其输入通道上的消息。使用泛型确保我们可以重用TakeUntil(f)函数并将其插入到许多其他管道中。以下是函数签名:

    func TakeUntilK any bool,quit chan int,input <-chan K) <-chan K
    
  3. 编写一个 goroutine,它将打印接收到的任何消息的内容到控制台,然后将消息转发到输出通道。再次使用泛型,以便函数可以在许多情况下重用:

    func PrintT any <-chan T
    
  4. 编写一个 goroutine,它从其输入通道中读取内容但不对其进行任何操作。这个 goroutine 只是读取一个消息然后丢弃它:

    func DrainT any
    
  5. 使用以下伪代码,在main()函数中将练习 1 到 4 中开发的组件连接起来:

    Create quit channel
    Drain(quitChannel,
        Print(quitChannel,
            TakeUntil({ s <= 1000000 }, quitChannel,
                GenerateSquares(quitChannel))))
    Wait on quit channel
    

摘要

  • 通信顺序进程(CSP)是一种使用同步通道通过消息传递的并发模型。

  • 在 CSP 中,每个执行都有自己的独立状态,并且不与其他执行共享内存。

  • Go 借鉴了 CSP 的核心思想,并增加了将通道视为一等对象的处理,这意味着我们可以在函数调用和其他通道中传递通道。

  • 可以使用退出通道模式来通知 goroutines 停止它们的执行。

  • 有一个共同的模式,即 goroutine 接受输入通道并返回输出,这使得我们能够轻松地将管道的各个阶段连接起来。

  • 扇入模式将多个输入通道合并为一个。只有当所有输入通道都关闭后,合并的通道才会关闭。

  • 扇出模式是指多个 goroutines 从同一个通道读取。在这种情况下,通道上的消息在 goroutines 之间进行负载均衡。

  • 扇出模式只有在消息的顺序不重要时才有意义。

  • 使用广播模式,输入通道的内容会被复制到多个通道中。

  • 在 Go 中,让通道作为一等对象意味着我们可以在程序执行时动态地修改我们的消息传递并发程序的结构。

第三部分:更多并发

在本书的这一部分,我们将探讨更高级的并发主题:并发模式、死锁、原子变量和 futexes。

我们将首先回顾一些常用的模式,这些模式可以将问题分解成多个部分,以便并行执行,我们还将看到哪些模式更适合不同类型的问题。我们还将探讨诸如循环级并行性、分支/合并、工作池和流水线等模式,并讨论每个模式的特点。

死锁可能是并发系统的一个不良副作用。当两个或更多执行线程以循环方式相互阻塞时,就会发生死锁。我们将检查一些死锁的例子,包括内存共享和消息传递,并讨论在程序中避免和防止死锁的各种选项。

在这本书中,我们已经探讨了各种并发工具的实现。在这里,我们将查看我们并发工具中最原始的一个:互斥锁。我们将探讨互斥锁如何通过内部使用原子操作,结合操作系统调用,以实现性能方面的最佳结果。

10 并发模式

本章涵盖

  • 通过任务分解程序

  • 通过数据分解程序

  • 识别常见并发模式

当我们有工作要做,并且有许多帮手时,我们需要决定如何划分工作,以便高效地完成。开发并发解决方案的一个重大任务是识别主要独立的计算——如果它们同时执行,不会相互影响的任务。将我们的编程分解为单独的并发任务的过程被称为分解

在本章中,我们将看到执行这种分解的技术和想法。稍后,我们将讨论在各种并发场景中使用的常见实现模式。

10.1 分解程序

我们如何将程序或算法转换为可以使用并发编程运行得更高效的版本?分解是将程序细分为许多任务并识别其中哪些任务可以并发执行的过程。让我们通过一个现实生活中的例子来看看分解是如何工作的。

想象我们正坐在一辆车里,和一群朋友一起开车。突然,我们听到车前传来奇怪的噪音。我们停车检查,发现我们有一个轮胎没气了。不想迟到,我们决定用备用轮胎更换轮胎,而不是等待拖车。以下是我们需要执行的步骤:

  1. 应用手刹。

  2. 卸下备用轮胎。

  3. 松开轮胎螺母。

  4. 将车顶起。

  5. 移除漏气的轮胎。

  6. 放置备用轮胎。

  7. 拧紧螺母。

  8. 降低车辆。

  9. 存放坏轮胎。

由于我们不是孤身一人,我们可以将一些步骤分配给其他人,这样我们就可以更快地完成工作。例如,我们可以让一个人卸下备用轮胎,同时另一个人松开轮胎螺母。为了决定哪些步骤可以与其他步骤并行执行,我们可以通过绘制如图 10.1 所示的任务依赖图来对工作进行依赖分析。

图 10.1 更换漏气轮胎的任务依赖图

通过查看任务依赖图,我们可以做出明智的决定,关于如何最佳地分配任务,以便我们更有效地完成工作。在这个例子中,我们可以指派一个人从后备箱卸下备用轮胎,同时另一个人松开轮胎螺母。我们还可以在移除坏轮胎后让另一个人存放它,同时另一个人放置备用轮胎。

构建任务依赖图是一个好的开始。然而,我们如何得出所需的步骤列表?如果我们能想出一个不同的步骤列表,这些步骤在并行执行时可能更有效,会怎样?为了帮助我们分解编程任务并考虑各种并发任务,我们可以从两个不同的角度考虑我们的程序:任务和数据分解。我们将结合使用这两种分解技术,并尝试将常见的并发模式应用于我们的问题。

10.1.1 任务分解

任务分解发生在我们考虑程序中可以并行执行的各种动作时。在任务分解中,我们提出问题,“我们可以执行哪些不同的并行动作来更快地完成任务?”作为一个类比,想想两个飞行员分配降落飞机和并行执行各种任务的工作(见图 10.2)。在我们的类比中,飞行员可以通过飞机的仪表访问相同的数据,但每个飞行员都在执行不同的任务,以确保飞机安全有效地降落。

图片

图 10.2 飞行员在降落飞机时执行不同的任务

在上一章中,我们看到了我们可以将不同的任务分配给不同的执行方式,例如当我们编写一个程序来查找一组网络文档中最长的单词时。在任务分解中,我们需要将问题分解成几个任务,例如

  • 下载网页

  • 提取单词

  • 查找最长单词

在获得任务分解后,我们可以先概述每个任务的依赖关系。在我们的查找最长单词的程序中,每个任务都依赖于前一个任务。例如,在下载网页之前,我们不能提取单词。

10.1.2 数据分解

我们也可以通过考虑数据在程序中的流动来分解我们的程序。例如,我们可以将输入数据分割并分配给多个并行执行(见图 10.3)。这被称为数据分解,其中我们提出问题,“我们如何组织程序中的数据,以便我们可以并行执行更多的工作?”

图片

图 10.3 数据可以在多个执行之间分割。

定义 数据分解可以在我们过程的各个点进行。输入数据分解发生在我们将程序输入数据分割并通过多个并发执行处理时。

在输入数据分解中,我们将程序输入数据分割并分配给我们的各种执行。例如,在第三章中,我们编写了一个并发程序,下载了各种网络文档并计算了字母频率。我们选择了输入数据分解设计,其中每个输入 URL 都分配给一个单独的 goroutine。该 goroutine 从输入 URL 下载文档,并在共享数据结构上计算字母。

定义 在输出数据分解中,我们使用程序的输出数据来在执行之间分配工作。

相比之下,我们在第六章中的矩阵乘法是基于输出数据分解的。在那个例子中,我们有单独的 goroutine,每个 goroutine 负责计算一个输出矩阵行的结果(见图 10.4)。对于一个 3×3 的矩阵,goroutine 0 计算第 0 行的结果,goroutine 1 计算第 1 行的结果,依此类推,直到整个矩阵。

图片

图 10.4 使用每执行一行输出数据的输出数据分解

注意:任务和数据分解是设计并发程序时应一起应用的原则。大多数并发应用程序都采用任务和数据分解的混合方法来实现高效的解决方案。

10.1.3 考虑粒度

当我们将问题的一部分分配给各种并发执行时,我们的子任务或数据块应该有多大?这就是我们所说的任务粒度。在粒度谱的一端,我们有细粒度任务,其中问题被分解成大量的小任务。在另一端,当问题被分解成几个大型任务时,我们说我们有粗粒度任务。

要理解任务粒度,我们可以想象一个开发者团队共同努力交付一个在线网店。我们可以将项目交付分解成更小的任务,并分配给开发者。如果我们使任务过于粗略,任务就很少且很大。如此少的任务,我们可能没有足够的任务分配给每个人。即使我们有每个开发者的任务,如果它们过于粗略,我们可能会有些开发者忙于处理他们的大型任务,而其他人则在快速完成他们的较小任务后闲置。这是因为每个任务中的工作量会有所不同。

如果另一方面,我们将项目分解成过于细粒度的任务,我们就能将工作分配给更多的开发者(如果他们可用)。此外,我们不太可能遇到不平衡的情况,即一些开发者空闲没有工作,而其他人正忙于处理大型任务。然而,在将任务分解得太细的情况下,我们创造了一个开发者浪费大量时间在会议上讨论谁做什么以及何时做的局面。大量的努力将花费在协调和同步各种任务上,整体效率将下降。

在这两个极端之间,存在一个最佳点,将给我们带来最大的加速——一个能够使我们以最短时间交付项目的任务粒度。这个甜蜜点的位置(见图 10.5)将取决于许多因素,例如我们有多少开发者以及他们需要参加多少次会议(在沟通上花费的时间)。最大的因素将是我们的项目性质,这将决定我们可以并行化多少任务,因为项目的某些部分将依赖于其他任务。

图片

图 10.5 构建在线商店时的任务粒度

选择合适的任务粒度类型的原则同样适用于我们的算法和程序。任务粒度对我们的软件并行执行性能有重大影响。确定最佳粒度取决于许多因素,但主要取决于你试图解决的问题。将问题分解成许多小任务(细粒度)意味着当我们的程序执行时,它将具有更多的并行性(如果存在额外的处理器)和更大的加速效果。然而,由于我们的任务过于细粒度而导致的增加的同步和通信将限制可扩展性。随着我们增加并行性,我们可能会对加速效果产生可忽略或甚至负面的影响。

如果我们选择粗粒度,我们将减少执行之间的许多通信和同步需求。然而,拥有少量的大型任务可能会导致较小的加速效果,并可能导致我们的执行之间出现负载不平衡。正如我们在在线商店的例子中一样,我们需要找到适合我们场景的正确平衡。这可以通过建模、实验和测试来实现。

提示:需要非常少的通信和同步(由于解决问题的性质)的并发解决方案通常允许我们拥有更细粒度的解决方案并实现更大的加速效果。

10.2 并发实现模式

一旦我们使用任务和数据分解的组合将问题分解,我们就可以为我们的实现应用常见的并发模式。这些模式中的每一个都适用于特定的场景,尽管我们有时可以在单个解决方案中结合多个模式。

10.2.1 循环级并行性

当我们需要对一组数据进行任务操作时,我们可以使用并发来同时在不同部分上执行多个任务。一个串行程序可能有一个循环来依次对集合中的每个项目执行任务。循环级并行模式将每个迭代任务转换为一个并发任务,以便可以并行执行。

假设我们需要编写一个程序来计算特定目录中文件列表的哈希码。在顺序编程中,我们会编写一个文件哈希函数(如下所示)。然后我们的程序会从目录中收集文件列表并遍历它们。在每次迭代中,我们会调用我们的哈希函数并打印结果。

列表 10.1 SHA256 文件哈希函数(为简洁起见省略了错误处理)

package listing10_1

import (
    "*crypto/sha256*"
    "*io*"
    "*os*"
)

func FHash(filepath string) []byte {
    file, _ := os.Open(filepath)      ❶
    defer file.Close()

    sha := sha256.New()               ❷
    io.Copy(sha, file)                ❷

    return sha.Sum(nil)               ❸
}

❶ 打开文件

❷ 使用 crypto sha256 库计算哈希码

❸ 返回哈希结果

我们可以不按顺序逐个处理目录中的每个文件,而是使用循环级别的并行性,并将每个文件馈送到一个单独的 goroutine。列表 10.2 从指定的目录读取所有文件,然后在一个循环中遍历每个文件。对于每个迭代,它启动一个新的 goroutine 来计算该迭代中文件的哈希码。这个列表使用 waitgroup 来暂停main() goroutine,直到所有任务完成。

列表 10.2 使用循环级别并行性计算文件哈希码

package main

import (
    "*fmt*"
    "*github.com/cutajarj/ConcurrentProgrammingWithGo/chapter10/listing10.1*"
    "*os*"
    "*path/filepath*"
    "*sync*"
)

func main() {
    dir := os.Args[1]
    files, _ := os.ReadDir(dir)                          ❶
    wg := sync.WaitGroup{}
    for _, file := range files {
        if !file.IsDir() {
            wg.Add(1)
            go func(filename string) {                   ❷
                fPath := filepath.Join(dir, filename)
                hash := listing10_1.FHash(fPath)         ❸
                fmt.Printf("*%s - %x\n*", filename, hash)  ❸
                wg.Done()
            }(file.Name())
        }
    }
    wg.Wait()                                            ❹
}

❶ 从指定的目录获取文件列表

❷ 启动一个 goroutine 来计算迭代中文件的哈希码

❸ 使用先前开发的函数计算并输出文件的哈希码

❹ 等待所有任务完成,这些任务正在计算哈希码

在特定目录上运行前面的列表会产生目录中文件的哈希码列表:

$ go run dirfilehash.go ~/Pictures/
surf.jpg - e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
wave.jpg - 89e723f1dbd4c1e1cedb74e9603a4f84df617ba124ffa90b99a8d7d3f90bd535
sand.jpg - dd1b143226f5847dbfbcdc257fe3acd4252e45484732f17bdd110d99a1e451dc
. . .

在这个例子中,我们可以轻松地使用循环级别的并行模式,因为任务之间没有依赖关系。计算一个文件的哈希码的结果不会影响下一个文件的哈希码计算。如果我们有足够的处理器,我们可以在每个迭代上执行一个单独的处理器。但如果我们迭代中的计算依赖于前一个迭代中计算的一个步骤呢?

定义循环携带依赖是指同一循环中一个迭代中的一个步骤依赖于另一个迭代中的不同步骤。

让我们扩展我们的程序来计算整个目录的单个哈希码,以说明循环携带依赖的例子。计算整个目录内容的哈希码将告诉我们是否有任何文件被添加、删除或修改。为了简化问题,我们只考虑一个目录中的文件,并假设没有子目录。为了实现这一点,我们可以遍历每个文件并计算其哈希码。在相同的迭代中,我们可以将每个哈希结果组合成一个单一的哈希值。最后,我们将有一个代表整个目录的单个哈希值。

在列表 10.3 中,我们使用一个顺序的main()函数来做这件事。顺序程序显示每个迭代都依赖于前一个迭代。循环中的步骤i需要步骤i-1完成。我们将哈希码添加到我们的sha256函数中的顺序很重要。如果我们改变这个顺序,我们将产生不同的结果。

列表 10.3 计算整个目录的哈希码(省略了导入)

func main() {
    dir := os.Args[1]
    files, _ := os.ReadDir(dir)                        ❶
    sha := sha256.New()                                ❷
    for _, file := range files {
        if !file.IsDir() {
            fpath := filepath.Join(dir, file.Name())
            hashOnFile := listing10_1.FHash(fpath)     ❸
            sha.Write(hashOnFile)                      ❹
        }
    }
    fmt.Printf("*%s - %x\n*", dir, sha.Sum(nil))         ❺
}

❶ 从指定的目录获取文件列表

❷ 为目录创建一个新的、空的哈希容器

❸ 计算目录中每个文件的哈希码

❹ 将计算出的哈希码连接到目录中

输出最终的哈希码

在前面的列表中,我们有一个循环依赖;在我们添加当前迭代之前,我们必须将前一个迭代的哈希码添加到全局目录哈希中。这给我们的并发程序带来了问题。我们不能像之前那样使用同样的技巧,因为现在我们必须在开始下一个迭代之前等待前一个迭代完成。相反,我们可以利用每个迭代内部指令的部分是独立的事实,并并发执行这些指令。然后我们可以使用同步技术来按正确的顺序计算携带依赖步骤。

在我们的目录哈希应用中,由于它是独立的,我们可以并行计算文件哈希码。在每次迭代中,我们需要等待前一个迭代完成,然后才能将文件哈希码添加到全局目录哈希中。图 10.6 展示了如何实现这一点。每次迭代的冗长部分——读取文件和计算文件哈希码——与同一循环中的任何其他迭代完全独立。这意味着我们可以在一个 goroutine 中执行这部分,而不需要等待。

图 10.6 每个迭代中的文件哈希计算可以并行进行。

一旦 goroutine 完成文件哈希的计算,它必须等待前一个迭代完成。在我们的实现中,我们使用通道来实现这个等待。每个 goroutine 等待从前一个迭代接收信号。一旦它计算了部分目录哈希码,它就通过向下一个迭代发送通道消息来发送完成信号。这在上面的列表中有所展示。

列表 10.4 目录哈希中的循环依赖(省略导入)

func main() {
    dir := os.Args[1]
    files, _ := os.ReadDir(dir)
    sha := sha256.New()
    var prev, next chan int
    for _, file := range files {
        if !file.IsDir() {
            next = make(chan int)                        ❶
            go func(filename string, prev, next chan int) {
                fpath := filepath.Join(dir, filename)
                hashOnFile := listing10_1.FHash(fpath)   ❷
                if prev != nil {                         ❸
                    <-prev                               ❸
                }                                        ❸
                sha.Write(hashOnFile)                    ❹
                next <- 0                                ❺
            }(file.Name(), prev, next)
            prev = next                                  ❻
        }
    }
    <-next                                               ❼
    fmt.Printf("*%x\n*", sha.Sum(nil))
}

❶ 创建 goroutine 用于发送就绪信号的下一个通道

❷ 在文件上计算哈希码

❸ 如果 goroutine 不在第一个迭代中,则等待直到前一个迭代发送信号

❹ 计算目录部分哈希

❺ 向下一个迭代发送完成信号

❻ 将下一个通道分配为前一个;下一个 goroutine 将等待当前迭代的信号

❽ 在输出结果之前等待最后一个迭代完成

注意 Go 的os.ReadDir()函数按目录顺序返回条目。这是我们列表正确工作的关键要求。如果顺序未定义,每次运行程序(目录未更改)时,哈希结果可能不同。

main() goroutine 通过在next通道上等待就绪消息来等待最终迭代完成。然后它打印出目录哈希码的结果。在上面的列表中,就绪消息只是发送到通道上的一个0。以下是列表 10.4 的输出:

$ go run dirhashsequential.go ~/Pictures/
7200bdf2b90fc5e65da4b2402640986d37c9a40c38fd532dc0f5a21e2a160f6d

10.2.2 分支/合并模式

fork/join 模式在需要创建多个执行以并行执行任务,然后收集和合并这些执行结果的情况下很有用。在这个模式中,程序为每个任务启动一个执行,然后等待所有这些任务完成后再继续。让我们在一个程序中使用 fork/join 模式来搜索具有深层嵌套代码块的源文件。

深层嵌套的代码难以阅读。以下代码的嵌套深度为 3,因为它在关闭之前打开了三个嵌套的代码块:

    if x > 0 {
        if y > 0 {
            if z > 0 {
                //do something
            }
        } else {
            //do something else
        }
    }

我们希望编写一个程序,递归地扫描目录并找到具有最深嵌套块的源文件。列表 10.5 显示了一个函数,当给定一个文件名时,它会读取该文件并返回该源文件的嵌套代码深度。它是通过每次找到开括号时增加计数器,找到闭括号时减少计数器来实现的。该函数跟踪找到的最高值,并将其与文件名一起返回。

列表 10.5 查找最深嵌套的代码块(省略了导入和错误处理)

package main

import (*...*)

type CodeDepth struct {file  string; level int}

func deepestNestedBlock(filename string) CodeDepth {
    code, _ := os.ReadFile(filename)                            ❶
    max := 0
    level := 0
    for _, c := range code {                                    ❷
        if c == *’{’* {
            level += 1                                          ❸
            max = int(math.Max(float64(max), float64(level)))   ❹
        } else if c == *’}’* {
            level -= 1                                          ❺
        }
    }
    return CodeDepth{filename, max}                             ❻
}

❶ 将整个文件读入内存缓冲区

❷ 遍历文件中的每一个字符

❸ 当字符是一个开括号时,将层级加 1

❹ 记录层级变量的最大值

❺ 当花括号关闭时,将层级减 1

❻ 返回包含文件名的结果

我们现在需要逻辑来递归地在目录中找到的所有源文件上运行这个函数。在一个顺序程序中,我们会简单地依次调用这个函数处理所有文件,并跟踪代码深度的最大值。图 10.7 展示了我们如何使用 fork/join 模式来并发地解决这个问题。在 fork 部分,main() goroutine 生成了一个执行deepestNestedBlock()函数的 goroutine 集合,然后它将结果输出到一个公共通道上。

图片

图 10.7 使用 fork/join 模式扫描源文件

模式的 join 部分是我们消费公共输出通道并等待所有 goroutine 完成的时候。在这个例子中,我们通过一个单独的 join goroutine 来实现这一部分,它收集结果并跟踪最深嵌套块。当它完成时,这个 goroutine 将结果发送到main() goroutine 以在控制台上输出。

在我们的实现中,main() goroutine 等待一个 waitgroup 直到所有 forked goroutines 完成。当 waitgroup 完成(意味着 forked goroutines 已经完成),它关闭公共输出通道。当 join goroutine 注意到公共通道已被关闭时,它将包含最深嵌套块文件名的结果发送到另一个通道给main()main() goroutine 简单地等待这个结果并在控制台上打印它。

列表 10.6 实现了此模式中的 fork 部分。它验证给定的路径不是目录,将 1 添加到 waitgroup,并启动一个 goroutine 在文件名上执行deepestNestedBlock()函数。在此列表中,我们不处理目录,因为我们稍后在main()函数中从filepath.Walk()调用此函数。deepestNestedBlock()的返回值发送到公共结果通道。一旦函数完成,它就在 waitgroup 上调用Done()

列表 10.6 实现了 fork/join 模式中的 fork 部分

func forkIfNeeded(path string, info os.FileInfo,
    wg *sync.WaitGroup, results chan CodeDepth) {
    if !info.IsDir() && strings.HasSuffix(path, "*.go*") {  ❶
        wg.Add(1)                                         ❷
        go func() {                                       ❸
            results <- deepestNestedBlock(path)           ❹
            wg.Done()                                     ❺
        }()
    }
}

❶ 通过检查扩展名验证路径是一个文件,并且它是一个 Go 源文件

❷ 将 1 添加到 waitgroup

❸ 启动一个新的 goroutine

❹ 调用函数并将返回值写入公共结果通道

❺ 标记 waitgroup 上的工作完成

对于 fork/join 模式中的 join 部分,我们需要一个 goroutine 来收集来自公共输出通道的结果,如列表 10.7 所示。joinResults() goroutine 从该公共通道消费并记录接收到的结果中最深嵌套块的最大值。一旦公共通道关闭,它将结果写入主通道,finalResult

列表 10.7 将结果合并到最终结果通道

func joinResults(partialResults chan CodeDepth) chan CodeDepth {
    finalResult := make(chan CodeDepth)    ❶
    max := CodeDepth{"", 0}
    go func() {
        for pr := range partialResults {   ❷
            if pr.level > max.level {      ❸
                max = pr                   ❸
            }                              ❸
        }
        finalResult <- max                 ❹
    }()
    return finalResult
}

❶ 创建一个将包含最终结果的通道

❷ 接收通道中的结果,直到它关闭

❸ 记录最深嵌套块的值

❹ 在通道关闭后,将结果写入输出通道

在列表 10.8 中,我们的main()函数将所有东西连接起来。我们首先创建公共通道和 waitgroup。然后我们递归地遍历参数中指定的目录中的所有文件,并为遇到的每个源文件创建一个 goroutine。最后,我们通过启动收集结果的 goroutine 来连接一切,我们等待 forked goroutines 完成,关闭公共通道,然后最终从finalResult通道读取结果。

列表 10.8 main()函数创建 goroutine 并输出结果

func main() {
    dir := os.Args[1]                                             ❶
    partialResults := make(chan CodeDepth)                        ❷
    wg := sync.WaitGroup{}

    filepath.Walk(dir,                                            ❸
        func(path string, info os.FileInfo, err error) error {    ❸
            forkIfNeeded(path, info, &wg, partialResults)         ❸
            return nil                                            ❸
        })                                                        ❸

    finalResult := joinResults(partialResults)                    ❹

    wg.Wait()                                                     ❺

    close(partialResults)                                         ❻

    result := <-finalResult                                       ❼
    fmt.Printf("*%s has the deepest nested code block of %d\n*",    ❼
        result.file, result.level)                                ❼
}

❶ 从参数中读取根目录

❷ 创建所有 forked goroutines 使用的公共通道

❸ 遍历根目录,并对每个文件调用 fork 函数,创建 goroutines

❹ 调用 join 函数并获取包含最终结果的通道

❺ 等待所有 forked goroutines 完成其工作

❻ 关闭公共通道,向 join goroutine 发出工作完成的信号

❼ 接收最终结果并在控制台输出

注意:与之前的目录哈希场景不同,此示例不依赖于部分结果的顺序来计算完整结果。没有这个要求使我们能够轻松采用 fork/join 模式,其中我们可以在 join 部分聚合结果。

当我们将所有列表放在一起时,我们可以用它来扫描指定的顶级源目录,以找出哪个文件具有最深的代码块。以下是输出:

$ go run deepestnestedfile.go ~/projects/ConcurrentProgrammingWithGo/
~/projects/ConcurrentProgrammingWithGo/chapter9/listing9.12_13/longestwords.go has the deepest nested code block of 6

10.2.3 使用工作池

在某些情况下,我们不知道我们将得到多少工作量。如果工作量将根据需求而变化,那么分解我们的算法并使它们并发工作可能会很困难。例如,我们可能有一个 HTTP 服务器,它每秒处理的请求数量会根据访问网站的用户的数量而变化。

在现实生活中,解决方案是拥有一定数量的工作者和一个工作队列。想象一下,一个银行分行有几位出纳员为单个客户队列提供服务。在并发编程中,工作池模式复制了这种现实生活中的队列和工作者模型。

同一模式的多种名称

工作池模式及其轻微的变化以许多不同的名称为人所知,例如线程池模式、复制工作者、主/工作模式或工作者-团队模型。

在工作池模式中,我们创建并准备好接受工作的 goroutine 数量是固定的。在这个模式中,goroutine 要么是空闲的,等待任务,要么正忙于执行一个任务。工作通过公共工作队列传递给工作池。当所有工作者都忙碌时,工作队列的大小会增加。如果工作队列填满其全部容量,我们可以停止接受更多的工作。在某些工作池实现中,通过增加 goroutine 的数量,工作池的大小也可以增加到一定限制,以处理额外的负载。

要看到这种并发模式的实际应用,让我们实现一个非常简单的 HTTP 网络服务器,它将静态文件作为网络资源提供服务。我们 HTTP 服务器中的工作池模式可以在图 10.8 中看到。在我们的设计中,几个 goroutine 参与工作池,等待工作队列中的工作到来。我们使用 Go 通道实现工作队列。当所有工作 goroutine 都从同一个通道中读取时,这会对通道上的项目进行负载均衡,使所有工作都分配给所有工作者。

图 10.8 在 HTTP 服务器中使用工作池

在我们的 HTTP 网络服务器中,我们的main() goroutine 从客户端接受套接字连接。一旦连接打开,main() goroutine 通过将连接放入通道将它们传递给任何空闲的工作者。空闲工作者处理 HTTP 请求,并以适当的响应回复。一旦响应发送,工作者 goroutine 就会回到通道上等待下一个连接。

列表 10.9 显示了最简单的 HTTP 协议处理。在列表中,我们从连接中读取请求(使用正则表达式),从资源目录中加载请求的文件,并以适当的头信息将文件内容作为响应返回。如果文件不存在或请求无效,函数会以适当的 HTTP 错误响应。这是工作池中的每个 goroutine 在从main() goroutine 在通道中接收到连接时将执行的逻辑。

列表 10.9 简单的 HTTP 响应处理器

package listing10_9

import (
    "*fmt*"
    "*net*"
    "*os*"
    "*regexp*"
)

var r, _ = regexp.Compile("*GET (.+) HTTP**/1.1\r\n*")

func handleHttpRequest(conn net.Conn) {
    buff := make([]byte, 1024)              ❶
    size, _ := conn.Read(buff)              ❷
    if r.Match(buff[:size]) {               ❸
        file, err := os.ReadFile(           ❸
            fmt.Sprintf("*../resources/%s*", r.FindSubmatch(buff[:size])[1]))
        if err == nil {                     ❹
            conn.Write([]byte(fmt.Sprintf(
               "*HTTP**/1.1 200 OK\r\nContent-Length: %d\r\n\r\n*",len(file))))
            conn.Write(file)
        } else {                            ❺
            conn.Write([]byte(              ❺
               "*HTTP**/1.1 404 Not Found\r\n\r\n<html>Not Found</html>*"))
        }
    } else {                                ❻
        conn.Write([]byte("*HTTP**/1.1 500 Internal Server Error\r\n\r\n*"))
    }
    conn.Close()                            ❼
}

❶ 创建缓冲区以存储 HTTP 请求

❷ 从连接读取到缓冲区

❸ 如果请求是有效的,则从资源目录读取请求文件

❹ 如果文件存在,则向客户端响应 HTTP 头和文件内容

❺ 如果文件不存在,则响应错误

❻ 如果 HTTP 请求无效,则响应错误

❼ 在处理请求后关闭连接

以下列表初始化工作池中的所有 goroutine。该函数简单地启动 n 个 goroutine,每个 goroutine 从包含客户端连接的输入通道中读取。当通道接收到新的连接时,调用 handleHttpRequest() 函数来处理客户端的请求。

列表 10.10 启动工作池

func StartHttpWorkers(n int, incomingConnections <-chan net.Conn) {
    for i := 0; i < n; i++ {                        ❶
        go func() {                                 ❶
            for c := range incomingConnections {    ❷
                handleHttpRequest(c)                ❸
            }
        }()
    }
}

❶ 启动 n 个 goroutine

❷ 从工作队列通道消耗连接,直到通道关闭

❸ 处理接收到的连接上的 HTTP 请求

接下来,我们需要 main() goroutine 在一个端口上监听新的连接,并将任何新建立的连接传递到工作队列通道。在列表 10.11 中,main() 函数创建工作队列通道,启动工作池,然后在端口 8080 上绑定 TCP 监听连接。在一个无限循环中,当建立新的连接时,Accept() 函数解除阻塞并返回连接。然后,这个连接通过通道传递给工作池中的某个 goroutine 使用。

列表 10.11 main() 函数将工作传递给工作池(省略错误处理)

package main

import (
    "*github.com/cutajarj/ConcurrentProgrammingWithGo/chapter10/listing10.9*"
    "*net*"
)

func main() {
    incomingConnections := make(chan net.Conn)             ❶
    listing10_9.StartHttpWorkers(3, incomingConnections)   ❷

    server, _ := net.Listen("*tcp*", "*localhost:8080*")       ❸
    defer server.Close()
    for {
        conn, _ := server.Accept()                         ❹
        incomingConnections <- conn                        ❺
    }
}

❶ 创建工作队列通道

❷ 使用三个 goroutine 启动工作池

❸ 将 TCP 监听连接绑定到端口 8080

❹ 阻塞直到有来自客户端的新连接

❺ 将连接通过工作队列通道传递

我们可以通过将浏览器指向 http://localhost:8080/index.html 或使用以下 curl 命令来测试前面的列表:

$ go run httpserver.go &
. . .
$ curl localhost:8080/index.html
<!DOCTYPE html>
<html>
<head>
    <title>Learn Concurrent Programming with Go</title>
</head>
<body><h1>Learn Concurrent Programming with Go</h1><img src="cover.png"></body>

注意:工作池模式在创建新的执行线程成本高昂时特别有用。在我们有新工作要做时,不是即时创建线程,而是在处理开始之前创建工作池,并且重用工作者。这样,当我们需要新工作来完成时,可以节省更多的时间。在 Go 中,创建 goroutine 是一个非常快速的过程,因此这种模式在性能方面不会带来很多好处。

尽管在工作池中 Go 不提供很多性能优势,但它们仍然可以用来限制并发量,以便程序和服务器不会耗尽资源。在我们的 HTTP 服务器中,我们可以选择在整个工作池忙碌时停止处理客户端连接,如图 10.9 所示。我们可以以非阻塞方式使用通道,以便 main() goroutine 向客户端返回“服务器忙碌”错误。

图片

图 10.9 服务器检测到太忙并返回错误消息

列表 10.12 在工作队列通道上实现了这种非阻塞行为。在这个列表中,我们使用了一个select语句,当没有空闲的工作池 goroutines 时,触发默认情况。默认情况中的逻辑向客户端返回“忙碌”错误信息。

列表 10.12 使用select的默认情况来限制服务器的负载

package main

import (
    "*fmt*"
    "*github.com/cutajarj/ConcurrentProgrammingWithGo/chapter10/listing10.9*"
    "*net*"
)

func main() {
    incomingConnections := make(chan net.Conn)
    listing10_9.StartHttpWorkers(3, incomingConnections)
    server, _ := net.Listen("*tcp*", "*localhost:8080*")
    defer server.Close()
    for {
        conn, _ := server.Accept()
        select {
        case incomingConnections <- conn:
        default:                               ❶
            fmt.Println("*Server is busy*")
            conn.Write([]byte("*HTTP**/1.1 429 Too Many Requests\r\n\r\n*" +
                "*<html>Busy</html>\n*")         ❷
            conn.Close()                       ❸
        }
    }
}

❶ 当没有 goroutines 从工作队列中消费时,触发默认情况。

❷ 向客户端返回“忙碌”信息

❸ 关闭客户端连接

当我们打开许多同时连接时,我们可以触发这个“忙碌”的错误信息。我们的工作池非常小,只有三个 goroutines,所以很容易让整个池子变得忙碌。使用以下命令,我们可以看到服务器返回这个错误信息。在这个命令中,带有-P100选项的xargs并行执行curl请求,有 100 个进程:

$ seq 1 2000 | xargs -Iname  -P100  curl -s "http://localhost:8080/index.html" | grep Busy
</html><html>Busy</html>
</html><html>Busy</html>
</html><html>Busy</html>
. . .

10.2.4 管道化

如果分解我们问题的唯一方式是拥有一系列任务,其中每个任务完全依赖于前一个任务完成,那会怎样?例如,考虑这样一个场景:我们正在运营一个纸杯蛋糕工厂。在我们工厂中制作纸杯蛋糕涉及以下步骤:

  1. 准备烘焙托盘。

  2. 倒入纸杯蛋糕混合物。

  3. 在烤箱中烘烤混合物。

  4. 添加配料。

  5. 将纸杯蛋糕装箱以供配送。

如果我们想要加快速度,仅仅雇佣员工并让他们去完成任何需要做的任务,从效率的角度来看,这并不是一个非常有效的策略,因为除了第一个步骤之外,每个步骤都依赖于前一个步骤。当我们有这种沉重的任务依赖时,应用管道模式将允许我们在相同的时间内完成更多的工作。

管道模式在许多制造业中都有应用。一个常见的例子是现代汽车装配线。汽车的底盘沿着生产线移动,在每一个阶段,不同的机器人会对正在建造的汽车执行不同的动作(例如安装零件)。

我们可以在我们的例子中使用同样的原则。我们可以让人们在不同的纸杯蛋糕批次上并行工作。每个人都在执行之前概述的不同步骤,一个步骤的输出被输入到下一个步骤(见图 10.10)。这样,我们可以充分利用劳动力,并在给定的时间内增加可以生产的纸杯蛋糕数量。

图片

图 10.10 使用管道模式的纸杯蛋糕工厂

在某些技术问题中,我们只能以这种方式分解任务。例如,考虑一个声音处理应用程序,其中需要将多个过滤器(如降噪、高切、带通等)叠加应用于声音流。还有类似的例子适用于视频和图像处理。在前一章中,我们使用管道模式构建了一个应用程序,该应用程序从网页下载文档,提取单词,然后计算单词频率。

让我们继续使用我们的纸杯蛋糕示例,并尝试实现一个模拟此过程的程序。然后我们可以使用这个程序来检查典型管道的各种属性。在以下列表中,我们在图 10.10 中概述的步骤在单独的函数中。在每个函数中,我们通过暂停 2 秒来模拟工作,除了 Bake() 函数,在那里我们暂停 5 秒。

列表 10.13 制作纸杯蛋糕的步骤

package listing10_13

import (
    "*fmt*"
    "*time*"
)

const (
    ovenTime           = 5
    everyThingElseTime = 2
)

func PrepareTray(trayNumber int) string {
    fmt.Println("*Preparing empty tray*", trayNumber)
    time.Sleep(everyThingElseTime * time.Second)        ❶
    return fmt.Sprintf("*tray number %d*", trayNumber)    ❷
}

func Mixture(tray string) string {
    fmt.Println("*Pouring cupcake Mixture in*", tray)
    time.Sleep(everyThingElseTime * time.Second)
    return fmt.Sprintf("*cupcake in %s*", tray)
}

func Bake(mixture string) string {
    fmt.Println("*Baking*", mixture)
    time.Sleep(ovenTime * time.Second)                  ❸
    return fmt.Sprintf("*baked %s*", mixture)
}

func AddToppings(bakedCupCake string) string {
    fmt.Println("*Adding topping to*", bakedCupCake)
    time.Sleep(everyThingElseTime * time.Second)
    return fmt.Sprintf("*topping on %s*", bakedCupCake)
}

func Box(finishedCupCake string) string {
    fmt.Println("*Boxing*", finishedCupCake)
    time.Sleep(everyThingElseTime * time.Second)
    return fmt.Sprintf("*%s boxed*", finishedCupCake)
}

❶ 除了烘焙步骤外,每个步骤都暂停 2 秒来模拟工作。

❷ 每个函数返回所执行操作的描述。

❸ 烘焙步骤暂停 5 秒而不是 2 秒。

为了比较并行执行与顺序执行的速度提升,让我们首先使用以下列表中概述的顺序程序依次执行所有步骤。在这里,我们通过依次执行一个步骤来模拟一个人生产 10 盒纸杯蛋糕。

列表 10.14 main() 函数顺序生产 10 盒纸杯蛋糕

package main

import (
    "*fmt*"
   "*github.com/cutajarj/ConcurrentProgrammingWithGo/chapter10/listing10.13*"
)

func main() {
    for i := 0; i < 10; i++ {                               ❶
        result := listing10_13.Box(                         ❷
            listing10_13.AddToppings(                       ❷
                listing10_13.Bake(                          ❷
                    listing10_13.Mixture(                   ❷
                        listing10_13.PrepareTray(i)))))     ❷
        fmt.Println("*Accepting*", result)
    }
}

❶ 执行 10 次

❷ 依次顺序执行一个步骤

当依次顺序执行一个步骤时,完成一盒纸杯蛋糕大约需要 13 秒。在我们的程序中,完成 10 盒纸杯蛋糕大约需要 130 秒,如执行前两个列表的输出所示:

$ time go run cupcakeoneman.go
Preparing empty tray 0
Pouring cupcake Mixture in tray number 0
Baking cupcake in tray number 0
Adding topping to baked cupcake in tray number 0
Boxing topping on baked cupcake in tray number 0
Accepting topping on baked cupcake in tray number 0 boxed
Preparing empty tray 1
. . .
Boxing topping on baked cupcake in tray number 9
Accepting topping on baked cupcake in tray number 9 boxed

real    2m10.979s
user    0m0.127s
sys    0m0.152s

现在我们将我们的程序转换为以管道方式运行的多重执行。简单管道中的步骤都遵循相同的模式:从类型为 X 的输入通道接收输入,处理 X,并在类型为 Y 的输出通道上产生结果 Y。图 10.11 显示了我们可以构建一个可重用组件,该组件创建一个从输入通道读取类型 X 的 goroutine,调用将 X 映射到 Y 的函数,并在输出通道上输出 Y。

图片

图 10.11 管道步骤接受 X,调用函数将其映射到 Y,并输出 Y

在列表 10.15 中,我们实现了这一点。在签名中,我们接受输入和输出通道以及一个映射函数 fAddOnPipe() 函数创建一个输出通道并启动一个无限循环调用映射函数的 goroutine。在实现中,我们使用通常的退出通道模式,如果退出通道(列表中命名为 q 的参数)被关闭,则停止。我们利用 Go 的泛型,以确保通道和映射函数的类型匹配。

列表 10.15 可重用管道节点

package main

import (
    "*fmt*"
   "*github.com/cutajarj/ConcurrentProgrammingWithGo/chapter10/listing10.13*"
)

func AddOnPipeX, Y any Y, in <-chan X) chan Y {
    output := make(chan Y)              ❶
    go func() {                         ❷
        defer close(output)
        for {                           ❸
            select {                    ❸
            case <-q:                   ❹
                return                  ❹
            case input := <-in:         ❺
                output <- f(input)      ❻
            }
        }
    }()
    return output
}

❶ 创建类型为 Y 的输出通道

❷ 启动 goroutine

❸ 在无限循环中调用 select

❹ 当退出通道关闭时,退出循环并终止 goroutine

❺ 如果有可用的消息,则在输入通道上接收消息

❻ 调用函数 f 并在输出通道上输出函数的返回值

我们现在可以使用列表 10.15 中的函数,将我们曲奇工厂的所有步骤添加到一个公共管道中。在下面的列表中,我们有一个main()函数,它使用AddOnPipe()函数包装每个步骤。然后它启动一个 goroutine,向PrepareTray()步骤中输入 10 条消息。这相当于运行我们的管道 10 次。

列表 10.16 连接和启动我们的曲奇管道

func main() {
    input := make(chan int)                        ❶
    quit := make(chan int)                         ❷
    output := AddOnPipe(listing10_1.Box, quit,     ❸
        AddOnPipe(quit, listing10_1.AddToppings,
            AddOnPipe(quit, listing10_1.Bake,
                AddOnPipe(quit, listing10_1.Mixture,
                    AddOnPipe(quit, listing10_1.PrepareTray, input)))))
    go func() {                                    ❹
        for i := 0; i < 10; i++ {                  ❹
            input <- i                             ❹
        }                                          ❹
    }()
    for i := 0; i < 10; i++ {                      ❺
        fmt.Println(<-output, "*received*")          ❺
    }                                              ❺
}

❶ 创建第一个输入通道,用于连接到第一个步骤

❷ 创建退出通道

❸ 将管道上的每个步骤连接起来,将每个步骤的输出馈送到下一个步骤的输入

❹ 创建一个 goroutine,向管道发送 10 个整数以生产 10 个曲奇盒子

❺ 从上一个管道步骤读取 10 个曲奇盒子作为输出

main()函数的末尾,我们等待 10 条消息到达并在控制台上打印出消息。以下是运行上一个列表时的输出:

$ time go run cupcakefactory.go
Preparing empty tray 0
Preparing empty tray 1
Pouring cupcake Mixture in tray number 0
Pouring cupcake Mixture in tray number 1
Preparing empty tray 2
Baking cupcake in tray number 0
Baking cupcake in tray number 1
Pouring cupcake Mixture in tray number 2
Preparing empty tray 3
Adding topping to baked cupcake in tray number 0
. . .
Boxing topping on baked cupcake in tray number 8
topping on baked cupcake in tray number 8 boxed received
Adding topping to baked cupcake in tray number 9
Boxing topping on baked cupcake in tray number 9
topping on baked cupcake in tray number 9 boxed received

real    0m58.780s
user    0m0.106s
sys    0m0.289s

使用我们算法的管道版本导致执行速度加快,大约为 58 秒而不是 130 秒。我们能否通过加快完成某些步骤所需的时间来进一步提高它?让我们对时间进行实验,在这个过程中,我们将发现管道模式的一些属性。

10.2.5 管道属性

如果我们加快所有手动步骤(不包括烘焙时间)的速度会发生什么?在我们的程序中,我们可以将常数everyThingElseTime(从列表 10.1)减小到一个更小的值。这样,除了烘焙时间之外的所有步骤都会运行得更快。以下是设置everyThingElseTime = 1时的输出:

$ time go run cupcakefactory.go
Preparing empty tray 0
. . .
topping on baked cupcake in tray number 9 boxed received

real    0m55.579s
user    0m0.117s
sys    0m0.242s

这里发生了什么?我们几乎加倍了几乎每个步骤的速度,但生产 10 个盒子的总时间几乎保持不变。要了解发生了什么,请看图 10.12。

图 10.12 增加非烘焙部分的速度并不会显著提高吞吐量。

注意:在管道中,吞吐量 速率 由最慢的步骤决定。系统的延迟是执行每一步所需时间的总和。

如果我们的管道是真实的,四个人会工作得快两倍,但在吞吐量方面几乎没有任何区别。这是因为我们的管道中的瓶颈是烘焙时间。我们最慢的步骤受限于我们有一个慢烤箱,它使一切变慢。为了提高单位时间内生产的曲奇数量,我们应该专注于加快我们最慢的步骤。

提示:为了提高系统的吞吐量,始终最好关注该系统的瓶颈。这是对降低我们性能影响最大的部分。

加快大多数步骤已经对从开始到结束生产一盒纸杯蛋糕所需的时间产生了影响。在第一次运行中,我们用了 13 秒来生产一盒。当我们设置everyThingElseTime = 1时,这个时间下降到了 9 秒。我们可以将这视为系统延迟。对于某些应用程序(如后端批量处理),高吞吐量更重要,而对于其他应用程序(如实时系统),提高延迟更好。

提示:为了减少管道系统的延迟,我们需要提高管道中大多数步骤的速度。

让我们进一步通过改进烘焙步骤并使其更快来实验我们的管道。在现实生活中,我们可以得到一个更强大的烤箱,或者可能有多个可以并行工作的烤箱。在我们的程序中,我们可以简单地设置变量ovenTime = 2而不是5,并将everyThingElseTime恢复到2。当我们再次运行程序时,我们得到以下输出:

$ time go run cupcakefactory.go
Preparing empty tray 0
. . .
topping on baked cupcake in tray number 9 boxed received

real    0m30.197s
user    0m0.094s
sys    0m0.135s

我们大大提高了生产 10 盒纸杯蛋糕所需的时间。这种加速的原因在图 10.13 中很清楚。我们可以看到,我们现在在时间上更有效率。每个 goroutine 都在不断忙碌,没有任何空闲时间。这意味着我们提高了吞吐量——单位时间内生产的纸杯蛋糕数量。

图 10.13 加快最慢的步骤对吞吐量的影响更大。

值得注意的是,尽管我们已经提高了吞吐量,但生产一盒纸杯蛋糕(系统延迟)的时间并没有受到很大影响。现在从开始到结束生产一盒纸杯蛋糕需要 10 秒,而不是 13 秒。

10.3 练习

注意:访问github.com/cutajarj/ConcurrentProgrammingWithGo以查看所有代码解决方案。

  1. 实现与列表 10.4 中相同的目录哈希,但不是使用通道在迭代之间同步,而是尝试使用 waitgroups。

  2. 修改列表 10.2,使得main() goroutine 和工作者池之间的工作队列通道具有 10 条消息的缓冲区。这样做将为您提供容量缓冲区,以便当所有 goroutine 都忙碌时,一些请求在它们被选中之前可以排队。

  3. 以下列表下载 30 个网页,并按顺序计算所有文档的总行数。将此程序转换为使用本章中解释的并发编程模式。

列表 10.17 网页行数

package main

import (
    "*fmt*"
    "*io*"
    "*net/http*"
    "*strings*"
)

func main() {
    const pagesToDownload = 30
    totalLines := 0
    for i := 1000; i < 1000 + pagesToDownload; i++ {
        url := fmt.Sprintf("*https://rfc-editor.org/rfc/rfc%d.txt*", i)
        fmt.Println("*Downloading*", url)
        resp, _ := http.Get(url)
        if resp.StatusCode != 200 {
            panic("*Server’s error:* " + resp.Status)
        }
        bodyBytes, _ := io.ReadAll(resp.Body)
        totalLines += strings.Count(string(bodyBytes), "*\n*")
        resp.Body.Close()
    }
    fmt.Println("*Total lines:*", totalLines)
}

摘要

  • 分解是将程序分解为不同的部分,并找出哪些部分可以并发执行。

  • 构建依赖图有助于我们了解哪些任务可以与其他任务并行执行。

  • 任务分解是将问题分解为完成整个工作所需的不同动作。

  • 数据分解是将数据划分成一种方式,使得对数据的任务可以并行执行。

  • 在将程序分解时选择细粒度意味着在同步和通信上花费时间以限制可扩展性的同时,可以获得更多的并行性。

  • 选择粗粒度意味着并行性较少,但它减少了所需的同步和通信量。

  • 如果任务之间没有依赖关系,可以使用循环级别的并行性来并发执行一系列任务。

  • 在循环级别的并行性中,将问题分解为并行和同步部分,允许依赖于前一个任务迭代。

  • Fork/join 是一种并发模式,当问题有一个初始的并行部分和一个最终步骤来合并各种结果时可以使用。

  • 当并发需求需要按需扩展时,工作池是有用的。

  • 在工作池中预先创建执行比动态创建对大多数语言来说都要快。

  • 在 Go 语言中,由于 goroutines 的轻量级特性,预先创建工作池与动态创建 goroutines 的性能差异极小。

  • 当需求意外增加时,可以使用工作池来限制并发性,以免服务器过载。

  • 当每个任务都依赖于前一个任务完成时,管道对于提高吞吐量是有用的。

  • 增加管道中最慢节点的速度会导致整个管道的吞吐量性能提高。

  • 增加管道中任何节点的速度会导致管道延迟的减少。

11 避免死锁

本章涵盖

  • 识别死锁

  • 避免死锁

  • 使用通道进行死锁

在并发程序中,死锁发生时,执行会无限期地阻塞,等待彼此释放资源。死锁是某些并发程序的不可取副作用,在这些程序中,并发执行尝试同时获取多个资源的独占访问权限。在本章中,我们将分析可能导致死锁的条件,并提出防止它们发生的策略。我们还将讨论在使用 Go 通道时可能发生的某些死锁条件。

死锁可能非常难以识别和调试。与竞态条件一样,我们可能有一个长时间运行而没有任何问题的程序,然后突然执行停止,没有明显的原因。了解死锁发生的原因使我们能够做出编程决策来避免它们。

11.1 识别死锁

我们可以编写最简单的并发程序,创建所有可能导致死锁发生的条件是什么?我们可以创建一个简单的程序,其中包含两个协程竞争两个独占资源,如图 11.1 所示。这两个协程,称为red()blue(),都试图同时持有两个互斥锁。由于锁是独占的,只有一个协程可以在另一个协程不持有任何锁的情况下获得这两个锁。

图 11.1 两个协程竞争两个独占资源

列表 11.1 显示了red()blue()协程的简单实现。这两个函数接受我们的两个互斥锁,当我们以单独的协程运行这些函数时,它们将尝试同时获取这两个锁,然后再释放它们。这个过程会无限循环。在列表中,有多个消息指示我们何时获取、保持和释放锁。

列表 11.1 red()blue()协程(为了简洁省略了导入)

func red(lock1, lock2 *sync.Mutex) {
    for {
        fmt.Println("*Red: Acquiring lock1*")
        lock1.Lock()                             ❶
        fmt.Println("*Red: Acquiring lock2*")      ❶
        lock2.Lock()                             ❶
        fmt.Println("*Red: Both locks Acquired*")
        lock1.Unlock(); lock2.Unlock()           ❷
        fmt.Println("*Red: Locks Released*")
    }
}

func blue(lock1, lock2 *sync.Mutex) {
    for {
        fmt.Println("*Blue: Acquiring lock2*")
        lock2.Lock()                             ❶
        fmt.Println("*Blue: Acquiring lock1*")     ❶
        lock1.Lock()                             ❶
        fmt.Println("*Blue: Both locks Acquired*")
        lock1.Unlock(); lock2.Unlock()           ❷
        fmt.Println("*Blue: Locks Released*")
    }
}

❶ 获取并保持两个锁

❷ 释放两个锁

我们现在可以在main()函数中创建我们的两个互斥锁,并启动red()blue()协程,如列表 11.2 所示。启动协程后,main()函数将休眠 20 秒,在此期间我们预计red()blue()协程将连续输出控制台消息。20 秒后,main()协程终止,程序退出。

列表 11.2 main()函数启动red()blue()协程

func main() {
    lockA := sync.Mutex{}
    lockB := sync.Mutex{}
    go red(&lockA, &lockB)         ❶
    go blue(&lockA, &lockB)        ❷
    time.Sleep(20 * time.Second)   ❸
    fmt.Println("*Done*")
}

❶ 启动红色()协程

❷ 启动蓝色()协程

❸ 允许红色()和蓝色()协程运行 20 秒

以下是运行列表 11.1 和 11.2 的输出示例:

$ go run simpledeadlock.go
. . .
Blue: Locks Released
Blue: Acquiring lock2
Red: Acquiring lock1
Red: Acquiring lock2
Blue: Acquiring lock1

一段时间后,程序停止输出消息,看起来在 20 秒休眠期结束前就卡住了。此时,我们的red()blue()协程陷入了死锁,无法继续进行。大约 20 秒后,main()协程完成并退出程序。为了理解发生了什么以及死锁是如何发生的,我们将在下一节中查看资源分配图。

注意:由于并发执行的非确定性,运行列表 11.1 和 11.2 并不总是会导致死锁。我们可以在red()blue()协程的第一个和第二个mutex.Lock()调用之间添加Sleep()调用,以进一步提高死锁的可能性。

11.1.1 使用资源分配图描绘死锁

资源分配图 (RAG)显示了各种执行所使用的资源。它们在操作系统中用于各种功能,包括死锁检测。

绘制这些图可以帮助我们想象并发程序中的死锁。图 11.2 显示了列表 11.1 和 11.2 中发生的简单死锁情况。

图片

图 11.2 red()blue()协程的资源分配图

在资源分配图中,节点代表执行或资源。例如,在图 11.2 中,节点是我们的两个协程,与两个互斥锁进行交互。在图中,我们使用矩形节点表示资源,圆形节点表示协程。边显示了执行请求或持有哪些资源。从执行指向资源的边(图 11.2 中的虚线)表示执行正在请求使用该资源。从资源指向执行的边(实线)告诉我们资源正在被该执行使用。

图 11.2 显示了我们的简单程序中死锁发生的情况。在blue()协程获取锁 2 之后,它需要请求锁 1。red()协程持有锁 1,并需要请求锁 2。每个协程都持有一个锁,然后继续请求另一个锁。由于另一个锁被另一个协程持有,第二个锁永远不会被获取。这导致了两个协程将永远等待对方释放其锁的死锁情况。

注意:图 11.2 包含一个图环:从任何节点开始,我们可以沿着边追踪一条路径,最终回到我们的起始节点。每当资源分配图中存在这样的环时,这意味着发生了死锁。

死锁不仅仅发生在软件中。有时,现实生活中的场景会创造死锁发生的条件。例如,考虑一个铁路交叉布局,如图 11.3 所示。在这个简单的布局中,一列长火车可能需要同时使用多个铁路交叉道。

图片

图 11.3 可能导致死锁的铁路交叉路口布局

铁路交叉路口,从本质上讲,是排他性资源——在任何时候只能有一列火车使用它们。因此,接近交叉路口的火车需要请求并预留对其的访问权限,以便其他火车不能使用它。如果另一列火车已经在使用交叉路口,任何需要相同交叉路口的其他火车都必须等待交叉路口再次空闲。

一列足够长以至于可以跨越多个交叉口的火车可能需要同时使用多个交叉路口。这类似于我们的执行同时持有多个排他性资源(如互斥锁)。图 11.3 显示,从不同方向接近的每列火车都需要同时使用两个交叉路口。例如,从左向右移动的火车 1 需要交叉 A 和 B,从上向下移动的火车 2 需要交叉 B 和 C,依此类推。

获取多个交叉口的用途不是一个原子操作;火车 1 将首先获取并使用交叉 A,然后,稍后,再使用交叉 B。这可能会造成每种火车都对其第一个交叉路口持有控制权,但它在等待前面的火车释放第二个交叉路口。由于火车轨道是以创建循环资源(一个交叉路口)依赖关系的方式设置的,可能会出现死锁情况。图 11.4 展示了示例死锁。

图片

图 11.4 铁路系统中发生的死锁

正如 goroutines 可能会永远等待资源被释放一样,火车司机甚至可能不知道系统已经陷入死锁。从那个人的角度来看,他们正在等待前面的火车移动,以便他们可以释放交叉路口。同样,我们可以通过使用资源分配图来识别系统是否处于死锁状态,如图 11.5 所示。

图片

图 11.5 铁路死锁的资源分配图

资源分配图清楚地显示,存在一个循环,这表明我们遇到了死锁。每列火车都获取了交叉路口的使用权,但正在等待下一列火车释放下一个交叉路口。这是一个具有四个单独执行(火车)的死锁示例,尽管死锁可以发生在任何大于一的数量。我们可以通过以循环方式添加更多交叉路口和火车来轻松地设计出涉及任何数量火车的火车布局。

在 1971 年发表的一篇题为“系统死锁”的论文中,Coffman 等人阐述了必须全部满足以下四个条件才能发生死锁:

  • 互斥—系统中的每个资源要么被一个执行使用,要么是空闲的。

  • 等待条件—持有一个或多个资源的执行可以请求更多资源。

  • 无抢占—执行持有的资源不能被夺走。只有持有资源的执行可以释放它们。

  • 循环等待——存在一个由两个或更多执行组成的循环链,其中每个执行都在等待链中下一个执行释放资源时被阻塞。

在现实生活中,我们可以看到许多其他死锁的例子。这些例子包括关系冲突、谈判和道路交通。事实上,道路工程师花费大量时间和精力设计系统以最小化交通死锁的风险。现在让我们看看软件中更复杂的死锁例子。

11.1.2 账本中的死锁

假设我们在一家银行工作,并被分配实施读取账本交易以将资金从一个账户转移到另一个账户的软件。一笔交易会从源账户中扣除余额并添加到目标账户。例如,山姆支付保罗 10 美元意味着我们需要

  1. 读取山姆的账户余额

  2. 从山姆的账户中扣除 10 美元

  3. 读取保罗的账户余额

  4. 向保罗的余额中添加 10 美元

由于我们希望能够处理大量交易,我们将使用多个 goroutine 和共享内存来并发处理交易。为了避免竞态条件,我们可以在源账户和目标账户上使用互斥锁。这确保了在从某个账户中扣除资金并添加到另一个账户时,goroutine 不会被中断。图 11.6 展示了处理账本交易的 goroutine 的逻辑。程序是首先获取源账户的互斥锁,然后是目标账户的互斥锁,然后才移动资金。

图 11.6 在处理账本交易时使用互斥锁锁定源账户和目标账户

使用单独的互斥锁,每个账户一个,这样当我们处理交易时,我们只锁定所需的账户。列表 11.3 展示了一个包含此互斥锁、标识符和余额的 BankAccount 类型结构。列表还包含一个 NewBankAccount() 函数,该函数实例化一个新的银行账户,默认余额为 100 美元和一个新的互斥锁。

列表 11.3 银行账户类型结构

package listing11_3_4

import (
    "*fmt*"
    "*sync*"
)

type BankAccount struct {
    id      string
    balance int
    mutex   sync.Mutex
}

func NewBankAccount(id string) *BankAccount {   ❶
    return &BankAccount{
        id:      id,
        balance: 100,
        mutex:   sync.Mutex{},
    }
}

❶ 创建一个带有 100 美元和一个新互斥锁的银行账户实例

列表 11.4 展示了如何实现一个 Transfer() 函数,该函数的逻辑在图 11.6 中概述。该函数通过 amount 参数从源 (src) 银行账户向目标 (to) 银行账户转账。出于日志记录的目的,该函数还接受一个 exId 参数。此参数表示调用此函数的执行过程。调用此函数的 goroutine 会传递一个唯一 ID,以便我们可以在控制台上记录它。

列表 11.4 资金转账函数

func (src *BankAccount) Transfer(to *BankAccount, amount int, exId int) {
    fmt.Printf("*%d Locking %s’s account\n*", exId, src.id)
    src.mutex.Lock()                                      ❶
    fmt.Printf("*%d Locking %s’s account\n*", exId, to.id)
    to.mutex.Lock()                                       ❷
    src.balance -= amount                                 ❸
    to.balance += amount                                  ❸
    to.mutex.Unlock()                                     ❹
    src.mutex.Unlock()                                    ❹
    fmt.Printf("*%d Unlocked %s and %s\n*", exId, src.id, to.id)
}

❶ 锁定源账户的互斥锁

❷ 锁定目标账户的互斥锁

❸ 从源账户扣除资金并将其添加到目标账户

❹ 解锁目标账户和源账户

我们现在可以有几个 goroutine 执行随机生成的转移,模拟我们正在接收大量交易的场景。列表 11.5 创建了四个银行账户,然后启动了四个 goroutine,每个 goroutine 执行 1,000 次转移。每个 goroutine 通过随机选择源账户和目标账户来生成转移。如果源账户和目标账户恰好相同,则选择另一个目标账户。每次转移的金额为 10 美元。

列表 11.5:goroutine 执行随机生成的转移

package main

import (
  "*fmt*"
  "*github.com/cutajarj/ConcurrentProgrammingWithGo/chapter11/listing11.3_4*"
  "*math/rand*"
  "*time*"
)

func main() {
    accounts := []listing11_3_4.BankAccount{
        *listing11_3_4.NewBankAccount("*Sam*"),
        *listing11_3_4.NewBankAccount("*Paul*"),
        *listing11_3_4.NewBankAccount("*Amy*"),
        *listing11_3_4.NewBankAccount("*Mia*"),
    }
    total := len(accounts)
    for i := 0; i < 4; i++ {
        go func(eId int) {                                         ❶
            for j := 1; j < 1000; j++ {                            ❷
                from, to := rand.Intn(total), rand.Intn(total)     ❸
                for from == to {                                   ❸
                    to = rand.Intn(total)                          ❸
                }
                accounts[from].Transfer(&accounts[to], 10, eId)    ❹
            }
            fmt.Println(eId, "*COMPLETE*")                           ❺
        }(i)
    }
    time.Sleep(60 * time.Second)                                   ❻
}

❶ 创建一个具有唯一执行 ID 的 goroutine

❷ 执行 1,000 次随机生成的转移

❸ 选择转移的源账户和目标账户

❹ 执行转移操作

❺ 一旦所有 1,000 次转移完成,输出完整消息

❻ 在终止程序前等待 60 秒

运行列表 11.5,我们期望在控制台上为我们的四个 goroutine 打印出 1,000 次转移,然后输出消息COMPLETE。不幸的是,我们的程序陷入了一个死锁,最后的消息没有被打印出来:

$ go run ledgermutex.go
1 Locking Paul’s account
1 Locking Mia’s account
1 Unlocked Paul and Mia
. . .
2 Locking Amy’s account
0 Locking Sam’s account
3 Locking Mia’s account
3 Locking Paul’s account
3 Unlocked Mia and Paul
3 Locking Paul’s account
3 Locking Sam’s account
0 Locking Amy’s account
2 Locking Paul’s account
1 Unlocked Amy and Mia
1 Locking Mia’s account
1 Locking Paul’s account

注意:每次我们运行列表 11.5 时,我们都会得到略微不同的输出,并不总是导致死锁。这是由于并发执行的非确定性性质。

从我们的输出中,我们可以观察到一些 goroutine 正在持有某些账户的锁,并试图获取其他账户的锁。在我们的例子中,死锁发生在 goroutine 0、2 和 3 之间。我们可以创建一个资源分配图来更好地理解死锁(见图 11.7)。

图 11.7

图 11.7:处理账本交易时的死锁

我们在图 11.7 中的资源分配图显示,死锁是由 goroutine 0、2 和 3 引起的,因为它包含了一个以这些 goroutine 为节点的循环。它还显示死锁可以通过阻止对它们的资源访问来影响其他 goroutine。在这个例子中,goroutine 1 在尝试获取保罗的账户锁时被阻塞。

11.2 处理死锁

我们应该怎么做才能确保我们的编程不会受到死锁的影响?我们有三种主要方法:检测、使用避免死锁的机制,以及以防止死锁场景的方式编写我们的并发编程。在接下来的几节中,我们将探讨这三种选项。

值得注意的是,在处理死锁时还有另一种方法:什么都不做。一些教科书将这种方法称为鸵鸟法,因为鸵鸟在危险时会将头埋在沙子里(尽管这是一个普遍的误解)。如果我们确定在我们的系统中,死锁很少发生,并且当它们发生时,后果并不严重,那么不采取任何措施来防止死锁才有意义。

11.2.1 检测死锁

我们可以采取的第一种方法是检测死锁,这样我们就可以采取一些措施。例如,在检测到发生死锁后,我们可以有一个警报,通知可以重启进程的人。更好的是,我们可以在代码中添加逻辑,每当发生死锁时,就会执行重试操作。

Go 内置了一些死锁检测机制。Go 的运行时会检查下一个应该执行的 goroutine 是哪个,如果它发现所有的 goroutine 都在等待一个资源(例如互斥锁)而阻塞,它将抛出一个致命错误。不幸的是,这意味着它只能检测到所有 goroutine 都阻塞的情况。

考虑列表 11.6,其中主 goroutine 正在等待 waitgroup,直到两个子 goroutine 完成它们的工作。这两个 goroutine 都会反复锁定互斥锁 A 和 B,以增加发生死锁的风险。

列表 11.6 触发 Go 的死锁检测

package main

import (
    "*fmt*"
    "*sync*"
)

func lockBoth(lock1, lock2 *sync.Mutex, wg *sync.WaitGroup) {
    for i := 0; i < 10000; i++ {
        lock1.Lock(); lock2.Lock()      ❶
        lock1.Unlock(); lock2.Unlock()  ❶
    }
    wg.Done()                           ❷
}

func main() {
    lockA, lockB := sync.Mutex{}, sync.Mutex{}
    wg := sync.WaitGroup{}
    wg.Add(2)
    go lockBoth(&lockA, &lockB, &wg)    ❸
    go lockBoth(&lockB, &lockA, &wg)    ❸
    wg.Wait()                           ❹
    fmt.Println("*Done*")
}

❶ 锁定和解锁两个互斥锁

❷ 标记 waitgroup 为完成

❸ 同时启动两个 goroutine,锁定两个互斥锁

❹ 等待 goroutine 终止

当运行前面的代码示例时,如果发生死锁,所有 goroutine 都将阻塞,包括主 goroutine。这两个 goroutine 将陷入死锁等待对方,而main() goroutine 将卡在等待 waitgroup 完成。以下是 Go 给出的错误消息摘要:

$ go run deadlockdetection.go
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [semacquire]:
. . .
    /usr/local/go/src/sync/waitgroup.go:139 +0x80
main.main()
    /deadlockdetection.go:22 +0x13c

goroutine 18 [semacquire]:
. . .
sync.(*Mutex).Lock(...)
    /usr/local/go/src/sync/mutex.go:90
main.lockBoth(0x1400011c008, 0x1400011c010, 0x0?)
    /deadlockdetection.go:10 +0x104
. . .

goroutine 19 [semacquire]:
. . .
sync.(*Mutex).Lock(...)
    /usr/local/go/src/sync/mutex.go:90
main.lockBoth(0x1400011c010, 0x1400011c008, 0x0?)
    deadlockdetection.go:10 +0x104
. . .

exit status 2

除了告诉我们有死锁之外,Go 还会输出我们的程序卡住时 goroutine 的详细信息。在这个例子中,我们可以看到标记为 18 和 19 的 goroutine 都在尝试锁定互斥锁,而我们的main() goroutine(标记为 goroutine 1)正在等待 waitgroup。

我们可以轻松地编写一个绕过这种死锁检测机制的程序。考虑下面的代码示例,我们修改了main()函数以创建另一个 goroutine 来等待 waitgroup。然后main() goroutine 休眠 30 秒,模拟执行其他工作。

列表 11.7 绕过 Go 的死锁检测

func main() {
    lockA, lockB := sync.Mutex{}, sync.Mutex{}
    wg := sync.WaitGroup{}
    wg.Add(2)
    go lockBoth(&lockA, &lockB, &wg)
    go lockBoth(&lockB, &lockA, &wg)
    go func() {                                     ❶
        wg.Wait()                                   ❶
        fmt.Println("*Done waiting on waitgroup*")    ❶
    }()                                             ❶
    time.Sleep(30 * time.Second)                    ❷
    fmt.Println("*Done*")                             ❸
}

❶ 在输出消息之前创建一个等待 waitgroup 的 goroutine

❷ 等待 30 秒

❸ 输出一条消息,然后程序终止

由于现在main() goroutine 并没有真正阻塞,而是在等待sleep()函数,Go 的运行时将不会检测到死锁。当发生死锁时,不会返回消息"Done waiting on waitgroup";相反,30 秒后,main() goroutine 输出"Done"消息,程序在没有死锁错误的情况下终止:

$ go run deadlocknodetection.go
Done

检测死锁的一个更完整的方法是程序化地构建一个资源分配图,该图将所有 goroutine 和资源作为节点,通过边连接,正如你在图 11.2、11.5 和 11.7 中看到的。然后我们可以有一个检测图中循环的算法。如果图中包含循环,系统就处于死锁状态。

要检测图中的循环,我们可以修改深度优先搜索算法以查找循环。如果我们跟踪在遍历过程中访问的节点,并且遇到已经访问过的节点,我们就知道存在循环。

这是一些其他框架、运行时和系统(如数据库)采用的方法。以下是一个由 MySQL(一个流行的开源数据库)返回的错误示例。在这种情况下,当有两个并发会话同时运行事务并尝试同时获取相同的锁时,会发生死锁。MySQL 跟踪所有会话和分配的资源,并在检测到任何死锁时,向客户端返回以下错误:

ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction

如果我们的运行时或系统提供了死锁检测,我们可以在检测到死锁时执行各种操作。一个选项是终止陷入死锁的执行。这与 Go 运行时采取的方法类似,不同之处在于 Go 终止了包含所有 goroutine 的整个进程。

另一个选项是在请求资源时,如果请求导致死锁,则向请求资源的执行返回错误。然后,执行可以决定在一段时间后执行某些操作以响应错误,例如释放资源并重试。这种方法在许多数据库中使用时通常被采用。通常,当数据库返回死锁错误时,数据库客户端可以回滚事务并重试。

为什么 Go 的运行时不提供完整的死锁检测?

通过检查资源分配图中的任何循环来检测死锁是一种相对昂贵的性能操作。Go 的运行时会维护一个资源分配图,并且每次有资源请求或释放时,Go 都必须在图上运行循环检查算法。在一个有大量 goroutine 请求和释放资源的应用程序中,这种死锁检测检查会减慢速度。在许多情况下,当 goroutine 没有同时使用多个独占资源时,这也会是不必要的。

在数据库事务中实现完整的死锁检测通常不会影响性能。这是因为相对于缓慢的数据库操作,检测算法速度很快。

11.2.2 避免死锁

我们可以通过以不会引起死锁的方式调度执行来尝试避免死锁。在图 11.8 中,我们再次使用列车死锁的例子,但这次,我们展示了每列火车在陷入死锁状态时的时间线。

图片

图 11.8 导致死锁的列车时间线

分配资源(例如,在本例中为火车道口)的系统可以拥有更智能的逻辑来分配资源,从而避免死锁。在我们的火车例子中,我们事先知道每列火车的行程和长度。因此,当火车 1 请求道口 A 时,我们已经知道道口 B 可能很快就会被请求。当火车 2 到来并请求道口 B 时,我们不是分配给它并允许火车继续前进,而是可以指示火车停车等待。

同样的事情也可能发生在火车 3 和 4 之间。当火车 4 到来并请求道口 D 时,我们已经知道它可能稍后会请求道口 A,而道口 A 目前正在被火车 1 使用。因此,我们再次指示火车 4 停车等待。然而,火车 3 可以无干扰地继续前进,因为道口 C 和 D 都是空闲的。目前没有火车正在使用可能在未来请求其中任何一个的道口。

这个火车调度示例在图 11.9 中展示。火车 1 和 3 无间断地通过道口,而火车 2 和 4 则停车等待。一旦道口再次空闲,火车 2 和 4 可以继续它们的行程。

图片

图 11.9 铁道交叉场景中的避免死锁

由 Edsger Dijkstra 开发的银行家算法就是这样一种算法,可以用来检查资源是否可以安全分配并避免死锁。只有当以下信息已知时,该算法才能使用:

  • 每个执行可以请求的每种资源的最大数量

  • 每个执行当前持有的资源

  • 每种资源的可用数量

定义 使用这些信息,我们可以决定系统是处于安全还是不可安全状态。只有当存在一种方式可以调度我们的执行,使它们都能完成(从而避免死锁),即使它们请求最大数量的资源,我们的系统状态才被认为是安全的。否则,系统状态被认为是不可安全的。

该算法通过决定是否授予资源请求来工作。只有在分配资源后系统仍然处于安全状态的情况下,它才会授予资源请求。如果会导致不可安全状态,请求资源的执行将被暂停,直到可以安全地授予其请求。

例如,考虑一个可以以有限方式被多个执行使用的资源,例如具有固定会话数的数据库连接池。图 11.10 展示了安全和不可安全场景。在场景 A 中,如果执行a请求并获得另一个数据库会话资源,系统最终会进入不可安全状态,如场景 B 所示。这是因为没有方法可以授予任何执行其最大数量的资源。在场景 B 中,我们只剩下两个资源,但执行abc可以请求额外的五个、三个和五个资源。现在不可避免地存在陷入死锁的风险。

图片

图 11.10 安全和不安全状态情景的示例

情景 A 被说成仍然处于安全状态,因为存在我们可以应用的调度,这将导致所有执行完成。在情景 A 中,我们仍然处于一个可以通过谨慎的资源分配来避免死锁的点。在图 11.10 的情景 A 中应用银行家算法,当它们请求更多资源时,我们可以挂起执行 ac,因为批准这些请求会导致不安全状态。算法只会允许 b 的请求,因为批准这些请求将使系统处于安全状态。一旦 b 释放足够的资源,我们就可以将它们授予 c,然后是 a(见图 11.11)。

图 11.11 安全资源分配的序列

银行家算法也可以与多种资源一起工作,例如锁定我们账本应用中的不同银行账户,如第 11.1.2 节所述。然而,对于我们的应用,我们不需要实现完整的银行家算法,因为我们事先知道每个 goroutine 将需要的全部资源集合。由于我们只锁定两个特定的银行账户,即源账户和目标账户,因此如果这两个账户中的任何一个当前正被另一个 goroutine 使用,我们的系统可以挂起 goroutine 的执行。

为了实现这一点,我们可以创建一个仲裁者,其任务是挂起请求当前正在使用的账户的 goroutines 的执行。一旦账户变得可用,仲裁者可以恢复 goroutines 的执行。仲裁者可以通过使用条件变量来阻塞 goroutine 的执行,直到所有账户都变得可用。这种逻辑在图 11.12 中显示。

图 11.12 使用条件变量在账户不可用时挂起 goroutines

当一个 goroutine 从仲裁者请求正在使用的资源时,该 goroutine 被迫在条件变量上等待。当另一个 goroutine 释放资源时,它会广播,以便任何挂起的 goroutine 可以检查所需资源是否已变得可用。通过这种方式,我们避免了死锁,因为资源只有在全部可用时才会被锁定。

在列表 11.8 中,我们定义了将在仲裁者中使用的结构。我们还包括一个初始化结构体字段的函数。accountsInUse 映射用于标记任何当前正在用于资金转移的账户,而条件变量用于在账户在使用时挂起执行。

列表 11.8 构建仲裁者

type Arbitrator struct {
    accountsInUse map[string]bool      ❶
    cond *sync.Cond                    ❷
}

func NewArbitrator() *Arbitrator{
    return &Arbitrator{
        accountsInUse: map[string]bool{},
        cond:          sync.NewCond(&sync.Mutex{}),
    }
}

❶ 存储账户及其可用状态,要么是空闲的,要么是在使用中

❷ 用于在账户不可用时挂起 goroutines 的条件变量

接下来,我们需要实现一个函数,允许我们在账户空闲时阻塞它们,或者在账户不空闲时暂停 goroutine 的执行。这可以在列表 11.9 中看到,其中包含了LockAccounts()函数。该函数获取与条件变量关联的互斥锁,并使用accountsInUse映射检查所有账户是否空闲。如果任何账户正在使用中,goroutine 将在条件变量上调用Wait()。这将暂停 goroutine 的执行并解锁互斥锁。一旦执行恢复,goroutine 重新获取互斥锁,并重复此检查,直到所有账户都空闲。此时,映射被更新以指示资源正在使用中,互斥锁被解锁。这样,goroutine 在获取所有所需的账户之前永远不会执行转账逻辑。

列表 11.9 暂停执行以避免死锁

func (a *Arbitrator) LockAccounts(ids... string) {
    a.cond.L.Lock()                                 ❶
    for allAvailable := false; !allAvailable; {     ❷
        allAvailable = true
        for _, id := range ids {
            if a.accountsInUse[id] {                ❸
                allAvailable = false                ❸
                a.cond.Wait()                       ❸
            }
        }
    }
    for _, id := range ids {                        ❹
        a.accountsInUse[id] = true                  ❹
    }                                               ❹
    a.cond.L.Unlock()                               ❺
}

❶ 在条件变量上锁定互斥锁

❷ 循环直到所有账户都空闲

❸ 如果账户正在使用中,则暂停 goroutine 的执行

❹ 一旦所有账户都可用,将请求的账户标记为正在使用中

❺ 在条件变量上解锁互斥锁

一旦 goroutine 完成了其转账逻辑,它需要将账户标记为不再使用。列表 11.10 展示了UnlockAccounts()函数。调用此函数的 goroutine 持有条件变量的互斥锁,将所有所需的账户标记为空闲,然后在条件变量上广播。这会唤醒任何挂起的 goroutine,然后它们将继续检查其账户是否已可用。

列表 11.10 使用广播恢复 goroutine

func (a *Arbitrator) UnlockAccounts(ids... string) {
    a.cond.L.Lock()                   ❶
    for _, id := range ids {          ❷
        a.accountsInUse[id] = false   ❷
    }                                 ❷
    a.cond.Broadcast()                ❸
    a.cond.L.Unlock()                 ❹
}

❶ 在条件变量上锁定互斥锁

❷ 将账户标记为空闲

❸ 向挂起的 goroutine 发送广播以恢复其执行

❹ 在条件变量上解锁互斥锁

我们现在可以在我们的货币转账逻辑中使用这两个函数。下一个列表显示了修改后的Transfer()函数,它在进行货币转账之前调用LockAccounts(),并在之后调用UnlockAccounts()

列表 11.11 在转账期间使用仲裁者锁定账户

func (src *BankAccount) Transfer(to *BankAccount, amount int, tellerId int,
    arb *Arbitrator) {
    fmt.Printf("*%d Locking %s and %s\n*", tellerId, src.id, to.id)
    arb.LockAccounts(src.id, to.id)       ❶
    src.balance -= amount                 ❷
    to.balance += amount                  ❷
    arb.UnlockAccounts(src.id, to.id)     ❸
    fmt.Printf("*%d Unlocked %s and %s\n*", tellerId, src.id, to.id)
}

❶ 锁定源账户和目标账户

❷ 在获得两个锁之后执行转账

❸ 在转账后解锁两个账户

最后,我们可以更新我们的main()函数以创建仲裁者实例并将其传递给 goroutine,以便在转账期间使用。这将在以下列表中展示。

列表 11.12 使用仲裁者(为简洁起见省略了导入)

package main

import (...)

func main() {
    accounts := []BankAccount{
        *NewBankAccount("*Sam*"),
        *NewBankAccount("*Paul*"),
        *NewBankAccount("*Amy*"),
        *NewBankAccount("*Mia*"),
    }
    total := len(accounts)
    arb := NewArbitrator()        ❶
    for i := 0; i < 4; i++ {
        go func(tellerId int) {
            for i := 1; i < 1000; i++ {
                from, to := rand.Intn(total), rand.Intn(total)
                for from == to {
                    to = rand.Intn(total)
                }
                accounts[from].Transfer(&accounts[to], 10, tellerId, arb)
            }
            fmt.Println(tellerId,"*COMPLETE*")
        }(i)
    }
    time.Sleep(60 * time.Second)
}

❶ 创建一个用于转账的新仲裁者

操作系统和语言运行时的死锁避免

能否在操作系统或 Go 的运行时中实现死锁避免算法,以便以避免死锁的方式调度执行?在实践中,当在操作系统和语言运行时中使用时,死锁避免算法,如银行家算法,并不非常有效,因为它们需要提前知道执行将需要的最大资源数量。这种要求是不切实际的,因为操作系统和运行时无法预期知道每个进程、线程或 goroutine 可能会提前请求哪些资源。

此外,银行家算法假设执行集不会改变。对于任何现实中的操作系统来说,进程都在不断启动和终止,这不是这种情况。

11.2.3 防止死锁

如果我们提前知道我们的并发执行将使用的全部独占资源集,我们可以使用排序来防止死锁。再次考虑列表 11.1 中概述的简单死锁。这个死锁发生是因为red()blue() goroutine 各自以不同的顺序获取互斥锁。red() goroutine 使用锁 1 然后是锁 2,而blue()使用锁 2 然后是锁 1。如果我们改变列表,使它们以相同的顺序使用锁,如以下列表所示,死锁就不会发生。

列表 11.13 排序互斥锁防止死锁

func red(lock1, lock2 *sync.Mutex) {
    for {
        fmt.Println("*Red: Acquiring lock1*")
        lock1.Lock()
        fmt.Println("*Red: Acquiring lock2*")
        lock2.Lock()
        fmt.Println("*Red: Both locks Acquired*")
        lock1.Unlock(); lock2.Unlock()
        fmt.Println("*Red: Locks Released*")
    }
}

func blue(lock1, lock2 *sync.Mutex) {
    for {
        fmt.Println("*Blue: Acquiring lock1*")
        lock1.Lock()
        fmt.Println("*Blue: Acquiring lock2*")
        lock2.Lock()
        fmt.Println("*Blue: Both locks Acquired*")
        lock1.Unlock(); lock2.Unlock()
        fmt.Println("*Blue: Locks Released*")
    }
}

死锁没有发生,因为我们从未处于两个 goroutine 都持有不同锁并请求另一个锁的情况。在这种情况下,当它们同时尝试获取锁 1 时,只有一个 goroutine 会成功。另一个将被阻塞,直到两个锁都可用。这创造了一个情况,即 goroutine 可以获取所有锁或一个也不获取。

我们可以将此规则应用于我们的账目应用。每当我们要执行一个事务时,我们可以定义一个简单的规则,指定获取互斥锁的顺序。规则可能是我们应该根据账户 ID 的字母顺序获取锁。例如,如果我们有一个从 Mia 转到 Amy 的 10 美元转账事务,我们应该首先锁定 Amy 的账户,然后是 Mia 的,因为 Amy 的账户 ID 在字母顺序中排在前面。如果在同一时间,我们还有另一个从 Amy 转到 Mia 的 10 美元转账事务,这个事务将在其第一个锁请求(Amy 的锁)上被阻塞。这个例子在图 11.13 中显示。

图片

图 11.13 在账目应用中使用排序来避免死锁

在我们的账目应用示例中,为了简化,我们将账户 ID 等同于账户持有者的姓名。在实际应用中,账户 ID 可能是数字或版本 4 的 UUID,两者都可以排序。以下列表显示了我们对应用中修改后的转账函数,其中我们按账户 ID 对账户进行排序,然后按顺序锁定它们。

列表 11.14 账户转账函数排序

func (src *BankAccount) Transfer(to *BankAccount, amount int, tellerId int) {
    accounts := []*BankAccount{src, to}            ❶
    sort.Slice(accounts, func(a, b int) bool {     ❷
        return accounts[a].id < accounts[b].id     ❷
    })                                             ❷
    fmt.Printf("*%d Locking %s’s account\n*", tellerId, accounts[0].id)
    accounts[0].mutex.Lock()                       ❸
    fmt.Printf("*%d Locking %s’s account\n*", tellerId, accounts[1].id)
    accounts[1].mutex.Lock()                       ❹
    src.balance -= amount
    to.balance += amount
    to.mutex.Unlock()                              ❺
    src.mutex.Unlock()                             ❺
    fmt.Printf("*%d Unlocked %s and %s\n*", tellerId, src.id, to.id)
}

❶ 将源账户和目标账户放入一个切片中

❷ 按 ID 对包含两个账户的切片进行排序

❸ 按 ID 锁定优先级较低的账户

❹ 按 ID 锁定优先级更高的账户

❺ 解锁两个账户

现在,我们可以运行前面的函数并看到账户总是按字母顺序锁定。此外,所有 goroutines 都完成了任务而没有陷入任何死锁。以下是输出样本:

$ go run ledgermutexorder.go
3 Locking Amy’s account
2 Locking Amy’s account
3 Locking Paul’s account
3 Unlocked Amy and Paul
. . .
1 Locking Mia’s account
1 Locking Paul’s account
. . .
2 COMPLETE
. . .
0 COMPLETE
. . .
3 COMPLETE
. . .
1 COMPLETE

我们还可以使用这种排序策略来防止死锁,如果我们事先不知道需要使用哪些互斥资源。这里的想法是不要获取低于我们当前持有的资源优先级的资源。当出现需要我们获取更高优先级资源的情况时,我们总是可以释放所持有的资源,并按正确的顺序重新请求它们。

在我们的账本应用中,考虑一个正在执行特殊事务的 goroutine,例如:“从艾米的账户中支付保罗 10 美元;如果艾米的账户资金不足,则使用米娅的账户。”在这种情况下,我们可以在 goroutine 中编写逻辑以执行以下步骤:

  1. 锁定艾米的账户。

  2. 锁定保罗的账户。

  3. 如果艾米的余额足以覆盖转账:

    1. 从艾米的账户中扣除资金并添加到保罗的账户中。

    2. 解锁艾米和保罗的账户。

  4. 否则:

    1. 解锁艾米和保罗的账户。

    2. 锁定米娅的账户。

    3. 锁定保罗的账户。

    4. 从米娅的账户中扣除资金并添加到保罗的账户中。

    5. 解锁米娅和保罗的账户。

这里的重要规则是,如果执行过程中持有更高优先级的资源,则绝不要锁定低优先级的资源。在这个例子中,我们必须在锁定米娅的账户之前释放保罗和艾米账户。这确保了我们永远不会陷入死锁状态。

11.3 使用通道的死锁

重要的是要理解,死锁不仅限于互斥锁的使用。当执行过程中持有互斥资源并请求其他资源时,都可能发生死锁——这也适用于通道。通道的容量可以被视为一个互斥资源。Goroutines 可以在持有通道的同时尝试使用另一个通道(通过发送或接收消息)。

我们可以将通道视为包含读取和写入资源的集合。最初,非缓冲通道没有读取和写入资源。当另一个 goroutine 尝试写入消息时,读取资源变得可用。写入操作在尝试获取写入资源的同时使一个读取资源可用。同样,读取操作在尝试获取一个读取资源的同时使一个写入资源可用。

让我们看看一个涉及两个通道的死锁示例。考虑一个简单的程序,它需要递归地输出文件详细信息,例如目录下所有文件的文件名、文件大小和最后修改日期。一个解决方案是有一个 goroutine 处理文件,另一个处理目录。目录 goroutine 的任务是读取目录内容,并使用通道将每个文件提供给文件处理程序。这将在下面的handleDirectories()函数中显示。

列表 11.15 目录处理程序(为了简洁,省略了错误处理)

package main

import (
    "*fmt*"
    "*os*"
    "*path/filepath*"
    "*time*"
)

func handleDirectories(dirs <-chan string, files chan<- string) {
    for fullpath := range dirs {                                    ❶
        fmt.Println("*Reading all files from*", fullpath)
        filesInDir, _ := os.ReadDir(fullpath)                       ❷
        fmt.Printf("*Pushing %d files from %s\n*", len(filesInDir), fullpath)
        for _, file := range filesInDir {                           ❸
            files <- filepath.Join(fullpath, file.Name())           ❸
        }                                                           ❸
    }
}

❶ 从输入主题读取完整的目录路径

❷ 读取目录的内容

❸ 将目录内容的每个项目推送到输出主题

在文件处理程序的 goroutine 中发生相反的情况。当文件处理程序遇到一个新的目录时,它将其发送到目录处理程序的通道。如果项目是文件,文件处理程序会从输入通道消费项目,并输出有关它的信息,例如文件大小和最后修改日期。如果项目是目录,它将目录转发给目录处理程序。这将在下面的列表中显示。

列表 11.16 文件处理程序(为了简洁,省略了错误处理)

func handleFiles(files chan string, dirs chan string) {
    for path := range files {                                        ❶
        file, _ := os.Open(path)
        fileInfo, _ := file.Stat()                                   ❷
        if fileInfo.IsDir() {                                        ❸
            fmt.Printf("*Pushing %s directory\n*", fileInfo.Name())    ❸
            dirs <- path                                             ❸
        } else {                                                     ❹
            fmt.Printf("*File %s, size: %dMB, last modified: %s\n*",
                fileInfo.Name(), fileInfo.Size() / (1024 * 1024),
                fileInfo.ModTime().Format("*15:04:05*"))
        }
    }
}

❶ 读取文件的完整路径

❷ 读取有关文件的信息

❸ 如果文件是目录,则将其写入输出通道

❹ 如果文件不是目录,则在控制台上显示文件信息

我们现在可以使用main()函数将两个 goroutine 连接起来。在列表 11.17 中,我们创建了两个通道并将它们传递给新创建的文件和目录处理程序 goroutine。然后我们将从参数中读取的初始目录推送到目录通道。为了简化列表(用于演示目的),我们让main() goroutine 睡眠 60 秒而不是使用 waitgroups 等待 goroutine 完成。

列表 11.17 main()函数创建文件和目录处理程序

func main() {
    filesChannel := make(chan string)                   ❶
    dirsChannel := make(chan string)                    ❶
    go handleFiles(filesChannel, dirsChannel)           ❷
    go handleDirectories(dirsChannel, filesChannel)     ❷
    dirsChannel <- os.Args[1]                           ❸
    time.Sleep(60 * time.Second)                        ❹
}

❶ 创建文件和目录通道

❷ 启动文件和目录处理程序 goroutine

❸ 从目录参数向目录通道提供数据

❹ 睡眠 60 秒

当我们在具有一些子目录的目录上运行所有列表时,我们立即陷入死锁。以下示例输出显示了目录处理程序尝试将 26 个文件推送到通道后不久,文件处理程序的 goroutine 尝试发送名为CodingInterviewWorkshop的目录时 goroutine 发生死锁:

$ go run allfilesinfo.go ~/projects/
Reading all files from ~/projects/
Pushing 26 files from ~/projects/
File .DS_Store, size: 8.00KB, last modified: Mon Mar 13 13:50:45 2023
Pushing CodingInterviewWorkshop directory

这里显示的死锁问题在图 11.14 中。我们在两个 goroutine 之间创建了一个循环等待条件。目录处理程序正在等待文件处理程序的 goroutine 从files通道读取,同时它阻止对dirs通道的任何写入。文件处理程序正在等待目录处理程序的 goroutine 从dirs通道读取,同时它阻止对files通道的任何写入。

图 11.14 两个通道的死锁

我们可能会想,通过在文件或目录通道上添加缓冲区来解决死锁问题。然而,这只会推迟死锁的发生。一旦我们遇到一个文件或子目录数量超过我们缓冲区处理能力的目录,问题仍然会出现。

我们还可以尝试增加运行文件处理器的 goroutine 数量。毕竟,一个典型的文件系统中的文件数量远多于目录。然而,这只会延迟问题。一旦我们的程序导航到一个包含的文件数量超过执行handleFiles()的 goroutine 数量的目录,我们又将陷入死锁状态。

我们可以通过移除循环等待来防止这种场景下的死锁。一个简单的方法是将我们的一个函数修改为通过使用新创建的 goroutine 来发送通道。列表 11.18 修改了handleDirectories()函数,使其每次需要将新文件推送到files通道时都启动一个新的 goroutine。这样,我们就让 goroutine 摆脱了等待通道可用的需要,并将等待委托给另一个 goroutine,从而打破了循环等待。

列表 11.18 使用单独的 goroutine 在通道上写入

func handleDirectories(dirs <-chan string, files chan<- string) {
    for fullpath := range dirs {
        fmt.Println("*Reading all files from*", fullpath)
        filesInDir, _ := os.ReadDir(fullpath)
        fmt.Printf("*Pushing %d files from %s\n*", len(filesInDir), fullpath)
        for _, file := range filesInDir {
            go func(fp string) {                       ❶
                files <- fp                            ❶
            }(filepath.Join(fullpath, file.Name()))    ❶
        }
    }
}

❶ 启动一个新的 goroutine,将每个文件发送到文件通道

一种不涉及创建大量单独 goroutine 的替代解决方案是使用select语句同时从我们的通道读取和写入。这同样会打破使用通道时导致死锁的循环等待。我们可以在目录或文件 goroutine 中采用这种方法。以下列表显示了handleDirectories() goroutine 的示例。

列表 11.19 使用select来打破循环等待

func handleDirectories(dirs <-chan string, files chan<- string) {
    toPush := make([]string, 0)                                         ❶
    appendAllFiles := func(path string) {
        fmt.Println("*Reading all files from*", path)
        filesInDir, _ := os.ReadDir(path)
        fmt.Printf("*Pushing %d files from %s\n*", len(filesInDir), path)
        for _, f := range filesInDir {                                  ❷
            toPush = append(toPush, filepath.Join(path, f.Name()))      ❷
        }                                                               ❷
    }
    for {
        if len(toPush) == 0 {                                           ❸
            appendAllFiles(<-dirs)                                      ❸
        } else {
            select {
            case fullpath := <-dirs:                                    ❹
                appendAllFiles(fullpath)                                ❹
            case files <- toPush[0]:                                    ❺
                toPush = toPush[1:]                                     ❻
            }
        }
    }
}

❶ 创建一个切片来存储需要推送到文件处理器通道的文件

❷ 将目录中的所有文件追加到切片中

❸ 如果没有文件要推送,则从输入通道读取目录,并将目录中的所有文件添加

❹ 从输入通道读取下一个目录,并将目录中的所有文件添加

❺ 将切片中的第一个文件推送到通道

❻ 从切片中移除第一个文件

根据哪个通道可用,让我们的 goroutine 完成接收或发送操作,可以消除导致死锁的循环等待。如果文件处理器的 goroutine 正忙于在其输出通道上发送目录路径,我们的目录 goroutine 不会被阻塞,仍然可以接收目录路径。select语句允许我们同时等待两个操作。目录的内容追加到切片中,以便当输出通道可用时,它们被推送到通道。

注意:在消息传递程序中存在死锁通常是一个程序设计不良的迹象。在使用通道时出现死锁意味着我们已经编写了一个通过相同 goroutines 传递的循环消息流。大多数情况下,我们可以通过设计程序使消息流不循环来避免可能的死锁。

11.4 练习

注意:访问 github.com/cutajarj/ConcurrentProgrammingWithGo 以查看所有代码解决方案。

  1. 在以下列表中,incrementScores() 如果与多个 goroutines 并发运行可能会产生死锁。你能修改这个函数以避免或防止死锁吗?

    列表 11.20 玩家分数的死锁

    type Player struct {
        name  string
        score int
        mutex sync.Mutex
    }
    
    func incrementScores(players []*Player, increment int) {
        for _, player := range players {
            player.mutex.Lock()
        }
        for _, player := range players {
            player.score += increment
        }
        for _, player := range players {
            player.mutex.Unlock()
        }
    }
    
  2. 在列表 11.19 中,我们修改了 handleDirectories() 函数,使其使用 select 语句来避免两个 goroutines 之间的循环等待。你也能以同样的方式修改列表 11.16 中的 handleFiles() 函数吗?goroutine 应该使用 select 语句在两个通道上同时接收和发送。

摘要

  • 死锁是指程序有多个执行无限期地阻塞,等待彼此释放各自的资源。

  • 资源分配图(RAG)通过连接它们来显示执行如何使用资源。

  • 在 RAG 中,一个请求资源的执行是通过从执行到资源的定向边来表示的。

  • 在 RAG(资源分配图)中,一个执行持有资源是通过从资源到执行的定向边来表示的。

  • 当 RAG 包含一个循环时,它表示系统处于死锁状态。

  • 可以在 RAG 上使用图循环检测算法来检测死锁。

  • Go 的运行时提供了死锁检测,但它只能在所有 goroutines 都阻塞的情况下检测到死锁。

  • 当 Go 的运行时检测到死锁时,整个程序将带错误退出。

  • 通过以特定方式调度执行来避免死锁只能在事先知道将使用哪些资源的情况下进行。

  • 可以通过以预定义的顺序请求资源来程序化地防止死锁。

  • 死锁也可能出现在使用 Go 通道的程序中。通道的容量可以被视为一个互斥资源。

  • 在使用通道时,请注意避免循环等待,以防止死锁。

  • 通过使用单独的 goroutines 发送或接收,通过将通道操作与 select 语句结合,或者通过更好地设计程序以避免循环消息流,可以使用通道避免循环等待。

12 原子操作、自旋锁和 futex

本章涵盖

  • 使用原子变量进行同步

  • 使用自旋锁开发互斥锁

  • 使用 futex 提升自旋锁

在前面的章节中,我们使用了互斥锁来同步线程对共享变量的访问。我们还看到了如何使用互斥锁作为原语来构建更复杂的并发工具,如信号量和通道。我们尚未探讨这些互斥锁是如何构建的。

在本章中,我们将介绍同步工具中最基础的:原子变量。然后,我们将探讨如何使用它通过称为 自旋锁 的技术来构建互斥锁。稍后,我们将看到如何通过使用 futex(一个操作系统调用,允许我们在等待锁变为空闲时减少 CPU 循环次数)来优化互斥锁的实现。最后,我们将关注 Go 如何实现捆绑的互斥锁。

12.1 使用原子变量进行无锁同步

互斥锁确保我们的并发代码的关键部分一次只由一个 goroutine 执行。它们用于防止竞态条件。然而,互斥锁的效果是将我们的并发编程的部分转变为顺序瓶颈。如果我们只是更新一个简单的变量,例如一个整数,我们可以使用原子变量来在 goroutines 之间保持一致性,而无需依赖于将我们的代码转变为顺序块的互斥锁。

12.1.1 使用原子数共享变量

在前面的章节中,我们查看了一个名为 Stingy 和 Spendy 的两个 goroutine 的示例,它们共享一个表示其银行账户的整数变量。对共享变量的访问是通过互斥锁保护的。每次我们想要更新变量时,我们都会获取互斥锁。一旦我们完成更新,我们就会释放它。

原子变量允许我们执行某些不会中断的操作。例如,我们可以在单个原子操作中向现有共享变量的值中添加,这保证了并发添加操作不会相互干扰。一旦操作执行,它就会完全应用到变量的值上,而不会中断。我们可以在某些场景下使用原子变量来替换互斥锁。

例如,我们可以轻松地将我们的 Stingy 和 Spendy 程序更改为使用这些原子变量操作。我们不会使用互斥锁,而是简单地在我们的共享金钱变量上调用原子 add() 操作。这保证了 goroutines 不会产生导致不一致结果的竞态条件(见图 12.1)。

图 12.1 在 Stingy 和 Spendy 上使用原子变量

在 Go 中,原子操作位于 sync/atomic 包中。该包中的所有调用都接受一个指向要执行原子操作的变量的指针。以下是从 sync/atomic 包中可以应用于 32 位整数的函数列表:

func AddInt32(addr *int32, delta int32) (new int32)
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func LoadInt32(addr *int32) (val int32)
func StoreInt32(addr *int32, val int32)
func SwapInt32(addr *int32, new int32) (old int32)

注意:相同的atomic包包含其他数据类型(如布尔型和无符号整数)的类似操作。

对于我们的 Stingy 和 Spendy 应用程序,我们可以在每次想要向或从共享变量中添加或减去时使用AddInt32()操作,如以下列表所示。除了将加法和减法更改为使用原子操作外,我们还消除了使用任何互斥锁的需要。

列表 12.1 使用原子操作的 Stingy 和 Spendy

package main

import (
    "*fmt*"
    "*sync*"
    "*sync/atomic*"                      ❶
)

func stingy(money *int32) {
    for i := 0; i < 1000000; i++ {
        atomic.AddInt32(money, 10)     ❷
    }
    fmt.Println("*Stingy Done*")
}

func spendy(money *int32) {
    for i := 0; i < 1000000; i++ {
        atomic.AddInt32(money, -10)    ❸
    }
    fmt.Println("*Spendy Done*")
}

❶ 导入原子包

❷ 原子地将 10 美元添加到共享的金钱变量中

❸ 原子地从共享的金钱变量中减去 10 美元

注意:AddInt32()函数在添加 delta 后返回新值。然而,在我们的 Stingy 和 Spendy goroutines 中,我们没有使用返回值。

我们可以通过使用LoadInt32()函数调用来修改我们的main()函数,以便读取原子变量的值。以下列表使用 waitgroup 等待 goroutines 完成,然后读取共享的money变量。

列表 12.2 使用原子变量的main()函数

func main() {
    money := int32(100)                                         ❶
    wg := sync.WaitGroup{}
    wg.Add(2)
    go func() {
        stingy(&money)
        wg.Done()
    }()
    go func() {
        spendy(&money)
        wg.Done()
    }()
    wg.Wait()                                                   ❷
    fmt.Println("*Money in account:* ", atomic.LoadInt32(&money)) ❸
}

❶ 创建一个值为 100 的 32 位整数

❷ 等待 waitgroup 直到两个 goroutines 都完成

❸ 读取共享金钱变量的值并在控制台上输出

如预期的那样,当我们一起运行列表 12.1 和 12.2 时,我们没有得到任何竞争条件,共享的money变量的最终值是 100 美元:

$ go run atomicstingyspendy.go
Spendy Done
Stingy Done
Money in account:  100

12.1.2 使用原子时的性能惩罚

为什么我们不直接使用原子操作来消除共享变量和意外忘记使用同步技术的风险呢?不幸的是,每次我们使用这些原子变量时,都需要付出性能代价。以正常方式更新变量比使用原子操作更新变量要快得多。

让我们看看这个性能差异。列表 12.3 使用 Go 的内置基准测试工具来测试原子更新变量与普通更新相比有多快。在 Go 中,我们可以通过在函数签名前缀添加Benchmark并使函数接受testing.B类型来编写基准单元测试。列表 12.3 展示了这个例子。在第一个基准函数中,我们使用正常的读取和更新操作更新total 64 位整数,而在第二个中,我们使用原子的AddInt64()操作来更新它。当使用 Go 的基准函数时,bench.N是我们基准将执行的迭代次数。此值会动态变化,以确保测试运行特定的时间(默认为 1 秒)。

列表 12.3 微基准测试原子加法运算符

package main

import (
    "sync/atomic"
    "testing"
)

var total = int64(0)                       ❶

func BenchmarkNormal(bench *testing.B) {
    for i := 0; i < bench.N; i++ {
        total += 1                         ❷
    }
}

func BenchmarkAtomic(bench *testing.B) {
    for i := 0; i < bench.N; i++ {
        atomic.AddInt64(&total, 1)         ❸
    }
}

❶ 创建一个 64 位整数

❷ 使用普通加法运算符向总变量中添加

❸ 使用原子操作函数向总变量中添加

我们现在可以通过在go test命令中添加-bench标志来运行这个基准测试。这个测试将告诉我们原子变量操作和普通变量操作之间的性能差异。以下是输出结果:

$ go test -bench=. -count 3
goos: darwin
goarch: arm64
pkg: github.com/cutajarj/ConcurrentProgrammingWithGo/chapter12/listing12.3
BenchmarkNormal-10      555129141           2.158 ns/op
BenchmarkNormal-10      550122879           2.163 ns/op
BenchmarkNormal-10      555068692           2.167 ns/op
BenchmarkAtomic-10      174523189           6.865 ns/op
BenchmarkAtomic-10      175444462           6.902 ns/op
BenchmarkAtomic-10      175469658           6.869 ns/op
PASS
ok    github.com/cutajarj/ConcurrentProgrammingWithGo/chapter12/listing12.3  
➥ 9.971s

我们的微基准测试结果表明,在 64 位整数上进行原子加法比使用正常操作慢三倍以上。这些结果会因不同的系统和架构而异,但在所有系统中,性能差异都是显著的。这是因为当使用原子时,我们放弃了编译器和系统的大量优化。例如,当我们像列表 12.3 中那样重复访问相同的变量时,系统会将变量保存在处理器的缓存中,使访问变量更快,但可能会定期将变量刷新回主内存,尤其是在缓存空间不足时。当使用原子时,系统需要确保任何并行运行的执行都能看到变量的更新。因此,每当使用原子操作时,系统都需要保持缓存的变量一致性。这可以通过刷新到主内存并使任何其他缓存无效来实现。需要保持各种缓存一致性最终会导致我们的程序性能降低。

12.1.3 使用原子数计数

使用原子变量的典型应用场景是在需要从多次执行中计数相同事物的出现次数时。在第三章中,我们开发了一个程序,该程序使用了多个 goroutine 来下载网页并计算英语字母的频率。每个字母的总计数保存在一个共享的切片数据结构中。后来,在第四章中,我们添加了一个互斥锁来确保对共享切片的更新是一致的。

我们可以将实现方式改为每次需要增加切片中字母的计数时都使用原子更新。图 12.2 显示我们仍然在使用内存共享,但这次我们只是向变量发送原子更新。之前的方法使用了读取值然后写入更新的两个步骤,迫使我们使用互斥锁。通过使用原子更新,如果我们需要更新计数,就不必等待另一个 goroutine 释放互斥锁。我们的 goroutine 将无任何阻塞中断地运行。即使两个 goroutine 试图同时应用原子更新,这两个更新也会顺序执行,不会发生冲突。

图片

图 12.2 使用原子操作进行我们的字母频率程序

列表 12.4 通过移除互斥锁的锁定和解锁操作,修改了countLetters()函数的先前实现,并改用原子变量操作。在列表中,我们直接使用切片中包含的整数的引用,并在遇到字母时每次增加计数1

列表 12.4 countLetters() 中的原子变量(省略了导入)

package main

import (...)

const allLetters = "*abcdefghijklmnopqrstuvwxyz*"

func countLetters(url string, frequency []int32) {
    resp, _ := http.Get(url)
    defer resp.Body.Close()
    if resp.StatusCode != 200 {
        panic("*Server returning error code:* " + resp.Status)
    }
    body, _ := io.ReadAll(resp.Body)                 ❶
    for _, b := range body {                         ❷
        c := strings.ToLower(string(b))
        cIndex := strings.Index(allLetters, c)       ❸
        if cIndex >= 0 {                             ❸
            atomic.AddInt32(&frequency[cIndex], 1)   ❹
        }
    }
    fmt.Println("*Completed:*", url)
}

❶ 读取网页正文

❷ 遍历文档正文中的每个字母

❸ 检查字母是否是英语字母表的一部分

❹ 使用原子加操作来增加字母的计数

接下来,我们需要稍微修改 main() 函数,以便切片数据结构使用 32 位整数。这是必需的,因为原子操作仅在特定的数据类型上工作,例如 int32int64。此外,我们需要使用原子函数 LoadInt32() 来读取结果。以下列表显示了一个经过这些更改的 main() 函数,并使用等待组等待所有协程完成。

列表 12.5 原子字母计数器的 main() 函数

func main() {
    wg := sync.WaitGroup{}
    wg.Add(31)
    var frequency = make([]int32, 26)                                 ❶
    for i := 1000; i <= 1030; i++ {
        url := fmt.Sprintf("*https://rfc-editor.org/rfc/rfc%d.txt*", i)
        go func() {
            countLetters(url, frequency)
            wg.Done()
        }()
    }
    wg.Wait()                                                         ❷
    for i, c := range allLetters {
        fmt.Printf("*%c-%d* ", c, atomic.LoadInt32(&frequency[i]))      ❸
    }
}

❶ 创建一个大小为 26 的 32 位整数切片

❷ 等待直到所有协程完成

❸ 从频率切片中加载每个计数值并在控制台上输出它们

注意:在上面的列表中,使用 LoadInt32() 函数并不是严格必要的,因为所有协程都已完成,我们读取结果时。然而,当与原子一起工作时,使用原子加载操作是一种好习惯,以确保我们从主内存中读取最新的值,而不是过时的缓存值。

在第三章,当我们没有使用任何互斥锁运行字母频率应用程序时(列表 3.2 和 3.4),它产生了不一致的结果。使用原子变量与使用互斥锁消除竞争条件的效果相同。然而,这次,我们的协程并没有互相阻塞。以下是当我们一起运行列表 12.4 和 12.5 时的输出:

$ go run atomiccharcounter.go
Completed: https://rfc-editor.org/rfc/rfc1018.txt
. . . 
Completed: https://rfc-editor.org/rfc/rfc1002.txt
a-103445 b-23074 c-61005 d-51733 e-181360 f-33381 g-24966 h-47722 i-103262 j-3279 k-8839 l-49958 m-40026 n-108275 o-106320 p-41404 q-3410 r-101118 s-101040 t-136812 u-35765 v-13666 w-18259 x-4743 y-18416 z-1404

12.2 使用自旋锁实现互斥锁

在上一个场景中,我们修改了字母频率程序以使用原子变量。这些更改很简单,因为我们只需要一次更新一个变量。那么,当我们有一个需要同时更新多个变量的应用程序时怎么办呢?在上一章中,我们有一个这样的场景——账本应用程序需要从一个账户中减去钱并添加到另一个账户中。在那个例子中,我们使用了互斥锁来保护多个账户。我们在整本书中使用了互斥锁,但从未查看过它们的实现细节。让我们选择一个必须使用互斥锁的不同场景,然后使用原子操作,这样我们就可以使用称为 自旋锁 的技术来构建自己的互斥锁实现。

假设我们正在为一家航空公司开发航班预订软件。当预订航班时,客户希望购买整个航线的机票,或者如果航线的一部分不可用,则根本不购买。图 12.3 显示了我们要解决的问题。当我们向用户展示整个航线有座位可用,而在此期间有人预订了部分航线的最后一张座位,整个购买就需要取消。否则,我们可能会让客户购买无用的机票,而这些机票并不能带他们到达他们想要的目的地。更糟糕的是,如果外程预订成功,但回程航班预订失败(因为座位已满),乘客可能会在目的地滞留。航班预订软件需要具备控制措施来避免这类竞争条件。

图片

图 12.3 一个编写不良的航班预订系统并发程序会导致竞争条件。

要实现这样一个预订系统,我们可以将每趟航班建模为一个独立的实体,包含诸如出发地、目的地、航班剩余座位数、出发时间、飞行时间等详细信息。使用原子操作来更新航班剩余座位数并不能解决图 12.3 中概述的竞争条件,因为当客户同时预订多趟航班时,我们需要确保我们以原子单元更新所有预订航班上的剩余座位变量。原子变量只能保证一次只对一个变量进行原子更新。

为了解决这个问题,我们可以采用与账本应用相同的方法,即对每个账户加锁。在这种情况下,在调整每趟航班之前,我们将对客户预订的每趟航班获取锁。列表 12.6 展示了我们如何使用结构类型来建模每趟航班的详细信息。在这个实现中,我们保持简单,只存储航班的出发地和目的地以及航班剩余的座位数。我们还使用了Locker接口,该接口只包含两个函数:Lock()Unlock()。这正是互斥锁实现的接口。

列表 12.6 表示航班的结构类型

package listing12_6

import (
    "*sync*"
)

type Flight struct {
    Origin, Dest string
    SeatsLeft int
    Locker    sync.Locker    ❶
}

❶ 提供一个包含锁定和解锁功能的接口

现在我们可以开发一个函数,当给定包含航班列表的预订时,调整SeatsLeft变量。列表 12.7 实现了这个函数,只有在输入切片中的所有航班都包含足够的座位来满足预订请求时才返回true。实现从使用起点和终点对输入航班列表进行字母排序开始。这种排序是为了避免死锁(见第十一章)。然后函数通过锁定所有请求的航班,以确保在更新它们时每个航班的剩余座位数不会改变。然后我们检查每个航班是否包含足够的座位来满足预订请求。如果它们都满足,我们将每个航班的座位数减少客户想要购买的座位数。

列表 12.7 航班预订函数

package listing12_7

import (
    "*github.com/cutajarj/ConcurrentProgrammingWithGo/chapter12/listing12.6*"
    "*sort*"
)

func Book(flights []*listing12_6.Flight, seatsToBook int) bool {
    bookable := true
    sort.Slice(flights, func(a, b int) bool {             ❶
        flightA := flights[a].Origin + flights[a].Dest    ❶
        flightB := flights[b].Origin + flights[b].Dest    ❶
        return flightA < (flightB)                        ❶
    })
    for _, f := range flights {                           ❷
        f.Locker.Lock()                                   ❷
    }
    for i := 0; i < len(flights) && bookable; i++ {       ❸
        if flights[i].SeatsLeft < seatsToBook {           ❸
            bookable = false                              ❸
        }                                                 ❸
    }
    for i := 0; i < len(flights) && bookable; i++ {       ❹
        flights[i].SeatsLeft-=seatsToBook                 ❹
    }
    for _, f := range flights {                           ❺
        f.Locker.Unlock()                                 ❺
    }
    return bookable                                       ❻
}

❶ 根据起点和目的地对航班进行字母排序

❷ 锁定所有请求的航班

❸ 检查所有请求的航班是否有足够的座位

❹ 仅在预订所需的所有座位都足够的情况下,从每个航班中减去座位数

❺ 解锁所有已锁定的航班

❻ 返回预订的结果

我们可以使用 Go 的sync.mutex,因为这为我们提供了Lock()Unlock()函数,但相反,让我们利用这个机会来实现我们自己的sync.Locker实现。通过这样做,我们将学习如何实现互斥锁。

12.2.1 比较和交换

原子变量的任何操作能帮助我们实现互斥锁吗?CompareAndSwap()函数可以用来检查并设置一个标志,指示资源已被锁定。这个函数通过接受一个值指针和oldnew参数来工作。如果old参数等于指针存储的值,则值被更新为与new参数匹配。这个操作(就像atomic包中的所有操作一样)是原子的,因此不能被其他执行中断。

图 12.4 显示了CompareAndSwap()函数在两种场景下的使用。图中的左侧,变量的值是我们预期的,等于old参数。在这种情况下,值被更新为new参数的值,并且函数返回true。图中的右侧显示了当我们对一个不等于old参数的值调用函数时会发生什么。在这种情况下,更新不会被应用,函数返回false

图 12.4 CompareAndSwap()函数在两种场景下的操作

这两种情况可以在列表 12.8 中看到实际操作。我们使用相同的参数调用同一个函数两次。对于第一次调用,我们将变量设置为与旧参数相同的值,而对于第二次调用,我们改变变量的值使其与旧参数不同。

列表 12.8 应用CompareAndSwap()函数

package main

import (
    "*fmt*"
    "*sync/atomic*"
)

func main() {
    number := int32(17)                                                    ❶
    result := atomic.CompareAndSwapInt32(&number, 17, 19)                  ❷
    fmt.Printf("*17 <- swap(17,19): result %t, value: %d\n*", result, number)
    number = int32(23)                                                     ❸
    result = atomic.CompareAndSwapInt32(&number, 17, 19)                   ❹
    fmt.Printf("*23 <- swap(17,19): result %t, value: %d\n*", result, number)
}

❶ 在 CompareAndSwap()中将变量设置为与旧参数相同的值

❷ 更改变量的值并返回 true

❸ 在 CompareAndSwap() 上设置变量为与旧参数不同的值

❹ 比较失败,变量值保持不变,并返回 false

当我们运行前面的代码列表时,第一次调用成功,更新了变量并返回 true。在更改变量的值之后,第二次调用失败,CompareAndSwap() 函数返回 false,变量保持不变。以下是输出:

$ go run atomiccompareandswap.go
17 <- swap(17,19): result true, value: 19
23 <- swap(17,19): result false, value: 23

现在我们知道了 CompareAndSwap() 函数的工作原理,让我们看看它是如何帮助我们实现 Locker 接口的。

12.2.2 构建互斥锁

我们可以使用 CompareAndSwap() 函数在用户空间中完全实现互斥锁,而无需依赖于操作系统。我们将首先使用原子变量作为指示器,显示互斥锁是否被锁定。然后我们可以使用 CompareAndSwap() 函数在需要锁定互斥锁时检查和更新指示器的值。要解锁互斥锁,我们可以调用原子变量的 Store() 函数。图 12.5 展示了这一概念。

图片

图 12.5 实现自旋锁

如果指示器显示为空闲,CompareAndSwap(unlocked, locked) 将成功,并将指示器更新为锁定。如果指示器显示为锁定,CompareAndSwap(unlocked, locked) 操作将失败,返回 false。在这种情况下,我们可以继续重试,直到指示器值改变并变为解锁。这种类型的互斥锁称为自旋锁。

定义 A 自旋锁 是一种锁,其中执行将进入循环以重复尝试获取锁,直到锁变为可用。

要实现我们的自旋锁指示器,我们可以使用一个整型变量。该整型变量可以具有 0 的值,表示锁是空闲的,如果它被锁定,则具有 1 的值。在列表 12.9 中,我们使用 32 位整数作为我们的指示器。

列表还展示了如何实现 lock()Unlock() 函数,完全实现 Locker 接口。在 lock() 函数中,循环调用 CompareAndSwap() 操作,直到调用成功并原子变量更新为 1。这是我们锁的自旋部分。锁定自旋锁的 goroutine 将继续循环,直到锁变为空闲。在 Unlock() 函数中,我们只需调用原子的 Store() 函数将指示器的值设置为 0,表示锁是空闲的。

列表 12.9 自旋锁实现

package listing12_9

import (
    "*runtime*"
    "*sync*"
    "*sync/atomic*"
)

type SpinLock int32                                        ❶

func (s *SpinLock) Lock() {
    for !atomic.CompareAndSwapInt32((*int32)(s), 0, 1) {   ❷
        runtime.Gosched()                                  ❸
    }
}

func (s *SpinLock) Unlock() {
    atomic.StoreInt32((*int32)(s), 0)                      ❹
}

func NewSpinLock() sync.Locker {
    var lock SpinLock
    return &lock
}

❶ 值为 0 表示锁是空闲的,而值为 1 表示锁已被锁定。

❷ 循环直到 CompareAndSwap() 成功并将值设置为 1

❸ 调用 Go 调度器以给其他 goroutines 分配执行时间

❹ 更新整数值为 0,标记锁为空闲

在我们的自旋锁实现中,每次 goroutine 发现锁已被另一个 goroutine 使用时,我们都会调用 Go 调度器。这个调用不是严格必要的,但它应该给其他 goroutines 一个执行的机会,并可能解锁自旋锁。用技术术语来说,我们可以说是 goroutine 正在 让步 它的执行。

列表 12.9 包含一个创建我们的自旋锁的函数,返回 Locker 接口的指针。我们可以在我们的航班预订程序中使用这个实现。以下列表展示了使用自旋锁创建一个新的、空的航班的实现。

列表 12.10 使用自旋锁创建新的航班

package listing12_10

import (
    "*github.com/cutajarj/ConcurrentProgrammingWithGo/chapter12/listing12.6*"
    "*github.com/cutajarj/ConcurrentProgrammingWithGo/chapter12/listing12.9*"
)

func NewFlight(origin, dest string) *listing12_6.Flight {
    return &listing12_6.Flight{
        Origin:    origin,
        Dest:      dest,
        SeatsLeft: 200,
        Locker:    listing12_9.NewSpinLock(),    ❶
    }
}

❶ 创建一个新的自旋锁

资源竞争 的定义是当一个执行(如线程、进程或 goroutine)以阻止和减慢另一个执行的方式使用资源。

使用自旋锁实现互斥锁的问题在于,当我们有高资源竞争,例如一个 goroutine 长时间占用锁时,其他执行将浪费宝贵的 CPU 周期,在自旋和等待锁释放。在我们的实现中,goroutines 将陷入循环,反复执行 CompareAndSwap(),直到另一个 goroutine 调用 unlock()。这种循环等待浪费了本可以用来执行其他任务的宝贵 CPU 时间。

12.3 改进自旋锁定

我们如何改进 Locker 实现以避免在锁不可用时连续循环?在我们的实现中,我们调用 runtime.Gosched() 以提供其他 goroutines 执行的机会。这被称为 让步 执行,在某些其他语言(如 Java)中,这个操作被称为 yield()

让步的问题在于,运行时(或操作系统)不知道当前执行正在等待锁变得可用。在锁释放之前,等待锁的执行可能会被多次恢复,浪费宝贵的 CPU 时间。为了解决这个问题,操作系统提供了一个称为 futex 的概念。

12.3.1 使用 futex 锁定

Futexfast userspace mutex 的缩写。然而,这个定义是误导性的,因为 futex 实际上根本不是互斥锁。futex 是一个我们可以从用户空间访问的等待队列原语。它赋予我们挂起和唤醒特定地址上执行的能力。当我们需要实现如互斥锁、信号量和条件变量等高效的并发原语时,futex 非常有用。

当使用 futex 时,我们可能会使用几个系统调用。每个操作系统的名称和参数都不同,但大多数操作系统都提供了类似的功能。为了简化起见,让我们假设我们有两个名为 futex_wait(address, value)futex_wake(address, count) 的系统调用。

不同操作系统中 futex 的实现

在 Linux 上,futex_wait()futex_wake() 都可以通过系统调用 syscall(SYS_futex, ...) 实现。对于等待和唤醒功能,我们可以分别使用 FUTEX_WAITFUTEX_WAKE 参数。

在 Windows 上,对于 futex_wait(),我们可以使用 WaitOnAddress() 系统调用。futex_wake() 调用可以通过使用 WakeByAddressSingle()WakeByAddressAll() 实现。

当我们调用 futex_wait(addr, value) 时,我们指定一个内存地址和一个值。如果内存地址处的值等于指定的参数 value,调用者的执行将被挂起并放置在队列的末尾。队列停放所有在相同地址值上调用 futex_wait() 的执行。操作系统为每个内存地址值建模不同的队列。

当我们调用 futex_wait(addr, value) 并且内存地址的值与参数值不同时,函数将立即返回,执行继续。这两个结果在图 12.6 中展示。

图片

图 12.6 使用两种不同结果调用 futex_wait()

futex_wake(addr, count) 唤醒在指定地址上等待的挂起执行(线程和进程)。操作系统恢复总共 count 个执行,并从队列的前端获取执行。如果计数参数为 0,则所有挂起的执行都将恢复。

这两个函数可以用来实现一个用户空间互斥锁,它仅在需要挂起执行时切换到内核。这就是我们的原子变量,代表锁,不是空闲的时候。想法是当一个执行发现锁被标记为锁定时,当前执行可以通过调用 futex_wait() 去睡觉。内核接管并将执行放置在 futex 等待队列的末尾。当锁再次可用时,我们可以调用 futex_wake(),内核从等待队列中恢复一个执行,以便它能够获得锁。这个简单的算法在列表 12.11 中展示。

注意:在 Go 中,我们没有访问 futex 系统调用的权限。下面的代码列表是 Go 的伪代码,以说明运行时如何使用 futex 实现高效的锁定库。

列表 12.11 使用 futex 锁定和解锁,尝试 #1(伪 Go)

package listing12_11

import "*sync/atomic*"

type FutexLock int32

func (f *FutexLock) Lock() {
    for !atomic.CompareAndSwapInt32((*int32)(f), 0, 1) {    ❶
        futex_wait((*int32)(f), 1)                          ❷
    }
}

func (f *FutexLock) Unlock() {
    atomic.StoreInt32((*int32)(f), 0)                       ❸
    futex_wakeup((*int32)(f), 1)                            ❹
}

❶ 尝试将原子变量标记为锁定,如果它是 0 则将其设置为 1

❷ 如果锁不可用,则等待,但只有当锁变量值为 1 时

❸ 更新原子变量使其值为 0,释放锁

❹ 唤醒 1 个执行

将值 1 传递给 futex_wait() 确保我们避免了一个竞争条件,即锁在我们调用 CompareAndSwap() 之后但 futex_wait() 之前被释放。如果发生这种情况,由于 futex_wait() 期望一个值为 1 但找到 0,它将立即返回,然后我们会再次检查锁是否空闲。

在之前的列表中,我们的互斥锁实现是对自旋锁实现的改进。当有资源竞争时,执行不会无谓地循环,浪费 CPU 周期。相反,它们会在 futex 上等待。它们将被排队,直到锁再次可用。

虽然我们在有竞争的场景中使实现更高效,但在反向情况下我们减慢了速度。当没有竞争时,例如当我们使用单个执行时,我们的Unlock()函数比自旋锁版本慢。这是因为我们总是在futex_wakeup()中进行昂贵的系统调用,即使没有其他执行在 futex 上等待。

系统调用很昂贵,因为它们会中断当前执行,切换上下文到操作系统,然后,一旦调用完成,再切换回用户空间。理想情况下,我们希望找到一种方法来避免在 futex 上没有其他执行等待时调用futex_wakeup()

12.3.2 减少系统调用

如果我们改变代表锁的原子变量的含义,使其告诉我们是否有执行正在等待锁,我们可以进一步改进我们互斥锁的实现性能。我们可以将0的值视为未锁定,1视为锁定,2表示告诉我们它已锁定且执行正在等待锁。这样,我们只有在值为2时才调用futex_wakeup(),并且在没有竞争时节省时间。

列表 12.12 展示了使用这个新系统的解锁函数。在这个列表中,我们首先更新原子变量为0来解锁互斥锁,然后,如果其前一个值是2,我们通过调用futex_wakeup()唤醒任何等待的执行。这样,我们只有在需要时才进行系统调用。

列表 12.12 仅在需要时唤醒 futex

package listing12_12

import "*sync/atomic*"

type FutexLock int32

func (f *FutexLock) Unlock() {
    oldValue := atomic.SwapInt32((*int32)(f), 0)  ❶
    if oldValue == 2 {                            ❷
        futex_wakeup((*int32)(f), 1)              ❸
    }
}

❶ 将锁标记为未锁定,并存储旧值

❷ 如果旧值是 2,这意味着执行正在等待。

❸ 唤醒一个执行

为了实现lock()函数,我们可以使用CompareAndSwap()Swap()函数共同工作。图 12.7 展示了这个想法。在这个例子中,左侧的执行首先执行正常的CompareAndSwap()并将原子变量标记为锁定。一旦完成锁定,它就调用带有0值的Swap()来解锁。由于Swap()函数返回2,它调用futex_wakeup()。在右侧,另一个执行发现原子变量已经被锁定后,交换值为2,由于Swap()函数返回了非零值,我们调用futex_wait()。这样,当我们标记变量为锁定且带有等待者(值为2)时,我们也会再次检查锁在此期间是否变为空闲。这个Swap()步骤会重复进行,直到它返回0,表示我们已经获得了锁。

图 12.7 使用 futexes 仅在有竞争时

图 12.7 仅在有竞争时使用 futexes

列表 12.13 展示了 Lock() 函数。该函数首先尝试通过执行正常的 CompareAndSwap() 来获取锁。如果锁不可用,它进入一个循环,尝试获取锁并同时在等待者上标记它为已锁定。它是通过使用 Swap() 函数来做到这一点的。如果 Swap() 函数返回非零结果,它将调用 futex_wait() 来挂起执行。

列表 12.13 标记锁变量为已锁定并带有等待者

func (f *FutexLock) Lock() {
    if !atomic.CompareAndSwapInt32((*int32)(f), 0, 1) {   ❶
        for atomic.SwapInt32((*int32)(f), 2) != 0 {       ❷
            futex_wait((*int32)(f), 2)                    ❸
        }
    }
}

❶ 当锁的值为 0 时交换 1。如果交换成功,就没有其他事情要做了。

❷ 否则,在将锁标记为值 2 的同时,再次尝试获取锁

❸ 如果在获取锁时失败,只有当锁的值为 2 时才在 futex 上等待

注意:在执行从 futex_wait() 唤醒后,它总是会将该变量设置为值 2。这是因为没有办法知道是否还有其他执行在等待。因此,我们采取安全起见,将其设置为 2,尽管这偶尔会导致不必要的 futex_wakeup() 系统调用。

12.3.3 Go 的互斥锁实现

既然我们现在知道了如何实现一个高效的互斥锁,那么研究 Go 的互斥锁实现以了解其工作原理就很有价值。在 futex 上调用等待会导致操作系统挂起内核级线程。由于 Go 使用用户级线程模型,Go 的互斥锁不直接使用 futex,因为这会导致底层的内核级线程被挂起。

Go 中使用用户级线程意味着可以完全在用户空间中实现一个类似于我们使用 futex 实现的排队系统。Go 的运行时将 goroutines 排队,就像操作系统为内核级线程所做的那样。这意味着我们通过不需要每次等待被锁定的互斥锁时切换到内核模式来节省时间。每当 goroutine 请求一个已经锁定的互斥锁时,Go 的运行时可以将该 goroutine 放入等待队列中等待互斥锁变为可用。然后运行时可以选取另一个 goroutine 来执行。一旦互斥锁被解锁,运行时可以从等待队列中选取第一个 goroutine,恢复其执行,并让它再次尝试获取互斥锁。

要完成所有这些,Go 中 sync.mutex 的实现使用了信号量。这个信号量实现负责在锁不可用的情况下排队 goroutines。这个信号量是 Go 内部的一部分,不能直接访问,但我们可以探索它以了解其工作原理。源代码可以在以下位置找到:github.com/golang/go/blob/master/src/runtime/sema.go

就像我们的互斥锁一样,这个信号量的实现使用一个原子变量来存储可用的许可证。它首先对表示可用许可证的原子变量执行 CompareAndSwap(),以减少一个许可证。当它发现许可证不足(就像被锁定的互斥锁一样),它就会将 goroutine 放入一个内部队列,并挂起 goroutine,暂停其执行。此时,Go 的运行时可以自由地从其运行队列中选取另一个 goroutine 并执行它,而无需切换到内核模式。

Go 的信号量实现中的代码难以理解,因为它有额外的功能,使其能够与 Go 的运行时一起工作并处理许多边缘情况。为了帮助我们理解信号量的工作原理,以下列表显示了使用原子变量实现信号量获取函数的伪代码。列表显示了 Go 源代码中 semacquire1() 函数的核心功能。

列表 12.14 使用原子变量实现信号量获取(伪代码)

func semaphoreAcquire(permits *int32, queueAtTheBack bool) {
    for {
        v := atomic.LoadInt32(permits)                               ❶
        if v != 0 && atomic.CompareAndSwapInt32(permits, v, v-1) {   ❷
            break                                                    ❸
        }
        *//The queue functions will only queue and park the*
        *//goroutine if the permits atomic variable* *is zero*
        if queueAtTheBack {                                          ❹
            queueAndSuspendGoroutineAtTheEnd(permits)                ❹
        } else {                                                     ❹
            queueAndSuspendGoroutineInFront(permits)                 ❹
        }
    }
}

❶ 读取原子变量的值

❷ 如果原子变量的值不是 0,则尝试原子性地将值减少 1

❸ 如果我们获取了信号量,则退出循环

❹ 只有当许可证为 0 时,才在队列的后面或前面排队和挂起 goroutine

此外,这个信号量实现还具有通过将其放置在队列的前面而不是后面来优先处理 goroutine 的功能。当我们想要给 goroutine 更高的优先级,以便在许可证可用时首先被选中时,可以使用此功能。我们将看到,这在完整的 sync.mutex 实现中非常有用。

sync.mutex 作为信号量的包装器,并且,它还添加了另一层复杂性,目的是提高性能。就像一个普通的自旋锁一样,Go 的互斥锁首先尝试通过在原子变量上执行简单的 CompareAndSwap() 来获取锁。如果失败,它就会回退到信号量,让 goroutine 睡眠,直到解锁被调用。这样,它就使用内部信号量来实现我们在前几节中看到的 futex 的功能。这个概念在图 12.8 中展示。

图片

图 12.8 Go 的互斥锁内部结构

这还不是全部的故事。sync.mutex 有一个额外的复杂层——它有两种操作模式:正常模式和饥饿模式。在正常模式下,当互斥锁被锁定时,goroutines 会正常排队到信号量队列的后面。每当释放锁时,Go 的运行时会从这个队列中恢复第一个等待的 goroutine。

正常模式下运行的互斥锁有一个问题:等待的 goroutine,无论何时恢复,都必须与刚到达的 goroutine 竞争。这些是刚刚调用lock ()函数并且尚未被放入等待队列的 goroutine。新到达的 goroutine 相对于恢复的 goroutine 有优势:因为它们已经在运行,所以它们获得锁的可能性比从队列中取出并恢复的 goroutine 要大。这可能导致等待队列中的第一个 goroutine 恢复是徒劳的,因为当它尝试执行CompareAndSwap()时,它会发现互斥锁已经被新到达的 goroutine 占用。这种情况可能会发生多次,使互斥锁容易发生饥饿;goroutine 将一直停留在队列中,直到有新的 goroutine 获得锁(见图 12.9)。

图片

图 12.9 新到达的 goroutine 相对于等待的 goroutine 有优势。

sync.mutex的实现中,当一个恢复的 goroutine 未能获得互斥锁时,同一个 goroutine 再次被挂起,但这次它被放置在队列的前端。这确保了下次互斥锁被解锁时,goroutine 首先被选中。如果这种情况持续一段时间(设置为 1 毫秒),并且 goroutine 在一段时间后(设置为 1 毫秒)未能获得锁,互斥锁将切换到饥饿模式。

当互斥锁处于饥饿模式时,互斥锁的行为更加公平。一旦互斥锁被解锁,它就被传递给等待队列前端的 goroutine。新到达的 goroutine 不会尝试获得互斥锁,而是直接移动到队列的尾部并挂起,直到轮到它们。一旦队列变空或等待的 goroutine 在不到 1 毫秒内获得锁,互斥锁将切换回正常模式。

注意:Go 的mutex源代码可以在go.dev/src/sync/mutex.go找到。

这额外复杂性的目的是在避免 goroutine 饥饿的同时提高性能。在正常模式下,当我们有低竞争时,互斥锁非常高效,因为 goroutine 可以快速获得互斥锁,而无需在队列上等待。当我们有高竞争并切换到饥饿模式时,互斥锁确保 goroutine 不会在等待队列上卡住。

12.4 练习

注意:访问github.com/cutajarj/ConcurrentProgrammingWithGo以查看所有代码解决方案。

  1. 在列表 12.9 中,我们通过使用整数实现了自旋锁。你能修改这个实现,使其使用sync/atomic Go 包中找到的原子布尔类型吗?就像在列表 12.9 中一样,实现需要提供sync.Locker中找到的Lock()Unlock()函数。

  2. Go 的互斥锁实现还包括一个TryLock()函数。使用之前的自旋锁实现和原子布尔值来包含这个额外的TryLock()函数。此函数应尝试获取互斥锁,并在获取成功时立即返回true,否则返回false。以下是完整的函数签名:

    func (s *SpinLock) TryLock() bool
    
  3. 原子变量也可以用来实现自旋信号量。编写一个可以初始化为指定许可数量的信号量实现。该信号量可以使用原子变量来实现以下函数签名:

    func (s *SpinSemaphore) Acquire()
    

    Acquire() 函数将可用许可的数量减少 1。如果没有更多许可可用,它将自旋在原子变量上,直到有一个可用:

    func (s *SpinSemaphore) Release()
    

    Release() 函数将可用许可的数量增加 1

    func NewSpinSemaphore(permits int32) *SpinSemaphore
    

    NewSpinSemaphore() 函数创建一个具有指定许可数量的新信号量。

摘要

  • 原子变量提供了对各种数据类型执行原子更新的能力,例如原子地增加一个整数。

  • 原子操作不能被其他执行中断。

  • 在多个 goroutines 同时更新和读取变量的应用程序中,可以使用原子变量而不是互斥锁来避免竞态条件。

  • 更新原子变量比更新普通变量要慢。

  • 原子变量一次只处理一个变量。如果我们需要保护多个变量的同时更新,我们需要使用互斥锁或其他同步工具。

  • CompareAndSwap() 函数原子地检查原子变量的值是否具有指定的值,如果是,则使用另一个值更新变量。

  • CompareAndSwap() 函数仅在交换成功时返回 true

  • 自旋锁是完全在用户空间中实现的互斥锁。

  • 自旋锁使用一个标志来指示资源是否被锁定。

  • 如果标志已被其他执行锁定,自旋锁将反复尝试使用CompareAndSwap()函数来确定标志是否解锁。一旦标志指示锁是空闲的,它就可以再次被标记为锁定。

  • 当存在高竞争时,自旋锁通过循环直到锁变得可用来浪费 CPU 周期。

  • 与不断循环在原子变量上实现自旋锁不同,可以使用 futex 来挂起和排队执行,直到锁变得可用。

  • 要使用原子变量和 futex 实现互斥锁,我们可以让原子变量存储三种状态:未锁定、锁定和带有等待执行的锁定。

  • Go 的互斥锁在用户空间中实现了一个排队系统,以挂起等待获取锁的 goroutines。

  • sync 包中的互斥锁围绕一个信号量实现,当没有更多许可可用时,它会排队并挂起 goroutines。

  • Go 中的互斥锁实现,在出现新到达的 goroutine 阻塞排队中的 goroutine 获取锁的情况下,会从正常模式切换到饥饿模式。

posted @ 2025-11-14 20:39  绝不原创的飞龙  阅读(10)  评论(0)    收藏  举报