Structured background child task for synchronous function

My program has a main actor isolated Task that wants to spawn a child Task: the child task should be cancelled when the parent task is cancelled, and it should run on a separate thread (so the UI remains responsive). I can achieve this with the following code:

import Foundation

@MainActor func main() {
    print(Thread.isMainThread)
    Task {
        await a()
    }
}

func a() async {
    print(Thread.isMainThread)
}

Task { @MainActor in
    main()
}
RunLoop.main.run()

Since a() is not doing any asynchronous work, I would prefer to remove the async keyword. But as soon as I do that, everything is run on the main thread. Using Task.detached would create a task that runs in a separate thread, but then it's not tied to the parent task anymore and cancelling the parent task doesn't automatically cancel the detached task.

A workaround would be to instead call a new async function that simply forwards to the asynchronous one:

func aAsync() async {
    a()
}

Is there a better solution?

Why don’t you like current approach with nonisolated async method? It naturally introduces concurrent work off main actor, I think it is perfect for the task.

I’m not sure on the Task propagating cancellation properly though, this is not a child task, just unstructured one. To have a child task you can use async let here.

This is not a thing. Only structured concurrency (async let, withTaskGroup) where you are required to wait for the subtask(s) to complete before returning from the parent function will propagate cancellation.

2 Likes

Because I need to call it in a synchronous context, too.

Thanks for pointing that out! Somehow I was convinced this was the same as creating child tasks in a task group.

Then you can either go with unstructured task and handle by yourself cancellation in someway, or provide two functions if you need to offload main thread in some cases. But you can try look at the case from another perspective – isolation. What I mean, is that if you need two variations – async and sync of the same function, I assume you can have it called from the main actor or from some other actor / no actor at all, and you want it be synchronous at this actor, but asynchronous on the main actor. You can try achieving this by global actor isolation as one of options, but this all now depends on many details what you want to achieve in the end.

In my other topic Returning non-Sendable object across actor boundaries via sending keyword I just learned that I can use async let in this case as well. Without a return type for the child task, it looks a bit ugly though:

@MainActor func main() {
    print(Thread.isMainThread)
    Task {
        async let _ = a()
    }
}

And if I wanted to await the child task before proceeding, I could write

async let a = a()
await a

with a compiler warning

Constant 'a' inferred to have type '()', which may be unexpected

If someone has a better solution, you're welcome to share it!

You still create unstructured task though. I think there is some important context missing in your examples. I’m not sure I fully understand what problem you are trying to solve.

Sorry if I was unclear. Yes, the Task spawned inside main() is unstructured, but the async let is a child task, isn't it? If I were to keep a reference to the task and cancel it, the async let would be cancelled as well... right?

If you keep the reference to the Task and manually cancel it, it doesn’t matter how exactly you run asynchronous work inside the task — you can use just await then. Using async let in your example changes nothing.

Do you actually need to run a() in a separate task from main() or are you only trying to unblock the main actor?

If the latter, you can simply await a() in main(). Since a is not isolated to the main actor, swift will automatically hop off the main actor so it wouldn't be free to execute UI work. Once a returns, main will be enqueued back on the main actor to continue execution there.

@MainActor func main() {
  // on main actor
  
  // a is async without a specific isolation so we will hop off the main actor here and to the global concurrent executor
  await a()

  // once a finishes we return to (enqueue on) the main actor here.
  // the main actor is free to execute other code while a is running
}

(There is currently some discussion around changing how this works exactly in the future but you could still get the same effect with slightly adjusted syntax.)

If you do need the separate task to run a in and want a structured subtask, then async let or TaskGroups are indeed the way to go. That wouldn't make much sense for the simplified example but if e.g. main wanted to start several pieces of work that could be executed concurrently, then you could start them using async let or TaskGroups and wait for them to finish.

If you were going to use async let to start multiple async operations you should probably be able to get rid of the Constant 'a' inferred to have type '()', which may be unexpected warning by explicitly specifying the type in this case:

async let a: () = a()

An async let that doesn't produce a value and is just used to await the end of an operation is probably somewhat rare and might be a mistake so the compiler just wants you to be explicit that this is actually intended and not a mistake.


Probably no longer relevant to your problem but I also added some discussion for how you could make a synchronous and call it from a (main actor) isolated Task and make sure it runs nonisolated.

Sidebar

As to your original question how a could be made synchronous while used from a Task, the issue there is that Task {} inherits the isolation of its context so in a @MainActor function the top level task code is main actor isolated as well and so a synchronous method call ends up on the main actor.

As you noted, Task.detached {} is one way to avoid that since Task.detached {} doesn't inherit the isolation context.

Generally, just keeping a async is probably the best solution since it currently (this could change in the future) ensures it will run on the global concurrent executor. Even if you don't do any other async work in the function, a being async still has value in that it allows us to switch executors when calling it. Only async calls can change executors, so we need an await somewhere to change the executor.

But if a really needs to be synchronous for some other reason, you could ensure it isn't run on the main actor as follows:

@MainActor
func onMainActor() {
  // option 1), create the task in a nonisolated function -> hop off the main actor here
  await spawnATask()

// option 2), keep the task main actor isolated but call the synchronous function indirectly via a nonisolated async function
// This is slightly worse than option 1) if we just want to call a since we need to start and end the task on the main actor unnecessarily but could be useful in some cases where we also need to do some main actor isolated work in the task anyway
  Task {
    // we inherit the main actor isolation here and start execution on the main actor

    // call an async function that isn't main actor isolated -> we hop off the main actor
    await asyncA()

    // and return to the main actor here...
  }
}

func spawnATask() async {
  // async function without specific isolation -> running on the global concurrent executor
  Task {
    // the task inherits the isolation -> also running on the global concurrent executor
    a()
  }
}

func asyncA() async {
  // async function without specific isolation -> we are running on the global concurrent executor
  a()
}

func a() {}

If Closure Isolation Control is proposed and accepted in the future, this would get a lot simpler:

@MainActor
func onMainActor() {
  // Use nonisolated to ensure the task doesn't inherit isolation and is instead directly enqueued on the global concurrent executor.
  Task { nonisolated in
    // starts execution on the global concurrent executor
    a()
  }
}

In my original sample code, as I learned, it does indeed change something: using Task as opposed to async let doesn't propagate a cancellation and doesn't run on another actor. I was just looking for a way to have a() synchronous and run it in off the main actor when needed, without having to declare a new async function whose only purpose is to call a(), so using async let a = a() solves that issue.

Right, I still have to get used to the fact that await unblocks the (main) actor if the called function is not isolated. This would indeed simplify my original code if I were to keep a() async.

1 Like

Ok, I got it now — you turn synchronous functions to be run as child task. That makes sense. I still have an impression that you actually have a different issue with isolations. If you get isolations right, you are more likely won’t have the issue in the first place.