Search code examples
swiftpromisekitswift-concurrency

What is the Swift concurrency equivalent to a promise–resolver pair?


With the PromiseKit library, it’s possible to create a promise and a resolver function together and store them on an instance of a class:

class ExampleClass {
    // Promise and resolver for the top news headline, as obtained from
    // some web service.
    private let (headlinePromise, headlineSeal) = Promise<String>.pending()
}

Like any promise, we can chain off of headlinePromise to do some work once the value is available:

headlinePromise.get { headline in
    updateUI(headline: headline)
}
// Some other stuff here

Since the promise has not been resolved yet, the contents of the get closure will be enqueued somewhere and control will immediately move to the “some other stuff here” section; updateUI will not be called unless and until the promise is resolved.

To resolve the promise, an instance method can call headlineSeal:

makeNetworkRequest("https://news.example/headline").get { headline in
    headlineSeal.fulfill(headline)
}

The promise is now resolved, and any promise chains that had been waiting for headlinePromise will continue. For the rest of the life of this ExampleClass instance, any promise chain starting like

headlinePromise.get { headline in
    // ...
}

will immediately begin executing. (“Immediately” might mean “right now, synchronously,” or it might mean “on the next run of the event loop”; the distinction isn’t important for me here.) Since promises can only be resolved once, any future calls to headlineSeal.fulfill(_:) or headlineSeal.reject(_:) will be no-ops.

Question

How can this pattern be translated idiomatically into Swift concurrency (“async/await”)? It’s not important that there be an object called a “promise” and a function called a “resolver”; what I’m looking for is a setup that has the following properties:

  1. It’s possible for some code to declare a dependency on some bit of asynchronously-available state, and yield until that state is available.
  2. It’s possible for that state to be “fulfilled” from potentially any instance method.
  3. Once the state is available, any future chains of code that depend on that state are able to run right away.
  4. Once the state is available, its value is immutable; the state cannot become unavailable again, nor can its value be changed.

I think that some of these can be accomplished by storing an instance variable

private let headlineTask: Task<String, Error>

and then waiting for the value with

let headline = try await headlineTask.value

but I’m not sure how that Task should be initialized or how it should be “fulfilled.”


Solution

  • Here is a way to reproduce a Promise which can be awaited by multiple consumers and fulfilled by any synchronous code:

    public final class Promise<Success: Sendable>: Sendable {
        typealias Waiter = CheckedContinuation<Success, Never>
        
        struct State {
            var waiters = [Waiter]()
            var result: Success? = nil
        }
        
        private let state = ManagedCriticalState(State())
        
        public init(_ elementType: Success.Type = Success.self) { }
        
        @discardableResult
        public func fulfill(with value: Success) -> Bool {
            return state.withCriticalRegion { state in
                if state.result == nil {
                    state.result = value
                    for waiters in state.waiters {
                        waiters.resume(returning: value)
                    }
                    state.waiters.removeAll()
                    return false
                }
                return true
            }
        }
        
        public var value: Success {
            get async {
                await withCheckedContinuation { continuation in
                    state.withCriticalRegion { state in
                        if let result = state.result {
                            continuation.resume(returning: result)
                        } else {
                            state.waiters.append(continuation)
                        }
                    }
                }
            }
        }
    }
    
    extension Promise where Success == Void {
        func fulfill() -> Bool {
            return fulfill(with: ())
        }
    }
    

    The ManagedCriticalState type can be found in this file from the SwiftAsyncAlgorithms package.

    I think I got the implementation safe and correct but if someone finds an error I'll update the answer. For reference I got inspired by AsyncChannel and this blog post.

    You can use it like this:

    @main
    enum App {
        static func main() async throws {
            let promise = Promise(String.self)
            
            // Delayed fulfilling.
            let fulfiller = Task.detached {
                print("Starting to wait...")
                try await Task.sleep(nanoseconds: 2_000_000_000)
                print("Promise fulfilled")
                promise.fulfill(with: "Done!")
            }
            
            let consumer = Task.detached {
                await (print("Promise resolved to '\(promise.value)'"))
            }
            
            // Launch concurrent consumer and producer
            // and wait for them to complete.
            try await fulfiller.value
            await consumer.value
            
            // A promise can be fulfilled only once and
            // subsequent calls to `.value` immediatly return
            // with the previously resolved value.
            promise.fulfill(with: "Ooops")
            await (print("Promise still resolved to '\(promise.value)'"))
        }
    }
    

    Short explanation

    In Swift Concurrency, the high-level Task type resembles a Future/Promise (it can be awaited and suspends execution until resolved) but the actual resolution cannot be controlled from the outside: one must compose built-in lower-level asynchronous functions such as URLSession.data() or Task.sleep().

    However, Swift Concurrency provides a (Checked|Unsafe)Continuation type which basically act as a Promise resolver. It is a low-lever building block which purpose is to migrate regular asynchronous code (callback-based for instance) to the Swift Concurrency world.

    In the above code, continuations are created by the consumers (via the .value property) and stored in the Promise. Later, when the result is available the stored continuations are fulfilled (with .resume()), which resumes the execution of the consumers. The result is also cached so that if it is already available when .value is called it is directly returned to the called.

    When a Promise is fulfilled multiple times, the current behavior is to ignore subsequent calls and to return aa boolean value indicating if the Promise was already fulfilled. Other API's could be used (a trap, throwing an error, etc.).

    The internal mutable state of the Promise must be protected from concurrent accesses since multiple concurrency domains could try to read and write from it at the same time. This is achieve with regular locking (I believe this could have been achieved with an actor, though).