I just read the blog of Meow God recently, and I feel that Swift asynchronous and concurrent programming is also a trend, so I quickly read it, and it is interesting, and I will record it. The following structured concurrency is recorded on meow God’s blog Swift structured concurrency. Here is my own version, you can jump directly to meow God’s blog to see the full version.

Structured concurrency

What is structuring

The word ‘structured’ is intrinsically loaded with good meaning: orderliness, sound logic and discipline. But structuration is not natural: in the early days of computer programming, assembly languages, even Fortran and Cobol, had only two basic control flows, sequential execution and jump, in order to better fit the actual way computers run. Using unconditional jumps (goTO statements) can cause your code to run haphazardly. After Dykstra’s “GOTO statement Is Bad”, the debate about whether structured programming should be used continued for some time. At this point in time, we can already see the triumph of structured programming: most modern programming languages no longer support the GOTO statement, or limit it to extremely restrictive conditions. Structured programming control flows based on conditional judgments (if), loops (for/while), and method calls have become absolutely mainstream

Goto statements

The GOTO statement is unstructured and allows control flow to jump unconditionally to a label. Although it seems that goTO statement has completely failed and is completely unpopular now, limited by the development of programming language, the design of control flow is more close to the actual execution in the early stage of program development, which is the main reason why GOTO statement is widely used. However, the drawback of Goto is quite obvious: unrestricted jumps can lead to a sharp decrease in code readability. If there is a Goto in the program, it is possible to jump to any part at any time, so the program is not a black box: the abstraction of the program is broken, and the methods you call don’t necessarily give you back control. In addition, jumping back and forth a lot often ends up as spaghetti code, which is every programmer’s nightmare when debugging.

Structured programming

After the concept of a code block, some basic encapsulation gave rise to new forms of control flow, including conditional statements, looping statements, and function calls that we use most often today. The programming paradigm they form is known as structured programming:

This not only makes the mental model of the code easier, but also makes it possible for the compiler to optimize at lower levels. If there is no GOTO in the code scope, then at the exit we can be sure that the local resources requested in the code block are no longer needed. This is critical to reclaiming resources (like closing files in Defer, disconnecting the network, or even automatically freeing memory).

Unstructured concurrency

However, just because a program is structured does not mean that concurrency is structured. Instead, Swift’s existing concurrency model faces problems similar to goto’s. Swift’s current concurrency means, the most common is to use the Dispatch library to Dispatch tasks and get results through callback functions:

func foo() -> Bool {
    bar(completion: { print($0) })
    baz(completion: { print($0) })
    return true
}
func bar(completion: @escaping (Int) -> Void) {
    DispatchQueue.global().async {
        // ...
        completion(1)
    }
}
func baz(completion: @escaping (Int) -> Void) {
    DispatchQueue.global().async {
    // ...
    async {
        // ...
        completion(2)
    }
}
Copy the code

Bar and Baz run tasks in a non-blocking manner through dispatch and report results through completion. But when we look at this control flow in terms of concurrency, there are pitfalls:

Our callback function was dispatched and then jumped, eventually “missing”. Even after some time, the dispatched operation was sent back to the closure through the callback function, but it had no information about the original caller (call stack, etc.), it was just a lonely jump.

The structured concurrency theory holds that this parallelism through distribution, through time or thread dislocation, actually achieves arbitrary jump. It’s just a “higher” form of the goto statement, no different in nature, with callbacks and closures masking its ugliness to some extent.

Unstructured concurrency faces a similar problem: once our concurrency framework allows the sending back pattern, we have this worry when we call any function:

  • Does this function produce a background task?

  • This function returns, but the background task it creates may still be running. When does it end, and what happens when it ends?

  • As the caller, where and how should I handle the callback?

  • Do I need to keep the resources used by this function? Do background tasks automatically hold these resources? Do I need to release them myself?

  • Can background tasks be managed, for example, if you want to cancel them?

  • Will the assigned tasks be assigned to other tasks? Will these other tasks be managed correctly? If the dispatched task is cancelled, will the second dispatched task also be cancelled correctly?

There are no general conventions for these answers, and no compiler or runtime guarantees. As with Goto, sending calls destroys concurrent black boxes.

So what exactly is structured concurrency?

In one sentence, it is necessary to ensure a single entry and exit of the control flow path even when concurrent operations are performed. A program can generate multiple control flows to achieve concurrency, but all concurrency paths should be in the completed (or cancelled) state at exit and merged together.

Structured concurrency: A task-based structured concurrency model

In Swift concurrent programming, structured concurrency needs to rely on asynchronous functions, which in turn must run in a task context, so it can be said that in order to carry out structured concurrency, task context is necessary. In fact, Swift structured concurrency is organized around tasks as a basic element.

Current Task Status

Swift concurrent programming abstracts asynchronous operations into tasks. In any asynchronous function, we always use withUnsafeCurrentTask to get and check the current task:

override func viewDidLoad() {
    super.viewDidLoad()
    withUnsafeCurrentTask { task in
        // 1
        print(task as Any) // => nil
    }
    Task {
        // 2
        await foo()
    }
}
func foo() async {
    withUnsafeCurrentTask { task in
        // 3
        if let task = task {
            // 4
            print("Cancelled: \(task.isCancelled)")
            // => Cancelled: false
            print(task.priority)
            // TaskPriority(rawValue: 33)
        } else {
            print("No task")
        }
    }
}
Copy the code
  1. WithUnsafeCurrentTask itself is not an asynchronous function; you can also use it in normal synchronous functions. If the current function is not running in any task context, that is, if there are no asynchronous functions in the call chain up to withUnsafeCurrentTask, the task will be nil.

  2. Using the Task initialization method, you can get a new Task environment. We’ve seen several ways to start tasks in the previous chapter.

  3. The call to Foo occurs in the scope of the Task closure from the previous step, and its runtime environment is the newly created Task.

  4. For a retrieved task, access its isCancelled and Priority properties and check its isCancelled and Priority properties to see if it has been cancelled and its current priority. We can even call cancel() to cancel the task.

Task level

Task { let t1 = Task { print("t1: \(Task.isCancelled)") } let t2 = Task { print("t2: \(task.iscancelled)")} t1.cancel() print("t: task.iscancelled ")} // t: false // t1: true // t2: false"Copy the code

In the above example, although T1 and T2 are newly generated and concurrent in the outer Task, there is no subordinate relationship between them and they are not structured. This can be seen from the fact that t: false precedes other outputs. T1 and T2 are executed after the end of the outer Task closure and escape, which is not consistent with structured concurrency bunching.

Creating structured concurrent tasks requires that the inner T1 and T2 have some kind of dependency to the outer Task. As you can guess, outer tasks as root nodes and inner tasks as leaf nodes can be used as tree data structures to describe the dependencies of each task and thus to build structured concurrency. This hierarchy is very similar to the View hierarchy in UI development.

By organizing the task hierarchy as a tree, we can capture the following useful features:

A task with its own priority and de-identification can have several sub-tasks (leaf nodes) and perform asynchronous functions in them.

When a parent task is cancelled, the cancellation flag of the parent task is set and passed down to all child tasks.

The child task reports the result up to the parent task, whether it completes properly or throws an error, and the parent task is not completed until all the child tasks complete properly or throw an error.

When the root node of a task exits, we ensure that all concurrent tasks have exited by waiting for all child nodes. The tree structure allows us to scale out more hierarchical child nodes from one child node to organize more complex tasks. The child node may have to follow the same rules, waiting for its second-level children to complete before it completes itself. In this way, all tasks in the tree are structured.

In Swift concurrency, there are two ways to create a leaf node in the task tree: through task groups or through the asynchronous binding syntax of async lets. Let’s look at some of the similarities and differences.

Task group

In the context of task execution, or more specifically, in an asynchronous function, we can add a structured set of subtasks to the current task with withTaskGroup:

struct TaskGroupSample { func start() async { print("Start") // 1 await withTaskGroup(of: Int.self) { group in for i in 0 .. < 3 { // 2 group.addTask { await work(i) } } print("Task added") // 4 for await result in group { print("Get result: \(result)") } // 5 print("Task ended") } print("End") } private func work(_ value: Int) async -> Int { // 3 print("Start work \(value)") await Task.sleep(UInt64(value) * NSEC_PER_SEC) print("Work \(value) done") return value } }Copy the code

Explain the number notation in the comments above. WithTaskGroup, you can start a new task group, whose full function signature is:

func withTaskGroup<ChildTaskResult, GroupResult>(
    of childTaskResultType: ChildTaskResult.Type,
    returning returnType: GroupResult.Type = GroupResult.self,
    body: (inout TaskGroup<ChildTaskResult>) async -> GroupResult
) async -> GroupResult
Copy the code
  1. This signature looks very complicated and a little scary, so let’s explain. ChildTaskResultType As the name suggests, we need to specify the return type of the subtasks. Subtasks within a TaskGroup can only have the same return type, to make the TaskGroup API easier to use and to satisfy the assumptions required by the strongly typed AsyncSequence protocol. Returning value types defined for the entire task group have default values that can be inferred and generally ignored. In the body argument you get an Inout decorated TaskGroup, which you can use to add structure to the current task context and issue subtasks.

  2. The addTask API adds a new task to the current task. The added task starts execution as soon as the scheduler obtains available resources. In this example, for… The three tasks in the IN loop are immediately added to the task group and executed.

  3. At the beginning of the actual work, we did a print output, which made it easier to observe the order of events.

  4. The group satisfies the AsyncSequence, so we can use the syntax for await to get the execution result of the subtask. When a task in the group completes, its results are put into the buffer of the asynchronous sequence. Whenever the group’s next is called, if there is a value in the buffer, the asynchronous sequence gives it as the next value; If the buffer is empty, wait for the next task to complete, which is standard behavior for asynchronous sequences.

  5. The end of “for await” means that the next method of the asynchronous sequence returns nil, the subtasks in the group have all been executed, and the closure of the withTaskGroup has come to the end. Next, the outer “End” is also printed. The entire structured concurrency completes execution.

The above code is called, and the output is:

Task {await TaskGroupSample().start()}  // Start // Task added // Start work 0 // Start work 1 // Start work 2 // Work 0 done // Get result: 0 // Work 1 done // Get result: 1 // Work 2 done // Get result: 2 // Task ended // EndCopy the code

The three asynchronous operations defined by Work execute concurrently, each running in its own subtask space. These subtasks begin to execute as soon as they are added and eventually come together when they leave the group scope. Using a diagram, we can see how this structured concurrency works:

An implicit wait

To get the result of the subtask, we use for await in the above example to explicitly wait for the group to complete. This explicitly satisfies the requirement of structured concurrency semantically: the subtasks end before the control flow reaches the bottom. A common question, however, is that the compiler doesn’t force us to write “for await” code. What happens if, for some reason, we forget to wait for the group because we don’t use these results? Will the task group continue to run and end without the original control flow being paused because there is no waiting? Does this violate the need for structured concurrency?

The good news is that even if we have no explicit await task group, the compiler automatically adds await for us when it detects the end of the structured concurrency scope and waits for all tasks to finish before continuing control flow. For example, in the code above, if we delete the “for await” part:

struct TaskGroupSample { func start() async { print("Start") // 1 await withTaskGroup(of: Int.self) { group in for i in 0 .. < 3 { // 2 group.addTask { await work(i) } } print("Task added") // 4 // for await result in group { // print("Get result: \(result)") //} // 5 print("Task ended") } print("End") } private func work(_ value: Int) async -> Int { // 3 print("Start work \(value)") await Task.sleep(UInt64(value) * NSEC_PER_SEC) print("Work \(value) done") return value } }Copy the code

The output will become:

// Start
// Task added
// Task ended
// Start work 0
// ...
// Work 2 done
// End
Copy the code

Although the output of “Task ended” seems to be earlier, the output of “End”, which represents the completion of the entire Task group, is still at the End, and must not happen until all subtasks have completed. For structured task groups, the compiler automatically generates await group code for us when we leave scope. The code above is equivalent to:

await withTaskGroup(of: Int.self) { group in for i in 0 .. < 3 {group. AddTask {await work(I)}} print("Task Added ") print("Task ended") for await _ in group {}}  print("End")Copy the code

It satisfies the single in and single out of structured concurrent control flow and controls the life cycle of subtasks within the scope of task group, which is also the main purpose of structured concurrent control. Even if we manually await part of the result in the group and then exit the asynchronous sequence, “structured concurrency still ensures that all subtasks are completed before the entire closure exits:

await withTaskGroup(of: Int.self) { group in for i in 0 .. < 3 { group.addTask { await work(i) } } print("Task added") for await result in group { print("Get result: \(result)") // Break} print("Task ended") // compiler automatically generated code await group.waitforall ()}Copy the code

Value capture for task group

Each subtask in a task group has a return value, and the Int returned by Work in the above example is the return value of the subtask. When for await a task group, you get the return value for each subtask. Task groups must be completed after all subtasks are completed, so we have the opportunity to “collate” the results of all subtasks and set a return value for the entire task group. For example, add up all the work results:

let v: Int = await withTaskGroup(of: Int.self) { group in var value = 0 for i in 0 .. < 3 { group.addTask { return await work(i) } } for await result in group { value += result } return value } print("End. Result: (v)")Copy the code

After each work subtask is completed, the result and value of the result will be accumulated. Running this code will output result 3.

A common error is to write value += result logic to addTask:

let v: Int = await withTaskGroup(of: Int.self) { group in var value = 0 for i in 0 .. < 3 {group.addtask {let result = await work(I) value += result return result}} // waitForAll subtasks to complete await group.waitforall ()  return value }Copy the code

Doing so causes a compilation error:

Mutation of captured var ‘value’ in partition-Executing code

When adding code to a task group through addTask, we must be aware that the code may run concurrently. The compiler can detect here that we have changed the state of a share in an obvious concurrency context. Unrestricted access from a concurrent environment is dangerous and can cause crashes. Thanks to structured concurrency, the compiler can now understand the difference in task context and discover it when statically checking, thereby fundamentally avoiding the memory risk here.

More strictly, even reading the var value is not allowed:

await withTaskGroup(of: Int.self) { group in var value = 0 for i in 0 .. < 3 { group.addTask { print("Value: (value)") return await work(i) } } }Copy the code

Will give the error:

Reference to captured var ‘value’ in Partition-Executing code

In the same way as changing a value, this access is not secure because the value can be changed while a concurrent operation is executed. We can avoid this error by changing the var value declaration to let value if we can ensure that the value of value will not be changed:

await withTaskGroup(of: Int.self) { group in
  // var value = 0
  let value = 0

  // ...
}
Copy the code

Or use the syntax of [value] to capture the current value. Since value is a value of a value type, it follows value semantics and is copied into the addTask closure for use. Access within a subtask closure will no longer use memory outside the closure to ensure safety:

await withTaskGroup(of: Int.self) { group in var value = 0 for i in 0 .. 0 group.addTask {[value] in let result = await work(I) print(" value: (value)") // value: 0 return result = 100 value = 100 //... }Copy the code

However, this static check will not work if we push value up to the member level of the class:

Class TaskGroupSample {var value = 0 func start() async {await withTaskGroup(of: Int.self) { group in for i in 0 .. < 3 {group. AddTask {// Print (" value: Value let result = await self.work(I) self.value += result return result}}} //... }}Copy the code

In Swift 5.5, although it compiles (and uses with almost no problems, especially when debugging locally), this behavior is wrong. Unlike Rust, Swift’s heap ownership model does not yet fully distinguish between borrow and move, so this data race and memory errors need to be addressed by developers themselves.

The Swift compiler is not unable to detect these errors; it simply tolerates them for the time being. Full compiler-level concurrent data security, including statically detecting the above errors, is the goal in future Swift releases. Swift now designs actor types to ensure data security when accessing shared data in a concurrent context.

Async let Asynchronous binding

In addition to task groups, async lets are another way to create structured and sub-tasks. WithTaskGroup provides a very “formal” way of creating structured concurrency: it explicitly delineates the role return of structured tasks, ensuring that every subtask generated inside the closure is awaited at the end of the group. By iterating over the group asynchronous sequence, we can process the results in the order in which the asynchronous tasks are completed. As long as certain usage conventions are followed, you can ensure that concurrency structures work correctly and benefit from them.

However, these advantages are sometimes also the disadvantages of withTaskGroup: every time we want to use withTaskGroup, we often need to follow the same template, including creating task groups, defining and adding subtasks, and waiting to complete with await, which is template code. And the requirement that the return value of all subtasks be of the same type also reduces flexibility or requires additional implementations (such as wrapping the return value of each task in a new type). The core of withTaskGroup is to generate a subtask and report its return value (or error) up to the parent task, which then aggregates the results of each subtask to end the current structured concurrency scope. This data flow pattern is very common, and if we could make it simpler, it would greatly simplify our use of structured concurrency. The async let syntax was created to simplify the use of structured concurrency.

The code in the withTaskGroup example, using async let, can be rewritten as follows:

func start() async {
  print("Start")
  async let v0 = work(0)
  async let v1 = work(1)
  async let v2 = work(2)
  print("Task added")

  let result = await v0 + v1 + v2
  print("Task ended")
  print("End. Result: (result)")
}
Copy the code

Async let is similar to let in that it defines a local constant and initializes it with an expression to the right of the equals sign. The difference is that the initialization expression must be a call to an asynchronous function, and by “binding” the asynchronous function to a constant value, Swift creates and executes the asynchronous function in a subtask that executes concurrently. After async let is assigned, the subtask starts to execute immediately. If you want to get the result of execution (that is, the return value of the subtask), you can await the assigned constant with await.

In the above example, we use a single await to wait for v0, v1, and v2 to complete. As with try, we only need to use an await for multiple expressions that need to be paused. Of course, we can also write the three expressions separately if we wish:

let result0 = await v0
let result1 = await v1
let result2 = await v2

let result = result0 + result1 + result2
Copy the code

It is important to note that although we are await here, it looks as if we are waiting for v0 to be evaluated before starting the pause of v1; And then I’m going to evaluate v1 and then I’m going to start v2. But in fact, when async lets, these subtasks begin to work together in a concurrent manner. In the example, work(n) takes n seconds to complete, so the above notation yields values for v0, v1, and v2 at 0, 1, and 2 seconds, rather than 0, 1, and 3 seconds (1 + 2 seconds).

Another question that arises from this is, what happens if we change the order of await? For example, would the following code bring a different timing:

let result1 = await v1
let result2 = await v2
let result0 = await v0

let result = result0 + result1 + result2
Copy the code

If the timing of the actual completion of each subtask is examined, the answer is no change: when async let creates the subtask, the task starts to execute, so the actual execution time of v0, V1 and V2 is still 0 seconds, 1 seconds and 2 seconds. However, the time when v0 is finally obtained with await is strictly after v2: when the v0 task is complete, its result will be temporarily stored on its own successor stack, waiting for the execution context to switch to itself via await. In other words, in the above example, after the async let is used to bind the task and start executing, await v1 will be completed in 1 second. After another 1 second, await v2 is finished; Then immediately after, await v0 will immediately return to result0 the result that was completed 2 seconds ago:

In this example, the final timing will be slightly different from the previous one, but this does not violate the rules of structured concurrency. And in most cases, it doesn’t affect the results and logic of concurrency. Both the aforementioned task groups and async lets generate subtasks that are structured. However, they are slightly different, and we’ll talk about that in a minute.

Implicit cancel

The compiler also does not force us to write wait statements like await v0 when using async let. Given the TaskGroup experience and Swift’s “secure by default” behavior, it’s not hard to guess that the compiler has done something for asynchronous bindings without await to ensure that the single-in, single-out structured concurrency still holds.

If there is no await, Swift concurrency implicitly cancels the bound subtask and then await it when the bound constant leaves scope. That is, for code like this:

func start() async {
  async let v0 = work(0)
    
  print("End")
}
Copy the code

It is equivalent to:

Func start() async {async let v0 = work(0) print("End") Void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void void voidCopy the code

The difference with the TaskGroup API is that the bound task will be cancelled before being await. This gives us additional opportunities to clean up or abort tasks that are not being used. However, this “hidden behavior” can cause a lot of confusion when asynchronous functions can be thrown. We have not yet covered the cancellation behavior of tasks and how to handle cancellations correctly. This is a relatively complex and separate topic, which we will focus on in detail in the next chapter. For now, just remember that, like Taskgroups, async lets satisfy structured concurrency requirements even without await.

Comparison task group

Since the same purpose is to write structured and concurrent programs, async lets are often compared to task groups. Semantically, the two paradigms are very similar, so it can be argued that async lets are just syntactic sugar for task group apis: task group apis are too cumbersome to use, while asynchronous bindings are much simpler syntactically.

But actually there are differences. Async lets cannot dynamically express the number of tasks, and the number of subtasks that can be generated must be determined at compile time. For example, for an array of inputs, we can start a number of subtasks with a TaskGroup, but we can’t rewrite this code with an async let:

func startAll(_ items: [Int]) async {
  await withTaskGroup(of: Int.self) { group in
    for item in items {
      group.addTask { await work(item) }
    }

    for await value in group {
      print("Value: (value)")
    }
  }
}
Copy the code

In addition to the above structured concurrent tasks that can only be created in one way or another, the difference between the task group API and the asynchronous binding API for interchangeable cases is that they provide two different styles of programming. A general rule of use is that if we need to be “serious” about defining the start of structured concurrency, the structure of concurrency becomes clearer by limiting it to a task group closure; If we just want to quickly start a few tasks concurrently and reduce the interference of other template code, then using async let for asynchronous binding makes the code cleaner and easier to read.

A combination of structured concurrency

On a single-level dimension withTaskGroup or a set of async lets used only once, it may be difficult to see the advantages of structured concurrency because the scheduling of tasks is still under control: It is perfectly possible to use traditional techniques, by adding semaphores, to “manually” control to ensure that concurrent tasks can eventually be merged together. However, as the system becomes more complex, there may be a need to perform task concurrency again in some concurrent subtasks. That is, form a multilevel subtask system. In this case, task management relying on raw semaphores becomes extremely complex. This is where the abstraction of structured concurrency really comes into its own.

By nesting withTaskGroup or Async let, you can flexibly build such multi-level concurrent tasks within a range that is easily understood by the average person. The simplest way to start a withTaskGroup is to add a task to the group:

Func start() async {// await withTaskGroup(of: Int. Self) {group in group. AddTask {// await withTaskGroup(of: Int.self) { innerGroup in innerGroup.addTask { await work(0) } innerGroup.addTask { await work(2) } return await innerGroup.reduce(0) { result, value in result + value } } } group.addTask { await work(1) } } print("End") }Copy the code

For the above useworkFor example, the function, one more layerinnerGroupThere is not much difference in execution: the three tasks are still executed in a structured and concurrent manner. However, this hierarchy gives us the opportunity to have more precise control over concurrent behavior. In a structured and concurrent task model, subtasks inherit from their parent tasksTask priorityAs well asTask local value; When handling a task cancellation, in addition to the parent task passing the cancellation to the child task, a throw in the child task also passes the cancellation upward. Whether we need to set up these behaviors precisely within a set of tasks, or simply for better readability, this approach of nesting more subdivided levels of tasks helps our goals.

Task local values are those that exist only in the context of the current task and are injected from outside. We will discuss this topic in a later chapter.

Using async lets is more tricky than nesting withTaskGroup. Async let assignment Accepts a call to an asynchronous function on the right-hand side of the equal sign. The asynchronous function can be a named function like work or an anonymous function. For example, the nested withTaskGroup example above, using async let, could simply be written as:

func start() async { async let v02: Int = { async let v0 = work(0) async let v2 = work(2) return await v0 + v2 }() async let v1 = work(1) _ = await v02 + v1  print("End") }Copy the code

Here, to the right of the v02 equals sign, is an anonymous asynchronous function closure call in which nested subtasks are started with two new Async lets. Note in particular that the writing in the above example is essentially different from the following await:

func start() async {
  async let v02: Int = {
    return await work(0) + work(2)
  }()

  // ...
}
Copy the code

Await work(0) + work(2) will execute work(0) and work(2) sequentially and add their results. The two operations are not executed concurrently and no new subtasks are involved.

Of course, we can also extract the two nested async lets into a named function, so that the call goes back to the familiar way:

func start() async {
  async let v02 = work02()
  //...
}

func work02() async -> Int {
  async let v0 = work(0)
  async let v2 = work(2)
  return await v0 + v2
}
Copy the code

Most of the time, it is better to extract parts of a subtask into named functions. For this simple example, however, it might be clearer to use the anonymous function directly, juxtaposing work(0) and work(2) with work(1) in another subtask.

Because both withTaskGroup and Async lets produce structured concurrent tasks, it is sometimes possible to mix them. For example, write a withTaskGroup on the right side of async let; Or bind new tasks with async let in group.addTask. However, this “static” approach to task generation is relatively easy to understand: as long as we can match the generated task hierarchy to the desired task hierarchy, there is no problem mixing the two.

Unstructured task

Taskgroup. addTask and Async let are the “only two” apis for creating structured concurrent tasks in Swift concurrency. They inherit attributes such as task priority from the current task running environment, create a new task environment for asynchronous operations to begin, and then add the new task as a subtask to the current task environment.

Task.init & Task.detached

Let’s take a look at task.init and task.detached to create a new Task and implement asynchronous functions init:

func start() async {
  Task {
    await work(1)
  }

  Task.detached {
    await work(2)
  }
  print("End")
}
Copy the code

These tasks have the highest flexibility, and they can be created anywhere. They generate a new task tree at the top level, are not subtasks of any other task, have a life cycle that is not bound to other scopes, and certainly have no feature of structured concurrency. Comparing the three, we can see the obvious differences between them:

  • TaskGroup.addTask 和 async let– Create structured subtasks that inherit priorities and local values.
  • Task.init– Create an unstructured task root node that inherits the running environment from the current task: actor isolation fields, priorities, local values, etc.
  • Task.detached– Creates an unstructured root node of the task, does not inherit the running environment such as priority and local value from the current task, and completely dissociates the task environment.

Detached There is a myth that when creating a root Task, we should try to use task.init instead of task.detached, which creates a completely “detached Task.” This is not entirely true, sometimes we want to inherit some facts from the current task environment, but sometimes we really want a “clean” task environment. The asynchronous program entry for the @main tag and the SwiftUI Task modifier, for example, use task.detached. Whether or not it is possible to inherit attributes from the current task environment, or whether or not they should be inherited, depends on a case-by-case basis.

We should avoid using unstructured tasks in the context of structured concurrency unless there is a specific reason why we want a task to be independent of the lifecycle of structured concurrency. This keeps structured task trees simple, rather than randomly generating new, unmanaged trees.

There are, however, situations where we tend to prefer unstructured concurrency, such as non-critical operations that don’t affect the rest of the asynchronous system. Writing a file to the cache after downloading it is a good example: we can end the core asynchronous behavior of “downloading” as soon as the download is complete and return the file to the caller at the same time the cache is started. Write caching, as a “by-pass” operation, should not be part of a structured task. A standalone task is more appropriate at this point.

summary

History has shown that abandoning the GOTO statement entirely in favor of structured programming helps us understand and write programs that control flow correctly. With the development of computers and the evolution of programming, we now come to another important point in time: should we completely use structured concurrency and abandon the original unstructured concurrency model? There’s a tendency to do that now, but everybody still has the same concurrency model. It may take some time, if at all.

Swift is currently one of the few languages that supports structured concurrency at the language and library level. Thanks to the secure default nature of the Swift language, as long as we follow a few simple rules (such as not passing out of closures, holding Task groups, etc.), we can write correct, secure and very understandable structured concurrent code. This provides an effective tool for simplifying concurrency. WithTaskGroup and Async let are equivalent in creating structured concurrency, but they are not completely interchangeable. Each has its own best case scenario and is slightly different in the details of implicit behavior that goes out of scope. Understanding these differences can help us choose the most appropriate tool for our task.

In this chapter we have only discussed the completion nature of structured concurrency: the parent task is not completed until all of its children are completed. This is only part of the story for structured concurrency, but the other big topic, task cancellation, is barely covered in this chapter. In the next chapter, we’ll explore the topic of task cancellation in more detail, which will give us a better understanding of the benefits of structured concurrency in simplifying the concurrent programming model.

Write in the last

Most of the content of this article is quoted from meowgod blog Swift structured and concurrent, and the final copyright belongs to the original author. If there is any infringement, please inform us.