3

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.”

bdesham
  • 15,430
  • 13
  • 79
  • 123
  • Don't try to combine async/await with Combine; they are sort of opposites. I think your first job is to decide which one you would like to use here! If the answer is Combine, then I think all you can do is simply not hook up your full chain until you know what you want to do after the promise is fulfilled. – matt Jul 22 '22 at 14:45
  • @matt I was only intending to use async/await, so if it sounded like I was trying to use Combine then I think I have a gap in my understanding :-) Which part of my question involves Combine? – bdesham Jul 22 '22 at 14:51
  • No worries, it's just that Combine does already have Promise and in general the jump from PromiseKit promise to Combine promise is pretty easy so that's what people have been using. It's totally legit to say "No, I want to use pure async/await". – matt Jul 22 '22 at 15:37
  • 1
    @matt Oh, I see. My goal here is to migrate directly from PromiseKit to pure async/await. – bdesham Jul 22 '22 at 16:51
  • I would suggest not creating a direct one-for-one analog of the promise-resolver pattern. It’s going to lead to somewhat contorted async-await code. Use Swift concurrency patterns. E.g., `try await` the network request and then update the UI in the next line of code. It leads to extremely simple and intuitive code. In more complicated scenarios, you might create an `AsyncSequence` that will trigger anytime the network request is re-run (e.g. see [this `MKLocalSearchCompleter` [example](https://stackoverflow.com/a/73043542/1271826)). But that feels over-engineered for this use-case. – Rob Jul 23 '22 at 06:55
  • @bdesham did you tried my solution and did it worked or you? If no, did you find a better solution? – Louis Lac Jul 31 '22 at 15:01
  • 1
    @LouisLac I haven’t had a chance to try your solution yet, but it seems reasonable so I went ahead and accepted it. Thank you! – bdesham Jul 31 '22 at 19:42

1 Answers1

2

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).

Louis Lac
  • 5,298
  • 1
  • 21
  • 36
  • I’m not tied to the promise/resolver way of doing things… I’d prefer to do whatever is more idiomatic, so I think the second part of your answer is closer to what I’m looking for. Once a value has been provided to the `channel`, does that mean that any future use of `for await value in channel` will immediately provide that same value? – bdesham Jul 22 '22 at 17:01
  • The `SwiftAsyncAlgorithms` package is still under development and not yet stabilized, but last time I checked yes, an `AsyncChannel` can be shared by multiple consumers which will all receive the input values. Though, obviously you need to observe the values (with `for-await-in`) as soon as the channel is created if you do not want to miss values. There are also buffering options, check the documentation. – Louis Lac Jul 22 '22 at 19:44
  • With the promise approach, anyone who tries to access the promise will immediately see the value that had been fulfilled previously. But it sounds like the `for-await-in` approach will *not* give you values that had been delivered before you started your loop. I’d like my solution to work in the case that only one value is ever delivered, so I might need to look into those buffering options. – bdesham Jul 22 '22 at 19:56
  • 1
    A minor observation for the sake of future readers: Apple [advises](https://developer.apple.com/videos/play/wwdc2022/110350/?time=1403) that one should, “Always use the `withCheckedContinuation` API for continuations unless performance is absolutely critical.” This stuff is complicated and it’s best to use checked continuations until you’ve mastered the patterns and only consider the unsafe renditions if performance is truly critical. (The performance difference is modest, and the added safety of checked continuations is prudent.) – Rob Jul 23 '22 at 07:10
  • @bdesham I updated the answer with a working solution. It is (I believe) safer and more encapsulated. – Louis Lac Jul 23 '22 at 14:54