Async/Await - multiple function calls, single execution

As far as I can understand, with SE-0338 soon being implemented, this can break in practice, now that withCheckedContinuation (a non-actor-isolated async function) will no longer be considered to inherit the caller's actor executor. Since this now creates a possibility of dispatch (due to executor switch), timings can be off, where some continuations are left dangling indefinitely until someone eventually call getDataFromServer() again.

A more rigid approach is perhaps modelling each outstanding request as an object, which would hold both the result OR any awaiting continuations. So when the new runtime behaviour goes in effect, the body of withCheckedContinuation can still recover the results if it is executed later than how it is currently.

An example:

The outstanding request object

final class OutstandingRequest<T, E: Error>: @unchecked Sendable {
    let lock = NSLock()
    let state = State.awaiting([])

    enum State {
        case completed(Result<T, E>)
        case awaiting([CheckedContinuation<String, Never>])
    }
}

where the actor holds a reference to the outstanding request object:

actor Manager {
    var current: OutstandingRequest<String, Never>? = nil
}

and the request deduplication logic goes:

// Manager.getDataFromServer() async -> String

// Check whether there is active work — this is actor isolated.
if let request = self.current {
    return await withCheckedContinuation { continuation in
        // Extra locking on `OutstandingRequest` is needed, since we are accessing it
        // from this non-actor isolated scope, which can be executed in parallel to the
        // actor after SE-0338.
        request.lock.lock()
        defer { request.lock.unlock() }

        switch request.state {
        case let .completed(result):
            continuation.resume(with: result)
        case let .awaiting(continuations):
            request.state = .awaiting(continuations + [continuation])
        }
    }
} else {
    let request = OutstandingRequest()
    self.current = request

    let result = await serverCall()
    self.current = nil

    request.lock.lock()
    let oldState = request.state
    request.state = .completed(result)
    request.lock.unlock()

    switch oldState {
    case let .awaiting(continuations):
        continuations.forEach { $0.resume(with: result) }
    case .completed:
        preconditionFailure("Not supposed to happen.")
    }

    return result
}
3 Likes