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 | import Foundation |
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 | import Foundation |
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 | func fetchImagesWithTaskGroup() async throws -> [UIImage] { |
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 | import Foundation |
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 | Main task started |
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 | Task(priority: .high) { |
The execution results are as follows:
1 | high <--- |
Yield the .high thread:
1 | Task(priority: .high) { |
Results of the:
1 | medium |
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