This article is the fourth in the Swift New Concurrency Framework series, focusing on Structured Concurrency and Unstructured Tasks based on Task.

This series of articles takes a step-by-step look at what is involved in Swift’s new concurrency framework, as follows:

  • Swift new concurrency framework async/await

  • Actor for Swift’s new concurrency framework

  • Sendable for Swift’s new concurrency framework

  • Task for Swift’s new concurrency framework

This post is also published on my personal blog

Overview


The previous three articles covered async/await for synchronizing asynchronous code, the concurrency security model Actor, and Sendable for restricting the safe transfer of values in a concurrent environment.

They do not have the ability to provide “concurrency” in the strict sense, but rather provide some basic assistance for concurrency.

Task, the subject of this article, provides “concurrent execution” capabilities.

Task


A few key points:

  • The basic unit (” code block “) for performing tasks in a concurrent environment;

  • All async functions run inside the Task;

  • Tasks are higher-level abstractions over threads, and the system is responsible for scheduling tasks to be executed on appropriate threads.

Task has three states:

  • Suspended – Two conditions can cause a Task to be suspended:

    • Task is ready to wait for the system to allocate a thread of execution.
    • Wait for external events. For example, after the Task encounters suspension Point, it may enter the suspended state and wait for external events to wake up.

    Note that when asynchronous function (A) calls another asynchronous function (B) and the caller pauses, it does not mean that the whole Task is paused.

    From function A’s point of view, it pauses and waits for function B to return;

    But from the Task’s point of view, it doesn’t have to pause and may continue executing the called function B on it.

    Of course, tasks can also be suspended if the function being called is to be executed in a different concurrent context.

  • Running — Task is currently running on a thread until it is completed, or enters the suspended state when it encounters suspension point;

  • Completed – Task All work has been completed.

In summary, a Task is a high-level abstraction of a thread that performs a Task.

Task provides some high-level abstraction capabilities:

  • A Task can carry scheduling information, such as Task priority.

  • Task, as the Handle of the executing Task, can be used to cancel, etc.

  • Task can carry task-local data provided by users.

Structured concurrency


Concurrency is a kind of Structured concurrency.

Tasks can have parent-child relationships and form a “Task tree” :

A set of tasks can be better managed by parent-child relationships between tasks:

  • It is important that the life cycle of a child Task does not extend beyond the scope of the parent Task;

  • Cancel is more convenient (when a Task is cancelled, all its sub-tasks are also cancelled);

  • Error handling is more convenient. Unhandled errors are automatically propagated from child Task to parent Task.

  • The child Task inherits the priority of the parent Task by default.

  • The parent and child tasks share task-local data.

  • The parent Task can easily collect the results of its children.

That’s all there is to structured concurrency!

The details are discussed one by one below.

Currently, there are two ways to achieve structured concurrency:

  • Async let;

  • The Task group.

async let

1  // given: 
2  // func chopVegetables() async throws -> [Vegetables]
3  // func marinateMeat() async -> Meat
4  // func preheatOven(temperature: Int) async -> Oven
5  //
6  func makeDinner(a) async throws -> Meal {
7    async let veggies = chopVegetables()
8    async let meat = marinateMeat()
9    async let oven = preheatOven(temperature: 350)
10
11   let dish = Dish(ingredients: await [try veggies, meat])
12   return try await oven.cook(dish, duration: .hours(3))
13 }
Copy the code

Let’s take a look at some of the key points:

  • Instead of await calls to asynchronous functions, add async let (lines 7~8) to the left of the assignment expression and call it async let binding.

  • Use await when the result of async let expressions is needed, and if the result may throw an error, you need to handle the error (lines 11 to 12);

  • Async lets can only appear in asynchronous contexts (Task Closure, Async Function, and Async Closure).

The above examples are from: swift-evolution/ 017-async-let. md at main · Apple /swift-evolution · GitHub

The above is our intuitive feeling, and the implementation mechanism behind it is as follows:

  • System for eachasync letCreate a concurrent subtask;

  • Start executing subtasks immediately after they are created;

  • The child task continues the priority and task-local datas of the parent task.

Therefore, in the example above, three sub-tasks are created and sent to execute chopVegetables, marinateMeat and preheatOven respectively.

Implicit async let awaiting

Question: normal flow requires an await operation on an async let. What happens if you do not await the async let?

Does it cause subtasks to overflow? (Beyond the life of the parent task?)

The answer is no.

1  func makeDinner(a) async throws -> Meal {
2    async let veggies = chopVegetables()
3    async let meat = marinateMeat()
4    async let oven = preheatOven(temperature: 350)
5  }
Copy the code

The system would add implicit cancel, await:

1  func makeDinner(a) async throws -> Meal {
2    async let veggies = chopVegetables()
3    async let meat = marinateMeat()
4    async let oven = preheatOven(temperature: 350)
5    // implicitly: cancel veggies
6    // implicitly: cancel meat
7    // implicitly: cancel oven
8    // implicitly: await veggies
9    // implicitly: await meat
10   // implicitly: await oven
11 }
Copy the code

Let’s verify this conclusion with a simple example:

1   func noAwaitAsynclet(a) async {
2     print("begin noAwaitAsynclet")
3     try? await Task.sleep(nanoseconds: 1 _000_000_000)
4     Task.isCancelled ? print("noAwaitAsynclet is cancelled") : print("end noAwaitAsynclet")
5   }
6  
7   func testAsynclet(a) async {
8     let parentTask =
9     Task {
10      async let test = noAwaitAsynclet()
11    }
12    
13    await parentTask.value
14    print("parentTask finished!")
15  }
Copy the code

The output from calling the testAsynclet method:

begin noAwaitAsynclet
noAwaitAsynclet is cancelled
parentTask finished!
Copy the code

cancel

As mentioned earlier, in structured concurrency, the Cancel operation is passed from the parent task to all child tasks.

1   func noAwaitAsynclet(a) async {
2     print("begin noAwaitAsynclet")
3     try? await Task.sleep(nanoseconds: 1 _000_000_000)
4     Task.isCancelled ? print("noAwaitAsynclet is cancelled") : print("end noAwaitAsynclet")
5   }
6  
7   func testAsynclet(a) async {
8     let parentTask =
9     Task {
10      async let test = noAwaitAsynclet()
11      await test
12    }
13    
14    parentTask.cancel()
15    await parentTask.value
16    print("parentTask finished!")
17  }
Copy the code

A simple change to the previous example:

  • Line 11 adds await on test;

  • Line 14 performs cancel on parentTask.

The output:

begin noAwaitAsynclet
noAwaitAsynclet is cancelled
parentTask finished!
Copy the code

As you can see, the cancel operation on the parent task is passed to the Async let child task.

Task group

To get a sense of the Task group, rewrite the makeDinner with Task Group:

func makeDinner(a) async throws -> Meal {
  // Prepare some variables to receive results from our concurrent child tasks
  var veggies: [Vegetable]?
  var meat: Meat?
  var oven: Oven?

  enum CookingStep { 
    case veggies([Vegetable])
    case meat(Meat)
    case oven(Oven)}// Create a task group to scope the lifetime of our three child tasks
  try await withThrowingTaskGroup(of: CookingStep.self) { group in
    group.addTask {
      try await .veggies(chopVegetables())
    }
    group.addTask {
      await .meat(marinateMeat())
    }
    group.addTask {
      try await .oven(preheatOven(temperature: 350))}for try await finishedStep in group {
      switch finishedStep {
        case .veggies(let v): veggies = v
        case .meat(let m): meat = m
        case .oven(let o): oven = o
      }
    }
  }

  let dish = Dish(ingredients: [veggies!, meat!])
  return try await oven!.cook(dish, duration: .hours(3))}Copy the code

A few key points:

  • There is no public init method for Task groups. The Task group instance can only be obtained by using withTaskGroup or withThrowingTaskGroup methods.

  • The addTask method of a Task group can be used to create concurrent subtasks, and the number of subtasks can be dynamic.

  • All subtasks in the same group must have the same result type;

    The above example uses enum (CookingStep) to encapsulate the associated value so that the results of all subtasks are of the same type.

  • The life cycle of subtasks does not extend beyond the group life cycle;

    So when the group(withTaskGroup, withThrowingTaskGroup) method returns, it means that all subtasks have completed or cancel;

  • By for await… In traverses the running results of all subtasks.

    Note that the order of traversal is the order in which subtasks are completed, not the order in which subtasks are added.

  • When an error is thrown within the group (such as an exception thrown by a subtask), all outstanding subtasks are cancelled.

As follows, what if you do not explicitly wait for all subtasks to complete within the group?

try await withThrowingTaskGroup(of: CookingStep.self) { group in
  group.addTask {
    try await .veggies(chopVegetables())
  }
  group.addTask {
    await .meat(marinateMeat())
  }
  group.addTask {
    try await .oven(preheatOven(temperature: 350))}}Copy the code

The group still implicitly waits for all subtasks to complete before returning.

Note the difference with async let. As mentioned above, async let subtasks are cancelled and await.

async let vs. Task group

Async let and Task group belong to the category of structured concurrency, how to choose in daily development?

Basic principle: Use async let instead of Task group.

As can be seen from the two versions of the makeDinner method:

  • Async let is lighter and more intuitive;

  • Task groups require that all subtasks compute the same type of result, often requiring an additional layer of encapsulation, such as the CookingStep enumeration in makeDinner. Also, the Task Group interface is based on closure, which further complicates the code.

What can Task groups do that async lets can’t?

There are two main points:

  • async letThe number of subtasks created is static, whereas Task groups can create subtasks dynamically.

    As follows, the loadImages method creates a subtask for downloading images for each URL, the number of which is dynamically determined by the parameter urls:

    func loadImages(urls: [String]) async- > [Image] {
      await withTaskGroup(of: Image.self, body: { group in
        for url in urls {
          group.addTask {
            return await downloadImage(url: url)
          }
        }
    
        var images: [Image] = []
        for await image in group {
          images.append(image)
        }
    
        return images
      })
    }
    Copy the code
  • async letThe order of waiting for subtasks to complete is fixed, and the results cannot be obtained according to the order of subtasks to complete.

    As follows, whichever of the three subtasks is completed first, we must get veggiesValue first, meatValue second, and ovenValue last.

    1  func makeDinner(a) async throws -> Meal {
    2    async let veggies = chopVegetables()
    3    async let meat = marinateMeat()
    4    async let oven = preheatOven(temperature: 350)
    5    let veggiesValue = await veggies
    6    let meatValue = await meat
    7    let ovenValue = await oven
    8 }
    Copy the code

    Task groups get results in the order in which the subtasks are completed.

    How does that help?

    func fastestResponse(a) async -> Int {
      await withTaskGroup(of: Int.self, body: { group in
        group.addTask {
          let _ = await requestFromServer1()
          return 1
        }
    
        group.addTask {
          let _ = await requestFromServer2()
          return 2
        }
    
        return await group.next()!})}Copy the code

    As shown above, there are two servers with the same service deployed, and you need to determine which server is faster in response.

    The feature of returning subtasks in order of completion via Task Group is easy to implement.

summary

From the discussion above, we know that structured concurrency has many advantages.

The most important of these is that the life cycle of the child task does not extend beyond the parent task.

It makes it easy to:

  • Controls a set of tasks, such as cancel, in which all child tasks are cancelled whenever a parent task is cancelled;

    This is difficult to do if the child task has a longer lifetime than the parent task. Because the parent task may have ended by the time you need to perform Cancel.

  • Wait for a set of tasks to complete, just wait for the parent task to complete, because the parent task completion means that all the child tasks are completed;

  • Dependencies between groups of tasks can be easily achieved with async/await.

It often takes a lot of work to implement these requirements in traditional concurrency models.

Unstructured tasks


To put it simply, there is no parent-child relationship between tasks and no “Task tree”.

As we learned above, the most important feature of structured concurrency is that the life cycle of the child task does not exceed that of the parent task.

Unstructured tasks do not have this constraint.

Sometimes you just need to create a concurrent task, or create an asynchronous environment in a synchronous context in order to invoke asynchronous methods.

The above two main application scenarios are for unstructured tasks.

There are two ways to create an unstructured task:

  • Task.init

  • Task.detached

Task.init

@frozen public struct Task<Success.Failure> : Sendable where Success : Sendable.Failure : Error {}

extension Task where Failure= =Error {
  public init(priority: TaskPriority? = nil.operation: @escaping @Sendable(a)async throws -> Success)
}
Copy the code
let dinnerHandle = Task {
  try await makeDinner()
}

await dinnerHandle.value
dinnerHandle.cancel()
Copy the code

As shown above, task.init returns a Task handle (dinnerHandle) that can be used to retrieve the result of the Task execution or cancel the Task.

Context inheritance

Tasks created through task.init inherit important meta-information from the current context, such as:

  • Task priority;

  • Task – the local data;

  • The actor the isolation.

If task.init is called in an asynchronous context (meaning Task exists on the call chain) :

  • The newly created task inherits the priority of the current task.

  • Copy all task-local data of the current task.

  • Task closure becomes actor-Isolated if task.init is called from an actor method.

    As you can see from the task.init definition above, Task closure is decorated with Sendable.

    Sendable Closure cannot capture actor-Isolated properties, as described in Swift’s New Concurrency framework Sendable: Actor- Isolated Property ‘x’ can not be referenced from a Sendable closure.

    Task Closure is an exception because it is also actor-Isolated, so the following code does not report an error:

    public actor TestActor {
      var value: Int = 0
    
      func testTask(a) {
        Task {
          value = 1}}}Copy the code

If task.init is called in a synchronous context (there is no Task in the call chain) :

  • The runtime extrapolates reasonable priorities;

Task.detached

extension Task where Failure= =Never {
  public static func detached(priority: TaskPriority? = nil.operation: @escaping @Sendable(a)async -> Success) -> Task<Success.Failure>}Copy the code
let dinnerHandle = Task.detached {
  try await makeDinner()
}
Copy the code

Detached Tasks created with task.detached are completely context-independent, meaning they don’t inherit the priority, task-local data, or actor isolation of the current context.

summary


At this point, all four forms of task-based Task creation are covered.

There’s a summary of them in Explore Structured Concurrency in Swift-WWDC21:

Structured concurrency is a major step forward that will make it easier to code concurrency in the future!

The resources

Swift evolution/0296-async-await. Md at main · Apple /swift evolution · GitHub

Swift evolution/ 0317-Async-let. md at main · Apple /swift evolution · GitHub

Swift evolution/0302-concurrent-value-and-concurrent-closures. Md at Main · Apple/Swift Evolution · GitHub

Swift evolution/0337-support-incremental-migration-to-concurrency-checking. Md at main · Apple/Swift Evolution · GitHub

Swift-evolution /0304- Structured – Concurrency. Md at Main · Apple/Swift-Evolution · GitHub

Swift evolution/0306- Actors. Md at main · Apple/Swift Evolution · GitHub

Swift evolution/0337-support-incremental-migration-to-concurrency-checking. Md at main · Apple/Swift Evolution · GitHub

Understanding async/await in Swift • Andy Ibanez

Concurrency — The Swift Programming Language (Swift 5.6)

Connecting async/await to other Swift code | Swift by Sundell

Explore structured concurrency in Swift – WWDC21 – Videos – Apple Developer

Swift concurrency: Behind the scenes – WWDC21 – Videos – Apple Developer