【译】「结构化并发」简析,或:有害的go语句

原博文(@vorpalsmith)写于 2018年4月25日html

每种并发API都有其并发执行代码的方式。下面是几个看上去使用了不一样API的例子:python

go myfunc();                                // Golang

pthread_create(&thread_id, NULL, &myfunc);  /* C with POSIX threads */

spawn(modulename, myfuncname, [])           % Erlang

threading.Thread(target=myfunc).start()     # Python with threads

asyncio.create_task(myfunc())               # Python with asyncio
复制代码

符号和术语的区别不影响语义的一致:它们都安排myfunc开始与程序的其他部分并发运行,而后当即返回以便父程序执行其余操做。git

另外一种选择是使用回调:程序员

QObject::connect(&emitter, SIGNAL(event()),        // C++ with Qt
                 &receiver, SLOT(myfunc()))

g_signal_connect(emitter, "event", myfunc, NULL)   /* C with GObject */

document.getElementById("myid").onclick = myfunc;  // Javascript

promise.then(myfunc, errorhandler)                 // Javascript with Promises

deferred.addCallback(myfunc)                       # Python with Twisted

future.add_done_callback(myfunc)                   # Python with asyncio
复制代码

状况依旧,符号不一样可是效果同样:从如今起,若是特定事件发生,执行myfunc。一旦设定完毕,就当即返回以便调用者执行其余操做。(有时候回调被包装得很漂亮,例如 promise combinators, or Twisted-style protocols/transports,可是核心概念同样。)github

而后……没了。随便找一个实际的例子,你会发现它不是属于前者就是后者,要么就是兼而有之,好比asyncio。golang

可是我(原博主@vorpalsmith)的新库 Trio有点怪,它两种方法都不用。取而代之的是,若是咱们想并行运行myfuncanotherfunc,这样写:算法

async with trio.open_nursery() as nursery:
    nursery.start_soon(myfunc)
    nursery.start_soon(anotherfunc)
复制代码

当人们首次遇到这种“nursery”(nursery,托儿所)结构时,他们会有点困惑。为何有一个缩进块?这个nursery对象是什么东西,还有为何派生任务以前还得有它?他们又会发现,别的框架里驾轻就熟的模式在这无法用了,就很恼火。它看起来古怪又独特,并且由于抽象层次太高也很难成为一个基本原语。这些反应均可以理解。但请容忍我。express

在这篇文章里,我想告诉你nursery模式一点也不古怪,而是一个像for循环或者函数调用同样基本的新控制流原语。更进一步,咱们以前看到的其余方法——线程派生,回调注册——通通都不须要,并且能换成nursery式写法编程

听起来不太可能?其实这样的事情家常便饭:goto语句曾是个王者,如今是个笑话。只有少数语言还有一些类goto语句,但仍然不一样于且远弱于本来的goto。大多数语言甚至有都没有。大多数人甚至都不知道这陈芝麻烂谷子的事。可是彼时彼刻,恰如此时此刻。goto如是,并发API亦如是。小程序

什么是goto?

让咱们回顾段历史:早期计算机用 汇编语言编程,或者别的甚至更基本的原语。这有点糟糕。因此在1950年代, 一些人像IBM的John Backus 还有Remington Rand的 Grace Hopper开发了 FORTRANFLOW-MATIC(更为知名的是他的直接继承者 COBOL)等等语言。

FLOW-MATIC当年野心勃勃。能够将它看做Python的曾曾曾……祖父:第一门“以人为本"的语言。下面是一些FLOW-MATIC的代码,你细品:

和现代语言不一样,它没有if块,循环块,或者函数调用。实际上它根本没有块分隔符或缩进,就是一列线性语句。这不是由于这个程序刚好不须要任何花哨的控制语法,而是块语法根本还没发明呢!

相反,FLOW-MATIC有两种用于控制流的选项。一般,代码是线性的,就像你期待的:从头开始,一直向下,一次执行一条语句。可是若是你执行了一条特殊语句好比JUMP TO,它就会改变控制流直接跳转到别的地方。好比,语句(13)跳转回语句(2)。

就像咱们最初的并发原语同样,对于如何称呼这种“单向跳转”有一些争议。在这里它是JUMP TO,可是约定俗成的名字是goto(就像‘go to’,懂吧?),因此我将用goto来称呼它。

下面是这个小程序里所有的goto跳转:

若是你看着心累,你不是一我的!这种基于跳转的编程风格是FLOW-MATIC从汇编语言直接继承来的。它功能强大,很是符合计算机硬件的实际工做方式,可是就这么使用它让人很是困惑。为何有“意大利面式代码”这种说法,就是由于这些错综复杂的箭头。显然,咱们得来点更好的。

可是……是什么致使了这些问题?为何有些控制结构不错,有的很差?咱们怎么选择那些好的?当时,一切都还很不明朗,并且若是你不理解的话很难解决这个问题。

什么是go语句?

但让咱们先停一下,每一个人都知道goto很差。这跟并发有什么关系?额,想一下Go语言著名的go语句,它被用来派生一个新的”goroutine“(轻量级线程):

// Golang
go myfunc();
复制代码

画个图?额,跟咱们以前看到的都有所不一样,由于控制流实际上真的分离了。咱们可能会这样画:

这里的颜色用来表示两条路径都被采用。从父goroutine(绿线)的角度来看,控制流线性执行:从头开始,而后当即出如今底部。与此同时,从子goroutine(紫线)的角度看,控制流从头开始,而后直接跳到myfunc函数。与常规函数调用不一样,这种跳转是单向的:当运行myfunc时,咱们切换到一个全新的栈,运行时会当即忘记咱们从哪来。

可是这不只仅适用于Golang。这就是咱们这篇文章开头列出的全部原语的流程控制图:

  • 线程库一般会提供一些相似handle对象的东西让你以后join该线程——但这是一个独立的操做,语言自己不会获取任何相关的信息。实际的线程派生原语具备以上的控制流。
  • 注册回调在语义上等同于启动一个后台线程,该线程(a)阻塞直到某个事件发生,而后(b)运行回调。(尽管实现显然不一样)因此就高级控制流而言,注册回调其实是一个go语句。
  • Future和Promise也是同样的:当你调用一个函数并返回一个promise时,意味着它将工做安排在后台进行,而后给你一个handle对象,以便稍后join(若是你想)。就控制流语义而言,这就像派生线程同样。而后在promise上注册回调,请参见前面的要点。

这种彻底相同的模式以多种形式出现:关键的类似之处在于,在全部状况下,控制流都会分离,一边执行单项跳转,另外一边返回到调用方。一旦你知道要找什么,你就会开始处处找——有趣的游戏![1]

不过使人恼火的是,这类控制流语句没有标准的名字。就像“goto语句”成为全部这些类goto语句的总称同样,我用“go语句”来称呼这类语句。为何是go?一个缘由是Golang给咱们提供了一个特别纯粹的例子。另外一个是……额,你可能已经猜到我想的了。看下面两张图,有什么类似之处?

没错:go语句是goto语句的一种形式。

众所周知,并发程序难以编写和推断。基于goto的程序也是如此。仅仅是巧合吗?有没有多是出于一样的缘由?在现代语言中,goto引发的问题基本上已经获得了解决。若是咱们研究它们是如何修复goto的,会不会能告诉咱们如何写出更易用的并发API?让咱们看看。

goto怎么了?

因此为何goto闯了这么多祸?在1960年代末, Edsger W. Dijkstra写了两篇如今颇有名的论文,让这个问题更加明晰:Go to statement considered harmful, Notes on structured programming (PDF).

goto:抽象破坏者

在这些论文中,Dijkstra担忧的是如何编写有意义的软件并使其正确无误。我不能简单地加以评判,有太多迷人的看法了。例如,你可能看到过如下引用:

没错,这正是Notes on structured programming里的话。可是他主要关心的是抽象。他想写的程序太大了,你脑子里很难装得下。要作到这一点,你须要将程序的某些部分视为一个黑箱,就像你看下面的Python代码同样:

print("Hello world!")
复制代码

你无需知道全部的细节,好比print是怎么实现的(字符串格式化,缓冲,跨平台差别……)。只需知道它会以某种方式打印出你给它的文本,而后你就能把你的精力集中在思考你的代码上,这是否是就是你如今想要的。Dijkstra但愿语言支持这种抽象。

至此,块语法被发明出来了,并且像ALGOL这样的语言已经积累了大约5种不一样类型的控制结构:它们仍然有线性流和goto

![](cdn.lsongzhi.cn/blog/sequen… (1).svg)

还有了if/else、循环和函数调用:

你能够用goto来实现更高级别的构造,早期人们就是这么看待它们的:方便的简写。可是Dijkstra指出,若是你观察这些图,goto不同凡响。对于其他全部构造,流控制来到顶部→[事情发生]→流控制到达底部。咱们能够称之为“黑箱规则”:若是一个控制结构具备此形状,在不关心内部细节时,可忽略[事情发生]部分,将整个程序视为常规的顺序流。更好的是,这也适用于由这些片断组成的任何代码。当我看到这段代码时:

print('Hello world!')
复制代码

我没必要去阅读print的定义和它全部的可传递依赖项来搞清楚控制流是怎么工做的。可能在print里面有一个循环,在循环中有一个if/else,在if/else中有另外一个函数调用……或者是别的缘由。这其实不重要:我知道控制流将流入print,该函数将执行它的操做,而后控制流最终将返回到我正在阅读的代码。

这彷佛显而易见,可是若是你有一种带有goto的语言——一种函数和其余一切都创建在goto之上的语言,并且goto能够在任什么时候间跳转到任何地方——那么这些控制结构就根本不是黑箱。若是你有一个函数,函数里有一个循环,循环里面有一个if/elseif/else里面有一个goto……而后,goto能够将控制流跳转到任何它想去的地方。也许控制流会忽然从另外一个函数返回,一个你甚至尚未调用的函数。你什么都不会知道!

这打破了抽象:这意味每个函数调用都有多是假装的goto语句,惟一知道的方法是当即将系统的所有源代码保存在大脑中。一旦goto在你的语言中出现,你就不能对流控制进行原地推断。这解释了为何goto会产生意大利面式代码。

如今Dijkstra明白了这个问题,他能够解决。他的革命性建议是:咱们应该中止把if/循环/函数调用看做是goto的简写,而应该将他们视为基本原语,并且咱们应该将goto从咱们的语言中彻底删除。

在2018年的当下,这彷佛已经足够明显了。可是你有没有看到过程序员在你以他们的愚笨会致使安全问题为由试图拿走他们的玩具时的反应?是的,大人,时代没变。1969年,这项提议引发了极大的争议。Donald Knuth为goto辩护。那些已经成为goto专家的人必须从新学习如何编程才能使用更新的、更有约束性的结构来表达他们的想法,而他们对此很是反感。固然,这须要一套全新的语言。

最后,现代语言对这一点的要求比Dijkstra最初的公式要低一些。它们将容许你使用breakcontinuereturn等语句一次从多个嵌套结构中分离出来。但从根本上说,它们都是围绕Dijkstra的思想设计的;即便是这些突破边界的语句,也只能以严格限制的方式来使用。特别是函数——这是将控制流包装在黑盒中的基本工具——被认为是不可侵犯的。不能从一个函数break到另外一个函数,return可让你从当前函数中断,但到此为止。无论一个函数内部的控制流多么花里胡哨,其余函数都没必要在乎。

左:传统`goto`。右:驯化过的`goto`,见于C,C#,Golang等语言。
无法跨越函数边界意味着它仍然能够在你鞋子上撒尿,就是不能把你的脸撕破而已。

这甚至延伸到goto自己。你会发现一些语言仍然有它们称之为goto的东西,好比C,C++,Golang……可是它们添加了严格的限制。起码,它们不会容许你从一个函数体跳转到另外一个函数体。除非你在执行汇编代码[2],经典的不受限的goto已经没了。Dijkstra赢了。

意外收获:移除goto开启了新特性

一旦goto消失,有趣的事来了:语言设计者能够开始添加依赖于结构化控制流的特性。

例如,Python有个很好的资源清理语法:with语句。你能够写出这样的代码:

# Python
with open("my-file") as file_handle:
    ...
复制代码

它保证文件会在…代码中打开,随后当即关闭。大多数现代语言都有一些等价物(RAII,using,try-with-resource,defer,……)。他们都假设控制流有序,结构化。若是咱们用goto来跳转到with块的中间……会发生什么?文件是否会打开?若是咱们再次跳出而不是正常退出呢?文件会关闭吗?若是你的语言中有goto,这个特性就不能正常工做。

错误处理也有一个相似的问题:当出现错误时,代码应该怎么处理?一般的答案是将错误沿堆栈传给代码调用者,让他们去处理它。现代语言有专门的构造来简化这一过程,好比异常或者其余形式的自动错误传播。但你的语言只有在有一个堆栈和可称之为“调用者”的概念存在的状况下才能提供这种帮助。再看看咱们的FLOW-MATIC程序中的意大利面式控制流,想象一下在代码当中引起一个异常。它会跳到哪?

goto语句:一行也不要写

因此goto——忽略函数边界的传统类型——不只是通常的很难正确使用的糟糕特性。若是是的话,它可能会幸存至今——就像许多坏特性同样。但它甚至更糟。

即便你本身不使用goto,在你的语言中仅把它做为一个选项,也会使一切都变糟。不管什么时候开始使用第三方库,都不能将其视为一个黑箱——必须通读全部函数,才能找出哪些函数是常规函数,哪些是假装的特殊控制流构造。这严重阻碍了原地推断。并且,你将失去诸如可靠的资源清理和自动错误传播等强大的语言功能。最好彻底删除goto,以支持遵循“黑箱”原则的控制流构造。

有害的go语句

goto的历史讲完了。如今,有多少能用在go语句上?额,基本上,所有!这个类比结果很是准确。

go语句破坏了抽象 还记得咱们说过若是咱们的语言容许goto,那么任何函数都有多是假装的goto吗?在大多数并发框架中,go语句会致使彻底相同的问题:每当调用函数时,它可能会也可能不会生成一些后台任务。函数彷佛返回了,但它是否是仍然在后台运行?若是不通读全部源代码,就没办法知道。何时结束?很难说。若是有go语句,那么函数就再也不是控制流的黑箱。在个人第一篇并发API文章中,我称之为“破坏了因果律”,并发现这是使用了asyncio和Twisted的程序中许多常见的实际问题的根源所在,好比backpressure问题,正常关闭时出现的问题等等。

go语句破坏了自动资源清理。 让咱们再回顾一下with语句的例子:

# Python
with open("my-file") as file_handle:
    ...
复制代码

之前,咱们说过咱们“保证”文件在…代码中运行,而后关闭。可是若是…代码派生了一个后台任务呢?而后咱们的保证就没了:看起来像在with块中的操做实际上可能会在with块结束后继续运行,而后崩溃,由于文件在它们仍在使用时被关闭。并且,你不能在原地将错误检查出来;要知道是否发生了这种状况,你必须通读全部在…代码中被调用的函数的源代码。

若是咱们想让这段代码正常工做,咱们须要以某种方式跟踪任何后台任务,并手动安排文件在完成后关闭。这是可行的——除非咱们使用的库在任务完成时不提供任何得到通知的方法,这是使人不安的常见现象(例如,由于它没有暴露任何能够join的handle)。但即便在最好状况下,非结构化的控制流也意味着语言没法帮助咱们。咱们又得手工执行资源清理,一朝回到解放前。

go语句破坏了错误处理。 正如咱们上面讨论的,现代语言提供了诸如异常之类的强大工具,以帮助咱们确保错误被检测到并传播到正确的位置。但这些工具依赖于“当前代码的调用者”的可靠概念。一旦派生任务或者注册回调,这种概念就被破坏了。所以,我所知道的每个主流并发框架都简单地放弃了。若是在后台任务中发生错误,而且你没有手动处理它,那么运行时只是……把它扔在地上,交叉手指,说它不过重要。若是你幸运的话,它可能会在控制台上打印一些东西(我使用过的惟一一个认为“打印并继续运行”是一个很好的错误处理策略的其余软件是糟糕的旧Fortran库,但咱们已经到这了)甚至Rust——这门被高中班级选为最热衷于线程正确性的语言——也为此感到羞愧。若是后台线程panic,Rust将丢弃错误并但愿得到最佳结果。

固然你能够在这些系统中正确地处理错误,方法是当心地确保join每一个线程,或者构建本身的错误传播机制,好比errbacks in Twisted或者Promise.catch in Javascript。可是如今你在写一个自定义的,脆弱的,你的语言已经拥有的特性的重实现。你已经失去了一些有用的东西,好比“回溯”和“调试器”。只要有一次忘记了调用Promise.catch,而后就会忽然间产生了巨大的错误,而你甚至都意识不到。即便你以某种方式解决了这些问题,你仍然获得两个冗余的作着一样事情的系统。

go语句:一行也不要写

就像goto是第一种使用高级语言的明显原语同样,go也是第一种实用并发框架的明显原语:它与底层调度程序的实际工做方式相匹配,而且它足够强大,能够实现任何其余并发流模式。但一样像goto同样,它破坏了控制流抽象,所以在你的语言中仅仅将它做为一个可选项就使得全部东西都很难使用。

不过,好消息是,这些问题均可以解决:Dijkstra向咱们展现了如何解决!咱们须要:

  • 找到一个具备相似能力但遵循“黑箱原则”的go语句的替代项。
  • 将这个新构造做为原语构建到咱们的并发框架中,而且不包含任何形式的go语句。

Trio就是这么干的。

“nursery”:一个go语句的结构化替代项

核心思想是:每次咱们的控件拆分红多个并发路径时,咱们都但愿确保它们再次链接起来。例如,若是咱们想同时作三件事,咱们的控制流应该是这样的:

注意上面只有一个箭头,下面也有一个箭头,因此它遵循Dijkstra的黑箱原则。如今,咱们怎样才能把这个草图变成一个具体的语言结构呢?有一些现成的结构能够知足这个约束,可是(a)个人建议与我所知道的全部的结构都有所不一样,并且比它们更有优点(特别是在想要使它成为独立的原语的状况下),(b)并发文献庞大而复杂,试图把全部的历史和取舍分开会完全打乱这场争论,因此我将其推迟到另外一篇文章。在这里,我将集中精力解释个人解决方案。但请注意,我并非声称本身发明了并发之类的概念,这篇文章从不少方面获取灵感,我站在巨人的肩膀上。[3]

不管如何,咱们要作的是:首先,咱们声明一个父任务在它首先为子任务建立了一个居住的地方:nursery以前不能启动任何子任务。它经过打开一个nursery块来实现这一点,在Trio中,咱们使用Python的async with语法来实现这一点:

打开一个nursery块会自动建立一个表示此nursery的对象,而且as nursery语法将此对象分配给名为nursery的变量。而后咱们可使用nursery对象的start_soon方法来启动并发任务:在本例中,一个任务调用函数myfunc,另外一个任务调用函数anotherfunc。从概念上讲,这些任务在nursery块内执行。实际上,将在nursery块中编写的代码看做是在建立块时自动启动的初始任务一般是很方便的。

最重要的是,在全部的任务都退出以前,nursery块不会退出——若是在全部的子任务完成以前,父任务到达块的结束,那么它停在那里等待它们。nursery自动扩大以容纳孩子们。

下面是控制流:你能够看到它是如何与咱们在本节开头显示的基本模式相匹配的:

这种设计有许多后果,并不是全部后果都显而易见。让咱们仔细想一想。

“nursery”保全了函数抽象

go语句的基本问题是,当你调用一个函数时,你不知道它是否会派生一些后台任务,这些任务在完成后仍在运行。使用“nursery”,你就没必要担忧这个问题:任何函数均可以打开一个nursery并运行多个并发任务,但在它们所有完成以前,函数不能返回。因此当一个函数真的返回时,你就知道它真的完成了。

“nursery”支持动态任务派生

这里有一个更简单的原语,也能够知足上面的流程控制图。它获取一个函数的列表而后并发地执行它们。

run_concurrently([myfunc, anotherfunc])
复制代码

但问题是你必须事先知道你要运行的任务的完整列表,并不老是可以如此。例如,服务器程序一般有accept循环,接受传入的链接并启动一个新任务来处理每一个链接。如下是Trio中最小的accept循环:

async with trio.open_nursery() as nursery:
    while True:
        incoming_connection = await server_socket.accept()
        nursery.start_soon(connection_handler, incoming_connection)
复制代码

对于“nursery”来讲,这很简单,可是用run_concurrently来实现将很是困难。若是你想的话,很容易就能够在“nursery”的基础上实现run_concurrently,可是实际上不必。由于run_concurrently能够处理的简单状况,“nursery”一样也能够处理,还更易读。

有一个出口

“nursery”对象还为咱们提供了一个逃生舱口。若是你真的须要编写一个函数来生成一个后台任务,然后台任务比函数自己还长,该怎么办?简单:向函数传递一个nursery对象。没有规则规定只有直接位于async with open_nursery()块内部的代码才能调用nursery.start_soon——只要该“nursery”块保持打开状态[4],那么任何获取对该“nursery”对象的引用的人均可以得到将任务生成到该nursery的能力。你能够将其做为函数参数传入,或经过队列发送。

实际上,这意味着你能够编写“违反规则”的函数,可是得在必定限制范围内:

  • 因为必须显式地传递"nursery"对象,你能够经过查看它们的调用位置当即肯定哪些函数违反了正常的流控制。所以仍然能够进行原地推理。
  • 函数生成的任何任务仍受传入的“nursery”生存期的约束。
  • 调用的代码只能传入它本身能够访问的“nursery”对象。

所以,这与那种任何代码均可以在任什么时候刻派生具备无限生存期的后台任务的传统模型有很大不一样。

有一点颇有用,那就是证实“nursery”有着和go语句同样的表达力,可是这篇文章已经够长了,因此我改天再说。

你能够定义跟“nursery”同样“嘎嘎叫”的新类型

标准的“nursery”语义提供了坚实的基础,但有时你想要不一样的东西。也许你羡慕Erlang还有它的supervisors,并但愿定义一个相似于“nursery”的类,该类经过从新启动子任务来处理异常。(译者注:有一个典型的例子,Bastion,一个从Erlang中汲取了灵感用Rust编写的高可用分布式容错运行时)这是彻底可能的,对你的用户来讲,它看起来就像一个普通的“nursery”:

async with my_supervisor_library.open_supervisor() as nursery_alike:
    nursery_alike.start_soon(...)
复制代码

若是有一个函数以一个“nursery”为参数,则能够传递其中一个参数来控制它派生的任务的错误处理策略。很漂亮。可是,这里有一个微妙之处,将Trio推向了不一样于asyncio或其余一些库的不一样约定:这意味着start_soon必须获取一个函数,而不是协程对象或者一个Future。(你能够屡次调用函数,可是没法重启一个协程对象或者Future。)我认为这是更好的约定,无论怎么说,有不少缘由(特别是由于Trio甚至没有Future!),可是仍然值得一提。

真的,“nursery”老是等着其中的任务退出

另外一件值得讨论的事情是,任务取消和任务join是如何相互做用的,这里有一些微妙之处,若是处理不当,可能会破坏nursery不变量。

在Trio中,代码能够随时接受取消请求。请求取消后,下次代码执行“检查点”操做(详细信息)时,将引起取消的异常。这意味着,请求取消和实际发生取消之间存在差距——任务执行检查点以前可能须要一段时间,以后一场必须解除堆栈、运行清理处理程序等。发生这种状况时,nursery老是等到彻底清理完毕。咱们从不在不给任务运行清理处理程序的机会的状况下终止任务,也从不让任务脱离nursery的监管,即便它正在被取消。

自动资源清理

由于nursery遵循黑箱原则,with块又能派上用场。比方说,在with块的末尾关闭一个文件不会意外中断仍在使用该文件的后台任务。

自动错误传播

如上所述,在大多数并发系统中,后台任务中未处理的错误只是被丢弃。而后就实在没什么事情能够作了。

在Trio中,因为每一个任务都位于nursery内,而且每一个nursery都是父任务的一部分,所以父任务须要等待nursery内的任务……咱们确实能够处理未处理的错误。若是后台任务因异常而终止,咱们能够在父任务中从新运行它。这里的直觉是,nursery相似于“并发调用”原语:咱们能够将上面的示例看做同时调用myfuncanotherfunc,所以咱们的调用堆栈已成为一棵树。异常向上传播这个调用树到根,就像它们向上传播一个常规调用堆栈同样。

不过,在此有一个微妙之处:当咱们在父任务中引起异常时,它将开始在父任务中传播。通常来讲,这意味着父任务将退出nursery块。可是咱们已经说过,当仍有子任务在运行时,父任务不能离开nursery块。那咱们该怎么办?

答案是,当一个未处理的异常发生在一个子任务身上时,Trio会当即取消同一个nursery中的全部其余任务,而后等待它们完成,而后再从新引起异常。这里的直觉是,异常会致使堆栈展开,若是咱们想展开堆栈树中的某个分支点,则须要经过取消这些分支来展开其余分支。

这确实意味着若是你想用你的语言实现nursery,你可能须要在nursery代码和你的取消系统中进行某种集成。若是您使用的是像C#或Go这样的语言,其中一般经过手动对象传递和约定来管理取消,或者(更糟的是)没有通用取消机制的语言,那么这可能会很棘手。

意外之喜:移除go语句开启新的特性

消除goto容许之前的语言设计人员对程序的结构做出更有力的假设,从而启用了新的功能:如块和异常;消除go语句也有相似的效果。例如:

  • Trio的取消机制比竞争对手更易用,也更可靠,由于它能够假设任务嵌套在一个规则的树结构中,有关完整的讨论,请参考Timeouts and cancellation for humans
  • Trio是惟一一个其中control-C的工做方式与Python开发者指望的(细节)相同的并发库。若是没有nursery提供传播异常的可靠机制,这是不可能的。

实践中的nursery

上面的全是理论,实践中怎么样?

额……这是一个经验性问题:你应该试试看,而后找出答案!但说真的,得不少人用过它以后咱们才能知道。在这一点上,我颇有信心,基础很牢靠,但也许咱们会意识到咱们须要调整一下,好比早期结构化编程倡导者最终中止摆脱breakcontinue

若是你是一个有经验的并发程序员,正在学习Trio,那么你应该会发现它有时会有点不稳定。你将不得不学习新的作事方法——就像70年代的程序员发如今没goto的状况下学习如何编写代码颇有挑战性同样。

固然,这就是重点。正如Knuth所写,(Knuth, 1974, p. 275):

Probably the worst mistake any one can make with respect to the subject of go to statements is to assume that "structured programming" is achieved by writing programs as we always have and then eliminating the go to's. Most* go to's shouldn't be there in the first place! What we really want is to conceive of our program in such a way that we rarely even* think about go to statements, because the real need for them hardly ever arises. The language in which we express our ideas has a strong influence on our thought processes. Therefore, Dijkstra asks for more new language features – structures which encourage clear thinking – in order to avoid the go to's temptations towards complications.*

到此为止,这是我使用nursery的经验:它们鼓励清晰的思考。它们带来了更健壮、更易于使用和更全面的设计。这些限制实际上使解决问题变得更容易,由于你花在没必要要的复杂问题上的时间更少。从一个很是真实的意义上说,使用Trio教会了我成为一个更好的程序员。

例如,考虑Happy eybells算法 (RFC 8305),这是一个简单的并发算法,用于加快TCP链接的创建。从概念上讲,这个算法并不复杂——你能够相互竞争多个链接尝试,交错开始以免网络过载。但若是你看看Twisted的最佳实现,他差很少有600行代码,并且至少还有一个逻辑错误。Trio中的等效实现至可能是其十五分之一。更重要的是,使用Trio,我能够在几分钟内而不是几个月内写出它,并且我在第一次尝试时逻辑就正确了。我不可能在任何其余框架中作到这一点,即便是那些我有更多经验的框架。你能够看我上个月在Pyninsula的演讲以了解更多细节。这是典型的吗?时间会证实一切,但这确定颇有但愿。

结论

流行的并发原语——go语句,线程派生函数、回调、FuturePromise……在理论和实践上它们都goto的变体。甚至不是现代的驯化goto,而是老式的火烧石的goto,能够跨越函数边界。即便咱们不直接使用它们,这些原语也是危险的,由于它们破坏了咱们对控制流的推理能力,破坏了从抽象的模块部分中构造出复杂系统的能力,并且它们干扰了有用的语言特性,好比自动资源清理和错误传播。所以,像goto同样,它们在现代高级语言中没有立锥之地。

Nursery提供了一个安全而方便的替代方案,它保留了语言的所有功能,并实现了强大的新功能(正如Trio的做用域级别任务取消和Ctrl-C处理所证实的那样),而且能够在可读性、效率和正确性方面有显著的提升。

不幸的是,为了彻底拥有这些好处,咱们确实须要彻底删除的旧的原语,这可能须要从头开始构建新的并发框架——就像消除goto须要设计新的语言同样。可是,尽管FLOW-MATIC在当时给人留下了深入的印象,但咱们大多数人对升级到更好的东西都乐见其成。我想咱们也不会后悔改用nursery,Trio证实了这是一种实用的、通用的并发框架的可行设计。

鸣谢

很是感谢Graydon Hoare、Quentin Pradet和Hynek Schlawack对这篇文章的草稿提出的意见。固然,剩下的任何错误都是个人错。

FLOW-MATIC样本代码来自于本手册(PDF),由计算机历史博物馆保存。Wolves in Action,做者:i:am. photography / Martin Pannier, 以 CC-BY-SA 2.0协议发布, 有所裁剪. French Bulldog Pet Dog by Daniel Borker, 以CC0 public domain dedication协议发布 .

脚注


  1. 至少对某一类人来讲是这样的. ↩︎

  2. 而WebAssembly甚至证实了没有 "goto "的低级汇编语言是可能的,至少在某种程度上是可取的: reference, rationale ↩︎

  3. 对于那些在不知道我是否知道他们最喜欢的论文的状况下,不会关注这篇文章的人,我目前已经阅读过的主题包括: the "parallel composition" operator in Cooperating/Communicating Sequential Processes and Occam, the fork/join model, Erlang supervisors, Martin Sústrik's article on Structured concurrency and work on libdill, and crossbeam::scope / rayon::scope in Rust. [Edit: I've also been pointed to the highly relevant golang.org/x/sync/errg… and github.com/oklog/run in Golang.] If I'm missing anything important, let me know. ↩︎

  4. 若是你在nursery块退出后调用 start_soon,那么start_soon会产生一个错误,反之,若是它没有产生错误,那么nursery块将被保证保持开放,直到任务结束。若是你正在实现你本身的nursery系统,那么你会但愿在这里当心地处理同步。 ↩︎

相关文章
相关标签/搜索