即使在围棋中,并发仍然不容易

2020-09-03 17:52:49

Go以通过良好的语言支持Goroutines而使并发变得容易而闻名。除了Go让事情变得简单之外,只有一个级别的并发性,即让您的代码并发地做事情并通过通道来回通信的细节级别。让它同时做正确的事情仍然取决于你,不幸的是,Go目前并没有为正确实现的标准并发模式提供大量的标准库支持。

例如,一种常见的需求是有限的并发性;您希望一次做几件事,但只做这么多件事。目前,这取决于您在goroutines、通道和syncpackage之类的东西上实现。这并不像看起来那么容易,在这里很有能力的人可能会犯错。碰巧的是,今天我手头上有一个例子。

GOPS是一个方便的命令列表(和诊断),列出(和诊断)当前在您的系统上运行的GO进程。在其他方面,它将告诉您它们是用哪个版本的Go编译的,如果您想查看是否有过时的二进制文件需要重新构建和重新部署,这是很方便的。GOP需要做的一件事是查看系统上的所有围棋进程,它会同时执行。但是,它不希望一次查看太多进程,因为这可能会导致文件描述符限制的问题。这是有限并发的典型案例。

GOPS目前使用goprocess.FindAll()中的代码实现了这一点,看起来像这样,形式略显简略:

Func FindAll()[]P{pss,err:=ps.Process()[...]。找到:=make(Chan P)limitCH:=make(chan struct{},concurrencyProcess)for_,pr:=range pss{limitCH<;-struct{}{}pr:=pr go func(){defer func(){<;-limitCH}()[...。获取带有一些错误检查的P...]。找到<;-P}()}[...]。Var Results[]P for p:=Range Found{Results=Append(Results,p)}Return Results}。

(在实际代码中,有一个用于协调的WaitGroup,找到的通道会相应地关闭。)。

这是如何工作的很清楚,并且是一个标准模式(在Eggo 101的渠道用例中介绍)。我们使用缓冲通道来提供有限数量的令牌;将值隐式地发送到通道接受一个令牌(如果令牌供应耗尽则阻塞),同时从通道接收一个值将一个令牌放回。我们在开始一个新的Goroutine之前拿到一个令牌,Goroutine在它完成后释放这个令牌。

不过,如果要检查的进程太多,则此代码有错误。即使知道这段代码中存在错误,也可能不明显。

缺陷在于,Goroutines仅在将其结果发送到未缓冲的find channel之后才从limitCh接收其令牌,而主代码仅在运行完整个循环后才开始从find接收,并且主代码在循环中获取令牌并在没有令牌可用的情况下阻塞。因此,如果您有太多的进程要处理,您可以启动N个goroutines,它们都会阻塞尝试从limitCH写入,并且不会从limitCH接收,而main for循环块会尝试发送到limitChand,并且永远不会到达它开始从find接收的点。

在某种程度上,这个bug是一个非常脆弱的bug;它的存在只是因为多种情况。如果goroutines通过向limitCH而不是main for循环发送令牌来获取令牌,那么bug就不存在了;main for循环将启动它们,许多会停止,然后它将继续从find接收,这样它们就可以从limitCH接收并释放令牌,这样其他goroutine就可以运行了。如果从limitCH接收的goroutine在发送到find之前释放它们的令牌,它就不会存在(但由于错误处理,延迟接收会更简单、更可靠)。如果整个for循环是在一个附加的goroutine中,则主代码将继续从find接收并解锁已完成的goroutine以释放它们的令牌,所以for循环被阻塞等待发送到limitCH这一事实并不重要。

在另一个层面上,这表明并发并不像围棋中看起来那么容易。您所需要的只是一个错误,事情就会陷入停顿,所有涉及到的代码在随机检查时都会看起来很好。让并发性正确对于人们来说是很难的(我们可以争论为什么,但我认为这是非常清楚的)。

(我确信,编写并批准将这种并发限制代码添加到GOP中的更改的人都是优秀的程序员。一个棘手的案例仍然让他们绊倒,通过了他们所有的检查。即使当我知道代码中存在并发问题以及它在哪里(因为我的GOP突然挂起来了,Delve告诉我所有东西都卡在哪里了),我也花了一些时间才弄清楚确切的问题是什么。)