Daniel's working notes

Explore structured concurrency in Swift

The idea is based on structured programming. The easiest example is if-else statement where it only execute some block of code conditionally while moving from top to bottom.

The problem is asynchronous code with completion handlers or callback are easier to understand and we should handle all edge cases and errors by ourself.

Task: provides new async context for executing code concurrently and because it’s integrated with Swift, the compiler can help us prevent some concurrency bugs.

Structured concurrency is balance between flexibility and simplicity.

Screen Shot 2021-06-13 at 11.50.44.png

When function throw an error, the other child task will be cancelled then await for it to finish before exiting the function. In fact, the task is not really cancelled but mark the result is no longer needed. When child tasks are cancelled, all subtasks are automatically cancelled too.

The code should check for task cancellation explicitly so we should design concurrent code with cancellation in mind.

Screen Shot 2021-06-13 at 11.59.59.png

A task group is form of structured concurrency that is designed to provide dynamic amount of concurrency at the same time.

Screen Shot 2021-06-13 at 12.06.44.png

We can still wrote unstructured task using async {}. When we anotate a function that with @MainActor Swift will make sure the task is created on the main thread and stay in that thread. Trade off with this flexibility is we should manage cancellations and errors manually.

There’s also detached task where we can construct an async task which is not bound from originating scope. They’re independent and not constrained with the same actor from where it called. the example given is how we can cache fetched thumbnails into disk with async task in the background.

Screen Shot 2021-06-13 at 13.28.39.png

Async let task

The simplest form of task, with this we can assign a variable an async function that returns value: async let image = downloadImage(). This will create image placeholder and then run the task. when we want to use image we have to await it.

A parent task can spawn more than one child task and can only complete if all of its child task is complete. If error happens, parent task must immediately exit. Note that cancel the task doesn’t means it immediately stop the task. It means that the output is no longer needed and every task should handle cancellation.

We can check task cancellation with Task.checkCancellation()

Task is guaranteed to be finished in this state:

Group Task

This can be useful when we don’t know the amount of concurrency we need. For example if we want to fetch thumbnail from dictionary or array.

We can create task group using withThrowingTaskGroup function. This will give us a group object which we can create child task and allow them to throw errors. Task cannot outlive the scope of block where the group is defined.

Inside group.async {} we can use async-let and it will compose naturally.

func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    try await withThrowingTaskGroup(of: Void.self) { group in
        for id in ids {
            group.async {
                thumbnails[id] = try await fetchOneThumbnail(withID: id)
            }
        }
    }
    return thumbnails
}

In sample code above, Swift will give us error because dictionary is not able to handle one access at a time. With the increasing concurreny, data races is often happens.

For data race safety:

  • @Sendable closure
  • Cannot capture mutable variables
  • Should only capture value types, actors, or classes that implement their own synchronization
func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    try await withThrowingTaskGroup(of: (String, UIImage).self) { group in
        for id in ids {
            group.async {
                return (id, try await fetchOneThumbnail(withID: id))
            }
        }
        // Obtain results from the child tasks, sequentially, in order of completion.
        for try await (id, thumbnail) in group {
            thumbnails[id] = thumbnail
        }
    }
    return thumbnails
}

If type conform to AsyncSequence protocol, we can use for-await to iterate through them

Unstructured Tasks

tasks that need to launch from non-async contexts must be manually cancelled or awaited

Detached tasks

  • unscoped lifetime, we should manually cancelled and awaited
  • do not inherti anything from originating context. by default not constrained to the same actor and don’t have to run at the same priority as where they launched
  • run independently with generic defaults for things like priority.

Linked Notes: