Initiating asynchronous work from synchronous code

You can check the new wording proposed in the update to the structured concurrency proposal here: [Struct Concurrency] Update for 3rd review by ktoso · Pull Request #1345 · apple/swift-evolution · GitHub

Indeed it would be useful to have some name for the task created by async{} in the proposal I just called it an “async task” vs a “detached task”. This is pretty true, it creates a task (internally known as AsyncTask), which is neither a child task, nor a detached task, so calling it by the plain old async task sounds good to me.

It cannot be a child task–by construction–because we would not be able to allocate it using the task allocator nor can we force it to end before the task from which it was created ends — because we’re potentially in a synchronous function and thus cannot await on it.

2 Likes

Ah! That does clarify. Seeing your nice updated text in the context of the larger proposal is quite helpful.

The name is not perfectly satisfying. (Aren’t all tasks asynchronous?)

Can you elaborate on that? Is it simply because there is no globally available notion of “current task?” Or is it something more subtle?

It seems in my naiveté that this isn’t strictly true: could a synchronous function not receive a taskGroup parameter, use taskGroup.spawn to create the child-that-is-truly-a-child task, but then exit immediately without awaiting?

I think this is a feature for the awkward situation where you would normally create a child task, but for some technical reason your caller needs you to be non-async, and your caller doesn’t actually need to wait for the task to finish. For example, an @IBAction can’t be async (AFAIK), but AppKit/UIKit doesn’t actually want to wait for the whole operation to finish—it just wants you to start it and return so it can process the next event.

Maybe something like withoutActuallyAwaiting(_:) would be a good name for this function.

2 Likes

There's two reasons:

  • no parent: since we're in a synchronous context, there may or may not be a task around to become a child of; so we can't just say this is a child task
  • lifetime: even if there was a parent to attach to, we'd by design by violting structured concurrency guarantees here. the purpose of this function is to not wait which violates parent/child task guarantees. The async created task can out-live the task from which it was created.

The lifetime issue also manifests in making some optimizations impossible, but that's secondary reasons.

We can't do that, as it would violate structured concurrency guarantees. The group must wait on all spawned children before it exits. This is why the group forms a scope: withTaskGroup { group ... } and it is not legal to pass around or escape the group.

Hah yeah that's a pretty lengthy but somewhat precise name for it... We found though that the use of this function is frequent so we would argue for a short nice name for it.

Though it also can return a Task.Handle which one could await on (in the revised structured concurrency proposal linked above).

I find this quite surprising! Not escaping, yes, sure, but not passing around?

Suppose I have this somewhat redundant task creation code:

func loadGameAssets() async throws -> GameAssets {
  try await withTaskGroup(of: GameAssets.self) { group in 

    for spriteURL in contentsOfDirectory("sprite") {
      group.spawn { 
        try await Sprite(url: spriteURL)
      }
    }
    for soundURL in contentsOfDirectory("sound") {
      group.spawn { 
        try await Sound(url: soundURL)
      }
    }
    for tileMapURL in contentsOfDirectory("tileMaps") {
      group.spawn { 
        try await TileMap(url: tileMap)
      }
    }

    // ...etc...
  }
}

Wouldn’t it be both desirable and well-formed to refactor it along these lines?

func loadGameAssets() async throws -> GameAssets {
  try await withTaskGroup(of: GameAssets.self) { group in
    try loadAssets(with: group, from: "sprites", using: Sprite.init)
    try loadAssets(with: group, from: "sounds", using: Sound.init)
    try loadAssets(with: group, from: "tileMaps", using: TileMap.init)

    // ...etc...
  }
}

func loadAssets(
  with group: TaskGroup,
  from assetDir: String,
  using loader: (URL) async throws -> Asset
) {
  for spriteURL in contentsOfDirectory(assetDir) {
    group.spawn {
      try await loader(spriteURL)
    }
  }
}

IIRC, Python’s very similar Trio library not only supports but encourages this pattern.


Re the terminology:

Too long, yes, but Becca’s name suggestion was genuinely helpful for me!

I’ll just mention doLater again. I like the word “later” in this context.

Certainly some term of art that gives one a heuristic toehold here would be nice. Ideally, I’d want some term that:

  1. prompts good guesses and good questions about the meaning the first time one sees it in code,
  2. is web-searchable (async alone means something else already), and
  3. makes it convenient to have conversations about the construct.

What you wrote is fine, I wasn’t very precise in my wording there.

By passing around I should have been more specific and specifically mean escaping / storing it somewhere beyond the lifetime of the withTaskGroup.

1 Like

So we came up with async / await to get away from the pyramid of blocks and now want to return to it?

This is just for the entry point, so it’s a very ‘shallow’ pyramid. :blush:

2 Likes

I'm very happy to see this. Looks like a piece of the puzzle that was missing.

Just some notes:
1.

async {
      g() // okay to call g synchronously, even though it's @Sendable
}

Looks a bit frustrating. My own mental model is that async {} gives me an ability to run some synchronous task asynchronously, like Dispatch.queue.async {}.
But in fact it behaves completely different. Though I see the word "async", in fact it behaves as "sync".
May be a compiler error or warning should appear, if we call sync function in async block?

  1. The word async here is treated as a verb, but in other places it is adjective.
    We already have do {} block, so the idea here is to name it as "asyncDo". Then we will have:
    asyncDo { g() } // compiler warning
    asyncDo { await g() } // ok
    asyncDetached { await g() } // ok
1 Like