[Concurrency] Structured concurrency

There is a goal that creating a child task via a nursery will be somewhat responsive to system load, which is why it can suspend. We’ll have to see how successful that is.

2 Likes

I'm hoping that most of the needs for nurseries can be captured in standard library functions, like concurrent variants of forEach, map, filter, reduce, etc.

I imagine these would cover most of users' needs, so they won't need to use nurseries directly.

5 Likes

Can't you accomplish the same exact thing using Combine?

I.e.:

func chopVegetables() -> AnyPublisher<[Vegetable], VegetableChoppingError>

etc.

Why do we need yet another API that does the same thing? I noticed your API does not strongly type errors like Combine does, which is something that we'd ideally want. (I guess it could be accomplished using the Result type like:

func chopVegetables() async -> Result<[Vegetable], VegetableChoppingError>

But I am curious why you think we need a new API for async rather than just using Combine or equivalent things like ReactiveSwift, RxSwift etc.

PS—Maybe an alternate proposal would be, "Open source Combine and add it to Swift." :D

While frameworks such as Rx and constructs such as futures can be used to cover a common subset of scenarios, they have different design goals.

Rx, and Combine, provide a way to react to streams of events. They are not task-oriented. Futures, and parts of the concurrency proposals, are intended to deal with tasks. Inherently one-off processes that return a result. Yes, the process may be initiated multiple times, but the results of each are independent. They don't form a time-dependent stream to which some handler will react to each event in turn.

Of course you have one-off streams, such as Just, and you have dependent operations in a task framework. And that increases the subset of scenarios where either could be used. However, the goals and focus are still distinct.

7 Likes

A lot of great stuff in this proposal. There is a lot to digest, so I think for now I'll focus on questions and comments about Deadline and leave some other things for later.

  • The proposal uses a comparison on a Deadline (Task.currentDeadline().remaining > duration), but it's not clear what protocols Deadline actually conforms with (or what it's full API is).
  • One of the challenges with deadlines as a timeout in practice is dealing with the different ideas of how clocks advance. Sometimes the desire is monotonically increasing, sometimes it is wall clock time (which of course can change). Sometimes it stops when a computer is asleep, sometimes it does not. Given the ambition of Swift to be appropriate for low level and high level programming, how will this stdlib deadline type allow for expression of these different kinds of times?
  • Providing API like .seconds(X) or .minutes(Y) is usually great, but we have to be careful not to go too far up this scale -- .days(Z) could be actively wrong in the case of daylight saving transitions.
  • On Darwin, the currency types for the concepts in the proposal are Date and TimeInterval. The proposal even shows an API which takes a Duration as an argument, but this is not a value that a developer will likely have on hand. It would have to be converted from one of the other time types we have, which is unfortunate.
  • Are any math operations available on Deadline, like addition or subtraction? Do we have the right numeric protocols already to express a timeline without also allowing for a potentially nonsense operation like multiplying two deadlines?
  • We went through an effort a few years ago to make sure all time-related API in the Darwin SDKs came with a tolerance parameter. It is really important to get additional context from a developer about how critical/exact the time value needs to be. This allows the scheduler to group timeouts together and wake up the CPU less frequently.
10 Likes

Also, if I wanted to unit test the behaviour of my code when a deadline is passed, how do I do that?

5 Likes

I think it might be better if the stdlib provides a basic cancellation facility, and build deadline on top of it. It's likely that there are codes that want wall-clock deadline (for user-facing interface) as well as ones that want cpu-time deadline (for codes that are costed by literal cpu time).

2 Likes

Hi Tony,
about the time types — they’re all TBD.

We need to have a wider discussion with various teams including Foundation and Dispatch to figure out what to do here.

It would be unfortunate if the only way to use deadlines required pulling in Foundation because of our interest where avoiding to do so shows noticeable cold start performance differences etc... But you’re definitely right that years of work on those existing types are also very important.

The only type we “really would want” is a form of (wall clock) Deadline, the other types in the proposal (Duration, are not being proposed and is just something to showcase the example situation that could have ocurred, I can change that to use DispatchTimeInterval).

We hear the concern and it’s an important topic we’ll get in touch about once a few more basic pieces are in place — thanks for the feedback!

3 Likes

Let me add a third voice here. Please don't use "nursery." I realize it's just a word and plants have nurseries too, but, speaking on behalf of those who have lost a child and/or can't have children, it's nice to have as few reminders as possible.

I can appreciate that someone has a particular focus and goal behind every proposal. However as William Gibson wrote, "The street finds its own uses for things."

What can a developer accomplish using this proposal that they cannot accomplish using an API that already exists, like Combine/RxSwift/ReactiveSwift/NSOperationQueue/DispatchGroup etc.?

If this is just sugar to tell the compiler to make a Future that gets cancelled in the deinit() method, what are the implications for plug-and-play with @Published types with SwiftUI & Combine, etc.? How easy would it be to transition one-off task code written in your proposal's manner, into being Combine publishers? (For when, if you're really good at makeDinner() then you might open a business where you perform makeDinner() hundreds of times per night.)

I'm just trying to understand what is unique about this proposal vis á vis similar approaches we already have. Because for example at my org, if our developers are writing async code, ideally this can be done a single way across the whole codebase so that we don't have the same thing being done multiple different ways when it needs to be compatible with module X vs. module Y.

To me, ideally the same async API could handle "one-off tasks" and "values over time" elegantly.

1 Like

Thanks. As I said above, this is one of those names we intend to change indeed.

It’s been mostly just imported from a library that inspired us from this approach but we are not strongly bound to it. I proposed Task.Scope before but we’ll need to discuss some more as we have those APIs actually working.

Hey Konrad,

Thanks for the response. I think we're mostly on the same page here that the time types need to be expanded to finish the pitch.

One of the things we learned quickly while designing Combine's APIs is that the concept of a scheduler, which is basically this pitch's Executor, seemed inherently tied to time. Most (all?) schedulers on the system either defined their own time or used one from a different scheduler.

In Combine we decided to abstract time using a protocol because we wanted to avoid defining a new type.

This turned out to have a lot of great benefits. Using API like receive(on:) with a Dispatch queue meant that you could pass through dispatch-specific types, which support different kinds of clocks, or one of our standard convertible ones. RunLoop could define its own specific options, like what modes the work should be done in. As @kiel may have been getting at, people can define a test scheduler with an integer time. It allows control of the execution timeline, making testing instantaneous and deterministic. One really cool thing about this is that you can accurately reproduce a race condition.

I'm not sure if this pitch is supposed to cover how all of these things work, but it seems the most related out of the ones posted so far.

17 Likes

It is safe to assume that structured concurrency will implemented as part of Swift runtime and not as a separated library? If that’s the case, is it correct to say that we would need to depend on the OS bundling the new Swift version in order to use structured concurrency?

Continuing a discussion from the roadmap thread here.

That defeats the goal of preventing resource leaks by design. If a task really needs to run to completion regardless of cancellation a programmer should have to say that.

It also doesn't work conceptually. If the outer scope must run uninterrupted then you can't opt-in to implicit cancellation without violating the intent of the outer scope to ignore cancellation.

Is nursery.add suppose to be an async function? The example reads like the for loop would result in sequential execution of all the chopping. After thinking about it, I understand that adding and chopping could be 2 different async actions, but it read weird to me.

Yes add currently is async on purpose. That’s an idea we’re pursuing — a nursery can provide a form of back-pressure by not resuming the add immediately and therefore only keep a limited number of tasks enqueued at any given time.

I agree though that it reads a bit weird, or rather, takes getting used to. We’re not yet sure if a lot of code day to day would interact with those directly, or use provided operations built on top of nurseries / task scopes — in which case them looking a bit more complex would be less of an issue...

To clarify:

withNursery { nursery in 
for w in work {
  await nursery.add { // awaiting on the _submission_ of task
    await w.work() // actually awaiting on the task, 
                  // when it’s going to run
  }
  // ... 
}

So what this code is saying is that “adding can suspend”, e.g. because a nursery could be configured to “never have more than 1,000” outstanding tasks, or similar.

We’ll have to learn and see once we have things up and running though if this is reasonable or not... It’s been a design we wanted to bring up and try out though (though we do not have a working infra yet, so it’s hard to judge the usefulness in the real world other than “it would be very good to be able to provide back-pressure here”).

8 Likes

Besides an async function starting scoped child tasks, a general facility for scoping tasks could be useful. Consider:

class Model {
    private let modelScope = TaskScope()
    var data: [String] = []
    deinit {
        modelScope.cancel()
    }
    func update(from url: URL) {
        modelScope.run { [weak self] in
            let newData = try await self?.getData(from: url)
            self?.data.append(contentsOf: newData)
        }
    }
    func getData(from url: URL) async throws -> [String] {
        ...
    }
}

This would allow for easy cleanup of still-executing coroutines at the end of the lifetime of their logical context.

This looks very similar to how Task.Handle should work. Maybe we can add HandleCollection or something,

class Model {
  private let handles: Task.HandleCollection = ...
  deinit { handles.cancel() }

  func update(...) {
    let handle = Task.runDetached {
      ...
    }

    handles.add(handle)
  }
}

I'd like to extend my support for this. The idea of test schedulers is simply amazing. Super powerful. By the way, congratulations @Tony_Parker. Combine is awesome!

3 Likes

This looks super interesting. I'm glad you mentioned clock drift as it was the first thing that came into my mind, haha. What @Tony_Parker mentioned about tolerance is also super important. Would be good to have a tolerance parameter for all time-related APIs.

1 Like

This is very exciting!

This was my initial impression and sentiment as well, but after reading the proposal more, it seems like there's quite a bit more going on.

One of the variables for a given async let must be awaited at least once along all execution paths (that don't throw an error) before it goes out of scope.

So this seems like async let is also pulling in DI for us, which would be difficult bookkeeping if we tried to do manually:

// A handle to run definite initialization of a variable
enum DIHandle {
  case initialized
  func use() { } // Considered a use of `self`
}

// Each `async let x = ...` declaration also declares a DI handle
var _x_di: DIHandle; defer { _x_di.use() }

// Each `await x` sugar also initializes the DI handle
_x_di = .initialized

// Also, every `try` or `throw` that could exit the function is
// wrapped in a do-catch, where catch
// initializes the handle before rethrowing the error.
do { try foo() } 
catch {
  _x_di = .initialized
  throw error
}


It would really help my understanding if the proposal had a short section showing the fully de-sugared version of an example function with explicit types provided.

Also, what does the "One of the variables" mean? Does that mean that async let (a, b) = (taskA, taskB) only requires that either a or b be awaited? What's the rationale?

2 Likes