12

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)
            }
        }
    }
10
  • 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. Commented Jan 13, 2022 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. Commented Jan 13, 2022 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. Commented Jan 13, 2022 at 15:30
  • 3
    Yes as long as no code in the actor says await. As soon as it says that, the actor becomes reentrant. Commented Jan 13, 2022 at 17:00
  • 1
    If that's the issue, I'd say this is a duplicate of stackoverflow.com/questions/68686601/…. I would just be repeating my answer from there. Commented Jan 13, 2022 at 20:54

1 Answer 1

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.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.