Make tasks in Swift concurrency run serially - swift

I've a document based application that uses a struct for its main data/model. As the model is a property of (a subclass of) NSDocument it needs to be accessed from the main thread. So far all good.
But some operations on the data can take quite a long time and I want to provide the user with a progress bar. And this is where to problems start. Especially when the user starts two operations from the GUI in quick succession.
If I run the operation on the model synchronously (or in a 'normal' Task {}) I get the correct serial behaviour, but the Main thread is blocked, hence I can't show a progress bar. (Option A)
If I run the operation on the model in a Task.detached {} closure I can update the progress bar, but depending on the run time of the operations on the model, the second action of the user might complete before the first operation, resulting in invalid/unexpected state of the model. This is due to the await statements needed in the detached task (I think). (Option B).
So I want a) to free up the main thread to update the GUI and b) make sure each task runs to full completion before another (queued) task starts. This would be quite possible using a background serial dispatch queue, but I'm trying to switch to the new Swift concurrency system, which is also used to perform any preparations before the model is accessed.
I tried using a global actor, as that seems to be some sort of serial background queue, but it also needs await statements. Although the likelihood of unexpected state in the model is reduced, it's still possible.
I've written some small code to demonstrate the problem:
The model:
struct Model {
var doneA = false
var doneB = false
mutating func updateA() {
Thread.sleep(forTimeInterval: 5)
doneA = true
}
mutating func updateB() {
Thread.sleep(forTimeInterval: 1)
doneB = true
}
}
And the document (leaving out standard NSDocument overrides):
#globalActor
struct ModelActor {
actor ActorType { }
static let shared: ActorType = ActorType()
}
class Document: NSDocument {
var model = Model() {
didSet {
Swift.print(model)
}
}
func update(model: Model) {
self.model = model
}
#ModelActor
func updateModel(with operation: (Model) -> Model) async {
var model = await self.model
model = operation(model)
await update(model: model)
}
#IBAction func operationA(_ sender: Any?) {
//Option A
// Task {
// Swift.print("Performing some A work...")
// self.model.updateA()
// }
//Option B
// Task.detached {
// Swift.print("Performing some A work...")
// var model = await self.model
// model.updateA()
// await self.update(model: model)
// }
//Option C
Task.detached {
Swift.print("Performing some A work...")
await self.updateModel { model in
var model = model
model.updateA()
return model
}
}
}
#IBAction func operationB(_ sender: Any?) {
//Option A
// Task {
// Swift.print("Performing some B work...")
// self.model.updateB()
// }
//Option B
// Task.detached {
// Swift.print("Performing some B work...")
// var model = await self.model
// model.updateB()
// await self.update(model: model)
// }
//Option C
Task.detached {
Swift.print("Performing some B work...")
await self.updateModel { model in
var model = model
model.updateB()
return model
}
}
}
}
Clicking 'Operation A' and then 'Operation B' should result in a model with two true's. But it doesn't always.
Is there a way to make sure that operation A completes before I get to operation B and have the Main thread available for GUI updates?
EDIT
Based on Rob's answer I came up with the following. I modified it this way because I can then wait on the created operation and report any error to the original caller. I thought it easier to comprehend what's happening by including all code inside a single update function, so I choose to go for a detached task instead of an actor. I also return the intermediate model from the task, as otherwise an old model might be used.
class Document {
func updateModel(operation: #escaping (Model) throws -> Model) async throws {
//Update the model in the background
let modelTask = Task.detached { [previousTask, model] () throws -> Model in
var model = model
//Check whether we're cancelled
try Task.checkCancellation()
//Check whether we need to wait on earlier task(s)
if let previousTask = previousTask {
//If the preceding task succeeds we use its model
do {
model = try await previousTask.value
} catch {
throw CancellationError()
}
}
return try operation(model)
}
previousTask = modelTask
defer { previousTask = nil } //Make sure a later task can always start if we throw
//Wait for the operation to finish and store the model
do {
self.model = try await modelTask.value
} catch {
if error is CancellationError { return }
else { throw error }
}
}
}
Call side:
#IBAction func operationA(_ sender: Any?) {
//Option D
Task {
do {
try await updateModel { model in
var model = model
model.updateA()
return model
}
} catch {
presentError(error)
}
}
}
It seems to do anything I need, which is queue'ing updates to a property on a document, which can be awaited for and have errors returned, much like if everything happened on the main thread.
The only drawback seems to be that on the call side the closure is very verbose due to the need to make the model a var and return it explicitly.

Obviously if your tasks do not have any await or other suspension points, you would just use an actor, and not make the method async, and it automatically will perform them sequentially.
But, when dealing with asynchronous actor methods, one must appreciate that actors are reentrant (see SE-0306: Actors - Actor Reentrancy). If you really are trying to a series of asynchronous tasks run serially, you will want to manually have each subsequent task await the prior one. E.g.,
actor Foo {
private var previousTask: Task<(), Error>?
func add(block: #Sendable #escaping () async throws -> Void) {
previousTask = Task { [previousTask] in
let _ = await previousTask?.result
return try await block()
}
}
}
There are two subtle aspects to the above:
I use the capture list of [previousTask] to make sure to get a copy of the prior task.
I perform await previousTask?.value inside the new task, not before it.
If you await prior to creating the new task, you have race, where if you launch three tasks, both the second and the third will await the first task, i.e. the third task is not awaiting the second one.
And, perhaps needless to say, because this is within an actor, it avoids the need for detached task, while keeping the main thread free.

Related

Run function after two functions has ran

So let's say I have these three functions:
func func_1() {
Task { #MainActor in
let state = try await api.get1State(v!)
print("cState func_1: \(state!)")
}
}
func func_2() {
Task { #MainActor in
let state = try await api.get2State(v!)
print("cState func_2: \(state!)")
}
}
func func_3() {
Task { #MainActor in
let state = try await api.get3State(v!)
print("cState func_3: \(state!)")
}
}
Since these function get info from api, it might take a few seconds.
How can I run func_3, after both func_1 and func_2 is done running?
I would advise avoiding Task (which opts out of structured concurrency, and loses all the benefits that entails) unless you absolutely have to. E.g., I generally try to limit Task to those cases where I are going from a non-asynchronous context to an asynchronous one. Where possible, I try to stay within structured concurrency.
As The Swift Programming Language: Concurrency says:
Tasks are arranged in a hierarchy. Each task in a task group has the same parent task, and each task can have child tasks. Because of the explicit relationship between tasks and task groups, this approach is called structured concurrency. Although you take on some of the responsibility for correctness, the explicit parent-child relationships between tasks lets Swift handle some behaviors like propagating cancellation for you, and lets Swift detect some errors at compile time.
And I would avoid creating functions (func_1, func_2, and func_3) that fetch a value and throw it away. You would presumably return the values.
If func_1 and func_2 return different types, you could use async let. E.g., if you're not running func_3 until the first two are done, perhaps it uses those values as inputs:
func runAll() async throws {
async let foo = try await func_1()
async let bar = try await func_2()
let baz = try await func_3(foo: foo, bar: bar)
}
func func_1() async throws -> Foo {
let foo = try await api.get1State(v!)
print("cState func_1: \(foo)")
return foo
}
func func_2() async throws -> Bar {
let bar = try await api.get2State(v!)
print("cState func_2: \(bar)")
return bar
}
func func_3(foo: Foo, bar: Bar) async throws -> Baz {
let baz = try await api.get3State(foo, bar)
print("cState func_3: \(baz)")
return baz
}
Representing that visually using “Points of Interest” tool in Instruments:
The other pattern, if func_1 and func_2 return the same type, is to use a task group:
func runAll() async throws {
let results = try await withThrowingTaskGroup(of: Foo.self) { group in
group.addTask { try await func_1() }
group.addTask { try await func_2() }
return try await group.reduce(into: [Foo]()) { $0.append($1) } // note, this will be in the order that they complete; we often use a dictionary instead
}
let baz = try await func_3(results)
}
func func_1() async throws -> Foo { ... }
func func_2() async throws -> Foo { ... }
func func_3(_ values: [Foo]) async throws -> Baz { ... }
There are lots of permutations of the pattern, so do not get lost in the details here. The basic idea is that (a) you want to stay within structured concurrency; and (b) use async let or TaskGroup for those tasks you want to run in parallel.
I hate to mention it, but for the sake of completeness, you can used Task and unstructured concurrency. From the same document I referenced above:
Unstructured Concurrency
In addition to the structured approaches to concurrency described in the previous sections, Swift also supports unstructured concurrency. Unlike tasks that are part of a task group, an unstructured task doesn’t have a parent task. You have complete flexibility to manage unstructured tasks in whatever way your program needs, but you’re also completely responsible for their correctness.
I would avoid this because you need to handle/capture the errors manually and is somewhat brittle, but you can return the Task objects, and await their respective result:
func func_1() -> Task<(), Error> {
Task { #MainActor [v] in
let state = try await api.get1State(v!)
print("cState func_1: \(state)")
}
}
func func_2() -> Task<(), Error> {
Task { #MainActor [v] in
let state = try await api.get2State(v!)
print("cState func_2: \(state)")
}
}
func func_3() -> Task<(), Error> {
Task { #MainActor [v] in
let state = try await api.get3State(v!)
print("cState func_3: \(state)")
}
}
func runAll() async throws {
let task1 = func_1()
let task2 = func_2()
let _ = await task1.result
let _ = await task2.result
let _ = await func_3().result
}
Note, I did not just await func_1().result directly, because you want the first two tasks to run concurrently. So launch those two tasks, save the Task objects, and then await their respective result before launching the third task.
But, again, your future self will probably thank you if you remain within the realm of structured concurrency.

Swift Concurrency async/await equivalent of a dispatch barrier / semaphore [duplicate]

I've a document based application that uses a struct for its main data/model. As the model is a property of (a subclass of) NSDocument it needs to be accessed from the main thread. So far all good.
But some operations on the data can take quite a long time and I want to provide the user with a progress bar. And this is where to problems start. Especially when the user starts two operations from the GUI in quick succession.
If I run the operation on the model synchronously (or in a 'normal' Task {}) I get the correct serial behaviour, but the Main thread is blocked, hence I can't show a progress bar. (Option A)
If I run the operation on the model in a Task.detached {} closure I can update the progress bar, but depending on the run time of the operations on the model, the second action of the user might complete before the first operation, resulting in invalid/unexpected state of the model. This is due to the await statements needed in the detached task (I think). (Option B).
So I want a) to free up the main thread to update the GUI and b) make sure each task runs to full completion before another (queued) task starts. This would be quite possible using a background serial dispatch queue, but I'm trying to switch to the new Swift concurrency system, which is also used to perform any preparations before the model is accessed.
I tried using a global actor, as that seems to be some sort of serial background queue, but it also needs await statements. Although the likelihood of unexpected state in the model is reduced, it's still possible.
I've written some small code to demonstrate the problem:
The model:
struct Model {
var doneA = false
var doneB = false
mutating func updateA() {
Thread.sleep(forTimeInterval: 5)
doneA = true
}
mutating func updateB() {
Thread.sleep(forTimeInterval: 1)
doneB = true
}
}
And the document (leaving out standard NSDocument overrides):
#globalActor
struct ModelActor {
actor ActorType { }
static let shared: ActorType = ActorType()
}
class Document: NSDocument {
var model = Model() {
didSet {
Swift.print(model)
}
}
func update(model: Model) {
self.model = model
}
#ModelActor
func updateModel(with operation: (Model) -> Model) async {
var model = await self.model
model = operation(model)
await update(model: model)
}
#IBAction func operationA(_ sender: Any?) {
//Option A
// Task {
// Swift.print("Performing some A work...")
// self.model.updateA()
// }
//Option B
// Task.detached {
// Swift.print("Performing some A work...")
// var model = await self.model
// model.updateA()
// await self.update(model: model)
// }
//Option C
Task.detached {
Swift.print("Performing some A work...")
await self.updateModel { model in
var model = model
model.updateA()
return model
}
}
}
#IBAction func operationB(_ sender: Any?) {
//Option A
// Task {
// Swift.print("Performing some B work...")
// self.model.updateB()
// }
//Option B
// Task.detached {
// Swift.print("Performing some B work...")
// var model = await self.model
// model.updateB()
// await self.update(model: model)
// }
//Option C
Task.detached {
Swift.print("Performing some B work...")
await self.updateModel { model in
var model = model
model.updateB()
return model
}
}
}
}
Clicking 'Operation A' and then 'Operation B' should result in a model with two true's. But it doesn't always.
Is there a way to make sure that operation A completes before I get to operation B and have the Main thread available for GUI updates?
EDIT
Based on Rob's answer I came up with the following. I modified it this way because I can then wait on the created operation and report any error to the original caller. I thought it easier to comprehend what's happening by including all code inside a single update function, so I choose to go for a detached task instead of an actor. I also return the intermediate model from the task, as otherwise an old model might be used.
class Document {
func updateModel(operation: #escaping (Model) throws -> Model) async throws {
//Update the model in the background
let modelTask = Task.detached { [previousTask, model] () throws -> Model in
var model = model
//Check whether we're cancelled
try Task.checkCancellation()
//Check whether we need to wait on earlier task(s)
if let previousTask = previousTask {
//If the preceding task succeeds we use its model
do {
model = try await previousTask.value
} catch {
throw CancellationError()
}
}
return try operation(model)
}
previousTask = modelTask
defer { previousTask = nil } //Make sure a later task can always start if we throw
//Wait for the operation to finish and store the model
do {
self.model = try await modelTask.value
} catch {
if error is CancellationError { return }
else { throw error }
}
}
}
Call side:
#IBAction func operationA(_ sender: Any?) {
//Option D
Task {
do {
try await updateModel { model in
var model = model
model.updateA()
return model
}
} catch {
presentError(error)
}
}
}
It seems to do anything I need, which is queue'ing updates to a property on a document, which can be awaited for and have errors returned, much like if everything happened on the main thread.
The only drawback seems to be that on the call side the closure is very verbose due to the need to make the model a var and return it explicitly.
Obviously if your tasks do not have any await or other suspension points, you would just use an actor, and not make the method async, and it automatically will perform them sequentially.
But, when dealing with asynchronous actor methods, one must appreciate that actors are reentrant (see SE-0306: Actors - Actor Reentrancy). If you really are trying to a series of asynchronous tasks run serially, you will want to manually have each subsequent task await the prior one. E.g.,
actor Foo {
private var previousTask: Task<(), Error>?
func add(block: #Sendable #escaping () async throws -> Void) {
previousTask = Task { [previousTask] in
let _ = await previousTask?.result
return try await block()
}
}
}
There are two subtle aspects to the above:
I use the capture list of [previousTask] to make sure to get a copy of the prior task.
I perform await previousTask?.value inside the new task, not before it.
If you await prior to creating the new task, you have race, where if you launch three tasks, both the second and the third will await the first task, i.e. the third task is not awaiting the second one.
And, perhaps needless to say, because this is within an actor, it avoids the need for detached task, while keeping the main thread free.

Unit testing a class that depends on an async function

I have a view model with a state property enum that has 3 cases.
protocol ServiceType {
func doSomething() async
}
#MainActor
final class ViewModel {
enum State {
case notLoaded
case loading
case loaded
}
private let service: ServiceType
var state: State = .notLoaded
init(service: ServiceType) {
self.service = service
}
func load() async {
state = .loading
await service.doSomething()
state = .loaded
}
}
I want to write a unit test that asserts that after load is called but before the async function returns, state == .loading .
If I was using completion handlers, I could create a spy that implements ServiceType, captures that completion handler but doesn't call it. If I was using combine I could use a schedular to control execution.
Is there an equivalent solution when using Swift's new concurrency model?
As you're injecting the depencency via a protocol, you're in a very good position for providing a Fake for that protocol, a fake which you have full control from the unit tests:
class ServiceFake: ServiceType {
var doSomethingReply: (CheckedContinuation<Void, Error>) -> Void = { _ in }
func doSomething() async {
// this creates a continuation, but does nothing with it
// as it waits for the owners to instruct how to proceed
await withCheckedContinuation { doSomethingReply($0) }
}
}
With the above in place, your unit tests are in full control: they know when/if doSomething was called, and can instruct how the function should respond.
final class ViewModelTests: XCTestCase {
func test_viewModelIsLoadingWhileDoSomethingSuspends() {
let serviceFake = ServiceFake()
let viewModel = ViewModel(service: serviceFake)
XCTAssertEquals(viewModel.state, .notLoaded)
let expectation = XCTestExpectation(description: "doSomething() was called")
// just fulfilling the expectation, because we ignore the continuation
// the execution of `load()` will not pass the `doSomething()` call
serviceFake.doSomethingReply = { _ in
expectation.fulfill()
}
Task {
viewModel.load()
}
wait(for: [expectation], timeout: 0.1)
XCTAssertEqual(viewModel.state, .loading)
}
}
The above test makes sure doSomething() is called, as you likely don't want to validate the view model state until you're sure the execution of load() reached the expected place - afterall, load() is called on a different thread, so we need an expectation to make sure the test properly waits until the thread execution reaches the expected point.
The above technique is very similar to a mock/stub, where the implementation is replaced with a unit-test provided one. You could even go further, and just have an async closure instead of a continuation-based one:
class ServiceFake: ServiceType {
var doSomethingReply: () async -> Void = { }
func doSomething() async {
doSomethingReply()
}
}
, and while this would give even greater control in the unit tests, it also pushes the burden of creating the continuations on those unit tests.
You can handle this the similar way you were handling for completion handler, you have the choice to either delay the completion of doSomething using Task.sleep(nanoseconds:) or you can use continuation to block the execution forever by not resuming it same as you are doing with completion handler.
So your mock ServiceType for the delay test scenario looks like:
struct HangingSevice: ServiceType {
func doSomething() async {
let seconds: UInt64 = 1 // Delay by seconds
try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
}
}
Or for the forever suspended scenario:
class HangingSevice: ServiceType {
private var continuation: CheckedContinuation<Void, Never>?
deinit {
continuation?.resume()
}
func doSomething() async {
let seconds: UInt64 = 1 // Delay by seconds
await withCheckedContinuation { continuation in
self.continuation?.resume()
self.continuation = continuation
}
}
}

How to test a method that contains Task Async/await in swift

Given the following method that contains a Task.
self.interactor is mocked.
func submitButtonPressed() {
Task {
await self.interactor?.fetchSections()
}
}
How can I write a test to verify that the fetchSections() was called from that method?!
My first thought was to use expectations and wait until it is fulfilled (in mock's code).
But is there any better way with the new async/await?
Ideally, as you imply, your interactor would be declared using a protocol so that you can substitute a mock for test purposes. You then consult the mock object to confirm that the desired method was called. In this way you properly confine the scope of the system under test to answer only the question "was this method called?"
As for the structure of the test method itself, yes, this is still asynchronous code and, as such, requires asynchronous testing. So using an expectation and waiting for it is correct. The fact that your app uses async/await to express asynchronousness does not magically change that! (You can decrease the verbosity of this by writing a utility method that creates a BOOL predicate expectation and waits for it.)
I don't know if you already find a solution to your question, but here is my contribution to other developers facing the same problem.
I was in the same situation as you, and I solved the problem by using Combine to notify the tested class that the method was called.
Let's say that we have this method to test:
func submitButtonPressed() {
Task {
await self.interactor?.fetchSections()
}
}
We should start by mocking the interaction:
import Combine
final class MockedInteractor: ObservableObject, SomeInteractorProtocol {
#Published private(set) var fetchSectionsIsCalled = false
func fetchSection async {
fetchSectionsIsCalled = true
// Do some other mocking if needed
}
}
Now that we have our mocked interactor we can start write unit test:
import XCTest
import Combine
#testable import YOUR_TARGET
class MyClassTest: XCTestCase {
var mockedInteractor: MockedInteractor!
var myClass: MyClass!
private var cancellable = Set<AnyCancellable>()
override func setUpWithError() throws {
mockedInteractor = .init()
// the interactor should be injected
myClass = .init(interactor: mockedInteractor)
}
override func tearDownWithError() throws {
mockedInteractor = nil
myClass = nil
}
func test_submitButtonPressed_should_callFetchSections_when_Always(){
//arrage
let methodCallExpectation = XCTestExpectation()
interactor.$fetchSectionsIsCalled
.sink { isCalled in
if isCalled {
methodCallExpectation.fulfill()
}
}
.store(in: &cancellable)
//acte
myClass.submitButtonPressed()
wait(for: [methodCallExpectation], timeout: 1)
//assert
XCTAssertTrue(interactor.fetchSectionsIsCalled)
}
There was one solution suggested here (#andy) involving injecting the Task. There's a way to do this by the func performing the task returning the Task and allows a test to await the value.
(I'm not crazy about changing a testable class to suit the test (returning the Task), but it allows to test async without NSPredicate or setting some arbitrary expectation time (which just smells)).
#discardableResult
func submitButtonPressed() -> Task<Void, Error> {
Task { // I'm allowed to omit the return here, but it's returning the Task
await self.interactor?.fetchSections()
}
}
// Test
func testSubmitButtonPressed() async throws {
let interactor = MockInteractor()
let task = manager.submitButtonPressed()
try await task.value
XCTAssertEqual(interactor.sections.count, 4)
}
I answered a similar question in this post: https://stackoverflow.com/a/73091753/2077405
Basically, given code defined like this:
class Owner{
let dataManager: DataManagerProtocol = DataManager()
var data: String? = nil
init(dataManager: DataManagerProtocol = DataManager()) {
self.dataManager = dataManager
}
func refresh() {
Task {
self.data = await dataManager.fetchData()
}
}
}
and the DataManagerProtocol is defined as:
protocol DataManagerProtocol {
func fetchData() async -> String
}
a mock/fake implementation can be defined:
class MockDataManager: DataManagerProtocol {
func fetchData() async -> String {
"testData"
}
}
Implementing the unit test should go like this:
...
func testRefreshFunctionFetchesDataAndPopulatesFields() {
let expectation = XCTestExpectation(
description: "Owner fetches data and updates properties."
)
let owner = Owner(mockDataManager: DataManagerProtocol())
// Verify initial state
XCTAssertNil(owner.data)
owner.refresh()
let asyncWaitDuration = 0.5 // <= could be even less than 0.5 seconds even
DispatchQueue.main.asyncAfter(deadline: .now() + asyncWaitDuration) {
// Verify state after
XCTAssertEqual(owner.data, "testData")
expectation.fulfill()
}
wait(for: [expectation], timeout: asyncWaitDuration)
}
...
Hope this makes sense?

How to execute a CPU-bound task in background using Swift Concurrency without blocking UI updates?

I have an ObservableObject which can do a CPU-bound heavy work:
import Foundation
import SwiftUI
#MainActor
final class Controller: ObservableObject {
#Published private(set) var isComputing: Bool = false
func compute() {
if isComputing { return }
Task {
heavyWork()
}
}
func heavyWork() {
isComputing = true
sleep(5)
isComputing = false
}
}
I use a Task to do the computation in background using the new concurrency features. This requires using the #MainActor attribute to ensure all UI updates (here tied to the isComputing property) are executed on the main actor.
I then have the following view which displays a counter and a button to launch the computation:
struct ContentView: View {
#StateObject private var controller: Controller
#State private var counter: Int = 0
init() {
_controller = StateObject(wrappedValue: Controller())
}
var body: some View {
VStack {
Text("Timer: \(counter)")
Button(controller.isComputing ? "Computing..." : "Compute") {
controller.compute()
}
.disabled(controller.isComputing)
}
.frame(width: 300, height: 200)
.task {
for _ in 0... {
try? await Task.sleep(nanoseconds: 1_000_000_000)
counter += 1
}
}
}
}
The problem is that the computation seems to block the entire UI: the counter freezes.
Why does the UI freeze and how to implement .compute() in such a way it does not block the UI updates?
What I tried
Making heavyWork and async method and scattering await Task.yield() every time a published property is updated seems to work but this is both cumbersome and error-prone. Also, it allows some UI updates but not between subsequent Task.yield() calls.
Removing the #MainActor attribute seems to work if we ignore the purple warnings saying that UI updates should be made on the main actor (not a valid solution though).
Edit 1
Thanks to the answer proposed by #Bradley I arrived to this solution which works as expected (and is very close to the usual DispatchQueue way):
#MainActor
final class Controller: ObservableObject {
#Published private(set) var isComputing: Bool = false
func compute() {
if isComputing { return }
Task.detached {
await MainActor.run {
self.isComputing = true
}
await self.heavyWork()
await MainActor.run {
self.isComputing = false
}
}
}
nonisolated func heavyWork() async {
sleep(5)
}
}
The issue is that heavyWork inherits the MainActor isolation from Controller, meaning that the work will be performed on the main thread.
This is because you have annotated Controller with #MainActor so all properties and methods on this class will, by default, inherit the MainActor isolation.
But also when you create a new Task { }, this inherits the current task's (i.e. the MainActor's) current priority and actor isolation–forcing heavyWork to run on the main actor/thread.
We need to ensure (1) that we run the heavy work at a lower priority, so the system is less likely to schedule it on the UI thread.
This also needs to be a detached task, which will prevent the default inheritance that Task { } performs.
We can do this by using Task.detached with a low priority (like .background or .low).
Then (2), we ensure that the heavyWork is nonisolated, so it will not inherit the #MainActor context from the Controller.
However, this does mean that you can no longer mutate any state on the Controller directly.
You can still read/modify the state of the actor if you await accesses to read operations or await calls to other methods on the actor that modify the state.
In this case, you would need to make the heavyWork an async function.
Then (3), we wait for the value to be computed using the value property returned by the "task handle".
This allows us to access a return value from the heavyWork function, if any.
#MainActor
final class Controller: ObservableObject {
#Published private(set) var isComputing: Bool = false
func compute() {
if isComputing { return }
Task {
isComputing = true
// (1) run detached at a non-UI priority
let work = Task.detached(priority: .low) {
self.heavyWork()
}
// (3) non-blocking wait for value
let result = await work.value
print("result on main thread", result)
isComputing = false
}
}
// (2) will not inherit #MainActor isolation
nonisolated func heavyWork() -> String {
sleep(5)
return "result of heavy work"
}
}
Maybe I have found an alternative, using actor hopping.
Using the same ContentView as above, we can move the heavyWork on a standard actor:
#MainActor
final class Controller: ObservableObject {
#Published private(set) var isComputing: Bool = false
func compute() {
if isComputing { return }
Task {
isComputing = true
print("Controller isMainThread: \(Thread.isMainThread)")
let result = await Worker().heavyWork()
print("result on main thread", result)
isComputing = false
}
}
}
actor Worker {
func heavyWork() -> String {
print("Worker isMainThread: \(Thread.isMainThread)")
sleep(5)
return "result of heavy work"
}
}
The function compute() is called on the main thread because of the MainActor's context inheritance but then, thanks to the actor hopping, the function heavyWork() is called on a different thread unblocking the UI.