Calling synchronous function with async let

I have two methods that I'd like to run in parallel. One is synchronous and the other asynchronous. This seems like an obvious target for an async let declaration like this one:

async let a = synchronous()
async let b = asynchronous()
await doSomething(a, b)

However, it isn't clear to me if I'm doing this correctly. Does this result in parallel execution of a function, even if it isn't an asynchronous function? Any reason I shouldn't be doing this?

Apologies if this is super obvious, but I can only find discussion of async let with asynchronous functions. Thanks!

The first async let is only a pretend one :slight_smile: It should not start a task.

So what you are doing should be harmless. However, I would like to hear from the experts though.

Run This Code
@main
struct AsyncLet {
    static func main () async {
        async let a = synchronous()
        print ("--> a:", await a)
        async let b = asynchronous()
        let c = await doSomething (a, b)
        print ("--> c:", c)
        print (c)
    }
}

func synchronous() -> Int {
    10
}

// 15 nano seconds
let fns = UInt64 (15_000_000_000)

func asynchronous() async -> Int {
    try! await Task.sleep (nanoseconds: fns)
    return 1000
}


func doSomething (_ u:Int, _ v: Int) async -> Int {
    await try! Task.sleep (nanoseconds: fns)
    return u + v
}

1 Like

If you want to compute a asynchronously you could wrap it in a task and gather the value:

async let a = Task { synchronous() }.value
2 Likes

I had considered wrapping the synchronous function in a Task, but I believe that when you do it that way, it isn't a child of the current task. I need to be able to cancel the containing task and have it cascade the cancellation to my two function calls.

Without more detail about the work you're performing, it's hard to recommend a cancellation approach, but you can use withTaskCancellationHandler to wrap async work such that it will be cancelled when the enclosing context is cancelled. However, unless your async work is also cancellable and you can connect that cancellation to the cancellation handler, it won't do what you want. In this example I create some long running but cancellable work by checking Task.isCancelled on an interval. In the output you can see that the async let with a cancellation handler is properly cancelled, while the other isn't.

@Sendable func someWork(id: String) {
    for count in 1...10 {
        if Task.isCancelled { print("\(id) cancelled"); return }
        print("\(id) sleep: \(count)")
        sleep(1)
    }
}

let task = Task {
    let one = Task { someWork(id: "one") }
    async let oneValue: Void = withTaskCancellationHandler {
        await one.value
        print("one complete")
    } onCancel: {
        one.cancel()
        print("one onCancel")
    }

    async let two: Void = Task { someWork(id: "two") }.value
    
    return await (oneValue, two)
}

task.cancel()
print("Cancelled")
_ = await task.value
print("Done")

Prints:

Cancelled
one onCancel
one cancelled
one complete
two sleep: 1
two sleep: 2
two sleep: 3
two sleep: 4
two sleep: 5
two sleep: 6
two sleep: 7
two sleep: 8
two sleep: 9
two sleep: 10
Done

To the best of my knowledge, any Task created with Task { .. } within the context of a greater task hierarchy is automatically a child of the Task that spawned it and is cancelled (if you check) when the parent is cancelled. It’s only Tasks created with Task.detached { .. } that won’t receive cancellation. But even if the parent is cancelled, your synchronous() method is going to complete execution unless you’re checking for cancellation within it with Task.isCancelled as done in Jon’s someWork(..) example.

Strikethrough thanks to clarification in reply by @John_McCall

Thanks to all for the help. I've been testing different scenarios to suss out how this works.

The async let syntax does seem to work with synchronous functions. Taking some inspiration from @Jon_Shier, I wrote the following:

let task = Task {
    async let a = run("a")
    async let b = run("b")
    await print(a, b, "done")
}
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 sec
task.cancel()
await print(task.value, "finished")

func run(_ name: String) -> String {
    for v in 0..<5 {
        if Task.isCancelled { return name }
        print(name, v)
        sleep(1)
    }
    return name
}

which outputs:

a 0
b 0
b 1
a 1
a 2
b 2
a b done
() finished

I profiled the code with the new swift concurrency template in Instruments and confirmed that the async let tasks are running in parallel.


I also tried wrapping the functions in a Task as suggested by @christopherweems. The code is identical to the above except the declarations of a and b:

async let a = Task { run("a") }.value
async let b = Task { run("b") }.value

Canceling task doesn't cancel the tasks wrapping a and b. Each task runs to completion. There's no parent/child relationship established when you declare a new Task.


The withTaskCancellationHandler was new to me, and could be handy for other purposes. Thanks @Jon_Shier! It seems to be extra boilerplate since the plain async let works fine, but I will definitely keep this in mind.

I believe this resolves the issue unless someone sees an error in my logic?

1 Like

Tasks created with Task are not child tasks of the current task, and changes to the current task like cancellation and priority escalation are not propagated to them. Task just copies more of the context of the current task than Task.detached does.

The only true child tasks (currently) are the tasks created by async let and TaskGroup.

5 Likes