Can I create a Task but manually start it later in Swift Concurrency?

Hi all, I'm reading that when I create a Task, it is automatically started. What if I want to defer it until needed?

Prior to Swift Concurrency, my project was using PromiseKit to do such a thing. A Promise<T> stores a bunch operation which can either return T or "throw" an error, but does not execute the operation immediately until it's resolved. I can do something like this:

let promise = Promise<DataModel>(resolver: { seal in
    do {
        try DataService.requestServerForALargeJSON { json in
            do {
                let model = try DataModel.parseJSON(json)
                seal.fulfill(model)
            } catch {
                seal.reject(JSONSerializationError.badJSON)
            }
        }
    } catch {
        seal.reject(NetworkRequestError.failure)
    }
})

I wrapped a time consuming logic into a Promise object, but I don't need to execute it now. When the user triggers a specific UI action, I then "start" the promise:

promise.map { model in
    // success
}.catch { error in
    // error
}.finally {
    // always executed at the end
}

This is like creating a Task but lazily executing it afterwards. What's more, PromiseKit enables me to do more complex things about multiple promises like:

let promises = [promise1, promise2, promise3]
// some time afterwards
when(fulfilled: promises).then { results in
     // ...
}.catch { error in
    switch error {
        case MyError.case1:
        //…
        case MyError.case2:
        //…
    }
}

This is like creating a group of Task's but lazily execute them and handle when they all succeeded or when one of them failed.

Since I have migrated my networking module to use Swift Concurrency, I cannot write like this:

let promise = Promise<DataModel>(resolver: { seal in
    do {
        let json = try await DataService.aLargeJSONFromServer()
        let model = try await DataModel.asyncParseJSON(json)
        seal.fulfill(model)
    } catch {
        seal.reject(error)
    }
})

because I can't call asynchronous code from a synchronous context (the resolver closure). I can create the Task inside the closure but I still have to write the map, catch and finally which I believe still falls into the category of old-fashioned "completionHandler".

I wish I could do something like this:

// this asyncTask is not started immediately
let asyncTask = AsyncThrowingTask<DataModel> {
    let json = try await DataService.aLargeJSONFromServer()
    let model = try await DataModel.asyncParseJSON(json)
    return model
}

// later on...

do {
    let model = try await asyncTask.start()
} catch {
    // process error
}

Could anyone help me? I would like to eventually prune PromiseKit from my project, if possible.

I guess if you really needed to, you could pass an async closure around

// A dummy actor with an async function
actor FooService {
    static func fooFunction() async -> FooModel {
        return FooModel()
    }
}
// A dummy model
struct FooModel: Sendable {}

// This closure isn't executed immediately
let fooClosure: () async -> FooModel = {
    return await FooService.fooFunction()
}

And later on...

// Start execution of 'fooClosure'
await fooClosure()

However, with Swift Concurrency you can often avoid blindly passing blocks of code around, which may hurt your ability to reason locally about what the code is doing. For example here:

let model = try await asyncTask.start() // ⚠️ What does this closure do?

You don't know what that code is doing other than what the type signature tells you: that it takes no inputs and somehow spits out a DataModel. WWDC 21's Swift concurrency: Update a sample app talk covers a bit more in detail about this local reasoning stuff and why it's important.

Instead I'd try to rearrange the code in such a way that I could directly call the (async) function that retrieves the DataModel, so I can see what the async call is doing exactly (and even jump to the code that will be called and inspect it!).

let model = try await someObject.fetchModel()

Hi Andropov, thanks for your reply and input! :blush:

Your solution indeed wraps the operation in an async closure, but it has no knowledge of priority or cancellation.

What I'm trying to achieve is that I can leverage the ability Task provides:

  • I can either create a local Task using Task.init or a detached task using Task.detached, controlling the isolation domain.
  • I can get the handle of the Task and cancel it later if I want to.
  • If a parent Task internally creates child Task's, the child tasks are automatically cancelled when the parent Task is cancelled.

However, an async closure won't be able to do these sophisticated jobs.

One of my use cases is: I construct an async task when the user pulls down on a UITableView (then a spinning loader will show). After the user lifts their finger from the screen, the loader continues spinning while waiting for the data response. When a deserialized DataModel comes in, the table view restores its content offset and hides the loader. I need the cancellation ability so I can cancel the network request when the view controller is dismissed, so I don't need to do the JSON deserialization.

But anyways, thanks for the timely response! Have a good day! :grin:

The closure will inherit the priority of the call site. I would say this is what one would want most of the time. If not, you can wrap the async call with Task(priority:) { ...}.

Similarly, if you cancel the asynchronous context in which the closure is invoked, cancellation information is also available inside the async closure. For example:

let fooClosure: () async throws -> FooModel = {
    // Waiting a few seconds to mimic a network operation...
    try? await Task.sleep(for: .seconds(3))
    // You can check for cancellations inside closures as well, and
    // prevent further execution.
    guard !Task.isCancelled else {
        print("fooClosure cancelled!")
        throw CancellationError()
    }
    return await FooService.fooFunction()
}

// Task is executed immediately
let someTask = Task {
    try await fooClosure()
}
// And then cancelled
someTask.cancel()

If you execute the above in a playground, you'll see "fooClosure cancelled!" in the debugger, as the cancellation gets to execute before the Task.sleep inside the closure is finished.

But let's not get tangled in the details of the async closures. As I said, async closures are not how I would approach this, just a quick and dirty replacement for what I thought you were asking. PromiseKit approaches concurrency a bit differently than Swift Concurrency, so in many cases it'll be best to avoid trying to find a 1:1 mapping for what PromiseKit used to do (creating tasks without executing them) and instead take a step back and look at the bigger picture. Why do I need to create tasks but not call them immediately?

For example, I don't think anything in this use case requires a task to be created but not started. Here's a little SwiftUI view that can do that, with cancellation:

actor DataService {
    static func fetchLargeJSON() async -> String {
        try? await Task.sleep(for: .seconds(5))
        return "FooString"
    }
}

struct FooModel {
    let fooProperty: String
    static func parseJSON(json: String) async -> FooModel {
        return FooModel(fooProperty: "Done!")
    }
}

struct FooView: View {
    @State var model: FooModel?
    @State var fetchTask: Task<Void, Error>?
    
    var body: some View {
        VStack(spacing: 12) {
            // Spinner / data
            if fetchTask != nil {
                ProgressView()
            }
            Text(model?.fooProperty ?? "No data")
            // Fetch button (this could be triggered by pull to refresh)
            Button("Fetch data") {
                self.fetchTask = Task {
                    model = try? await refreshData()
                    fetchTask = nil
                }
            }
            .buttonStyle(BorderedProminentButtonStyle())
            .disabled(fetchTask != nil)
            // Cancel button (this could be triggered by leaving the view)
            Button("Cancel fetching task") {
                fetchTask?.cancel()
            }
            .disabled(fetchTask == nil)
        }
    }
    
    private func refreshData() async throws -> FooModel {
        let json = await DataService.fetchLargeJSON()
        // Check for cancellation...
        guard !Task.isCancelled else {
            print("refreshData() cancelled!")
            throw CancellationError()
        }
        let model = await FooModel.parseJSON(json: json)
        return model
    }
}
2 Likes

Thanks for the clarification. I initially thought the closure captures the context of the declaration site, rather than the call site.

This is exactly what I needed! Great! :partying_face:

After a weekend clearing the mind, I would say you are indeed correct. I tried to "not create a task and execute it later" but to rewrite the logic using naïve Swift and Swift Concurrency, and I succeeded. :exploding_head: I in fact can initiate the Task right when it's needed to execute. Later on, I found out that the PromiseKit involvement in many cases in my project is arguably unnecessary.

A million thanks for your kind demonstration! :smiling_face_with_three_hearts: SwiftUI is so much more concise than UIKit and I look forward to adopting it in my app progressively. For now I have so much work to do: using async-await, removing PromiseKit, migration to Swift6...

2 Likes