Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Swift Concurrency Manifesto 中文翻译

原文链接:https://gist.github.com/lattner/31ed37682ef1576b16bca1432ea9f782

Swift 并发宣言

作者: Chris Lattner

内容

介绍

这篇文档是以“Swift进化宣言"的形式发布的,概述了以长期视角来看,如何处理一个非常大型的问题。它探索了一个可能的方案,来为Swift添加一种”最高层级“的并发模型,进而促进有益的讨论,最终得到一个最优的设计方案。因为如此,它并不是一个已经被采纳或定稿的、Swift最终会采用的设计。在公开的swift-evolution邮件列表中的讨论和迭代,才应该对这项工作负责,而且我们可能会得到一个完全不同的方案。

我们会聚焦在客户端和服务端应用中经常遇到的,基于任务的并发抽象,特别是那些高度事件驱动化的场景(比如,响应UI的事件或者请求)。这里并不是要尝试全面研究所有的可能性,也不是要尝试解决并发中所有可能遇到的问题。相反,它概述了一个连贯的设计思路,来驱动Swift在几年时间内慢慢变得更加优秀

在Swift1...4中的并发

到目前为止,为了避开大多数并发的话题,Swift被小心翼翼地设计着,因为我们特别不想丧失任何未来可能的方向。相反的,Swift程序员使用操作系统提供的抽象(例如GCD, pthreads等等)来启动和管理任务。GCD的设计和Swift的尾闭包契合得很好,特别是在Swift 3中对于GCD的API作出了重大更新之后

即使Swift一般都远离并发的话题,在实践中还是作出了一些让步。例如,ARC的引用计数操作是原子的,使类的引用可以在线程间被共享。弱引用也保证是线程原子性的,写时复制的类型比如字典和字符串是可共享的,并且runtime还提供了一些其他的基本保证

本宣言的目标以及避开的方向

并发是一个广阔而全面的概念,可以包含很多的话题。为了把讨论范围缩小,以下列了一些本提案避免讨论的内容:

  • 我们会集中在基于任务的并发,而不是数据并行。这也就是为什么我们基于GCD和线程来讨论,而完全不会关注SIMD向量化、循环的数据并行等等
  • 就系统编程而言,Swift开发者能够选择性地接触到底层的一些东西是很重要的,如C或C++的内存一致性模型。这肯定是一个有趣的方向,但是和本工作无关
  • 我们不会讨论去优化现有并发模式的API(如原子整型,更好的GCD API等等)

那么我们的具体目标是什么?因为我们已经能够用GCD来编写并发的app,我们的目标是,通过利用Swift的核心价值:减少编程者从想法到实现必须花费的时间,使体验远远优于现有方案。具体来说,我们的目标是,通过以下一些来改进Swift的并发方案:

  • 设计:Swift应该提供(刚好)足够的语言上和库的支持,让开发者明白,在考虑并发抽象时应该使用什么。应该有一个结构化的”正确“的方法来实现大多数任务
  • 可维护性:这些抽象应该让Swift代码变得更易于理解。例如,经常我们会很难搞清楚哪个GCD队列保护了哪些数据,或者一个堆数据结构的不变量是哪些
  • 安全性:Swift目前的模型没有对竞态条件、死锁或其他并发问题给予任何帮助。完成回调可能会在一个意想不到的队列上被调用。这些问题应该被改善,我们最好能找到一个”默认安全“的编程模型。
  • 可伸缩性:尤其是在服务端程序,成千上万的活跃的任务可能会同时出现(例如每一个活跃的客户端都需要一个任务)
  • 性能:作为一个不易实现的目标,能提升性能是非常好的,例如减少需要执行的同步操作,甚至可能减少许多ARC操作中的原子访问操作。我们需要帮助编译器理解,当数据在何时何地,它们可以跨越任务间的边界
  • 优秀:更抽象来说,我们应该参考其他语言和框架所提供的并发模型,把我们找到的所有最好的想法聚在一起,最终实现整体上比任何竞争者更优秀

也就是说,必不可少的是,任何新的模型会与现有的并发概念和API并存。我们不能构建一个概念上非常优美,但却无法兼容现有app的新世界。

为什么是一个”最高层级“并发模型?

非常明确的是,多核的世界不是未来:而是现在!因为如此,必不可少的是,Swift需要让开发者直接地使用已经普遍存在于世上的硬件。同时,我们现在已经能写出并发程序:由于在Swift中加入并发模型会使它变复杂,我们需要一个非常强的理由来这么做。为了展示优化的可能性,我们先看下目前情况下Swift开发者所面临的的痛苦。因为几乎所有Swift开发者都使用GCD,这里我们会关注它。

异步API非常难以使用

现代Cocoa开发涉及到很多使用闭包和完成回调的异步编程,但是这些API使用起来不方便。在许多异步操作、错误回调被一起使用时,或控制流需要在异步调用中切换时,问题尤其突出。

这里有许多的问题,包括经常发生的”回调地狱“

func processImageData1(completionBlock: (result: Image) -> Void) {
    loadWebResource("dataprofile.txt") { dataResource in
        loadWebResource("imagedata.dat") { imageResource in
            decodeImage(dataResource, imageResource) { imageTmp in
                dewarpAndCleanupImage(imageTmp) { imageResult in
                    completionBlock(imageResult)
                }
            }
        }
    }
}

错误处理尤其不好看,因为Swift自带的错误处理机制此时无法使用。你最终会写出这样的代码:

func processImageData2(completionBlock: (result: Image?, error: Error?) -> Void) {
    loadWebResource("dataprofile.txt") { dataResource, error in
        guard let dataResource = dataResource else {
            completionBlock(nil, error)
            return
        }
        loadWebResource("imagedata.dat") { imageResource, error in
            guard let imageResource = imageResource else {
                completionBlock(nil, error)
                return
            }
            decodeImage(dataResource, imageResource) { imageTmp, error in
                guard let imageTmp = imageTmp else {
                    completionBlock(nil, error)
                    return
                }
                dewarpAndCleanupImage(imageTmp) { imageResult in
                    guard let imageResult = imageResult else {
                        completionBlock(nil, error)
                        return
                    }
                    completionBlock(imageResult)
                }
            }
        }
    }
}

部分原因是异步API使用起来非常繁重,有许多API具有阻塞的同步形式(如UIImage(named: ...)),并且它们其中有许多没有异步版本。如果有一个自然、规范的方法来定义和使用这些API,可以使他们被更广泛地使用。这点对于新兴的Swift开发尤其重要,如Swift on Server组。

我在哪个队列上?

除了语法上的不便,完成回调的问题还在于,它们语法上暗示了自身会在当前队列上被调用,但这却不一定。举例来说,StackOverflow上最推荐的做法是,像这样实现你自定义的异步操作(Objective-C 语法):

- (void)asynchronousTaskWithCompletion:(void (^)(void))completion;
{
  dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{

    // Some long running task you want on another thread

    dispatch_async(dispatch_get_main_queue(), ^{
      if (completion) {
        completion();
      }
    });
  });
}

注意它硬编码了完成回调会在主线程上被调用。这是一个不易被发现的问题,会造成意料之外的结果,和类似竞态条件的bug。例如,由于很多iOS代码已经在主线程上运行,你可能使用了由它们构建的API也没遇到问题。但是,一个把代码移动到后台队列的简单重构,就会造成一个非常难以处理的问题,代码会隐式地等待队列跳转,进而引入不易察觉的未定义行为!

解决这种情况有几种直观的办法,比如更好的GCD的API文档。然而,本质的问题在于,队列和在它们其中运行的代码之间,并没有显然的联系。这使得代码变得难以设计、理解和维护,并且让调试、测试性能和找到问题原因变得更有挑战。

共享可变状态不利于软件开发者

让我们先定义什么是”共享可变状态“:”状态“是指程序使用的数据。”共享“指的是数据在不同的任务(线程、队列,以及任何并发抽象)中被共享。只是自己使用的状态是无害的:只要没有人修改数据,有多个读取者也是没问题的。

问题在于,当共享的数据可变,就会存在有人在改变它的同时,有其他人同时也在读取它。这打开了一个巨大的虫罐子,数十年来整个世界都在努力克服它。由于有多个来源正在查看和修改数据,必须要有某种同步机制,不然就会带来竞态条件、语义上不一致或其他的一些问题

自然地,开始第一步是使用mutex和锁。我不打算展开讨论这个话题,而是想说明锁和mutex带来了一系列问题:你必须保证数据一直被正确的锁保护着(不然会带来bug和内存安全问题)、决定锁的粒度、避免死锁,并且处理一些其他的问题。已经有一些优化这种情况的尝试,著名的Java中的synchronized方法(后来也被引入了Objective-C)。这种做法改进了语法的这一边,但是没有修复深层的问题。

当一个app开始运行,你会遇到性能问题,因为mutex通常是非常低效的——尤其是在多核多线程的情况下。由于这个模型的使用了数十年,已经有了许多方案去尝试解决一部分问题,包括读写锁双重检查锁定、底层原子操作和类似read/copy/update的高级技术。他们每一个都在某种程度上优化了mutex,但是带来的超高的复杂度、不安全和不可靠的方案,本身也是一个问题。

说了这么多,共享可变状态当你在进行系统编程时非常重要:比如你在用Swift实现GCD API或者内核,你必须有做到这些的全部能力。这就是为什么Swift最终需要一个默认的、内存一致的模型。尽管有一天这件事会变得很重要,这些努力是从另一个角度,因此不是本提案的重点

对每个对此感兴趣的人,我建议阅读Is Parallel Programming Hard, And, If So, What Can You Do About It? 这是Paul E. McKenny所写的一篇很好的调查研究,他一直在努力使Linux内核扩展到大规模的多核机(数百个核心)。不仅是作为一篇印象深刻的硬件特点总结和软件同步方案,它也揭示了当你需要去考虑多核的扩展性和共享可变状态时,存在大量的、复杂的情况。

共享可变状态不利于硬件

从硬件角度,共享的可变状态有许多问题。简而言之,当前的世界里多核是普遍的——尽管把他们看成是共享内存的设备,事实上他们其实是NUMA/non-uniform

粗略地说,考虑两个不同的核心尝试去读写同一块内存数据:储存数据的缓存通路由MESI协议控制,在单个处理器中只允许一条缓存通路的数据是可变的。这样一来,性能断崖式地下跌:缓存通路在不同核心中来回,并且在其中的数据变化,需要被分发到其他正在读取它的核心中。

这还带来一系列其他的冲击:处理器已经快速演进到具有relaxed consistency models,让共享内存的编程变得更加复杂。原子性访问(以及其他与并发相关的原语,如比较/交换)现在比非原子性访问慢20~100倍。这些开销和问题随着核心数量而继续增长,而且当今要找到一台具有几十甚至上百个核心的机器并不困难。

如果你关注一下最新的硬件性能的突破,他们都来自于那些去掉了共享内存的硬件。值得注意的是,GPU因为可以扩展到非常多的核心数而非常成功,同样值得注意的是,这是因为他们使用了极高速的本地内存,而不是使用全局内存的编程模型。超级计算机经常使用MPI来做显式的可控内存传输,等等。如果你从第一性原理来看,光速和线缆的延迟变成了超大型共享内存系统的限制因素。

这些说明的问题是,Swift非常需要朝着一个方向而演进——Swift程序可以在大型的、多核的机器上很好的运行。如果有幸的话,这可能会帮助开启下一次硬件革命。

共享可变状态无法突破单进程

是的,这有点啰嗦,但是任何共享可变状态都无法摆脱共享内存。

因为如此,软件产业在进程间通信系统上的复杂性剧烈地增长:如sockets,信号、管道、MIG、XPC和其他一些东西。操作系统总是在单个进程中引入一些同一个概念的变体,包括锁(文件锁)、共享可变状态(内存映射文件)等等。除了进程间通信,分布式计算和云API也重新用另一种方式实现了同样的抽象,因为共享内存在那些情况下是无法实现的。

这里的关键点在于,事情处于一个令人遗憾的状态。一个更好的世界应该是,让app开发者们有能力,在大型的、甚至是正运行着多台机器的云环境中,来构建数据抽象、并发抽象,并且理解他们的应用程序。如果你希望你的单进程应用在一个进程间通信,或者分布式的设定中运行,你应该只需要让你的类型学会自行序列化/编码、处理可能的新的错误,然后配置需要在哪里运行每段代码。你不需要重写应用的大的部分——显然不应该在一个全新的技术栈中这样做。

毕竟,app的开发者们不会把JSON作为每个方法的输入和输出,那云开发者又为什么要这么做呢?

总的愿景

这份宣言概述了几个主要的步骤来解决这些问题,它们可以在未来几年里被逐渐地加入到Swift中。第一步是非常确定的,但是接下来的几步越来越不确定:这还是一份比较早期的宣言,还有更多的设计工作要做。注意这里的目标并不是要提出本质上虚幻的想法,而是把我们所能得到的最好的想法放在一起,然后把这些想法合成为一个自洽的、适合Swift其余部分的东西。

首先需要有的洞察是,存在四个主要的计算抽象,在他们之上来建立一个模型比较有意思:

  • 传统控制流
  • 异步控制流
  • 信息传递和数据隔离
  • 分布式数据和计算

对于第一点Swift已经有了一个完整实现的模型,在这几年被不断提炼和改进,因此我们不再讨论它。比较重要需要了解的是,绝大部分底层的计算受益于命令式的控制流、使用值语义改变和类的引用语义。这些是重要的底层原语,计算过程建立在其之上,它们也反映了CPU的基本抽象。

异步是接下来Swift会处理的抽象,因为这是在真实世界中编程所必要的,在与其他机器通信、与低速设备(旋转着的碟片还是存在的!)或者想要在互相独立的操作中实现并发时,必须要面对的。进一步来说,明显是相同操作的延时会受到剧烈抖动的影响,例如:网络丢失了一个包(超时重试)和快路径/慢路径优化(如缓存)。

幸运的是,Swift并不是第一个面对这些挑战的语言:整个业界已经一起与这条巨龙搏斗,并且选定了async/await作为正确的抽象。我们会直接选择这个已经被证明的概念(语法上Swift化)。采用async/await会极大地改善现有的Swift代码,与现有和未来的异步处理的方法相吻合。

下一步是定义一个面对开发者的抽象,来定义并为独立程序中的任务以及他们所包含的数据建模。我们提议一种最高层级的Actor模型,来定义和思考互相之间异步通信的、互相独立的任务。Actor模型有着长久的历史,也被ErlangAkka所采用和证实,这两者对大量可伸缩的可靠系统提供支持。以Actor模型为基线,我们相信,通过保证被发送给Actor的数据不会带来共享可变状态,进而能够实现数据的隔离。

谈及可靠系统,引入Actor模型是一个很好的机会和理由,来引入一种处理、从运行时错误中部分恢复的机制(比如强制解包失败,数组越界等等)。我们探索几种可能的选项来实现,并推荐一种我们认为适合UI和服务端应用的方法。

最后一步是处理系统性问题,让Actor能在不同的进程,甚至是在不同的机器上运行,同时仍然能通过发送信息来实现异步通信。这样可以推断出一些长期的可行性,我们会简单探索下。

第一部分:Async/Await,漂亮的异步API

注意:这一部分已经非常确定,有一个完全的细化的提案

无论Swift的全局并发的模型是怎样的,我们很难忽视使用异步API的问题。异步在处理互相独立的运行中系统是无法避免的:比如涉及到I/O(磁盘、网络等等)、服务器、甚至是同一个系统中的其他进程。通常,由于有些东西需要一段时间来加载就阻塞当前执行线程是无法接受的。在一个多核机器上并行执行多个独立操作通常也会遇到异步的问题。

当前Swift中对于这个问题的解决方案是采用闭包形式的完成回调。这种做法被广泛地理解,但也有许多著名的问题:它们经常会堆起来变成一个”回调地狱“,使错误处理变得尴尬,也让控制流变得处理困难。

对于这个问题有一个著名的解决方案,被称为async/await。这是一个流行的编程风格,被首次应用于C#,而后在很多其他语言中也被采纳,包括Python, Javascript, Scala, Hack, Dart等等。由于它在业界中的广泛的成功和接受度,我建议我们在Swift中也显然应该实现它。

给Swift设计async/await

async/await的总体设计可以直接适用于Swift,不过如果添加一些修改,就可以让它与Swift的其他部分更加一致。我们建议把async作为方法的修饰符,类似已有的throws方法修饰符。函数(和函数类型)可以被声明为async,这将意味着这个函数是一个协程。协程是这样的一种函数:要么正常返回一个值,要么暂停,并在内部返回后继续执行。

这种方案使完成回调被融合进语言中。例如,以前你可能会写:

func loadWebResource(_ path: String, completionBlock: (result: Resource) -> Void) { ... }
func decodeImage(_ r1: Resource, _ r2: Resource, completionBlock: (result: Image) -> Void)
func dewarpAndCleanupImage(_ i : Image, completionBlock: (result: Image) -> Void)

func processImageData1(completionBlock: (result: Image) -> Void) {
    loadWebResource("dataprofile.txt") { dataResource in
        loadWebResource("imagedata.dat") { imageResource in
            decodeImage(dataResource, imageResource) { imageTmp in
                dewarpAndCleanupImage(imageTmp) { imageResult in
                    completionBlock(imageResult)
                }
            }
        }
    }
}

而现在你可以写:

func loadWebResource(_ path: String) async -> Resource
func decodeImage(_ r1: Resource, _ r2: Resource) async -> Image
func dewarpAndCleanupImage(_ i : Image) async -> Image

func processImageData1() async -> Image {
    let dataResource  = await loadWebResource("dataprofile.txt")
    let imageResource = await loadWebResource("imagedata.dat")
    let imageTmp      = await decodeImage(dataResource, imageResource)
    let imageResult   = await dewarpAndCleanupImage(imageTmp)
    return imageResult
}

await是个有点像现有try的关键字:在运行时是一个空操作,但对管理者表明此时本地没有控制流可以执行。除了增加await关键字,async/await模型也让你能写出清晰干净的命令式代码,并且编译器会帮你生成状态机和回调处理。

总的来说,添加它们可以使处理完成回调的体验大幅度改进,并且提供一个自然的模型来创建futures和其他API。更多的细节包含在这个完整的提案里。

新的异步API

在语言中引入async/await,给在Cocoa、甚至是一整个新的框架扩展中引入更多异步API,提供了很好的机会(例如一个修改后的异步文件I/O API)。Server APIs Project也在积极地定义新的Swift API,其中很多天然都是异步的。

第二部分:Actor:消灭共享可变状态

拥有了定义和使用富有表现力的”命令式“控制流异步API,我们现在可以思考提供给开发一个途径,来把他们的应用分成多个并发任务。我们提议采用Actor模型:Actor天然代表着真实世界中的概念,如”一个文档“、”一个设备“、”一个网络请求“,特别适合事件驱动的架构,如UI应用程序、服务器、设备驱动程序等等。

那什么是一个Actor?作为一个Swift开发者,最简单的理解方式是把他想象成一个组合:由DispatchQueue、被队列保护的数据和队列中运行的消息组成。因为他们由一个(内部的)队列抽象来表达,你与Actor异步地通信,并且Actor保证,他们所保护的数据只允许被运行在那个队列上的代码来操作。这实现了”并发的海洋中串行的岛屿“。

把现有的软件来适配Actor的接口是很直观的,并且也可以逐步地在采用GCD或者其他并发原语的系统中,采用Actor模式。

Actor模型理论

Actor有一个非常深入的理论基础,自从1970年代就被学术界发现——如果你想深入研究支持它的理论基础的话,维基百科上的Actor页c2的维基页是很好的参考。这个工作的挑战之一(为了Swift的目标)是,学术界假定的是一个纯净的Actor模式(”所有东西都是Actor“),也假定了一个非常受限制的通信模型,不适合Swift。我会提供这种纯净模型的一个总结,然后探讨如何解决这些问题。

维基百科上说到:

当响应一个收到的消息时,一个Actor能够:做出本地的决策、创建更多Actor、发送更多消息,并且决定如何回复下一条收到的消息。Actor可以修改私有的状态,但是只能通过发送消息来影响到其他人(避免了使用任何锁)。

Actor创建起来成本很低,并且你能够用高效的单向异步消息来与之通信("往信箱里发送一个消息")。因为这些消息是单向的,不会有等待,因此死锁是不可能发生的。在理论模型中,所有被发送的数据是被深拷贝的,也就意味着不可能在Actor之间共享任何数据。因为Actor不能触碰其他人的状态(也没有权限访问全局状态),就不需要任何同步的结构,消除了所有共享可变状态的问题。

为了让它在Swift编程中可行,我们需要解决几个问题:

  • 我们需要为一个任务中所有的计算建立坚实的计算基础。好消息是:在Swift 1...4中已经做到了!
  • 单向异步消息非常棒,但是某些情况下不够方便。我们需要这样一个模型:允许消息返回一个值(即使我们不希望它们这么做),这样一来就需要一种等待那个值的方式。这就是为什么要增加async/await。
  • 我们需要让消息发送非常高效:深拷贝每一个参数是无法接受的。幸运但也不意外的是——我们已经有了写时复制的值类型和转移语义作为基础。这个技巧就是使用引用类型,接下来会讨论。
  • 我们需要找到如何处理全局可变状态(已经在Swift中存在)的方法。下面考虑了一种可能。

对于Swift的Actor模型设计范例

把Actor模型加入到Swift有好几种可能的办法。就本宣言的目的而言,我会用一个新的Swift类型来描述,因为这是最不会让人迷惑的办法,何况这也不是一个正式的提案。我在这里预先说明,这只是一种可能的设计:真正正确的方法可能是让Actor作为一种特殊的类,如以下所展现的模型。

在这种设计中,你会定用actor关键字定义一个Actor。正如你所期望的那样,一个Actor能够包含任意数量的数据成员来作为实例成员,可以有普通的方法,可以有扩展。Actor是引用类型,也有一个可以被作为值来传递的标识。正如你所期望的那样,Actor可以实现一个协议,也具有另外一些已有的Swift特性。

我们需要一个简单的可以运行的例子,那么就假设,我们正在为一个展示一列字符串的tableView构建数据模型。这个app包含了添加和操作数据的UI。可能会像这样:

  actor TableModel {
    let mainActor : TheMainActor
    var theList : [String] = [] {
      didSet {
        mainActor.updateTableView(theList)
      }
    }
    
    init(mainActor: TheMainActor) { self.mainActor = mainActor }

    // this checks to see if all the entries in the list are capitalized:
    // if so, it capitalize the string before returning it to encourage
    // capitalization consistency in the list.
    func prettify(_ x : String) -> String {
      // Details omitted: it inspects theList, adjusting the
      // string before returning it if necessary.
    }

    actor func add(entry: String) {
      theList.append(prettify(entry))
    }
  }

这展现了一个Actor模型的关键的几点:

  • Actor定义了作为实例数据的本地状态,在这个例子中就是对mainActortheList的引用。
  • Actor能够给任何其他他们所引用的Actor发送消息,使用经典的点语法。
  • 出于方便,普通(非Actor)方法也可以被定义在Actor中,他们对于自己的状态有完全的访问权限。
  • actor方法就是Actor可以接受的消息。把一个方法标记为actor会加入某些限制,以下会说到。
  • 在范例中没有展现,不过新的Actor实例会像任何其他类型一样,使用他们的初始化方法来创建:let dataModel = TableModel(mainActor)
  • 同样在范例中没有展现,但是actor方法含有隐式的async关键字,所以他们能自由的调用async方法,并await他们的返回结果

在其他的Actor系统中已经被发现,像这样的Actor抽象会促使应用程序采用”正确“的抽象,并且与开发者脑海中所思考的数据形式十分契合。例如,使用这个数据模型可以非常简单地创建Actor的多个实例,在MDI应用中每个文档创建一个。

这是在Swift中一个非常直接的Actor模型的实现,并且已经足够实现基本的优点。然而,注意这些也很重要:这里引入了一些不是那么明显的局限性,包括:

  • 一个actor方法不能返回一个值、抛出一个error或拥有一个inout的参数
  • 所有的参数需要在被拷贝时生成独立的值(参见以下)。
  • 本地状态和非actor方法只能被语法上定义在Actor中,或其extension之中的方法所访问。

通过await来拓展模型

如同我们已经提到的,第一个限制(actor方法无法返回值)很容易解决。假如一个app开发者需要一个快速的办法来获取列表中成员的数量,而这个办法也可以被其他的Actor看到。我们应该简单地让他们来定义:

  extension TableModel {
    actor func getNumberOfEntries() -> Int {
      return theList.count
    }
  }

这能够让他们await来自其他Actor的结果:

  print(await dataModel.getNumberOfEntries())

这与async/await模型中的其他部分完美吻合。这与本宣言无关,但我们会发现,把以上例子定义成actor var是更通顺的。Swift目前不允许属性的访问器来throw或者成为async。当这个限制被放开时,更直接的做法是采用actor var来提供更加自然的API。

注意这个扩展让模型能够产生比这多得多的用途,但是打破了Actor模型的”免死锁“的保证。在一个actor方法上await会暂停当前任务,又因为你可能会遇到循环等待,这样就会死锁。这是因为一个actor在同一时间只能处理一个消息。这个简单的场景当一个Actor等待自身的时候就会发生(可能通过一个引用链):

  extension TableModel {
    actor func f() {
       ...
       let x = await self.getNumberOfEntries()   // trivial deadlock.
       ...
    }
  }

这个简单的情况也能被编译器简单地诊断出来。复杂的情况理想中会根据运行时的实现,在运行时利用trap来诊断,。

针对这个情况的解法,是鼓励人们使用返回Voidactor方法,”触发后不管“。有几个理由可以相信这会变成主流:async/await模型在语法上鼓励人们不要去使用(因为要求标记),许多使用Actor的应用是事件驱动的应用(本质上是单向的),最终UI和其他系统框架可以鼓励开发者使用正确的模式,当然文档也可以描述最佳的实践。

关于主线程

以上的例子展示了mainActor被传入(到初始化方法),满足了理论上的纯粹的Actor。然而,在UIKit和AppKit中的主线程已经是全局状态,因此我们还不如承认这个现状,并把各处的代码变得更好。因此,有理由让AppKit和UIKit定义并提供一个全局常量的Actor引用,比如像这样:

public actor MainActor {  // Bikeshed: could be named "actor UI {}"
   private init() {}      // You can't make another one of these.
   // Helpful public stuff could be put here to make app developers happy. :-)
}
public let mainActor = MainActor()

这可以让app开发者把拓展加入到MainActor,使他们的代码变得更明确清楚地说明,什么需要在主线程上运行。如果我们再激进一些,有一天Swift应该让数据成员可以在类的扩展中被定义,那么app开发者就可以把必须要在主线程上操作的状态直接定义在MainActor中

数据隔离

Actor消除共享可变状态以及显式同步的方法,是通过深拷贝所有通过消息发送给Actor的数据,并阻止直接而不经过这些消息发送来访问Actor的状态。这些做起来很漂亮,但是很快会带来实际上的低效,因为所有的数据都要被拷贝。

Swift很好地处理了这些,有一些原因:它非常强调值语义,也就是说所有的Swift开发者都了解,拷贝这些值是一个核心操作。其次,写时复制是一个非常适合这个模型的实现。注意,在以上的例子中,DataModelActor发送了一份theList数组的副本到UI线程,来更新自身。在Swift中,这是一个O(1)的非常高效的操作,做了一些ARC的工作:但它并没有拷贝或者触碰到数组中的元素。

第三点正在开发中,会作为所有权宣言的成果加入到Swift。当它可以使用的时候,高级的开发者会具备在Actor间移动复杂值的能力,这也是非常高效的O(1)操作。

这给我们带来了三个未解决的问题:1) 我们如何得知某个东西具有合适的值语义,2) 我们应该对引用类型做些什么(类和闭包),3) 我们应该对全局状态做些什么。所有这三个选项应该被仔细探索,因为可能有很多种可能的解法。以下我会探索一种简单的模型,来证明一种设计的存在,但是我不会说这是能找到的最好的模型。

一个类型能提供合适的值语义吗?

这是一个很多Swift开发者都想知道的答案,比如定义在只面对值语义时正确的通用算法。有大量的提案来讨论如何确定这件事,这里我不会总结它们,而是概述一个简单的提案,来证明一个答案的存在性:

  • 以定义一个简单的、只有一个要求的标识协议开始(我故意取了个傻名字来防止过早的琐碎的讨论)protocol ValueSemantical { func valueSemanticCopy() -> Self }
  • 使所有标准库中的类型实现ValueSemantical。例如,如果一个数组的元素遵从这个协议,那么数组本身也就遵从它——注意一个由引用类型组成的数组并不总是提供我们需要的语义。
  • 就像我们为Codable做的一样,如果结构体和枚举的成员都是ValueSemantical的话,教编译器学会如何也为他们实现这个协议。
  • 编译器只检查是否遵从ValueSemantical协议,并拒绝任何不遵从的参数或返回值

重申一下,ValueSemantical并不是一个正确的名字:举例来说如UnsafePointer就不应该遵从它。列举所有可能的选项,并评估他们的取舍是将来的任务。

认识到一点比较重要:这个设计并不保证内存安全。有人可能会错误地实现这个协议(也就是假装实现了要求),那么共享可变状态会出现。在作者的意见里,这是一个正确的取舍:解决这个问题会需要引入繁重的类型系统技术(如同在Pony语言中的capabilities system)。Swift已经提供了一个模型,让内存安全的API(如数组)以内存不安全的的形式实现(如UnsafePointer),这里描述的方法也是类似的。

另一种设计:另一种实现是移除协议中的要求:只是把协议作为一个标识,应用在已经有着正确行为的类型上。当有必要自定义复制操作时(比如对引用类型),解决的方案是把那个类型用提供值语义的结构体包起来。这会让遵从(协议)变得更奇怪,但是这个设计避免了“另一种复制”操作,并鼓励更多的类型提供值语义。

引用类型:类

这个解决方案很简单:类必须合理地遵从ValueSemantical(并实现要求),不然他们不能在一个actor方法中被用作参数或返回结果。在作者的意见中,把合适的值语义加到类,并不是一件大事,有以下一些原因:

  • 默认的(没有遵从协议)是正确的缺省做法:只有人们认为(需要遵从)的类才会遵从(协议)
  • 追溯一致性使app开发者能够处理框架工程师没有解决的问题
  • Cocoa有一些只能在主线程上使用的类(如整个UI框架)。根据定义,他们不会被四处分发。
  • 一些Cocoa中的类是语义上不可变的,这使得他们要遵从(协议)变得简单和低成本。

除此之外,当你开始使用一个Actor系统,不去分配和传递大的对象图会变成与生俱来的设计:你只在需要操作他们的Actor中分配他们。这在Scala/Akka中已经被证明是对的。

引用类型:闭包和函数

在一个Actor的消息中传递一个函数类型的值是不安全的,因为它可能包含了任意的、属于Actor的数据。如果那些数据是通过引用的形式被包在里面,那么接收方的Actor就可以任意地访问发送方Actor的状态。那样一来,就至少有一个非常重要的例外:当一个闭包字面量中包含的数据是被复制的,那么传递他就是安全的:使用以上提到的相同的ValueSemantical复制语义。

这碰巧成为了一个非常有用的副产品,因为它允许一些有趣的“回调”抽象可以被自然地表达,而并不在Actor之间耦合。这里有个傻例子:

    otherActor.doSomething { self.incrementCount($0) }

在这个例子中OtherActor并不需要知道selfActor中定义的incrementCount,减少了Actor之间的耦合

全局可变状态

既然我们是朋友,我会直接告诉你:这个没有很好的答案。Swift和C已经支持了全局可变状态,所以我们能做的最好的就是尽量不使用它。我们不能自动发现一个问题,因为Actor需要传递地使用并没有定义在其中的任意代码。举例来说:

func calculate(thing : Int) -> Int { ... }

actor Foo {
  actor func exampleOperation() {
     let x = calculate(thing: 42)
     ...
  }
}

没有实际的方法能够知道'calculate'是不是线程安全的。唯一的方法是去到处寻找大量的注释/注解,包括C代码的头文件。我认为不太能做到。

实际操作中,这并不像听起来那么糟糕,因为大家最常使用的操作已经在内部实现(线程)同步,大部分因为人们已经在编写多线程的代码。尽管能魔术般地解决这个长期存在于已有系统中以来的问题会很好,我认为更好的办法是完全忽略它,并告诉开发者不要去定义或者使用全局变量(全局let是安全的)

这并不是已经没有希望了:也许我们可以考虑把全局var从Swift中废弃,来促使大家远离它们。同时,任何从Actor中访问不安全的全局可变状态能够也应该被警告。使用这些方法能够消灭大部分明显的bug。

可伸缩运行时

到目前为止我们一直在回避一个问题:Actor的运行时应该怎么实现。我是故意的,因为我不是运行时方面的专家!从我的角度来看,以GCD作为基础来开发就很好(如果可以的话),因为它久经考验,并可以减少并发设计带来的风险。我也认为GCD是一个合理的出发点:它提供了正确的语义,有着很好的底层性能,并且它有一些高级功能,比如QoS支持,对Actor和其他东西都很有用。如果要给每个Actor提供这些高级功能,通过给他们添加gimmeYourQueue()方法会很方便。

使用GCD有一些潜在的问题需要我们解决:

内核线程激增

我们的目标是,让Actor作为一个程序中的核心抽象来使用,也就意味着,我们想让开发者能够创建任意他们想要的数量,而不会遇到性能问题。如果伸缩性问题出现,你就不得不把逻辑上分开的东西合并到一起,来减少Actor数量,带来复杂度并失去一些数据隔离的好处。因此这个被提出的模型应该有着很好的伸缩性,但是实际的实现需要依赖运行时。

GCD已经有着很好的伸缩性,但一个担忧是,当一个GCD任务以内核和运行时无法了解的形式阻塞,就会受到内核线程激增的影响。作为回应,GCD运行时会分配新的内核线程,它们每个都会得到一个栈……然后这些栈会使堆碎片化。这在一个会生成成千上万Actor的服务器上会带来问题——至少每个网络连接会需要一个Actor。

在一个需要调用C代码和非纯Swift编写的现有系统的运行时上,可靠地解决线程激增的问题是不可能或者不实际的。在那种情况下,完美不是必须的:我们只需要一条朝着那个方向的路,并在用到一个不合作的框架或API时,给开发者一个方法来完成他们的工作。我建议采用三个步骤来解决这个问题:

  • 随着时间推移,让已有的框架逐渐变得”异步安全“。确保新的API采用正确的方式实现,并确保已有的API不会从”异步安全“变得”异步不安全“
  • 提供一种机制,让开发者能够处理他们实际中遇到的有问题的API。可能是一种类似”用一个闭包把你的调用包起来,并传给一个特殊的GCD方法“,或者其他具有类似复杂度的方法
  • 继续优化性能和调试工具来帮助找到实际中可能有问题的情况。

这种聚焦在开发者实际中遇到的有问题的API的方法,应该对服务器的工作尤其适合,这种情况下很可能同时需要非常多的Actor。已有的服务器的库也很有可能对异步比对C代码更加友好。

Actor的销毁

Actor如何被销毁也有一些疑问。理想的模型是,当Actor的引用计数降为0且队列中最后的消息完成后,会被隐式释放。这可能需要一些运行时集成的时间。

有限的队列深度

另一个潜在的担忧是GCD队列有无限的深度:如果你有一个生产者/消费者的情景,一个快速的生产者生产的速度,可能超过消费者消费的速度,并持续地积累队列中的任务。在这种情况下,研究这些可能会比较有趣:提供有限的队列来控制或阻塞生产者生产的速度。另一个选项是,把这看成一个纯粹的API问题,促使采用响应流和其他提供back pressure的抽象。

其他的设计:Actor成为类

以上的设计是简单且自洽的,但可能不是正确的模型,因为Actor与类在概念上有非常多的重合。看下:

  • Actor有着引用语义,和类一样
  • Actor能形成一张图,这意味着我们需要能够对他们有weak/unowned引用
  • Actor的子类就像类的子类一样,也会有相同的行为
  • 有些人错误地认为Swift讨厌类:这是恢复一些它们之前的荣耀的机会

然而,Actor并不是简单的类,这里有一些区别:

  • 只有Actor能含有actor方法。这些方法有一些额外的要求,用以在编程模型中提供我们所需要安全性。
  • ”Actor类“不能继承自”非Actor基类“,因为基类可能把self或者本地状态通过不安全的方式泄露出去

讨论中一个重要的枢轴点在于,是否有继承Actor的需要。如果可以的话,用一种特殊的类为他们建模,会是一个非常好的简化的假设,因为类已经提供了很多复杂的特性(包括所有的初始化规则等等)。如果不这么做,那么把他们定义成一种新的类型也是说得通的,因为那样会很简单,而且成为一个另外的类型,可以更简单地解释他们所具有的额外规则。

语法上,如果我们决定把他们作为类,让它变成一个类的修饰符,这是可以理解的,因为Actor本质上改变了类的条件。例如:

actor class DataModel : SomeBaseActor { ... }

或者说,因为你总是不能从非Actor类来继承,我们可以把Actor用作基类:

class DataModel : Actor { ... }

更多扩展

上面的设计草稿是一个为语言构造并发抽象的最小化、但重要的前进的一步,但如果要实际上让模型变得丰满,几乎肯定还需要一些其他的通用抽象。例如:

  • Reactive streams是一个通用的、处理异步Actor之间的通信的方法,并且也为backpressure提供了方案。Dart's stream design就是一个例子
  • 相关的,有理由把for/in循环拓展到异步的序列里——可能通过一种新的AsyncSequence协议。无论如何,它可能会被加入到C# 8.0
  • 一个最高层级的Future类型经常被要求实现。我预计它的重要性相比那些没有(或一开始没有)async/await的语言来说要弱地多,但是对于想在一个方法中启动多个重叠的计算来说,还是一个很有用的抽象。

Actor内部并发

另一个可以考虑的高级的概念是让人可以定义一个”多线程的Actor“,它提供标准的ActorAPI,但是同步和任务调度是由Actor自己处理的,不使用GCD而是用传统的同步抽象。添加这些意味着Actor内部会有共享可变状态,但是在Actor之间的隔离还是得以保留。有一些原因使这个考虑变得有意思:

  • 这让编程模型具有一致性(Actor的实例表示一个东西),即使这个东西可以用内部并发来实现。例如,考虑一个网卡/网络栈的抽象:它可能想根据自己的规则,来为运行中的任务做内部的调度和优先级安排,但是也可以在其之上提供一个简单易用的ActorAPI。Actor能够处理多个并发请求这件事,是一个实现细节,使用者并不需要重写一遍来理解。
  • 把这作为非默认可以提供合适的、渐进的复杂度暴露。
  • 你还是会得到更好的安全性和整体的系统隔离,即使单个Actor是以这个方式优化的
  • 当逐渐把代码迁移到Actor模型时,为已有的、建立在共享可变状态上的并发子系统添加Actor的外壳,变得简单很多
  • 像这样的做法,对能够支持多个并发同步请求的引入的RPC服务而言,可能也是正确的抽象。
  • 这种抽象就内存安全的角度而言是不安全的,但这在Swift中有很多先例。许多安全的抽象建立在内存不安全的原语之上——想一想Array如何建立在UnsafePointer之上——这是Swift编程模型中实用主义和”把事情做成“里一个重要的部分。

这样来说,这绝对是一个高级用户的功能,并且我们需要先理解、建造和体验基础的系统,然后再添加类似这些的东西

第三部分:错误隔离带来可靠性

Swift在设计上有很多方面,考虑使编程错误(也就是软件bug)能在编译时被发现:静态类型系统,optionals,鼓励覆盖switch cases等等。然而,有些错误只能在运行时被发现,包括数组越界访问,整型溢出,和强制解包为空。

如同在Swift错误处理原理中提到的,必须要做一些取舍:不应该强迫开发者处理每一个能想到的边缘情况:就算不考虑带来的样板(重复),这些逻辑本身也可能是无法很好测试,因此包含很多bug。我们必须对这些复杂的问题作出很好的权衡和取舍,来获得一个平衡的设计。这些取舍带来了Swift的做法:让开发者去思考和编写所有需要处理可能为空的指针引用的代码,但不需要为每一个算数操作考虑整型溢出。这个新的挑战是,整型溢出仍然会在某种程度上被发现和处理,并且开发者不需要写任何恢复的代码。

Swift通过快速失败的哲学来处理这个问题:最好是尽快地发现并报告一个编程问题,而不是继续错下去并祈求错误不会带来影响。与严格的测试相结合(可能未来会有静态分析技术),目标是让bug不太严重,并且在发生时提供栈的追踪和其他一些信息。这会促使它们在开发的早期就被发现和修复。然而,当app上线后,这个哲学只有在所有bug都被发现时才是好的,因为一个未被发现的错误会使app突然自己关闭。

突然的进程停止如果损坏了用户数据,甚至在服务端app中同时有几百个用户正在连接的时候,会是一个很大的问题。即使使用通用的方法来完美地解决任意的程序错误是不可能的,已经有一些优雅地处理常见错误的办法。举例来说,在Cocoa中,如果一个NSException传播到了runloop的顶层,尝试保存修改后的文档到一个另外的位置会很有用。这不保证在每个情况下都有效,但是当它有效的时候,用户会很高兴并没有丢失他们的工作进程。类似的,如果一台服务器在处理一个用户的请求时崩溃,一个可能的恢复方式是,完成当前进程中其他已经建立的连接,但是把新的连接请求转移到一个重新启动的服务器进程中去。

Actor的引入是一个改进这个情况的好机会,因为当开发者思考他们维护的不变量时,Actor提供了一个介于”整个进程“和”单个类“之间的有趣的粒度。确实,现在已经有了一些创建可靠Actor系统的技术,并且再一次的,Erlang是其中的领袖之一(想详细了解的话查看Joe Armstrong的博士论文)。我们会从设计基础模型开始,然后讨论一个可能的设计方案。

Actor可靠模型

这里基本的概念是,一个出错的Actor违反了本身的不变性,但是其他Actor中的不变性仍然成立:因为我们没有在里面定义共享可变状态。这就给了我们一个选择,终止这个破坏了本身不变性的单个Actor,而不是不关闭整个进程。根据基础Actor模型发送单向异步消息的定义,有可能运行时可以直接丢弃任何发送给Actor的新消息,并且系统中的其余部分可以继续运行,甚至不知道那个Actor已经崩溃了。

采用这个简单的方法,会有两个问题:

  • 具有返回值的Actor方法可能会正处于被await的过程,如果Actor崩溃了,那些awaits就永远完成不了了。
  • 丢弃消息可能本身会造成死锁,因为更高级的通信不变性被打破了。举例来说,考虑这个Actor,它在等待10个消息后传递消息:
  actor Merge10Notifications {
    var counter : Int = 0
    let otherActor = ...  // set up by the init.
    actor func notify() {
      counter += 1
      if counter >= 10 {
        otherActor.notify()
      }
    }
  }

如果10个给这个Actor发送通知的Actor其中之一崩溃了,那么程序就会永远等待那第10个通知。因为如此,设计一个”可靠“Actor的人需要考虑更多的问题,并且付出略微更多努力来实现这样的可靠性。

建立可靠性

由于建造一个可靠的Actor需要比建造简单的Actor需要更多的思考,需要去找寻默认提供渐进的复杂度暴露的模型。你最先需要的是一个建立它的方法。在具有Actor语法的条件下,有两个广泛的选择:最高层级的Actor语法,或是一个类型定义标识,也就是以下之一:

  reliable actor Notifier { ... }
  reliable actor class Notifier { ... }

当一个人为Actor建立了可靠性,一个新的条件会被添加到所有具有返回值的actor方法上:它们现在也需要被声明为throws。这强制使Actor的调用方为Actor的崩溃做好准备。

隐式地丢弃消息仍然是一个问题。我不太熟悉其他系统中采用的方式,但我想象了两种可能的方案:

  1. 提供一个为Actor注册失败处理的标准库API,让更高层级有能力去思考如何处理和应对这些失败。一个Actor的init()方法可以使用这个API来在系统中注册失败处理逻辑。
  2. 强迫所有的actor方法来抛出错误,使用Actor一旦崩溃就抛出的语义。一个可靠Actor的调用方被强制要求处理一个潜在的崩溃,并且以所有发送给他的消息的粒度来做。

在两种方案之间,第一种方案更吸引我,因为它把通用的失败逻辑抽取到一个地方,而不是让每个调用者去编写(难以测试)的的逻辑来细粒度地处理失败。举例来说,一个文档Actor可能会注册一个失败处理逻辑,在它崩溃之后尝试把数据保存到另一个地方。

也就是说,两种方案都是可行的,并且需要被细化。

另一种设计:另一种方案是让所有的Actor都变成”可靠的“Actor,通过把额外的限制变成一个Actor模型的一个简单部分来实现。这减少了一个Swift开发者需要或不得不做的选择。如果async/await模型,最终变成async会隐式地抛出错误,那么这可能是正确的方向,因为在一个带有返回值的方法上await也隐式地带有try标识。

运行时模型的可靠性

除了编程者面对的高层的语义模型的问题,也存在运行时应该是什么样的问题。当一个Actor崩溃时:

  • 他的内存处于什么状态?
  • 进程能从失败中清理到什么程度?
  • 我们是否要释放Actor管理的内存和其他资源(如文件标识符)?

有几种可能的设计,但我鼓励采用一种没有清理操作的设计:如果一个Actor崩溃了,运行时会把错误传播给其他Actor,运行恢复的处理逻辑(如在之前段落描述的那样),但是它不应该进一步清理Actor拥有的资源。

这么做有很多原因,但是最重要的是,Actor刚刚才通过进行无效的操作破坏了他自身的一致性。在这个时间点,他可能开启了一个事务但还没有完成,或者可能处于一些其他形式的不一致的、未定义的状态。考虑到内部不一致性有非常大的可能,有可能有一些类的更高层级的不变性变得不完整,也就是说运行类deinit方法是不安全的。

除了我们面对的语义问题,还有实际上的复杂度和效率问题:它需要代码和元数据来具备展开Actor的栈和释放活跃资源的能力。这些代码和元数据会在应用中占据一些空间,并且也需要一些时间来编译生成。这样的话,如果要提供一个具备从这些错误中恢复的能力的模型,意味着消耗大量的代码体积和编译时间,而这些本来不应该发生。

一个最终的(我承认较弱)采用这个方案的理由是,一个”过于干净“的清理会带来一个风险,就是开发者会将快速失败的情况作为一个软错误,而不会紧急处理它。我们非常希望这些bug能被找到以及修复,来实现一个我们追求的高可靠性的软件系统。

第四部分:改进系统架构

就如在动机部分描述的,单个应用进程运行在一个更大的系统中:通过IPC通信的多进程(如一个应用和一个XPC守护进程),或客户端和服务端通过网络通信,或服务端在云环境中互相通信(使用JSON,protobuf,GRPC等等)。他们的共同点是,都包含互相独立的、把结构化数据作为异步消息发送来互相通信的任务,他们实际上不能使用共享可变状态。这听起来开始变得熟悉了。

也就是说,他们也存在不同,并且尝试把他们封装起来(就像在之前在Objective-C中做的分布式对象系统)会造成严重问题:

  • 客户端和服务端通常是不同的人来写的,也就是说API必须独立地演进。Swift在这一点已经很好了。
  • 网络会引入一些原始API几乎一定不会预料到的错误模式。这会被上面提到的”可靠Actor“所涵盖。
  • 消息中的数据需要是已知的Codable
  • 远程系统的延迟会高得多,因为过于精细的API会工作得很差

为了与Swift的目标相一致,我们不能故意忽视这些问题:我们想要让开发过程迅速,但是”启动并运行一个东西“并不是目标:它真的需要能够工作——即使是在失败的情况下。

进程间和远程计算的设计草稿

在这个领域中Actor模型是一个著名的方案,并且已经被成功部署在不那么主流的语言中,如Erlang。把它带入到Swift需要我们确定它非常干净地融入到现有的设计中,利用好Swift的特性,并确保一直符合它的指导原则。

这些原则之一是渐进的复杂度暴露:一个Swift开发者如果不关心IPC或分布式计算,他就不应该担心这些。这就意味着Actor需要通过一个新的声明标识来引入,与他最终的设计相匹配,也就是以下之一:

  distributed actor MyDistributedCache { ... }
  distributed actor class MyDistributedCache { ... }

因为它已经做了这些,Actor现在需要接受两个额外的要求:

  • Actor必须满足可靠Actor的要求,因为分布式Actor是由可靠Actor进一步提炼而来。例如,这意味着所有带返回值的actor方法必须可以抛出错误。
  • actor方法的参数和返回值必须遵从Codable

额外的,Actor的作者必须考虑在分布式环境下actor方法是否有意义,考虑到所面对的更高的延迟。使用粗粒度的API可能在性能上有很大优势。

做了这些以后,开发者就能够正常地编写他们的Actor:不用改变语言或工具,不用改变API,没有大量概念上的改变。不管你是在通过JSON还是用protobuf和/或GRPC与云服务通信,都是如此。模型中几乎没有缺陷,而那些不完美之处也有非常明确的理由:改变全局状态的代码不会在整个app架构中被看到,在文件系统中创建的文件可以在IPC上下文中工作,而不是分布式的上下文中,等等

应用开发者现在可以把他们的Actor放进一个打包中,在他们的应用和服务之间共享。主要的代码改变是在MyDistributedCache的初始化的地方,现在需要使用一个在其他进程中创建Actor的API,而不是直接调用初始化方法。如果你开始使用标准云API,你应该可以通过引入一个提供Actor接口的API的包,让你的代码可以摆脱JSON。

需要的新API

这项工作的主要的困难之处在框架这一边,例如,开始构建这些会很有趣:

  • 需要构建新的API,在有趣的地方启动Actor:IPC上下文,云服务等等。这些API应该是互相一致的
  • 下层的运行时需要被构建,具有处理序列化、握手、Actor的分布式引用计数等等
  • 为了调优共享内存间的IPC通信(mmap),引入一个新的协议来提炼ValueSemantical。重型的类就可以在合适的时机来选择它。
  • 一个描述云API的DSL应该被创建(或者采用一个已有的),自动生成必要的样板代码来提供一个云服务的ActorAPI。

不论哪种情况,这里都有很多工作需要做,并且会需要好几年来创建原型、建造、迭代,来使它变得完美。当我们最终达到那里,会是美好的一天。

第五部分:疯狂又灿烂的未来

在这条路上往远处看,存在更多的机会去消灭意外的复杂度,通过在我们的语言、工具和API中消灭任意的差别。你可以在这些地方找到它们:查看带有异步通信模式、消息发送和事件驱动模型,和共享可变状态工作得不太好的地方。

例如,GPU计算和DSP加速器具备所有这些特征:CPU通过异步命令与GPU通信(如通过DMA请求和中断)。有可能可以使用Swift代码的一个子集(加上GPU的特殊操作,如纹理获取API)来处理GPU计算任务。

另一个可以关注的是事件驱动应用,比如嵌入式系统的中断处理程序,或者是Unix中的异步信号。如果一个Swift脚本想要注册SIGWINCH的通知,通过注册你的Actor并实现正确的方法会比较简单。

进一步,这样的模型会需要重新评估一些在软件社区中的长期讨论,比如微内核和宏内核。微内核通常在学术上被认为是更好的(比如不同模块的内存隔离,独立于内核核心之外的驱动开发等等),但是宏内核倾向于更务实(更有效率)。这个提案中的模型允许一些非常有趣的混合的方法,允许子系统在需要效率时被移入进程,或者在它们不受信任或者可靠性非常重要的情况下被移出进程,而不需要写很多代码来实现它。Swift聚焦于稳定的API和API弹性,也促使并使内核和驱动开发分开成为可能。

无论如何,有很多让软件世界变得更好的机会,但是采用深思熟虑和有意的方法来设计和构建每一各部分,也是一条很长的路。我们一次只走一步,确保每一步都是我们能做到的最好的。

从其他的并发设计中学习

当为Swift设计一个并发系统,我们需要从其他语言的设计中学习,并确保我们实现最好的系统。有成千上万种不同的编程语言,但是大多数只有很小的社区,也就很难从这些社区中吸取好的经验。这里我们来看一些不同的系统,集中注意看他们的并发设计如何工作,忽略在他们设计中的语法上以及其他无关的方面。

Pony

也许最相关的活跃的研究语言是Pony编程语言。它是基于Actor的,并将它和其他技术一起使用来提供一个类型安全、内存安全、免死锁,和免数据竞争的编程模型。Pony和Swift设计中最大的语义区别是,Pony花费了很多的设计复杂性来提供引用能力,带来很高的学习曲线。相反的,这里提出的模型建立在Swift成熟的值语义系统之上。如果在Actor之间转移对象图在未来变得重要(以保证内存安全的方式),我们可以研究扩展Swift所有权模型来覆盖更多的使用场景。

Akka Scala中的Actor

Akka是一个用Scala编写的框架,它的使命是”更简单地建立强大的响应式、并发的、分布式应用“。这里的关键使他们设计良好的Akka actor系统,作为开发者使用的原则的抽象来实现这些目标(它反过来也是受到了Erlang的很大影响)。Akka最好的一个特点是,它很成熟并且被很多不同的组织和人使用。这意味着我们可以从它的设计、它的社区探索的模式和描述它实际中工作得多好的经验报告中学习。

Akka的设计与这里的提案有很多相似之处,因为它是以同样的Actor模型来实现的。它建立在futures、异步消息发送,每个Actor是并发的一个单位,有著名的模型来描述Actor应该在什么时候、用怎样的方法来通信,并且Akka支持简单的分布式计算(他们称之为”位置透明“)

Akka和这里提到的模型的一个区别是,Akka是一个基于库的功能,而不是基于语言的功能。这意味着它不能提供我们这里描述的模型提供的、额外的类型系统和安全功能。例如,有可能意外地共享可变状态,带来bug并破坏模型。他们的消息循环也是用模式匹配手动实现的,而不是自动被分发到actor方法——这带来一些样板代码。Akka Actor消息是无类型的(由Any表示),可能会引起意外的bug,也很难推断出一个Actor的API是什么(虽然Akka Typed研究项目正在研究如何修复这个问题)。除此以外,这两个模型非常有可比性,并且这不是一个意外。

记住这些不同后,我们通过阅读很多的博客和其他的在线文档,来学习这个模型实际中能运行的多好,比如:

进一步的,有可能Swift社区中有一些成员已经遇到了这个模型,如果他们能分享他们的经验会很棒,包括正面和负面的。

Go

Go编程语言支持一个最高层级的方法来实现编写并发程序,通过goroutines和(双向的)频道。这个模型在Go社区中非常流行,直接反映了很多Go语言中的核心价值,包括简单性以及在底层抽象编程的偏好。我并没有证据证明它,但是我推测这个模型受到了Go繁荣的领域的影响:Go的频道和独立goroutine通信模型几乎直接反映了服务器如何在网络连接上通信(包括核心操作,如select)。

Swift的设计提案相比Go模型有更高的抽象,但是直接反映了Go中最常见的模式:goroutine的主体是在一个频道上的无限循环,对发到频道上的消息进行解码并对它们进行操作。可能最简单的例子是这个Go代码(从这个博客上改编而来)

func printer(c chan string) {
  for {
    msg := <- c
    fmt.Println(msg)
  }
}

... 基本上和这个提出的Swift代码类似:

actor Printer {
  actor func print(message: String) {
    print(message)
  }
}

Swift的设计比Go而言更加声明式,但并没有在如此小的角度上展现太多优缺点。然而,在更实际的例子中,高层级的声明式方法展现了优点。例如,goroutines监听多个频道是很普遍的,对于每个它们响应的消息各一个频道。这个例子(来自这篇博客)很典型:

// Worker represents the worker that executes the job
type Worker struct {
  WorkerPool  chan chan Job
  JobChannel  chan Job
  quit        chan bool
}

func NewWorker(workerPool chan chan Job) Worker {
  return Worker{
    JobChannel: make(chan Job),
    quit:       make(chan bool)}
}

func (w Worker) Start() {
  go func() {
    for {
      select {
      case job := <-w.JobChannel:
        // ...
      case <-w.quit:
        // ...
      }
    }
  }()
}

// Stop signals the worker to stop listening for work requests.
func (w Worker) Stop() {
  go func() {
    w.quit <- true
  }()
}

这种东西在我们提案的模型中被表现得自然的多:

actor Worker {
  actor func do(job: Job) {
    // ...
  }

  actor func stop() {
    // ...
  }
}

说了这些,Go模型也有一些优点和取舍。Go基于CSP构建,它允许更多临时通信的结构。例如,因为goroutines可以监听多个频道,偶尔会更容易建立一些(高级的)通信模式。发往一个频道的同步消息,只能在有人监听和等待它们的时候被完全地发送,这可能带来性能优势(和一些劣势)。Go并不尝试去提供任何的内存安全和数据隔离,所以goroutines有着mutexes和其他API供使用,并且会遇到一些标准的bug如死锁和数据竞争。竞争甚至可能会破坏内存安全

我认为Swift社区能从Go的并发模型中学到的最重要的是,一个高度可伸缩的运行时带来的大量好处。经常会有成千上万甚至百万的goroutines运行在同一台服务器上。具备不再担心”线程不够用“的能力很重要,并且也是在云中使用Go的一个关键决定点。

另一个教训是(即使在并发世界中实现有一个”最好的默认“方案非常重要),我们不应该过度限制开发者能够表达的模式。这是async/await设计独立于futures或者其他抽象的一个关键原因。一个Swift中的频道库会和Go之中的一样高效,并且如果共享可变状态和频道是某个问题的最好方案,那么我们应该拥抱现实,而不是逃避它。虽然这么说,我期待这些情况非常罕见 :-)

Rust

Rust的并发方案建立在它的所有权系统之上,使基于库的并发模式可以在它之上建立。Rust支持消息传递(通过频道),但是也支持锁和其他共享可变状态的典型抽象。Rust的方法非常适合系统开发者,他们也是Rust的主要用户。

好的方面是,Rust的设计提供了很多灵活性、更多的不同并发原语可供选择,也对C++开发者是更熟悉的抽象。

不好的方面是,它们的所有权模型比这里的设计有更高的学习曲线,它们的抽象一般在很低的层级(对系统开发者是好事,但是不如高层那么有帮助),并且它们也没有提供编程者指导来选择哪个抽象,或者如何构建一个应用等等。Rust也没有提供如何扩展到分布式应用的显而易见的模型。

这样来说,当Swift所有权模型的基础实现以后,为Swift系统编程者改进同步会变成一个目标。到那个时候,有理由再看一下Rust的抽象,决定哪些东西可以被带入到Swift。

@wanhmr

This comment has been minimized.

Copy link

@wanhmr wanhmr commented May 16, 2020

一处 Markdown语法错误,如下图所示:
截屏2020-05-16 下午12 58 10

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.