译者个人领悟,一家之言:
并发和并行确实可以明确区分出来,因为cpu的速度非常快,在执行一个任务时经常要等其他组件,比如网络,磁盘等,如果一直串行等待这样就会造成很大的浪费. (就类似于烧水的同时,可以切菜,不用等烧水完成了才去切菜,我可以烧一会水,火生起来了水壶放上了,随后这段时间就能去切菜,切着切着菜发现水烧开了,就又可以切换到烧水,并发就是高频高速做这样的切换) .
即并发就是做这样事情的, 不用一直等着,可以去做别的事,因为cpu一直切换,单线程,或者说单核cpu也可以不停切换. 以Go来说, go func1, go func2 ... go func100
和 for循环里启动100个协程 for i:=0;i<100;i++ { go func666 }
, 其实都叫并发, 区别是 go func1,go func2 需要是互相不依赖的才会这样写,而for循环里启多个func666, func666相互之间更不存在依赖关系了(其实和外面go func666, go func666 ...写100个等效)
即当go func1,go func2..go func100 无相互关系时, 和循环里启100个go func666 两者本质一样(其实效果也一样,只不过for里写方便点,代码量少一点).
(面试要求中常说的高并发,其实等同于高QPS,一定程度上模糊了其概念和作用)
如果是单核情况下的并发,本质上还是同一时间,只能做一个事,不可能既烧水又做饭 (cpu高频,看起来是能同时做), 即现在这100个协程,由GMP机制不停调度, 但在任何一个时刻,能得到执行的,还是最多只有1个协程 (但这种情况还是可以导致map等有并发问题...). 即func1,func2..func100同一时间点只能有一个在运行,或者for循环里启100个协程,同时也只有一个在执行(go淡化了协程id的概念,不给程序员暴露,但其实是有协程id的~)
而并行,必须得是多核cpu. 即无多核,不并行...因为是有多核,所以同一时刻,可以做多件事情.多核cpu情况下的并发, 就算是并行了(并行不一定都在执行并发相关的代码,大概率可能在做别的; 比如多核cpu,一个核在处理并发相关的逻辑块,另外的核在处理其他程序比如qq,或者哪怕在处理同样说的上面这个100个协程的go程序,也可能在处理这个程序并发无关的模块,所以有后面这句话的最多), 还是以上面100个协程的case来说, 比如有4核心,那同一时刻,确实可以有最多4个协程得到执行...
即1号协程的func1,2号协程的func2 .. 是可以同一时间点都运行的
而异步, 异步帮助实现并发,在多线程环境中是实现并行的一种方法。
所以说,和串行相对的概念不是并行,而是并发.. 串行是从上到下func1完了做func2,再func3....而并发是这些没有前后关联的func可以同时做, 在某个func陷入停滞时,去做其他func 而不会阻塞在那干等着(如何才能判断某个func陷入停滞? go里面是每个协程都高速切换,最多有10ms执行机会)
本篇内容是根据2019年12月份Concurrency, parallelism, and async design[1]音频录制内容的整理与翻译,
Go 在设计时就考虑到了并发性。这就是为什么我们有 goroutine、通道、等待组和互斥锁等语言原语。如果使用得当,它们非常强大,但如果使用不当,它们可能会非常复杂。
Roberto Clapis[2] 再次加入团队,为大家带来异步智慧。不过别担心,我们会以串行方式进行。😉
过程中为符合中文惯用表达有适当删改, 版权归原作者所有.
Mat Ryer[3]: 你好,欢迎收听 Go Time。我是 Mat Ryer。今天我们要讨论的是并发编程。Go 是为并发设计的,这就是为什么它提供了 goroutine、channel、WaitGroup、Mutex 等语言原语。它们在正确使用时非常强大,但如果使用不当,也可能非常复杂。为了帮助我今天理清这些思路,我邀请了 Johnny Boursiquot。你好,Johnny!
Johnny Boursiquot[4]: 你好,Mat。
Mat Ryer: 你怎么样?
Johnny Boursiquot: 我来帮忙抽丝剥茧。
Mat Ryer: 很好,太好了。不过你不需要一个人拉这些线……我们还邀请到了 Jaana B. Dogan。你好,Jaana。
Jaana Dogan[5] (译者注,压测工具hey的作者): 你好,大家好。
Mat Ryer:你今天怎么样?
Jaana Dogan:我刚喝了今天的第一杯咖啡……
Mat Ryer:恭喜啊。
Jaana Dogan:……所以我还稍微有点慢热。
Mat Ryer:是的,慢慢来。还有一位嘉宾,如果你听到了他的声音……他是 Roberto Clapis。你好,Rob!你怎么样?
Roberto Clapis:你好!我很好。今天话题会比较复杂,所以我打算轻轻地探讨……
Mat Ryer:哦,听到你这么说我很高兴。
Johnny Boursiquot:不错,不错……我想我们今天开了个好头。
Roberto Clapis:谢谢!
Mat Ryer:[笑声] 好,那我们就开始吧?C 语言是在 70 年代设计的,当时的计算机只有一个核心,远不像现在的计算机那么复杂……而现代的计算机有多核处理器,因此同时处理多个任务的潜力更大了,无论这意味着什么……所以在设计 Go 的时候,他们当然考虑到了这些情况,因此我们有了 goroutine、channel 和其他这些原语。Go 因为并发编程而闻名,实际上,当你谈论编程语言时,Go 和并发几乎是同义的。你觉得 Go 配得上它在并发编程方面的声誉吗?
Roberto Clapis:我觉得配得上。实际上,在使用 Go 之前,我已经用了很多年的 Python。有一次,我需要在大学的集群上使用所有 36 个核心来解决一个问题……用一门没有为并发设计的语言来完成这个任务,真的让我头疼不已。于是我想:“好吧,为什么不学一门可能让这变得更简单的语言呢?” 用 Go,我只写了大约 100 行代码就解决了问题。
Mat Ryer:哇。
Jaana Dogan:我来自 JVM 背景,每次我们开始这种大规模、流程密集的任务时,我总是抱怨它占用了我所有的处理能力……但听起来这反而是件好事,而不是手动完成这些工作……
Mat Ryer: 给不熟悉 Go 的听众快速概述一下我们今天可能会使用的一些原语和术语,确保大家知道我们在讨论什么。你可以把 goroutine 想象成另一个正在运行的线程。实际上它不是线程,但你可以把它想象成你的主应用程序在运行时就是一个线程,而如果你想在后台做一些工作,你可以启动一个 goroutine 来完成这些任务。实际上,你可以启动很多 goroutine 去执行它们的工作,理论上,它们会尽量利用可用的硬件资源完成任务。
然后当然我们还有 channel,它允许 goroutine 之间进行通信……它们非常酷……你可以以安全的方式发送和接收信息。 因为如果这些 goroutine 同时访问同一片内存,你可能会遇到问题。
所以我想这些是最基本的两个东西。还有一个 go 关键字,它用来启动 goroutine。你可以调用一个函数并通过它启动一个 goroutine。任何想了解更多这方面知识的人都可以在你喜欢的搜索引擎上搜索一下,比如 DuckDuckGo,或者其他的搜索引擎。好了,我的概述到这里……
Johnny Boursiquot:我觉得还应该谈谈适合并发的场景。并不是所有的问题都需要你使用并发编程。通常,当你编写一个程序时,你会问自己:“这个程序的哪些部分可以并发运行?或者哪些部分可以独立于其他部分运行?” 你通常是试图确定哪些部分可以独立工作,而不依赖于其他部分。如果你没有任何部分依赖于其他部分的结果,那么这些情况通常是并发可能对你有用的地方。
goroutine 本身——你实际上不需要使用 channel 的消息传递机制来利用 Go 的并发;仅仅使用 go
关键字就可以让你开始。真正的关键是,当初学者试图理解“Go 中的并发是如何工作的”时,他们通常会发现:“哦,原来主程序本身也是一个 goroutine”,而如果我要启动其他的 goroutine,它们需要以某种方式互相等待。你的主 goroutine 需要等待其他 goroutine 完成工作,然后你的程序才能结束。
这就是为什么“如何将这些东西串联起来?如何正确使用 Go 的并发?”变得非常重要的原因。我认为我们在社区中应该花更多时间去教育初学者,教他们如何在 Go 中思考和理解并发。
Roberto Clapis:我非常喜欢 Go 的一个地方是并发与并行是分开的,你不用去刻意思考它们。 比如,当你使用 HTTP 包时,每次你的 handler 被调用,它都会在一个独立的 goroutine 中运行。但当你编写代码时,它感觉是同步的;你总是需要一个独立的执行线程,但你不需要改变你的思维模式来正确使用它。你不需要使用异步操作或者其他不同的原语;你只需编写你想要的代码,它甚至不需要与其他东西并行运行……因为比如你只在一个核心上运行,你可能只想让它这样运行,然后你就写代码。
我们可能应该多放出一些材料,教大家如何思考和学习并发,但我真正喜欢的是你可以非常容易地开始,只需编写同步代码,你不用担心其他东西。
Jaana Dogan:是的,我觉得将同步代码迁移到并发代码也很容易。Go 的魔力在于 select
语句……有人抱怨 select
的行为不太容易理解,但除此之外,它真的非常易读,而且没有什么魔法。我觉得这是我写并发代码时最舒服的语言之一,其他人也能很容易理解我在做什么……所以 Go 目前肯定是我进行并发编程的最佳工具。
Mat Ryer:是的,我同意你对 select
语句的看法……当你真正掌握它时,它非常强大。我最近常用的一个方法是检查 context 是否已经完成。如果我正在一个循环中做一些工作,我会在某处设置一个 select
块,检查 context 是否被取消,这样我就可以在任务被取消时中断工作并优雅地退出……当你把它和 HTTP 请求的 context 结合起来时,这非常不错,因为这样一来——如果用户在浏览器中取消了请求,理论上,你为准备该请求而进行的工作也会停止……这种节省可能微乎其微,但知道程序在做这样的事,真的让人感到非常满意。
译者注: 可参考 Go context.WithCancel()的使用
Jaana Dogan:你觉得 Go 能不能在 context 方面做更多的内置支持?我觉得有很多样板代码……你需要确保 context 没有被取消,没有超时,等等……你觉得有没有什么改进空间?也许 select
语句可以自动处理某些情况,或者其他什么……我现在只是随便想想……
Mat Ryer:是的,我不知道,这确实是个有趣的问题。我想过让 context 成为语言的一部分——当然这有点违背 Go 的风格,因为 Go 喜欢显式编程…… 但我能想象 context 始终存在,而且你始终可以取消它,子函数和其他功能也会以同样的方式被取消。你几乎可以想象它。这有点像其他语言中的异常处理机制,反过来工作。所以我不确定这种想法会得到多少支持……但有时我确实发现自己在每个方法中都传递了 context,几乎每个地方都传递,因为甚至我的日志记录可能也需要 context,所以在这种情况下,所有东西都需要它;我不太喜欢这样。但话说回来,这样做是显式的,而且你不能在没有 context 的情况下调用方法。
Roberto Clapis:我希望自动化的 context 不会成为现实……我喜欢在启动 goroutine 时知道它什么时候开始,什么时候结束。举个例子,我获取了一个 mutex,并用 defer 释放它。这没问题。然后我改变了一些状态,然后做了其他事情……如果我遇到错误,我希望能够回滚我已经做的事情。你并不总能使用 defer,有时候当你编写事务性代码时,你希望确保你的操作不会被中断……除非程序崩溃,那样什么都不会被提交,你确实想知道程序是如何退出的。
我真的想知道我写的代码何时会被中断,我想控制这种情况何时发生以及如何发生。如果 context 被取消,我希望有机会说:“好吧,那就回滚我已经开始的操作。”
Mat Ryer:是的,这是个好观点。select
块的一个好处是它确实是一个块。它有 case,然后在某些条件下执行代码……所以你确实有机会按照你描述的那样,进行一些清理工作……而且它是显式的。
Roberto Clapis:是的。
Mat Ryer:你提到你想知道 goroutine 何时结束。有没有人有什么技巧来找出 goroutine 已经完成了?
译者注: 可参考 Go语言通知协程退出(取消)的几种方式
Roberto Clapis:我觉得无论如何,你都应该知道……我见过很多程序和项目变得无法维护,就是因为它们只是在启动 goroutine 并让它们运行,而没有处理它们的优雅关闭。有人也有类似的经历吗?
译者注: 可参考 Goroutine泄露的危害、成因、检测与防治
Mat Ryer:嗯,当你的程序结束时,它们都会关闭,对吧?[笑声]
Johnny Boursiquot:希望如此……
Jaana Dogan:所以你的程序崩溃了……
Johnny Boursiquot:……否则你就有问题了。
Mat Ryer:是的。
Jaana Dogan:你知道有一种策略是定期让程序崩溃,以便释放那些资源……所以这也是个合理的观点。
Johnny Boursiquot:[笑] 只要重启一下就好了……
Roberto Clapis:是啊。我都在想我们为什么还需要垃圾回收器了。[笑声] 你不需要……
Mat Ryer:是的,重启就好了。
Jaana Dogan: "这就是PHP的本质。" [笑声]
Johnny Boursiquot: 你问我们是否知道或者能否知道goroutine何时结束——我认为没有任何生命周期事件或者类似的东西可以让你知道goroutine何时结束。然而,通常你需要做的是,这也是涉及到通信的地方开始显现的。例如,如果你在主goroutine中,想知道你启动的另一个goroutine何时完成工作,并且你想等待它完成,Go语言中有一些机制可以实现这一点。比如,使用WaitGroup
就是一个很好的方式,它可以阻塞直到其他goroutine完成工作,然后你就可以继续执行。
这也是channel
派上用场的地方。如果你启动了一个goroutine去执行某些工作,当你初始化它时,如果你能传递一个channel
,用于接收工作完成的通知,不论是你的主goroutine还是其他需要知道该事件的goroutine,都能收到通知。因此,这种通信管道——无意双关——是你了解工作状态的必要手段。你可以通过这种方式知道goroutine的工作状态,比如它是否完成了。除此之外,还有其他方法可以实现这种通信,但没有直接的方式告诉你“这个goroutine是否结束了”。你需要围绕它进行一些监控。
Jaana Dogan: 你提到生命周期,确实没有相关的API可以使用……这是设计上的选择,目的是让用户体验更加简洁和紧凑。不过,过去几年里我遇到了很多场景,我确实想施加一些限制,或者想将某些goroutine或底层操作系统的线程绑定到某些处理器上等等。你是否遇到过类似的问题?你觉得Go在执行方面非常简单,它会帮你处理所有事情,并且将问题封装起来,但也在某种程度上限制了用户……你对此有何看法?
Roberto Clapis: 我认为它确实在某种程度上限制了用户,但这是好事。从某些方面来说,Go提供的抽象层让你能够构建更好的抽象。例如,errgroup
是我到处都会用到的东西,因为当你取消上下文时,可以立刻知道所有工作何时结束并传播关闭信号。但与此同时,如果你想深入到底层,就像你说的那样……比如你想要降低权限。如果你希望某个goroutine说“好,这个程序现在以root身份运行,但它不应该再是root身份了。” 降级权限的操作仍然存在问题,而且这个问题已经存在了九年……因为它有竞争条件,并且存在一些问题。Go并没有给你底层线程的精细控制,例如无法将线程固定在某个核心上,比如在图形处理的场景中。现在唯一能做到的方式是从主函数中说:“好,把我固定在这个OS线程上,永远这样,我将负责绘图。” 这非常笨拙。
译者注: 可参考 ErrGroup-有错误返回的Goroutine控制[6]
Jaana Dogan: 是的,没错。而且随着NUMA[7]和所有这些新的调度控制方式的出现,你可以将自己固定在某些处理器或某些处理器组上。人们确实会为了这些精细的优化这样做,因为你可能对任务本身或你正在运行的东西了解更多,通过将它们分组或其他方式……这确实合理。我曾经在实验性地使用Go做这类事情,但这真的是一个非常棘手的话题。你唯一能做的就是将自己锁定在OS线程上,然后通过一些C库对OS线程进行一些控制……这很有趣。
译者注: "NUMA node(s)"(非一致性存储访问节点)是一种计算机体系结构设计,用于处理多个物理处理器和内存子系统之间的数据访问。NUMA是一种在多处理器系统中优化内存访问性能的方法。
Roberto Clapis: 我想是Ristretto[8]的作者们提到,他们需要一个线程本地存储,但Go没有提供这个功能,所以他们使用了sync.Pool
,虽然是有损的,但他们仍然觉得可以接受,因为即使有损,也比和其他线程共享东西要好……我猜当你走到这一步时,可能你在用错东西。 [笑声]
Johnny Boursiquot: 公平地说,他们确实提到在底层确实有某种形式的线程本地存储,但作为语言用户你无法访问它。
Roberto Clapis: 是的,确实如此,所以他们才这么做。
Jaana Dogan: 实际上有一些人正在滥用底层存储,我看到有些公司在基于这个做一些自动化的监控……类似于某种执行跟踪类型的东西。这非常有争议,当然也不推荐,但人们正在逆向工程并劫持这个功能……这挺吓人的。 [笑]
Mat Ryer: 那么,对于Go中用于并发工作的某些包,你们有什么看法?我想到的是sync
包。sync.Once
是一个非常有用的小工具。基本上,你给它一个函数,它保证该函数只会被调用一次。所以在某些场景下非常有用,比如一个web处理器,如果你需要在第一次调用时做一些初始化工作,但你可能想延迟到第一次调用时再执行——那么你可以把它放在处理器内部……但是,由于每个请求在Go中都会启动自己的goroutine,两个请求可能会同时到达,启动两个goroutine,它们都试图执行初始化工作。或者它们在检查是否为nil,或者它们在做某些其他操作,结果可能会互相冲突。你可以使用sync.Once
来防止这种情况发生。
它的工作方式是,第一个到达的会执行函数,其他的则会等待该函数完成,然后才会继续执行。非常有用,很实用,是个很棒的小工具……不过还有一些更底层的工具,对吧?
Roberto Clapis: 是的,比如sync.Map
,我认为这是Go标准库中被滥用最多的数据结构。
Mat Ryer: 真的吗?
Roberto Clapis: 是的,因为人们以为它只是一个线程安全的哈希表……其实并不是。我认为线程安全的哈希表就是一个带有互斥锁的map,仅此而已。sync.Map
实际上是为了减少缓存争用。我见过很多人到处使用sync.Map
,但实际上他们需要的只是一个带有保护机制的普通map。sync.Map
只有在你注意到你的缓存争用太多,并且读操作远多于写操作时才有用……我认为这就是它的用途。它的文档里甚至写着,“不要使用这个。” Sync Atomic
也是如此,明确写着“不要自作聪明”。 (译者注: 即适用于 读多写少 的情况)
Jaana Dogan: 是的,我觉得是命名问题……它叫sync.Map
,所以我觉得它的名字并不够自描述。这是主要原因。因为Go文档里明确说明“实际上大多数时候你需要的是一个普通的Go map。” 但是它的名字并没有表明这一点。它应该被叫作sync.某某map
。
Johnny Boursiquot: 所以对于听众来说,建议是基本上只需要使用互斥锁来保护map的访问,对吗?Rob,你是这个意思吗?
Roberto Clapis: 是的,基本上就是这样,直到你意识到互斥锁是个问题之前,不要切换到其他东西。
Mat Ryer: 那这就挺有意思了。Johnny,你可以告诉我们什么是map和互斥锁吗?它们是如何工作的?
Johnny Boursiquot: 好的。默认情况下,你创建的普通Go map并不是线程安全的。所以可能会有多个goroutine试图同时往同一个键写入数据……这种情况。对于读操作可能还好,但通常你会希望限制goroutine的数量,确保在任何时候只有一个goroutine在写入或读取map的内容。这就是互斥锁——“互斥”的缩写——的作用。它保证在任何时候,只有一个goroutine能够访问或修改map的内容。
所以我们讨论的就是“sync包中的Map类型是否提供了这种功能?” 回应Jaana的观点——它的名字看起来像是应该提供这种功能……但其实并没有。你应该使用一个普通的map,但需要引入一个互斥锁来处理可能的争用问题。
Mat Ryer: 是的,谢谢。正如Johnny所说,如果你想访问这个map,你需要锁定互斥锁,访问完之后再解锁……如果其他东西在你锁定的时候尝试锁定这个互斥锁,它们会等待你解锁。互斥锁是同步点,它们确实会造成争用。我们说我们有一个并发程序,但在这些点上,它不是并发的。你必须在这些点上等待某些事情发生……这让思考变得有些棘手。
Roberto Clapis: 我想打断一下……你应该先锁定互斥锁,接着使用defer
解锁,然后再访问map。
Johnny Boursiquot: 哦,我们来谈谈defer
吧。Mat,你一直想谈谈defer
,对吧?[笑声]
Mat Ryer: 我爱defer
。我真的想做一期关于defer
的完整节目,但我想我们只能在这个季末做了,对吧……不能在季中做…… [笑声] 它必须是最后一集。
Johnny Boursiquot: 关于defer
,有趣的是,最近在性能上做了很多改进。我记得以前使用defer
时,有一些基准测试显示,defer
会带来性能损耗,尤其是在大量使用defer
的情况下。现在这种情况有所缓解。我不知道它是否完全不是问题了,但defer
现在快多了。所以Rob,当你说“你应该锁定并且使用defer
解锁”时,我想“是的,我现在完全支持这个。”
Roberto Clapis: 是的。现在几乎不可能测量出defer
所需的时间。如果你在函数中只有一个defer
语句并进行测量,你会得到一些波动的结果。有时甚至会发现defer
更快,因为现在几乎无法测量它的开销了。
Mat Ryer: 那是不是在这种情况下defer
被编译器优化掉了?因为如果它只是一个普通函数体中的defer
,而不是在循环中……如果它在一个for
循环中,那么你当然得等到运行时才能知道会有哪些需要defer
的操作。但通过静态分析,你可能可以查看一个函数并说“好,我可以看到所有的退出点,所以我会在那些地方调用所有的清理方法。” 我不知道它是否是这么做的,还是它优化了整个机制……
Roberto Clapis: 这并不容易,因为有panic
的存在。你可能看到了返回点,但你还需要检查所有可能导致panic
的语句,因为你需要在panic
发生时执行defer
语句……此外,循环体可能会展开,所以你实际上可能会提前执行你本来要defer
的几个语句……所以编译器里有很多黑科技,而我认为这是最有趣的部分之一。
Jaana Dogan: 我刚刚读到,如果你不使用recover
,性能会有额外的损耗;也许是因为他们在做一些优化……但如果你需要recover
,那么问题可能会变得更加复杂。我不太清楚具体是怎么运作的,但你知道的…… [笑]
Mat Ryer: 但从可读性角度看,defer
绝对是无可争议的赢家。当你打开一个文件并检查错误,然后说“好,defer
关闭文件”,所有与打开和关闭文件相关的操作都在同一个地方……而且当你忘记关闭某些资源时,这也很容易发现,因为你就在那个区域查找。它就在你打开文件的附近。所以我觉得从可读性上来说,它绝对是赢家,对吧?
Johnny Boursiquot: 通常我听到有经验的开发者像你们这样说“使用defer
,因为可读性好,而且你不想忘记关闭文件句柄”,或者类似的资源管理问题。但我会说,有时——我两种方式都用过,是的,我倾向于使用defer
……但同时,取决于我正在处理的函数有多大,以及我在里面做了多少事情,如果我打开一个文件,我可能会选择在做完2-3个额外操作后,明确地关闭文件,而不是使用defer
。
所以总的来说,确实应该使用defer
,但我不认为它应该被当作金科玉律。
Jaana Dogan: 的确如此,这取决于具体的任务。在某些监控库中,我们明确不想使用defer
……但如果我有超过10微秒的工作要做或者其他什么的……我不在乎,我会直接使用defer
。
Mat Ryer: 是的。当然,另一个好处是,如果你使用defer
,那么无论你从函数的哪个地方退出,资源清理都会被执行。这是唯一值得说的事情。所以如果你要打开几个文件,并且要做一些更加复杂的事情,那么它确实会有所帮助……但是的,我想像所有事情一样,这取决于具体情况,不幸的是……
Johnny Boursiquot: “这取决于……” 这是我们对一切问题的守护神。
Roberto Clapis: 是的。
Mat Ryer: 的确如此。
Roberto Clapis: 那么,关于并发呢? [笑声]
Mat Ryer: 哦对了……我告诉过你我们可以做一期关于defer
的完整节目。
Roberto Clapis: 我相信你可以。实际上,当你提到defer
时,我说我们应该和select
一起讨论……因为我认为它非常优雅;大多数人说Go的channel很棒,但归根结底,channel不过是一个带有互斥锁的队列。而select
,完全是另一回事,它非常难以实现。我认为select
才是真正的美丽所在。
Mat Ryer: 这很有趣,因为从外部看,它似乎非常简单,你只需要想“好,有一些事件会发生,我会把每个事件作为一个case。” 它看起来确实相当简单。但它也非常强大。
Roberto Clapis: 这就是Go的美丽所在。
Mat Ryer: 是的,没错。
Jaana Dogan: 我认为Go语言中最主要的并发特性是select
语句,而不是其他东西。这就是神奇的地方,它看起来对你来说非常简单……但实际上实现起来非常复杂。
Johnny Boursiquot: 我想深入探讨一下这个问题。我见过一些代码,其中有select
语句,并且有多个不同的case。有时会有默认情况(default
),有时没有……有人能解释为什么有时会有默认情况,而有时没有吗?在select
语句中有默认情况与没有默认情况有什么影响?
Roberto Clapis: select
用于从通道(channel)中接收和发送数据,select
会阻塞,直到其中一个case可以执行。如果你有一个默认case,它就像switch
语句一样。基本上,如果没有其他case可以执行,select
将继续执行程序。这个特性需要一点时间适应,因为我见过有人在循环中使用默认case……他们只是不断地尝试获取一些工作,然后“嗯,工作还没准备好,我们再来一轮”,实际上它本该阻塞。而在其他情况下,人们在检查上下文取消时没有默认case,这会阻塞所有内容,而这很难调试,因为——比如HTTP处理器不会检测到死锁,类似的情况。
所以,是的,有默认case就不会阻塞,没有默认case就会阻塞。阻止程序继续执行的最佳方法是使用一个空的select
。
译者注: 使用selct {} 也许是比time.Sleep(1e9*86400) 和 for{} 更好的阻止程序退出的方式
Mat Ryer: 是的……有时这很有用。有时候你确实希望程序一直运行而不会消耗所有资源……即使在一个for循环中也比直接阻塞在select
中做更多的工作,对吧?
Roberto Clapis: 是的。实际上,我觉得很有趣的是,如果你查看运行时的源码,运行时会构建一个依赖图。所以,当某个goroutine准备好执行工作时,运行时会说“好吧,这是下一个要调度执行的”。如果我有一个空的select
,这是一种向运行时传达“这个永远不会准备好”的方式,因此运行时就不再处理它了。它会停在某个地方,并且保持在那里。
Mat Ryer: 太棒了。
Johnny Boursiquot: 所以,空的select
就像一种人道的方式在告诉程序“我希望它停下来,但这并不是说所有goroutine都进入了睡眠状态……我是真的希望一切都停下来。”
Jaana Dogan: 你知道的,你只是希望当前的goroutine挂起。其他一切都会继续运行。
Mat Ryer: 是的。如果你有一个主程序,并且启动了五个线程,这些线程会在程序中持续工作,我可以看到你可能会在主线程中使用空的select
……不过,如果你考虑上下文,你可以通过Ctrl+C捕获信号并取消上下文。这样的话,你实际上可以优雅地从Cmd+C中关闭程序,并且可以编写一个模式,使第二次按下信号时,程序会真正终止。这是一个非常不错的小技巧……类似的东西。
Roberto Clapis: 是的。只需要一个一位的通道。这非常好用。
Mat Ryer: 是的,一个带有一个缓冲空间的通道。
Roberto Clapis: 是的。
Mat Ryer: 当你使用这些信号通道时……这些通道并不打算传递任何信息,你只想发送一个事件信号,比如“我完成了”之类的信号,你会使用什么类型?你有最喜欢的吗?因为我有一个最喜欢的……这是个已经预设好的问题,我只是想告诉你我最喜欢的是什么,所以我们快点说完你的……[笑声]
Roberto Clapis: 一瞬间我以为你要讨论缓冲通道,我想“哦,这是个有争议的问题……”但你找到了一个更好的问题。
Johnny Boursiquot: [笑] 好吧,有一个流行的习惯用法是使用空结构体作为消息传递的机制。因为它几乎不占用任何内存,没有分配,基本上……你只是发送信号,只是一些简单的信号。初学者可能会倾向于使用布尔值,我见过有人使用整型,我还见过人们通过通道传递错误信息作为信号机制。
我并不是说这些机制是错误的。有时你从信号中接收到的值——基本上,你将其既作为信号,也作为你要处理的值,这取决于你的具体情况……但通常,如果你想要零分配类型的机制,仅仅是发送一个信号,那么空结构体是你的好朋友。
Jaana Dogan: 这也值得一提,具体情况确实有所不同。如果这是一个公开的API,比如信号包,或者有不同的事件等,我认为定义类型是有意义的;你可以为信号定义一个类型,并且可以从该包中导出更多预定义的信号等。但如果它是一个自包含的东西,使用空结构体是完全可以的。
Mat Ryer: 是的,空结构体的好处是你无法放入任何信息。它非常清楚地表明了它的用途。我也见过布尔值的使用,但我总是不知道我传递true或false是否重要,感觉好像有些API需要我传递……但用空结构体就非常清楚,它只能作为信号使用,因此我喜欢用它来向程序员传达信息。这有助于提高代码的可读性。
Roberto Clapis: 发送false的话,我是——
Johnny Boursiquot: [笑] 是零值吗?
Roberto Clapis: 是的,使用布尔值很危险。[笑声] 因为如果你收到true,那是一个信号。但如果你收到false……
Johnny Boursiquot: 你就不知道了。[笑声]
Mat Ryer: 这是我在Go Time节目中听到的最极客的一段话,至少是在我主持期间……
Johnny Boursiquot: 我们都笑了,因为我们都知道你在说什么。[笑声]
Mat Ryer: 我们需要更多这样的片段。
Roberto Clapis: 目前我做的大部分工作是代码审查。现在我读的代码比写的多得多。当我看到有人使用map存储某些布尔值时,我总是问“如果你得到false,但键不存在呢?” 对于通道来说也是一样——“你想告诉你的用户什么?” 或者像大小为50的缓冲通道。我能理解一两个缓冲,但当它开始变成100时,我就需要一条注释告诉我为什么了。
Mat Ryer: 是的。
Johnny Boursiquot: 这就像是把它当作队列……
Mat Ryer: 可能是在做某些性能调优或其他操作,但你说得对,它隐藏起来了,看起来很奇怪……没人敢碰它,因为你会觉得“为什么是50呢?” 这有点像《迷失》里的那些数字,当他们不知道是否应该把数字放在那里时……你就是无法停止这么做,以防万一……
译者注: 《迷失》是一部美国超自然电视连续剧,4 8 15 16 23 42,这组神奇的数字多次出现在剧情中。 戴斯蒙曾守候小岛多年,就是为了每隔108分钟按一次这组数字,好让人类不会灭绝
Johnny Boursiquot: 那么让我们谈谈并发,尤其是在库中使用并发的情况。有一种惯用的做法是,你可以在库中使用并发,但完全隐藏它,让库的使用者看不到这种并发……同时也有一种说法,建议让库的使用者来决定如何使用你的库来组织并发操作。你可以同步执行,也可以跨越边界进行并发操作。
所以,如果你要在库中使用并发,最好在内部整齐地处理它,但在其他情况下,你应该让库的使用者自己组织并发操作。如果你的库可能会并发操作,它应该接受一个通道,用于返回信号或并发操作的结果。我见过各种各样的建议……我很好奇你对此有何看法。
Roberto Clapis: 我不喜欢默认异步的API。
Johnny Boursiquot: 嗯。
Roberto Clapis: 每次我看到一个库,比如接受一个通道并通过该通道发送结果……甚至更糟的是,你调用一个函数,它返回一个通道——我是这个做法的反对者。因为我总是要阅读代码,来确认如果我取消了上下文,他们是否会每次发送时检查取消,或者我需要排空通道?如果通道填满,他们会阻塞吗?这些问题经常出现……我更喜欢我的库是同步的。我不喜欢Promise机制。
Jaana Dogan: 我完全同意这一点。我认为一切都应该尽可能阻塞……因为在Go中组织这些并发操作非常容易,我完全同意将这种精确的控制权交给用户更加有价值。一些库实际上在同一个包中提供了两种不同的API,它们实际上是在重复相同的API,这是完全没有必要的,因为在Go中组织并发和组合操作是非常容易的。
Go语言目前做得不太好的一点是,没有一个容易的方式——无论是在Go文档中还是其他地方——来清楚地标注“哦,这个操作实际上会在另一个goroutine中运行”等等。比如HTTP包——每个处理器都会在不同的goroutine中运行,你可能需要在Go文档中留下评论……但这并不是很直观,或者有些人就会忽略掉……我认为我们需要一种更好的方法来解释底层实现是如何从并发的角度工作的。抱歉,我稍微改变了一下话题……
Johnny Boursiquot: 不不,没关系……
Roberto Clapis: 我同意。一种情况是你调用一个函数,它可能会启动goroutine,但只要它在返回之前收集它们,那就没问题。但如果那些东西继续运行,或者它会在另一个goroutine中运行你传递的闭包,你可能希望知道——比如filepath.Walk
函数,你可能想知道它是否会并发运行,因为你可能在闭包中引用了一个变量,而你不希望这个变量在多个线程之间被修改。嗯,其实它是同步的,但如果我们有一种方式来说明“这不会导致同步问题”,那就好了。
Jaana Dogan: 是的。在Go语言的初期,我见过很多人实际上为他们试图从多个闭包中访问的某些内容使用了互斥锁……但实际上根本不需要这样做,因为库已经给出了保证,同一时间内只有一个函数会被执行等等……但除了在Go文档中添加这些信息之外,几乎没有其他方法可以解释这一点,而这并不直观。
Mat Ryer: 是的,这很有趣。这让我想到了一件事,那就是每当你编写并发代码时,最好把它保持在非常局部的范围内,让所有并发代码都集中在一起……然后调用函数去执行其他工作。不要试图在同一个地方处理所有工作,这样页面会拉长,你的并发代码会分散在各处……
此外,像通过指针传递互斥锁之类的事情——如果你能避免这种情况,只在一个地方使用互斥锁并在一个函数中处理所有并发操作,那就更容易维护,也更容易以后理解。经验告诉我这样做更好,所以我现在倾向于这样做——我倾向于把所有并发代码集中在一个地方……如果使用WaitGroup
来做一些工作,我会调用一个函数去执行实际的工作。这样我的并发代码就不会被这些操作搞得混乱不堪。
Roberto Clapis: 我完全同意。
Johnny Boursiquot: 所以我们在使用“并发”这个词时是非常谨慎的,对吧?所以,当你第一次学习Go时,你会了解到并发并不等于并行。通过编写并发代码,你允许系统并行运行你的代码,但这并不是你可以控制的。
我记得Rob Pike曾做过一个演讲,标题是《并发不是并行》[9],这个演讲揭示了这个机制。那么,你有没有遇到过并行不是正确选择的情况?比如你希望没有并发代码最终在并行执行?有没有遇到过你没有预料到的竞争条件或者其他问题?
Roberto Clapis: 我曾经在处理init
函数时遇到了一个大问题,因为我在代码中启动了goroutine,并在这些goroutine返回之前阻塞了程序。但在init
函数中,你不能启动goroutine。呃,你可以启动它们,但它们不会运行。所以那段代码在启动时死锁了。
Mat Ryer: 哇,这真是个棘手的问题。
Johnny Boursiquot: 从未听说过这个问题。这……哇。这真是太惊人了。
Roberto Clapis: “惊人”是个说法,没错……[笑声]
Mat Ryer: 但,我的意思是,不要使用init
函数……如果我们不使用init
,我们可能就能避免类似的问题。
Johnny Boursiquot: 同意,我完全同意。[笑声]
Roberto Clapis: 是的,不要使用init
函数。我同意。
Johnny Boursiquot: 继续吧,JBD。
Jaana Dogan: 其实有时候我确实需要并行处理,但并不是那种并发的需求。我只是想把一个goroutine锁定在某个处理器上,然后我希望能够在不使用内部锁的情况下访问所有东西。Go语言没有给我这种精确的控制权,这有点有趣。Go语言提供了很多和并发相关的特性,但如果我只是想把一些工作分布到不同的处理器上,而且我已经知道数据的亲和性是什么样的,我却做不到,因为Go并没有提供这样的能力。
并行和并发是不同的问题,它们带来的优势也不同。我觉得完全没有方法去调整这些东西,这有时让人有点不爽。我觉得这对普通用户来说可能并不是什么大问题,但有时你会觉得自己有点被限制住了。
Mat Ryer: 是啊,我的经验主要是构建很多Web项目,像是Web API、网站、博客之类的东西……[笑声] 所以我不需要说:“嘿,我想让这个goroutine在这个核心上运行,别让它跑到别的地方去。” 对我来说,只要它们完成任务就行了,比如渲染一些页面内容。但确实,语言设计上总是有这些取舍,而你需要做出判断,决定这些取舍是否值得,这其实也就是你对编程语言的判断。
Jaana Dogan: 你可以想象一下,如果我们有那种精确的控制权,那会变得非常复杂,尤其是如果库开始调整这些参数之类的……所以我完全能够理解,提供一个简单的API界面,像是只提供goroutines和一些同步机制,而不提供其他复杂的东西,对整个社区其实是有益的。
这也从某种程度上给社区和用户群体划定了界限,基于语言所提供的功能……所以你可能不会选择去做某些任务,因为你知道语言没有提供那种功能。如果这种需求在未来变得至关重要,我们可以重新考虑,但也许这就是事情的运作方式。我也不确定。
Roberto Clapis: 去年我在Go Nuts上关注了一个非常长的讨论,某个时刻有人——我记得是Ian Taylor——建议使用Unix包来调用set affinity,把goroutine锁定到特定的处理器。我见过一些代码使用这种方式:调用Unix的系统调用——当然,这不是跨平台的,虽然我们很喜欢Go的跨平台特性,但如果你深入到这样的层次,你可能还是要像在C语言中那样去做系统调用。
Jaana Dogan: 是的,我就是这么做的。我锁定我的OS线程,然后调用set affinity或者类似NUMA这种东西。不幸的是……
译者注: 更多看参考 runtime包 LockOSThread
Mat Ryer: 这应该成为一个演讲主题啊!我们需要知道你为什么这么做,以及怎么做的。这才是我们想了解的。
Jaana Dogan: 我的工作需要这么做,因为有一些基准测试之类的东西,所以这基本上是一个全职项目。这不是某种生产环境的服务之类的……但你知道,这些工具是可用的,没错。
Mat Ryer: 有意思。
Johnny Boursiquot: 你可能干脆直接用C语言写算了……
Roberto Clapis: 我开玩笑的啦。[笑声]
Jaana Dogan: 我的生活就是个笑话,老兄……[笑声] 我喜欢活在边缘。
Johnny Boursiquot: 流血不止的那种。
Mat Ryer: 还有一个小技巧,我发现非常有用,我试着描述一下……可能不是最容易解释的东西……有时候你想处理很多工作,有很多任务要完成,但你知道你一次只能处理五个。你希望它尽可能快地运行,但同时只处理五个任务。在Go中实现这个的一个简单方法是使用一个带缓冲的通道,缓冲区的大小就是你允许同时运行的goroutines的数量。然后每个goroutine尝试向这个通道写入一些东西。如果成功写入,它就会继续执行它的任务。
当然,前五个没问题,它们可以写入通道。第六个goroutine到来时,通道已经满了,因为前面的goroutines占据了所有的空间,所以这个goroutine就得等待,直到通道中有空间释放出来。任务完成后,通道中的东西会被释放,就像互斥锁一样,你在锁之后会延迟解锁。在这里,你会写入通道,然后在defer语句中读取通道,释放出一个空间,让另一个goroutine可以运行。
如果你了解通道的基本语义并知道如何在Go中使用它们,这个模式就很容易理解……但实际上它非常强大,尤其是因为缓冲区的大小是可配置的,你甚至可以让程序的用户通过一个标志来指定你希望同时运行多少个任务。
当然,还有另一种方式,你可以启动一组固定数量的goroutines,让它们从一个队列中取任务……但我觉得这种方式有点复杂,因为你还需要有另一个goroutine来以某种方式填充任务队列,这感觉有点奇怪。但这是我发现的一个比较喜欢的小模式。
Roberto Clapis: 我非常喜欢这个模式,我也非常喜欢Go语言的简洁性,使得你只用三行代码就能实现一个信号量。你刚才描述的其实就是一个信号量,而通道是一个更高级的原语,它允许你实现任何你想要的东西。即使你需要一个带trylock方法的互斥锁,因为你想尝试获取锁,如果获取不到,就稍后再试……你可以用通道配合select和一个空的default块来实现。通道比互斥锁更具表现力。
Mat Ryer: 是的,你还提醒了我,在time
包中有个time.After
的函数,它返回一个通道,在指定的时间之后向通道发送当前时间……所以你可以在select块中使用它,比如说“我们要等待这个goroutine完成。如果它在一秒钟内没有完成,我们就执行另一个case。” 你可能会更新统计数据,或者展示一些信息给用户,这样每一秒用户都会看到更新。一旦任务完成,当然会触发另一个case,它就会执行其他任务。
time.After
还可以用在测试代码中,非常适合表达超时,如果你在等待某个测试完成……在测试中加入一些超时机制也很实用。
Roberto Clapis: 我对time
包有过不好的体验。[笑声]
Johnny Boursiquot: 总体上吗?[笑]
Roberto Clapis: 是的。
Mat Ryer: 听起来像是一场独角戏,主演是Roberto Clapis……[笑声]
Roberto Clapis: 我在time
包里遇到麻烦了……
Johnny Boursiquot: “时间包独白”……
Mat Ryer: 没错,正是。
Roberto Clapis: 我真的为此画了一张流程图,试图搞清楚我可以在哪些情况下调用哪些方法。比如停止ticker后,你是否需要清空它?如果需要的话该怎么做……这些事情。所以这是个不错的包,但使用时要小心。
Mat Ryer: 这是很明智的建议……非常明智的建议。这也自然引出了我们今天节目的结束。非常感谢我们的嘉宾和共同主持,Johnny Boursiquot, Jaana B. Dogan 和 Roberto Clapis。很高兴和你们在一起,谢谢。
Roberto Clapis: 谢谢你!
Mat Ryer: 下次见,Roberto,现在不是你说话的时候……我在做结束语呢。[笑声] 我们下次见,在Go Time节目上!
Concurrency, parallelism, and async design: https://changelog.com/gotime/109
[2]Roberto Clapis: https://github.com/empijei
[3]Mat Ryer: https://github.com/matryer
[4]Johnny Boursiquot: https://github.com/jboursiquot
[5]Jaana Dogan: https://github.com/rakyll
[6]ErrGroup-有错误返回的Goroutine控制: https://juejin.cn/post/7165086210753789966
[7]NUMA: https://en.wikipedia.org/wiki/Non-uniform_memory_access
[8]Ristretto: https://github.com/dgraph-io/ristretto
[9]《并发不是并行》: https://www.youtube.com/watch?v=oV9rvDllKEg