[Concurrency] Structured concurrency

This function does a bunch of work that isn't needed, because the let binding x is never used. It produces a warning:

func f() {
  let x = someHugeCollection.reduce(0.0, +)
}

It doesn't seem obvious that wasted concurrent work due to an unused async let is a substantially more serious problem.

1 Like

No; we're just trying to understand the semantic model. These are the kinds of things we wonder about when trying to understand what's actually being proposed, and what we can expect to do with the facilities.

I guess we envisioned something like this:

extension Task {
  /// Suspends until `wakeUpTime` is reached, or (if `cancelable == true`)  
  /// until canceled, whichever happens first.
  public static func sleep(until wakeUpTime: Deadline, cancelable: Bool = true) async throws
}

Of course that API has the minor disadvantage of undconditionally requiring try at the call site even if cancelable == false. On the other hand, I don't see any way to implement that API in terms of the un-cancelable sleep(). It seems to me that with the sleep() proposed, once sleeping starts there's no way to interrupt it. Am I missing something?

1 Like
await try nursery.add {

Why is add() an async operation? It seems like adding should be a fast operation, not a long-running task of its own.

See https://forums.swift.org/t/concurrency-structured-concurrency/41622/96 If too many tasks have already been created at once, adding a new one can suspend.

1 Like

Structured concurrency is so straightforward. I'm surprised that it's a relatively new concept.

I have a some questions regarding nurseries/task groups:

What happens if child tasks are executed faster than new ones are added? Will the task group return when the last child in the group completes, and refuse adding new children?

Could task groups be presented to the user in the form of concurrent for-loops? The example in the proposal uses for-loops for adding and retrieving child tasks, and I can't think of a situation where task groups aren't used with some loops.

Lastly, a perhaps unpopular opinion: I quite like the term "nursery". It gets across the idea that child tasks are kept alive beyond the scope from which they were added.

1 Like

Task groups will allow you to add new child tasks until the task group itself is destroyed. The “return” out of the scope introduced for a task group is under the user control.

I think task groups are a lower-level mechanism that will end up being used to create higher-level abstractions. For example, concurrent forms of operations like Sequence.map.

Doug

I am concerned that Task.sleep() and other standard async facilities not being cancelable by default would lead to lots of async code that is not cancelable, even though it easily could be. Worse, it would lead to surprised programmers when their code keeps running after they canceled it. Now, of course, the fix is simple enough, and certainly an "I should have thought of that" moment, but I would not think any less of a fellow developer when they make the mistake of writing something like

class MyViewController : UIViewController {
    private var task: Task.Handle?
    @IBAction func buttonTapped() {
        task?.cancel()
        task = Task.runDetached {
            // wait a while in case the button is pressed soon again
            await Task.sleep(until: .now() + 1.0)
            await doSomethingExpensive()
        }
    }
}

And then having their expensive operations run despite cancellation. The fix, of course, is to add an explicit check for the cancellation; however, if the Task.sleep function were cancelable (i.e. throwing), they would have been led to the correct solution right from the start. The goal of structured concurrency is to effect correct composition of async functions, and a major point of said composition is composition of cancellation. The standard tools should do their best to direct programmers toward that goal. Uncancelable standard utilities sound more like a footgun to me.

Surely there can be scenarios where an uncancelable delay is needed instead, but I would posit that they are the less common case; furthermore, it is then easy to notice that the cancelable delay is not what they need, and search for the noncancelable alternative. It is not really possible to use the cancelable variant by mistake here. Therefore I think it would be best that the most-discoverable facilities, i.e. functions named something like Task.sleep or Task.yield be the cancelable variants, and the noncancelable variants have more explicit names, e.g. Task.sleepUncancelably.

For what it is worth, Kotlin does this so that delay et al. are always cancelable, and if one wants to run uncancelable code, one wraps it in withContext(NonCancellable) {}. I don't think this approach is best for Swift, though, as then any sleep or such in the noncancelable code would need meaningless trys. In any case, so far the number of times I have used NonCancellable in Kotlin code is zero; I expect that the vast majority of async functions I would write in Swift to be cancelable also.

From a purely function prototype perspective, I believe this could be written

public static func sleep(
    until wakeUpTime: Deadline,
    onCancel: () throws -> Void = { throw CancellationError() }
) async rethrows

Then the call site would be either await try Task.sleep(until: deadline), or await Task.sleep(until: deadline, onCancel: {}), and would also allow for customization of the error. This is probably not a good idea though; it is likely not conducive to an efficient implementation, and is also a bit cumbersome to use.

3 Likes

If it's a problem that doSomethingExpensive runs "despite cancellation", that's a much larger problem than what sleep does. The problem would exist even without the preceding sleep.

The real problem with the proposed sleep is that it doesn't keep the notions of suspension and cancellability separate — it prevents them from being separate-but-composable, because it prevents the suspension from being cancelled at all.

I don't really understand why Task.sleep needs to exist, period.

FWIW, I think:

  • A global sleep function scope (which you are proposing, it looks like) would be confusing, because of the direct clash with existing synchronous sleep functions.

  • Naming a static Task function sleep also seems like a bad idea for semantic reasons. If its purpose is to suspend a task, and it's documented to suspend a task (as it is in the proposal), surely it should be called suspend?

1 Like

This was not my intent, and I have edited my post to reflect that. Namingwise, I don't like the name sleep either, precisely because of the confusion with existing sleep functions.

Delays can be quite useful sometimes, as anyone who has used dispatch_after or NSObject.perform(:with:afterDelay:) can surely verify. It would be unfortunate to have to fall back to those interfaces when needed.

This I agree with, and it is another, though more fundamental, reason why Task.sleep should handle cancellation.

There's an (apparent, if not real in the implementation) semantic difference between sleeping (which starts immediately) and delaying (which starts something else later). I don't have the same distaste for the "delay" version. :slight_smile:

My intention is mainly to reinforce the idea that cancellableSleep is not a composition of suspension + cancellability, and that uncancellableSleep is a composition of cancellable suspension + immunity from cancellation.

IOW, the cancellable one is "fundamental" (to use @Douglas_Gregor's word), not the uncancellable one.

1 Like

I was investigating ways we could implement checking for ObjC completion-handler-based APIs imported as async, to validate that the ObjC implementation in fact does call the callback without discarding it, and doesn't try to invoke it multiple times, since either issue can be a big problem with a raw UnsafeContinuation—dropping the continuation without resuming the task will leak any resources the task holds, and re-resuming the same continuation will destroy the universe. A straightforward way to implement this would be to provide a helper class that wraps the raw UnsafeContinuation and tracks its state:

class CheckedContinuation<T> {
	var continuation: UnsafeContinuation<T>?
	var function: String

	// Initial state: continuation must be resumed
	init(continuation: UnsafeContinuation<T>, function: String = #function) {
		self.continuation = continuation
		self.function = function
	}

	// Resume the task if it hasn't been yet, or else log that we tried to resume it again
	func resume(returning x: T) {
		if let c = continuation {
			c.resume(returning: x)
			// Clear out the continuation so we don't try to resume again
			continuation = nil
		} else {
			print("\(function) tried to resume its continuation more than once with \(x)!")
		}
	}

	// Log if the object is deallocated before its continuation is resumed
	deinit {
		if continuation != nil {
			print("\(function) leaked its continuation!")
		}
	}
}

We can provide compiler support for generating checked async invocations of imported ObjC APIs, but I can easily imagine developers who are manually using UnsafeContinuation wanting this same checking while developing their async Swift interface to catch these common issues with callback-based APIs. We may want to make a checked wrapper class like this be part of the API for end users as well.

16 Likes

The continuation APIs deserve their own scrutiny, so I forked off another proposal and thread to explore them: [Concurrency] Continuations for interfacing async tasks with synchronous code

6 Likes

Pardon me for awakening a dormant discussion, but I came here after attending a session about the current state of async/await, which had a link to this proposal. I have a couple of quick questions:

  1. The second code section under Task handles includes the statement let meal = try await mealHandle(). I presume this was supposed to be let meal = try await mealHandle.get() instead?

  2. In the session I attended, I believe it was stated that a detached task doesn't begin running until .get() is called on its handle. On further reflection, that doesn't seem right to me. Is it?

Thanks.

Hey there,

Yes, you're right -- a handle has to be called on with get() to wait there.

I think we fixed this in the more recent revisions

Not sure what session you mean :slight_smile: You’re right though — detached tasks (started by Task.runDetached) run right away, and the get is just what is where you suspend and wait for the result.

Hope this helps,

FYI: a more up to date proposal and discussion thread is: [Pitch #2] Structured Concurrency if you’d like to read up or ask more questions.

Hi,

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 asynnchronously 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.