How to make an "AsyncLazy" primitive/wrapper with an `async get` and a sync `getOrNil`?

91 Views Asked by At

I have a situation where I need to compute something at-most-once. This value is computed asynchronously but also needs to be available without await-ing. If the value isn't computed, then nil is fine. Basically, the following protocol:

/// A value that is computed at most once. The value is asynchronously
/// computed the first time it's accessed.
public protocol AsyncLazy<T> where T : Sendable {
  associatedtype T

  /// The value, or nil if it hasn't been fetched yet.
  var valueOrNil: T? { get }

  /// The value, once it is computed
  var value: T { get async }
}

It would be used like this:

val someValue = AsyncLazyImpl { someComputation() }

...

// On main thread...
let x = someValue.valueOrNil

// On other threads...
let x = await someValue.value

How would I implement this? I'm lost trying to make sense of what locks and concurrency primitives I'd use.

1

There are 1 best solutions below

0
Rob On

I gather that someComputation is slow, synchronous, and does not explicitly yield during the calculation. If so, that means we have to get it out of the Swift concurrency system. In WWDC 2022 video Visualize and optimize Swift concurrency, Apple says:

If you have code that needs to [perform slow, synchronous functions that cannot periodically yield to the Swift concurrency system], move that code outside of the concurrency thread pool – for example, by running it on a dispatch queue – and bridge it to the concurrency world using continuations. Whenever possible, use async APIs for blocking operations to keep the system operating smoothly.

So, with the above caveat in mind, you theoretically could do something like:

@MainActor
class AsyncValue<T: Sendable> {
    private var task: Task<T, Never>!
    
    private(set) var currentValue: T?
    
    init(block: @Sendable @escaping () -> T) {
        task = Task {
            let value = await withCheckedContinuation { continuation in
                DispatchQueue.global().async {
                    let result = block()
                    continuation.resume(returning: result)
                }
            }
            currentValue = value
            return value
        }
    }
    
    func asynchronousValue() async -> T {
        return await task.value
    }
}

Generally, combining GCD with Swift concurrency is a mistake, but given the constraints of the original question, this is an exception to the rule.

Please note that this is not a generalized solution, but a straw-man that is narrowly answering your question. A few observations:

  1. I have isolated this whole thing to the main actor so that you can fetch the currentValue (what you called valueOrNil) from there without awaiting. (If you were only invoking this from asynchronous Swift concurrency contexts, you could instead just make this an actor.)

  2. I have supplied an asynchronousValue function (which is analogous to the value property in your example) that awaits the Task that was initiated when the AsyncValue was instantiated.

  3. Be forewarned that this just launches a task on a global queue (which is subject to the thread-explosion risks that plague legacy GCD code, namely, if you created lots of these objects). You should consider using a static serial queue, or operation queue with some reasonable maxConcurrentOperationCount or the like.

    Also note that this is non-cancellable.


Having illustrated a possible solution, I would consider this an anti-pattern, because we generally strive to:

  • remain entirely within the Swift concurrency system;
  • avoid unstructured concurrency; and
  • support cancelation handling.

I would encourage you to reevaluate the situation in which you contemplated using this pattern, and see if there might be some more idiomatic Swift concurrency approach.