Swift Concurrency Series 04 | Task usage boundaries
Task is not the universal entrance to asynchronous code. What really matters is who creates it, who cancels it, and who is responsible for the results.
The most common bad habit that many teams develop when they first encounter Swift Concurrency at scale is abusing Task.
Because it’s so convenient.
You cannot directly await in the button callback, then write a Task. You cannot directly await in the UIKit proxy method, then write a Task. If you want to sneak into the asynchronous world in a certain synchronization method, the easiest way is to write Task.
Over time, Task will change from “a gateway that bridges synchronization and asynchronousness” to “a layer of concurrency tape covering any problem.”
What this article really wants to answer are three questions closer to engineering:
- What type of problem does it solve?
- Under what circumstances is it a natural selection, and under what circumstances is it just hiding structural problems.
- What questions should you ask yourself first when deciding to open a
Task.
1. First, let’s make the positioning clear: Task is the creation point of asynchronous tasks, not a process design tool.
When I first saw Task {}, what I thought of was “asynchronous execution of a piece of code”.
This understanding is not wrong, but it is not enough.
More precisely, what Task does is:
- Create a new concurrent task
- Put a piece of code into an asynchronous context
- Bind responsibilities such as execution, cancellation, priority, results, etc. to this task
So Task is never just about “throwing the code into the background and running it”.
Once I wrote it down, I actually made several decisions at the same time:
- This piece of work now begins to stand on its own
- It may end later than the current call point
- It may or may not be canceled
- Its result is either consumed or discarded
- It establishes a certain relationship with the current object, current page, and current user action
This also shows that Task cannot be understood only grammatically.
Syntactically it is just a code block, but engineeringly it means “the task life cycle is created”.
2. The most suitable scenario for Task: it is really necessary to move from the synchronous world to the asynchronous world
The most natural usage scenario of Task is actually very simple:
The current context is not
async, but an asynchronous process needs to be triggered.
Such as these types of situations.
1. User interaction callback
Button("保存") {
Task {
await viewModel.save()
}
}
It is reasonable to use Task here, because the action of Button itself is not async, but the saving action is obviously an asynchronous process.
2. UIKit/AppKit’s synchronization proxy method
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
Task {
await viewModel.search(keyword: searchText)
}
}
The proxy callback signature is determined by the framework, not whether it can be changed to async. To enter an asynchronous process, a bridging point is required.
3. Application life cycle or notification callback
NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
Task {
await refreshIfNeeded()
}
}
The value of Task here is still the same: convert a synchronous event into an asynchronous task.
If you look at these examples together, you will find one thing in common:
- Events come from sync API
- Business processing is expected to be asynchronous
Taskis just the entrance, not the main body
At this time Task is a good tool.
3. The real danger: Treat Task as a repair method of “fix whatever error is reported”
The most common problem in the team is “using it too naturally”.
The most typical wrong postures are as follows.
1. If the compiler does not allow await, it will include a layer of Task
func refresh() {
Task {
await loadData()
}
}
This code may not be wrong when viewed in isolation. The problem is that in many cases, refresh() itself can be designed as async, and then the upper layer decides when to call it.
Once the default thinking becomes “can’t await, then open Task”, you will lose control of the task boundaries.
2. Already in the async function, we need to add another Task
func loadPage() async {
Task {
await loadUser()
}
Task {
await loadFeed()
}
}
The problem with this type of code is that it breaks up the control flow that originally belongs within a function.
You will encounter several problems immediately:
- Who guarantees the ending order of these two tasks.
- How to handle failures uniformly.
- How does the caller know when the entire
loadPage()is actually completed. - If the outer task is canceled, will the two subtasks stop together?
If the intention is to execute in parallel, it is usually clearer to write it as async let or task group, rather than creating two additional opaque Task.
3. When encountering status competition, I want to “shift the peak” by opening a few more Task
Some code will be written like this:
Task {
self.loading = true
}
Task {
let data = await service.fetch()
self.items = data
}
Task {
self.loading = false
}
On the surface it looks like taking things apart, but in fact it leaves state consistency directly to luck.
I don’t know if the third task will be completed before the second one, and I don’t know in which state the interface will stop when cancellation occurs.
The root cause of this kind of problem is usually that the state stream that should be connected is broken up.
4. To judge whether you should open a Task, ask these four questions first
This is the most useful set of engineering checklists. Much more useful than memorizing grammar.
1. Who created this task?
Is it created by a button click? Created when the page appears? Created during ViewModel initialization? Or was it secretly created by a service layer?
If the answer to “who created it” is unclear, it will be nearly impossible to figure out “who should cancel it” later on.
2. Who owns this task?
If the mission is just fire-and-forget, that usually means no one is actually managing it.
But many businesses are not suitable for fire-and-forget.
For example, search, paging, saving, uploading, and polling, these tasks should often be explicitly held by an object:
final class SearchViewModel: ObservableObject {
private var searchTask: Task<Void, Never>?
func updateKeyword(_ keyword: String) {
searchTask?.cancel()
searchTask = Task {
await search(keyword)
}
}
}
What’s really valuable here is:
-There is only one entrance for similar tasks
- When new tasks appear, old tasks will be canceled
- The task belongs to
SearchViewModel
Task that does not “own” will usually become a ghost mission later on.
3. If the user leaves the page, should it continue running?
This issue is particularly important because it directly determines where the task life cycle should be bound.
For example:
- Page above the fold request: Users usually do not have to continue after leaving the page
- Order submission: may need to continue to be completed even if the page is closed
- Image prefetching: the priority can be very low, and it should be canceled when leaving the page
Different types of tasks have completely different designs.
Without answering this question first, it’s easy to write all tasks like this:
Task {
await doSomething()
}
On the surface they are unified, but in fact the semantics are completely messed up.
4. Who will consume the results of this task?
The results of some tasks will be written back to the UI, the results of some tasks will need to update the cache, and some tasks will just be reported.
When results don’t have a clear destination, two types of bad smells usually arise:
- The task was opened, but no one answered the error
- The task was completed, but no one used it
So I’m not a big fan of unbridled fire-and-forget. Most business tasks are not “just send them out”.
5. When already in the async world, give priority to structured concurrency instead of creating additional Task
This is a point that many articles fail to address truthfully.
Already in the async function, it means that you already have asynchronous control flow. Writing additional Task at this time is often bypassing the constraints given by structured concurrency.
Look at the two comparisons.
Mistake tendency: Use multiple Task hard demolitions in parallel
func loadDashboard() async {
let userTask = Task { await api.loadUser() }
let statsTask = Task { await api.loadStats() }
let noticesTask = Task { await api.loadNotices() }
let user = await userTask.value
let stats = await statsTask.value
let notices = await noticesTask.value
self.state = .loaded(user, stats, notices)
}
This code is not entirely wrong, but it is not explicit enough. Because what the caller sees is “I actively created three tasks” instead of “There are three parallel dependencies here”.
Better expression: async let
func loadDashboard() async throws {
async let user = api.loadUser()
async let stats = api.loadStats()
async let notices = api.loadNotices()
self.state = try .loaded(user: user, stats: stats, notices: notices)
}
The advantage of this type of writing is that the semantics are clearer:
- These jobs belong to the current function
- They are chained to the same structure as the current call
- The current function will wait for the result before ending
- When the outer layer is canceled, the inner concurrency is also canceled.
In other words, the difference between Task and structured concurrency lies in who is responsible for the life cycle.
6. The most common disaster at the page level: open one Task for each entrance
Taking a very real list page as an example, there are usually these trigger points:
- First entry page loading
- Pull down to refresh
- Search keyword changes
- Switch filters
- Click “Retry”
- Turn to the next page to automatically load more
If each entry is written by itself:
Task {
await load()
}
After one or two iterations, the page will most likely have these phenomena:
- Multiple requests fly out at the same time
- Old results overwrite new results
- Obviously the latest keyword is
swift, but the interface shows the results ofswi - After the user exits the page, the callback is still writing
loading,isRefreshing,errorfight with each other
A common situation at this stage is to mistakenly think that what you are encountering is “complex concurrency”.
In fact, the problem is more specific: **The task entrance is too scattered, and there is no unified closing of status changes. **
A more stable approach is usually to concentrate “what tasks to open” into a state object, rather than letting the view layer create new tasks everywhere.
For example:
@MainActor
final class ArticlesViewModel: ObservableObject {
@Published private(set) var state: ViewState = .idle
private var reloadTask: Task<Void, Never>?
func reload() {
reloadTask?.cancel()
reloadTask = Task {
state = .loading
do {
let articles = try await repository.fetchArticles()
guard !Task.isCancelled else { return }
state = .loaded(articles)
} catch is CancellationError {
// 忽略取消
} catch {
state = .failed(error)
}
}
}
}
There are three problems that this code really solves:
- There is a unique entrance for similar tasks
- The substitution relationship between similar tasks is clear -Status writeback in one place
Task is still used here, but it is already a “controlled entrance to task management”.
7. When is it appropriate to hold Task reference and when is it not necessary?
This is also a signal to judge the maturity of the code.
Suitable for scenarios where references are held
- Search input anti-shake
- Page refresh task
- Requests that can be triggered repeatedly and new tasks should replace old tasks
- Polling, listening, and long-running synchronization processes
Because these scenarios naturally involve cancellation or replacement.
It is not necessary to hold the referenced scene
- Short tasks that users only do once after clicking once
- It is clearly the burying point, log and cache cleaning of fire-and-forget
- Tasks whose life cycle has been managed by the outer framework for the team
The focus is not on “whether or not it must be more advanced”, but on whether the task is correctly managed.
If a task may be canceled, replaced, or may affect user visibility, then it most likely should not be set off as anonymous fireworks.
8. Task.detached is a stronger isolation statement
Although this article mainly talks about Task, many teams will soon further use Task.detached after randomly opening Task.
Here’s a quick reminder:
Task {}will inherit part of the current contextTask.detached {}is more like "separate from the current context and run independently"Therefore, if the attribution and cancellation of ordinaryTaskare not straightened out,detachedshould not be used to enlarge the degree of freedom.
Many Task.detached end up being an escape from responsibility.
9. A practical judgment criterion: Are you creating tasks or escaping modeling?
This is the most commonly asked question in my reviews.
When ready to write:
Task {
...
}
Stop for two seconds and ask yourself:
- Am I creating a task with a clear life cycle?
- Or is it just because it is too troublesome to change it to
async, so it is temporarily covered with a layer? - Do I know when it ends, who cancels it, and to whom it ends?
If you can’t answer these questions, in most cases the current level of abstraction has not been straightened out.
10. Conclusion: Task is worth using frequently, but not worth using casually.
Task is certainly important in Swift Concurrency and is often used frequently.
But its correct value is:
- Safely enter the asynchronous process at the synchronous entrance
- Explicitly create tasks when an independent life cycle is required
- Provide clear concurrency boundaries when cancellation, replacement, and isolation are needed
So I prefer to understand it this way:
Taskis an explicit declaration of the task life cycle.
When used as a statement, the code becomes clearer and clearer. When using it as a patch, sooner or later the code will become “every layer can run, but no one can tell why it runs like this”.
读完之后,下一步看什么
如果还想继续了解,可以从下面几个方向接着读。