Categories
iOS Swift

Task with @MainActor gotcha in Swift

While working on a codebase with a mix of structured concurrency and good old DispatchQueues I stumbled on something. Most of the time this would not be a problem, but in my case, it was important that events would happen in order. Let’s jump to an example for illustrating what I am talking about.

Firstly, let’s add an extension to Task which adds a static function for running operations on the main actor. Similar to the existing Task.detached. This made sense since I needed to push the Task to main actor in multiple places.

extension Task {
@discardableResult static func main(
operation: @escaping () async throws -> Success
) -> Task<Success, Failure> where Failure == any Error {
Task { @MainActor in
print(Thread.isMainThread) // logging purposes
return try await operation()
}
}
}

This extension was used in a class which observes state using non-async await code. If the state changed, it would trigger a closure.

final class Observer {
private var didChange: ((Contact) async throws -> Void)?
func start(didChange: @escaping (Contact) async -> Void) {
self.didChange = didChange
}
func simulateChange() {
DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(1)) { [self] in
let changed = Contact(name: "Toomas \((1…5).randomElement()!)")
Task.main {
print(Thread.isMainThread) // logging purposes
try await self.didChange?(changed)
}
}
}
}
view raw Observer.swift hosted with ❤ by GitHub

The idea is that when the state changes, we jump to the main actor from the beginning, since it will always end up updating state in a @MainActor annotated class. In this example, a view model.

@MainActor final class ViewModel: ObservableObject {
@Published private(set) var contact: Contact?
let observer: Observer
init() {
observer = Observer()
observer.start(didChange: { [weak self] contact in
print(Thread.isMainThread) // logging purposes
self?.contact = contact
})
}
func refresh() {
observer.simulateChange()
}
}
view raw ViewModel.swift hosted with ❤ by GitHub

Looks great, we use Task.main which calls the operation on the main actor which in turn sets the updated state to a main actor guarded view model. If I run the code and observe isMainThread print statements, then it prints: true, false, true. Here is where the gotcha is. I expected it to be true, true, true since I made sure Task uses @MainActor and also the view model is @MainActor. Here is the change I needed to do for getting the expected true, true, true in console log.

extension Task {
@discardableResult static func main(
// note the @MainActor
operation: @escaping @MainActor () async throws -> Success
) -> Task<Success, Failure> where Failure == any Error {
Task { @MainActor in
print(Thread.isMainThread) // logging purposes
return try await operation()
}
}
}

I was missing the @MainActor from the closure’s definition. What happened was that task was forced to main actor, but the operation closure did not specify any isolation, and Swift happily switched to the global executor instead.

If I rethink about what happened, then most of the time this would not be a problem, but in my specific case the timing was important. It was important that when we jump to the main actor, then no other code path should not change the state in the view model.

All in all, this is just something to keep in mind when mixing structured concurrency with DispatchQueues.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
Swift

A few examples of async await in Swift

Async await was added to Swift 5.5 and brought a new way how to write asynchronous code. Functions can be annotated with an async keyword, which enables them to be called with the await keyword. When an async function is being awaited, the execution of the current context is suspended and is resumed when the async function finishes. There are a lot more details in the SE-0296. In this blog post, we’ll take a look at a few examples of async await.

Wrapping completion handler based function

We can add a separate async functions for completion hander code. Which is nice since we do not reimplement everything we have using completion handlers. Take a look at a bit longer explanation in Adding async await to existing completion handler based code.

public func storeData(_ dataProvider: @escaping () -> Data?, identifier: Identifier = UUID().uuidString) async throws -> Identifier {
return try await withCheckedThrowingContinuation({ continuation in
self.storeData(dataProvider, identifier: identifier) { result in
continuation.resume(with: result)
}
})
}
Wrapping a completion handler function which uses the Result type.

Calling async function from non-async context

When using the await keyword then the current asynchronous context suspends until the awaited async function finishes. Suspension can only happen if we are in an asynchronous context. Therefore, a function without the async keyword can’t use await directly. Fortunately, we can create asynchronous contexts easily with a Task. One example of this is when we use MVVM in SwiftUI views and the view model has different methods we want to call when, for example, a user taps on a button

func refreshDocuments() {
Task(priority: .userInitiated) {
do {
self.documents = try await fetcher.fetchAllDocuments()
}
catch {
// Handle error
}
}
}
Creating a Task for enabling to use await on an async function.

Running tasks concurrently

Sometimes we have several independent tasks we need to complete. Let’s take an example when we want to preload messages for conversations. The code snippet below takes an array of conversations and then starts loading messages for each of the conversation. Since conversations are not related to each other, we can do this concurrently. This is what a TaskGroup enables us to do. We create a group and add tasks to it. Tasks in the group can run at the same time, which can be a time-saver.

func preloadMessages(for conversations: [Conversation]) async {
await withThrowingTaskGroup(of: Void.self) { taskGroup in
for conversation in conversations {
taskGroup.addTask {
try await self.preloadMessages(for: conversation)
}
}
}
}

Retrying a task with an exponential delay

This is especially related to networking code, where we might want to retry a couple of times before giving up and displaying an error. Additionally, we might want to wait before each request, and probably we want to wait a bit longer with each delay. Task has a static function detached(priority:operation:) so let’s create a similar retried() static function. In addition to priority and operation, we have arguments for defining how many times to retry and how much to delay, where the delay is increased exponentially with each retry. The first retry attempt is delayed by default 1 second, then the next 2 seconds, the third 4 seconds and so on. If the task happens to be cancelled while waiting, then the Task.sleep(nanoseconds:) throws CancellationError.

extension Task {
static func retried(times: Int, backoff: TimeInterval = 1.0, priority: TaskPriority? = nil, operation: @escaping @Sendable () async throws -> Success) -> Task where Failure == Error {
Task(priority: priority) {
for attempt in 0..<times {
do {
return try await operation()
}
catch {
let exponentialDelay = UInt64(backoff * pow(2.0, Double(attempt)) * 1_000_000_000)
try await Task<Never, Never>.sleep(nanoseconds: exponentialDelay)
continue
}
}
return try await operation()
}
}
}
Retrying a Task with an exponential delay.

self.documents = try await Task.retried(times: 3) {
try await self.fetcher.fetchAllDocuments() // returns [Documents]
}.value
Example of Task.retried() with accessing the returned value.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.