Task hopping to Main

I want do run some stuff without blocking the main thread; also it needs to be cancelable.

So I did:

func doPeration(_ oper: Operation, useY: Bool = true)
{
	myTask = Task.detached(priority: .high)
	{  () -> () in	
				
		print("\(#function) → prepareValues isMainThread: \(Thread.isMainThread)")	 
		await self.prepareValues()
		...
		print("\(#function) do operation    isMainThread: \(Thread.isMainThread)")	 
		rTemp = await xx.frictorial()	
		...
		print("\(#function) → finalise      isMainThread: \(Thread.isMainThread)")	 
		await self.finalise(oper.rawValue, useY: useY) 
	}
}

This prints:

doPeration(_:useY:) → prepareValues isMainThread: false ← as expected
prepareValues(useY:) start          isMainThread: true
doPeration(_:useY:) do operation    isMainThread: true
frictorial() start                  isMainThread: true
doPeration(_:useY:) → finalise      isMainThread: false ← why suddenly off the main again?
finalise(_:useY:) start             isMainThread: true

The output is somehow random: sometimes my frictorial does not run on the main thread.

I really want (and did expect) that all stuff, which gets started in myTask, runs detached from the main thread.

What am I doing wrong? How can this be fixed?

Gerriet.

First of all, I believe you cannot control which thread to run an async operation from call site. Some of them are already isolated to specific actors (which each runs on its own thread). While others are non-actor-isolated, SE-0338 has already clarified that these executions will not preserve caller’s context, which means they could be scheduled globally and randomly.

But of course you have full control on all the sync pieces in your Task — by isolating it to a global actor! For example, if you want all the print()s to run on the main thread, simply add @MainActor after Task.detached {, which marks the task body to be isolated with MainActor, a built-in actor that ensures every task will run on the main thread.

What am I doing wrong? How can this be fixed?

TL;DR: Task does not give any guarantee what thread it uses. If you want it to run on main thread, you can use @MainActor (as @stevapple mentioned). If you want to use a background thread: use something else, e.g. a dispatch queue. (A custom executor would be the way to go with Task).


See the first note here: “Swift doesn’t make any guarantee about which thread that function will run on”

No guarantee must be read as: “Could be main, too”
“function” is to be read as PartialAsyncTask.

Thus, you should never have any busy wait in a task:

  • If you have actual work to do (number crunching): Don’t do it in a Task.
  • What you do might read data non-async from a disk? (Data(contentsOf:options:)) Don’t do it in a Task.
  • Want to parse data using a Decoder? Don’t do it in a Task (unless you know it is small).

Task.detached does not create a task that is detached from your current thread, it detaches it from your current task. If your current task is bound to a main thread executor, the detached task is not bound to that executor. But not being bound does not mean that it must not use a thread that is used by that executor.

I’d refer you to the documentation on Task which mentions that custom executors are the way to solve this but unfortunately, the chapter about Concurrency in the Swift Book does not mention executors.

Ole Begemann wrote about executors but also mentions that custom executors didn’t make it into Swift 5.5 (and, as it seems, 5.6).

You are right: MainThread or not is quite irrelevant for my problems.

The real question is: MainActor or not.

I was using a ViewController:

ViewController ← NSViewController ← @MainActor class NSResponder : NSObject

and so all my methods run on the MainActor and block the User Interface.

But this works fine for me:

func doPeration(_ oper: Operation)	//	function in ViewController
{
	Task	 
	{			
		operationTask = Task(priority: .high)
		{ [self] () -> (UInt64, TaskResult) in	
		
			... some serious number crunching, not using ViewController functions ...	
			... does not block the UI and can be cancelled

			try Task.checkCancellation()
		}

		guard let operationTask = operationTask else { return }
					
		let result = await operationTask.value	
	}		
}

By the way: is there something like: Actor.isMainActor, which returns true when running in the MainActor?

I believe your problem is still around the main thread. If a task is running on the main thread, it could possibly block the UI (which is actually another long-running task on the main thread). As @dhoepfl pointed out, a non-actor-isolated task is still possible to run on the main thread, but that’s rare and should have little impact on the UI.

Which executor is a task running on is determined (as of Swift 5.7). If it is actor-isolated, it should run on the serial executor of the Actor it’s isolated to. If it’s not, it should run on the global concurrent executor.


TL;DR: I strongly doubt that what you see (tasks “sticking to MainActor’s executor”) is caused by the implementation of Swift 5.5 and 5.6, which is now corrected by SE-0338.

In the current implementation, calls and returns from actor-isolated functions will continue running on that actor's executor. As a result, actors are effectively "sticky": once a task switches to an actor's executor, they will remain there until either the task suspends or it needs to run on a different actor. But if a task suspends within a non-actor-isolated function for a different reason than a call or return, it will generally resume on a non-actor executor.

This rule perhaps makes sense from the perspective of minimizing switches between executors, but it has several unfortunate consequences. It can lead to unexpected "overhang", where an actor's executor continues to be tied up long after it was last truly needed. An actor's executor can be surprisingly inherited by tasks created during this overhang, leading to unnecessary serialization and contention for the actor. It also becomes unclear how to properly isolate data in such a function: some data accesses may be safe because of the executor the function happens to run on dynamically, but it is unlikely that this is guaranteed by the system. All told, it is a very dynamic rule which interacts poorly with how the rest of concurrency is generally understood, both by Swift programmers and statically by the Swift implementation.

Thus I would suggest you try with Xcode 14 beta and see if there’s anything different. A plain Task.detached should be enough for offloading a task to background thread.