7

Using Swift's new async/await functionality, I want to emulate the scheduling behavior of a serial queue (similar to how one might use a DispatchQueue or OperationQueue in the past).

Simplifying my use case a bit, I have a series of async tasks I want to fire off from a call-site and get a callback when they complete but by design I want to execute only one task at a time (each task depends on the previous task completing).

Today this is implemented via placing Operations onto an OperationQueue with a maxConcurrentOperationCount = 1, as well as using the dependency functionality of Operation when appropriate. I've build an async/await wrapper around the existing closure-based entry points using await withCheckedContinuation but I'm trying to figure out how to migrate this entire approach to the new system.

Is that possible? Does it even make sense or am I fundamentally going against the intent of the new async/await concurrency system?

I've dug some into using Actors but as far as I can tell there's no way to truly force/expect serial execution with that approach.

--

More context - This is contained within a networking library where each Operation today is for a new request. The Operation does some request pre-processing (think authentication / token refreshing if applicable), then fires off the request and moves on to the next Operation, thus avoiding duplicate authentication pre-processing when it is not required. Each Operation doesn't technically know that it depends on prior operations but the OperationQueue's scheduling enforces the serial execution.

Adding sample code below:

// Old entry point
func execute(request: CustomRequestType, completion: ((Result<CustomResponseType, Error>) -> Void)? = nil) {
    let operation = BlockOperation() {
        // do preprocessing and ultimately generate a URLRequest
        // We have a URLSession instance reference in this context called session
        let dataTask = session.dataTask(with: urlRequest) { data, urlResponse, error in
        completion?(/* Call to a function which processes the response and creates the Result type */)
        dataTask.resume()
    }

    // queue is an OperationQueue with maxConcurrentOperationCount = 1 defined elsewhere
    queue.addOperation(operation)
}

// New entry point which currently just wraps the old entry point
func execute(request: CustomRequestType) async -> Result<CustomResponseType, Error> {
        await withCheckedContinuation { continuation in
            execute(request: request) { (result: Result<CustomResponseType, Error>) in
                continuation.resume(returning: result)
            }
        }
    }
bplattenburg
  • 623
  • 1
  • 8
  • 33
  • 1
    "I want to fire off from a call-site and get a callback when they complete" No. No callbacks. That's the point. Erase that notion from your thoughts. And an actor is exactly a context serializer, so please show your code that fails to accomplish this. Once you say `await` you cannot proceed until the async material finishes, so what's the problem? Straightening this stuff out, without callbacks, is exactly what async/await does. – matt Jan 13 '22 at 15:09
  • Callback was a poor word choice, the wrapper I've written already is an async function which returns a Result (replacing the old closure/callback entry point) - I'm just trying to modernize the implementation inside. – bplattenburg Jan 13 '22 at 15:23
  • As I said, you should provide some code. What you're asking to do sounds completely straightforward so it would be useful to see why it isn't. For example I easily wrote a demo example that draws the Mandelbrot set and you can't even start to redraw the set until the existing redraw finishes; they line up serially exactly in the way you suggest, thanks to an actor. So please show why that doesn't work for you. – matt Jan 13 '22 at 15:30
  • I added a little more context and sample code above for my use case - the big distinction I think is that each Operation doesn't internally know that it depends on a previous operation to complete, but the OperationQueue manages that and handles all of the scheduling – bplattenburg Jan 13 '22 at 15:55
  • As long as the actor is still running code and never said `await`, it blocks reentrancy and you have a serial queue. – matt Jan 13 '22 at 16:01
  • So if I'm following this correctly (still wrapping my head around the new way of doing things) I could essentially skip the queue entirely and replace each Operation with an async method on an actor to accomplish the same thing? – bplattenburg Jan 13 '22 at 16:34
  • 2
    Yes as long as no code in the actor says await. As soon as it says that, the actor becomes reentrant. – matt Jan 13 '22 at 17:00
  • Thanks for walking me through that. I was able to frame out a test version of this and it works the way I want it to pretty easily - this is really clean and exciting! If you'd like to post something similar as an answer I'll accept it – bplattenburg Jan 13 '22 at 20:18
  • 1
    If that's the issue, I'd say this is a duplicate of https://stackoverflow.com/questions/68686601/analog-of-configureawait-method-in-swift. I would just be repeating my answer from there. – matt Jan 13 '22 at 20:54
  • Fair enough, I would consider this a duplicate. – bplattenburg Jan 13 '22 at 21:08

1 Answers1

7

A few observations:

  1. For the sake of clarity, your operation queue implementation does not “[enforce] the serial execution” of the network requests. Your operations are only wrapping the preparation of those requests, but not the performance of those requests (i.e. the operation completes immediately, and does not waiting for the request to finish). So, for example, if your authentication is one network request and the second request requires that to finish before proceeding, this BlockOperation sort of implementation is not the right solution.

    Generally, if using operation queues to manage network requests, you would wrap the whole network request and response in a custom, asynchronous Operation subclass (and not a BlockOperation), at which point you can use operation queue dependencies and/or maxConcurrentOperationCount. See https://stackoverflow.com/a/57247869/1271826 if you want to see what a Operation subclass wrapping a network request looks like. But it is moot, as you should probably just use async-await nowadays.

  2. You said:

    I could essentially skip the queue entirely and replace each Operation with an async method on an actor to accomplish the same thing?

    No. Actors can ensure sequential execution of synchronous methods (those without await calls, and in those cases, you would not want an async qualifier on the method itself).

    But if your method is truly asynchronous, then, no, the actor will not ensure sequential execution. Actors are designed for reentrancy. See SE-0306 - Actors » Actor reentrancy.

  3. If you want subsequent network requests to await the completion of the authentication request, you could save the Task of the authentication request. Then subsequent requests could await that task:

    actor NetworkManager {
        let session: URLSession = ...
    
        var loginTask: Task<Bool, Error>?
    
        func login() async throws -> Bool {
            loginTask = Task { () -> Bool in
                let _ = try await loginNetworkRequest()
                return true
            }
            return try await loginTask!.value
        }
    
        func someOtherRequest(with value: String) async throws -> Foo {
            let isLoggedIn = try await loginTask?.value ?? false
            guard isLoggedIn else {
                throw URLError(.userAuthenticationRequired)
            }
            return try await foo(for: createRequest(with: value))
        }
    }
    
  4. If you are looking for general queue-like behavior, you can consider an AsyncChannel. For example, in https://stackoverflow.com/a/75730483/1271826, I create a AsyncChannel for URLs, write a loop that iterate through that channel, and perform downloads for each. Then, as I want to start a new download, I send a new URL to the channel.

  5. Perhaps this is unrelated, but if you are introducing async-await, I would advise against withCheckedContinuation. Obviously, if iOS 15 (or macOS 12) and later, I would use the new async URLSession methods. If you need to go back to iOS 13, for example, I would use withTaskCancellationHandler and withThrowingCheckedContinuation. See https://stackoverflow.com/a/70416311/1271826.

Rob
  • 415,655
  • 72
  • 787
  • 1,044