[Pitch #3] Structured Concurrency

Thanks everyone, I updated the proposal to clean up these issues below; I also have some replies further down:


In response to some other comments:

In order to do so, we would need to special case the type checker treatment of withGroup and group.add, to understand that their body closures are invoked only once, and that all add-ed child tasks complete by the end of the withGroup body. async let may be a bit of a special case too, but it also cuts down on the withGroup boilerplate, which a number of people pointed out is rather verbose for this common "pass a single value from a child task back to its parent" pattern. While we've discussed various "this closure runs only once" general annotations in the past, it is also less clear to me that the relationship between add and withGroup would be a generally useful thing for the compiler to understand, and it also isn't entirely static, because an add may fail if a group has already been canceled.

Relative to Kotlin, I think the main difference in our model is that tasks are more strongly isolated, and that async functions are already strongly tied to executors via actors, so the task-executor relationship is more ephemeral. If a task touches no global or actor-bound state, then it generally doesn't matter what executor it runs on, and it will automatically hop to the appropriate actor executor if it does access shared state. Therefore, by default, I think it makes sense to avoid unnecessarily bottlenecking parallelism for the system by starting most new tasks in the global concurrent pool; they should automatically switch to more restricted executors when they need to.

This is an interesting idea; perhaps the add method could optionally take a cancelOnGroupEnd: argument.

3 Likes

I recall reading something about structured concurrency being intended to be usable orthogonally to actors. This sounds a little contrary to that; more like that programmers need to immediately learn about actors as well. All for the better, I suppose.

I do worry about ergonomics a little, though. Perhaps the global actors pitch would be the better place for this, but alas we don't have one yet. In a @MainActor class, and I do foresee a lot of these in UIKit apps to come, I am going to want to launch some tasks to handle async processes, but when I write Task.runDetached, the code inside will not be confined to @MainActor, and then I would end up with a lot of

@MainActor
class Thing {
    func startTask() {
        // code here is isolated to MainActor
        Task.runDetached {
            // code here is not isolated to MainActor
            await self.taskBody()
        }
        // code here is again isolated to MainActor
    }
    private func taskBody() async {
        // code here is isolated to MainActor, but this function
        // only really exists to get back inside MainActor
    }
}

We had discussed allowing global actor annotations on closures too for that sort of thing, which would let you write:

@MainActor
class Thing {
    func startTask() {
        // code here is isolated to MainActor
        Task.runDetached { @MainActor in
            // code here is still isolated to MainActor
            ...
        }
        // code here is again isolated to MainActor
    }
}

However, even without the annotation, in many cases you'd end up getting kicked over to @MainActor when your taskBody eventually does the thing that makes it main actor bound to begin with.

Thanks for the proposal updates! I think this issue is still in the updated proposal:

Should the code in that section be?

func eat(mealHandle: Task.Handle<Meal, Error>) {
      let meal = try await mealHandle.get() // not just mealHandle()
      meal.eat() // yum
}

I think Konrad already fixed that one. In the latest update of the proposal it uses .get().

2 Likes

I see it now - thanks!

I know that naming discussions are discouraged here for well known reasons, but I am not sure if “task” is a good name corresponding to “thread” in the synchronous world as it somehow invokes a closed instead of open ended world. I can see a word like “trail” to work better here.

Another way we can manage single-value child tasks, without going all the way to custom syntax, would be to provide a wrapper around single-child-task groups. A task group with a single child can be used like a scoped future, since next-ing or exiting the scope of the group awaits the completion of the child in the same way that Handle.get() does for a detached task, but without compromising the hierarchical invariants of child tasks in structured concurrency. So we could have an API that looks something like this:

// async let child = childWork()
withChildTask {
  childWork()
} in: { childHandle in 
  // parentWork(try await child)
  parentWork(try await childHandle.get())
}

which can be implemented in terms of the Group API something like this:

`ChildTask` implementation ``` public class ChildTask { private let group: Task.Group private var result: Result? internal init(group: Task.Group) { self.group = group self.result = nil } public func get() async throws -> T { // ...assert that group is still valid if let result = self.result { return try result.get() } else { do { let value = group.next()! self.result = .success(value) return value } catch { self.result = .error(error) throw error } } } } public func withChildTask(child: () async throws -> T, body: (ChildTask) async throws) async throws -> Void { Task.withGroup { group in group.add(child) body(ChildTask(group: group)) } } ```

This still has "pyramid of doom" issues if you have more than one child task to deal with at a time, but it does at least take only one level of indentation compared to two for manually forming a Group. I personally think that the async let sugar is still worth it, though this would still be a nice way of describing how async let desugars.

1 Like

Could the ChildTask (or Task or Task.Handle) be a property wrapper?

@propertyWrapper
public final class ChildTask<T> {
  public init(wrappedValue: @escaping @autoclosure () async throws -> T)
  public var wrappedValue: T { get async throws }
}

(The initializer might also take priority and deadline parameters, with default arguments.)

It's an interesting idea, though I think in order to define the group scope, we'd need a way for the property wrapper to capture the rest of the scope as a closure or something like that.

A special #group literal expression could refer to an implicit Task.Group for the current scope.

 public init(
   wrappedValue: @escaping @autoclosure () async throws -> T,
+  group: Task.Group = #group
 )

Review Scheduled

Hi everyone – thanks for participating in the pitch discussion. The review for this proposal is scheduled to run starting this Thursday through the 16th March.

5 Likes

Maybe I'm misunderstanding this code, but shouldn't this eat function mentioned in the pitch be declared as async throws?

func eat(mealHandle: Task.Handle<Meal, Error>) {
  let meal = try await mealHandle.get()
  meal.eat() // yum
}
1 Like

Thanks for spotting this - yes it's an omission / typo, fixed here: [structured-concurrency] add missing async throws by ktoso · Pull Request #1292 · apple/swift-evolution · GitHub

Thanks!

Hi,

(Reposing this which I mistakenly posted to the discussion for an older pitch, as it appears relevant to the latest pitch).

I'm struggling to determine the appropriate way to enable a UI-based application to manage async tasks that may complete in any order.

Suppose I have a list view for which the app wants to asynchronously fetch an associated image for each currently visible row in the list view.

Traditionally, I might do something like this (ignoring the possibility of errors for brevity):

for let row in /* list of rows for visible list view items */ {
imageService.fetchImage(url: row.imageURL) {
imageData in
// update appropriate list view item 'row' imageData
}
}

Now whatever order each image fetches complete, the respective list item image will be updated.

Supposing I then decide to switch to using structured concurrency, I might imagine that imageService could/should be wrapped as an actor. Say I have this in variable imageServiceActor.

Now, however, it isn't clear how the UI-based code would interact with such an actor such that each image could be displayed once its (async) fetch has completed.

Looking at the option of child tasks (Task.withGroup), it looks like the caller (e.g. of imageServiceActor.fetchSomeImages) would need to have the actor "call back" to the UI thread as each child image fetch completes.

Looking at theoption of detached tasks (Task.runDetached), it looks like the caller (e.g. of imageServiceActor.fetchSomeImages) would need to receive back from the actor an array of task handles or similar?

Which leads to some questions:

(1) What is the likely recommended pattern for migrating UI-based code that currently benefits from "any-order" completion of async network requests, to using actors?

(2) If child tasks are proposed: how can/should an actor "call back" the UI thread in this kind of situation?

(3) If detached tasks are proposed: how can a caller that receives an array of task handles "poll" the handles similar to a task group's 'next' function?

I appreciate that structured concurrency is intended to allow for async patterns for a variety of environments (including servers on many-core systems), and we may hope that in future, ARC overheads can be avoided/reduced significantly in actor-based systems so that Swift will be more compelling for building server applications.

However a huge number of Swift apps are UI-based client applications, and updating the pitches/proposals to explicitly cover expected/recommended patterns for actor usage with UI-based apps (that currently benefit from "out of order" completion of async requests), seems to me to be extremely important.