Alice 2: Future and promise

创建项目时,Alice 在我心中已经有了一个完整的轮廓。

✨✨✨

我会边写边记录,但记录不会事无巨细,所以配合代码食用更佳。

非常期待你的实时反馈!欢迎使用 issue 或 pr 参与 Alice 的开发!😉

异步模型

我要做的第一个决定是异步模型的选择。

我的选项有两个:

他们俩都是久经考验的异步模型,RxSwiftPromiseKit 分别是这两种模型的代表。

在很多场景下,它们都可以代替彼此——想想 PromiseKit 里的 Promise 和 RxSwift 里的 Single。

没有太多思考我就选择了 Future & Promise,主要因为两点:

  1. Reactive 处理的更多是 stream,而 Future & Promise 关注的是 once。有些异步任务只有两个状态:未决,已决,比如一次 HTTP 请求,比如一次读写文件。这时 stream 的概念有些太重了,future 的意象不多不少,恰如其当。
  2. Reactive 已经不单是一个异步框架,它已经成为了编程模式。它太大了,引入 Reactive 往往意味着架构级的改变。如果开发者的项目里之前没有 Reactive,我不希望之后因为 Alice 引入 Reactive。

Future & Promise

Future & Promise 已经是老生常谈的编程概念,它是非常成熟的异步方案。相比 Reactive,它更轻,简单,又灵活。不少语言都提供了 Future & Promise 的官方实现,比如:c++,js,dart,python,java,rust……

——好像热门语言都有。

但 Swift 没有。🤦‍♂️

幸运的是开源社区为 Swift 提供了充足的可靠实现,前有 mxcl 的 PromiseKit,后有 google 的 promises,以及 BrightFuture,Hydra,Then,When,等等等等。

我之前就读过 PromiseKit 和 promises 的源码,所以这次选择同样没有让我纠结。

我很喜欢 PromiseKit 的实现,它 API 的设计有些「跳」,这是一种「文风」,mxcl 的作品大都给我这种感觉,有些「轻」,「浪」,飘逸。与 mattt 大相径庭。一个华山一个少林的感觉,呃……好像跑题了。

这些库我不是不喜欢架构,就是不喜欢 API。程序员心理开始作用,干脆自己写了一个。

我叫它 Async

设计

因为 Async 是之前就写好的——更多背景小故事可以看Alice Pre: 起源,所以这一部分的设计只是回顾,预计会有三篇。

作为 Alice 计划中最基础的组件,Async 最重要的关键词就是性能

我参考 promises 的测试流程写了一个 benchmark,对比谷歌的 Promises,mxcl 的 PromiseKit 以及 BrightFuture 和 Hydra,Async 的表现如下:

可以看到,嗯~

架构

与 PromiseKit 或 Promises 的设计不同,Async 把 Promise 的角色一分为二:FuturePromise。其中 Future 与 PromiseKit 中的 Promise 戏份相当。

它们的关系是:

class Future {

    func whenComplete(_ callback: @escaping (Result) -> Void) { 
    	// ...
    }
}

struct Promise {

    let future: Future
    
    func complete(_ result: Result) { 
    	// ...
    }
}

func async() -> Future {
    let promise = Promise()

    request(url) { response in
        promise.complete(.success(response))
    }

    return promise.future
}

结构体 Promise 决定着类 Future 的状态。Promise 是承诺,Future 是未来,Promise 可以自由传递,Future 不能自己决定自己的状态。

对比 PromiseKit 中的 Promise:

let p = Promise { resolver in 
    async(.background) { 
        resolve()
    }
}

可以看到,Async 中的 Future 不再持有task body了。任务的完成由 Promise 负责。这样的设计见仁见智。

一个明显的好处就是:责任分明,把 complete 的责任交给 PromiseFuture 不再持有 body 也就不再持有 body 可能捕获的资源。两个意象都变得更加纯粹,这也一向是我偏爱的编程理念。

实现

Future 的原理非常简单,即在调用 future.whenComplete(_ callback: Callback) 时,检查当前状态,如是已决,直接执行 callback,如是未决,持有 callback,在已决时统一执行。

核心步骤如下:

class Future<Success, Failure> {
    var result: Result?
    var callbacks: [Callback]
    
    func whenComplete(_ callback: Callback) { 
        guard let result = self.result else {
            self.callbacks.append(callback)
            return
        }
        callback(result)
    }
}

这样的话,性能的关键在于两个操作:

  1. 检查是否完成
  2. 组织 callbacks

我做了两个优化:

一是锁的选择。

我有一个选择锁的流程:

  1. 锁的持续时间是否很长,比如读写文件。-> DispatchSemaphore
  2. 锁是否需要重入,比如递归。-> NSRecursiveLock
  3. 锁是否需要条件。-> NSConditionLock
  4. 是否在 darwin 平台上,是否对加锁解锁的顺序敏感。-> os_unfair_lock
  5. NSLock

回过头来看 Future 的需求,一个理想的选择是在 canImport(Darwin) 时用 os_unfair_lock,否则用 NSLock

#if canImport(Darwin)
final class SpinLock: NSLocking {
    
    var _lock = os_unfair_lock()
    
    init() { }
    
    func lock() {
        os_unfair_lock_lock(&self._lock)
    }

    func unlock() {
        os_unfair_lock_unlock(&self._lock)
    }
}
#endif

final class Lock {

    let wrapped: NSLocking

    init() {
        #if canImport(Darwin)
        self.wrapped = SpinLock()
        #else
        self.wrapped = NSLock()
        #endif
    }
}

二是组织 callbacks。

组织 callbacks 要麻烦些。简单的数组看起来似乎足够,因为没有 unsubscribe 操作,也不用考虑 remove 操作。

但我之前读 swift-nio 源码时对它 CallbackList 设计的妙处印象深刻:

struct CallbackList {

    typealias E = () -> CallbackList

    var _first: E?
    var _others: [E]?
}

它重要优化的地方是两点:

一是变量 first,很多情况下 future 只会有一个 callback,这时直接设置为成员变量比加到数组里要快得多。

二是 ECallbackList 中的元素是 () -> CallbackList,每执行一个 callback,pop 该 callback,继续执行返回的 CallbackList。就这样消除了调用 run() 时可能出现的递归。代码如下:

func _run() {
    switch (self._first, self._others) {
    case (.none, _):
        break
    case (.some(let cb), .none):
        var cbList = cb()
        loop: while true {
            switch (cbList._first, cbList._others) {
            case (.none, _):
                break loop
            case (.some(let cb), .none):
                cbList = cb()
                continue loop
            case (.some(let cb), .some(let cbs)):
                var next = [cb] + cbs
                while next.count > 0 {
                    let pending = next
                    next = []
                    for cb in pending {
                        next.append(contentsOf: cb()._allCallbacks())
                    }
                }
                break loop
            }
        }
    case (.some(let cb), .some(let cbs)):
        var next = [cb] + cbs
        while next.count > 0 {
            let pending = next
            next = []
            for cb in pending {
                next.append(contentsOf: cb()._allCallbacks())
            }
        }
    }
}

细节

还有两个小细节值得分享一下:

  1. @inlinable@usableFromInline。我在 Async 里大量用到了这两个修饰符。前者用来修饰方法,后者用来修饰方法会用到的类型和变量。它们可以帮助编译器跨模块优化代码,这对 Async 这种要求极致性能的基础工具库来说是非常重要的。因为 @usableFromInline 无法修饰私有变量,你会在 Alice 中看到一些应该用 private 修饰的变量却没有用。
  2. self。我喜欢简单的设计,我之前的 self 习惯是,能不用就不用。但在读 swift-nio 的源码时,我意识到了 self 的好处:当一个类、方法变得非常复杂时,self 可以让你轻松地区别成员变量与其它变量,清晰地梳理代码的脉络结构。在易读性面前,啰嗦一点根本不是事儿。

✨✨✨

就是这样,你可以在 Async 找到所有源码。

接下来就是各种操作符的设计与实现,比如 whenAllwhenAnyflatMapvalidatetimeout 以及 scheduler,有了他们,Async 才完整。

求交流,求 star——

你的反馈就是对我最大的鼓励。


  1. future + promise 与 callback list 的设计借鉴自 swift-nio
  2. 推荐读下 PromiseKit 的源码,真的很飘逸,可能和 mxcl 之前写 ruby 的经验有关。

#swift #http #future #alice #alice-serial

Comments