03. Task

It will take about 10 minutes to finish reading this article.

1. Backgroud

async/await are used to write natural, efficient asynchronous code. However, async/await does not have concurrency capabilities on its own, but asynchronous functions (introduced with async) can abort the executing thread at any given pause point (marked by await), which is necessary for building highly concurrent systems. Swift 5.5 introduces task, which is designed to provide concurrency.

2. what exactly is Task?

A task is the basic unit of concurrency; each asynchronous function executes in a task. When a function makes an asynchronous call, the called function still runs as part of the same task (the caller waits for the function to return).

Similarly, when the function returns from an asynchronous call, the caller continues to run in the same task. So tasks are different from threads in that they are a higher abstraction above threads, and the system is responsible for scheduling the execution of a task on the appropriate thread.

2.1 “Task is to asynchronous functions as threads are to synchronous functions”
Synchronized Functions and Threads

In traditional synchronous functions, code is executed sequentially, with one function call waiting for its internal operations to complete before moving on to the next function call. This model tends to cause the program to block when performing time-consuming operations, as the execution of one function may prevent the execution of subsequent functions. To solve this problem, multithreaded programming can be used. A thread is the smallest unit of execution scheduled by the operating system that executes different tasks in parallel, thus improving the concurrency and responsiveness of the program.

Asynchronous functions and tasks

Asynchronous functions, unlike synchronous functions, allow the program to continue performing other operations without blocking while waiting for time-consuming operations to complete. This is accomplished by using the “await” keyword, which allows a function to suspend while waiting for an operation to complete, and then resume execution once the operation completes.

2.2 State of the Task

Task has 3 states:

(1) Suspended, suspended tasks are schedulable.
There are 2 situations that will cause the Task to be in a suspended state:
First, the Task is ready and waiting for the system to allocate execution threads;

Second, wait for external events. For example, after a Task encounters a suspension point, it may enter a suspended state and wait for external events to wake up.

(2) Running, the running task is currently running on a thread.
It will run until it returns from the initial function (completion) or reaches a pause point (pause).

(3) Completed, the task has no work to do and will never enter any other state.
Code can wait for tasks to complete in various ways, the main one being through await.

2.3 Some advanced uses of Task

Tasks carry scheduling information, such as the priority of the task.

Task is a handle through which operations such as cancellation and query can be performed.

Can carry user-supplied task-local data.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import Foundation

func performTask() {
let task = Task.detached(priority: .userInitiated) {
print("Task started with priority: \(Task.currentPriority)")

// Simulate a long-running task
for i in 1...5 {
if Task.isCancelled {
print("Task was cancelled")
return
}

print("Task processing step \(i)")
sleep(1)
}

print("Task completed")
}

// Cancel the task after 3 seconds
DispatchQueue.global().asyncAfter(deadline: .now() + 3) {
task.cancel()
}

// Wait for the task to complete or be cancelled
task.value?.waitForCancellation()
print("Task finished or cancelled")
}

performTask()

In this example, we demonstrate how to use task handles to implement tasks such as priority, cancellation, and query operations.

2.4 child task

Asynchronous functions can create child tasks. The child task inherits part of the structure of the parent task, including priority, but can run concurrently with the parent task. However, this concurrency has limits. This kind of parent-child relationship between Tasks has the following characteristics:

(1) The life cycle of the child Task will not exceed the scope of the parent Task (this is very important);

(2) When a Task is canceled, all its sub-Tasks will also be canceled;

(3) Unhandled errors will automatically be propagated from the child Task to the parent Task;

(4) The child Task will inherit the priority of the parent Task by default;

(5) Task-local data will be shared between parent and child Tasks;

(6) The parent Task can easily collect the results of the child Task.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import Foundation

func performParentTask() {
let parentTask = Task {
print("Parent task started")

let childTask1 = Task.detached {
print("Child task 1 started")
await Task.sleep(1_000_000_000) // Sleep for 1 second
print("Child task 1 completed")
}

let childTask2 = Task.detached {
print("Child task 2 started")
await Task.sleep(1_500_000_000) // Sleep for 1.5 seconds
print("Child task 2 completed")
}

await childTask1 // Wait for childTask1 to complete
await childTask2 // Wait for childTask2 to complete

print("Parent task completed")
}

parentTask.waitForAll()
}

performParentTask()

In this example we have a function called performParentTask which creates a main task parentTask. Inside the main task, we create two child tasks childTask1 and childTask2 using Task.detached. These two child tasks perform asynchronous sleep operations respectively to simulate time-consuming operations. We then use the await keyword to wait for the child task to complete.

2.5 Task groups and child tasks

Task groups define a scope within which new child-tasks can be created programmatically. Like all child-tasks, child-tasks in a task group scope must complete when that scope exits. If the scope throws an error on exit, the child-tasks will first be implicitly canceled.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func fetchImagesWithTaskGroup() async throws -> [UIImage] {
let urlStrings = [
"https://picsum.photos/300",
"https://picsum.photos/300",
"https://picsum.photos/300",
"https://picsum.photos/300",
"https://picsum.photos/300"
]

var images: [UIImage] = []

// Use a throwing task group to fetch images concurrently
try await withThrowingTaskGroup(of: UIImage.self) { group in
// Add tasks to the group
for urlString in urlStrings {
group.addTask {
try await self.fetchImage(urlString: urlString)
}
}

// Collect the images as they are fetched
for try await image in group {
images.append(image)
}
}

return images
}

2.6 Task.init and Task.detached

If you create a new task using the regular Task.init initializer, the work starts running immediately, inheriting the caller’s priority, any task local values, and its actor context.

However, tasks created through Task.detached are completely independent of the current context, that is, they will not inherit the priority, task-local data, and actor isolation of the current context. The Task.detached function returns a handle that you can use to cancel, query task status, etc.

Consider a scenario where you need to execute two tasks in the background, one using Task.init and the other using Task.detached, and observe their behavior. Here is sample code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import Foundation

func performTaskWithInit() async {
print("Task with Task.init started")
await Task.sleep(2_000_000_000) // Sleep for 2 seconds
print("Task with Task.init completed")
}

func performTaskDetached() async {
print("Detached task started")
await Task.sleep(1_000_000_000) // Sleep for 1 second
print("Detached task completed")
}

Task {
print("Main task started")

// Using Task.init
await performTaskWithInit()

// Using Task.detached
await Task.detached {
await performTaskDetached()
}

print("Main task completed")
}

// Sleep to allow tasks to finish before the program exits
sleep(5)

In this example, we have three tasks: the main task, the task created using Task.init, and the task created using Task.detached. The main task will run the performTaskWithInit function and execute a task in the background that takes 2 seconds. The task created using Task.detached then runs the performTaskDetached function and performs a task in the background that takes 1 second. The point is that the main task will wait for the completion of the performTaskWithInit function and will not wait for the task created using Task.detached. Therefore, the main task continues execution after performTaskWithInit completes without waiting for performTaskDetached to complete.

After running the example, you will see output like this:

1
2
3
4
5
6
Main task started
Task with Task.init started
Task with Task.init completed
Main task completed
Detached task started
Detached task completed

2.7 Task.yield

Task.yield() is used to actively pause the current task so that Swift can give other tasks a chance to continue when needed. Look at a sample code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Task(priority: .high) {
print("high")
}
Task(priority: .userInitiated) {
print("userInitiated")
}
Task(priority: .medium) {
print("medium")
}
Task(priority: .low) {
print("low")
}
Task(priority: .utility) {
print("utility")
}
Task(priority: .background) {
print("background")
}

The execution results are as follows:

1
2
3
4
5
6
high <---
userInitiated
medium
utility
low
background

Yield the .high thread:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Task(priority: .high) {
await Task.yield()
print("high")
}
Task(priority: .userInitiated) {
print("userInitiated")
}
Task(priority: .medium) {
print("medium")
}
Task(priority: .low) {
print("low")
}
Task(priority: .utility) {
print("utility")
}
Task(priority: .background) {
print("background")
}

Results of the:

1
2
3
4
5
6
medium
userInitiated
high <---
low
utility
background

Note:
Calling yield() does not always mean that the task will stop running: if the task has a higher priority than other waiting tasks, it is entirely possible for the task to resume work immediately.

Reference

[1] https://github.com/apple/swift-evolution/blob/main/proposals/0304-structured-concurrency.md
[2] https://juejin.cn/post/7084640887250092062