Cancelling a top-level Task.sleep(for:) from another top-level Task

i have 3 tasks arranged roughly so:

let task:Task<Void, Never> =
{
    var sleep:Task<Void, Never> = .init
    {
        try? await Task.sleep(for: .milliseconds(heartbeat))
    }

    // sends the sleep task to someone who might cancel it
    send(sleep: sleep)

    await sleep.value
}

task.cancel()
// will this resume immediately?
await task.value
  1. main task: awaits task, can cancel task
  2. task: started from the main task, creates sleep tasks that escape to someone who may cancel them early, and awaits on those sleep tasks.
  3. sleep: started from the task task, sleeps until the time runs out, or someone cancels it.

now, my question is: what happens to the sleep tasks if the main task cancels the task task?

is the

await task.value

line guaranteed to resume immediately?

so, testing this out on a nightly toolchain, it looks like the answer is no:

@main
enum Main
{
    public static
    func main() async
    {
        let task:Task<Void, Never> = .init
        {
            let sleep:Task<Void, Never> = .init
            {
                try? await Task.sleep(for: .milliseconds(2_000))
            }

            await sleep.value
        }

        task.cancel()
        // will this resume immediately?
        await task.value
    }
}
$ time ./cancellation 

real    0m2.006s
user    0m0.006s
sys     0m0.000s

we have to do

@main
enum Main
{
    public static
    func main() async
    {
        let task:Task<Void, Never> = .init
        {
            let sleep:Task<Void, Never> = .init
            {
                try? await Task.sleep(for: .milliseconds(2_000))
            }
            await withTaskCancellationHandler
            {
                @Sendable () -> () in await sleep.value
            }
            onCancel:
            {
                sleep.cancel()
            }

        }

        task.cancel()
        await task.value
    }
}
$ time ./cancellation 

real    0m0.005s
user    0m0.005s
sys     0m0.000s

You're using un-structured tasks here, so there's no cancellation relation between any of these tasks.

Only child tasks, i.e. "structured concurrency", have the property that "inner" ("child") tasks are cancelled when their parents are. Only ways to create child tasks are: TaskGroup and async let.

At least in this toy example, the sleep task could be an async let or a group child task and you'd get cancellation propagation as expected.

// Relatedly, I'll see if/how/when we can update the swift book to provide more details about these things

6 Likes

Wait, so

let parent = Task {
	print("parent start")
	let child = Task {
		print("child start")
		try await Task.sleep(nanoseconds: 3_000_000_000)
		print("child finish")
	}
	try await child.value
	print("parent finish")
}

is not only not structured concurrency, my variable names are lying to me?!

for the longest amount of time i thought “structured concurrency” meant Task, and “unstructured concurrency” referred to things like EventLoopFuture...

That’s right.

Check out the structured concurrency talks from wwdc.
Also, there’s a new talk coming up also covering this: https://twitter.com/ktosopl/status/1601151750526095360

I agree we should have better and more explicit docs in the swift book about this btw. It’s a … process, to get things fixed there but we should be able to do so soon since it’s become open source and easier to submit changes to :slight_smile:

3 Likes

That’s not right, please check out the Explore structured concurrency in Swift - WWDC21 - Videos - Apple Developer talk.

yeah, i figured that out around late 2021, my point is the swift 5.5+ concurrency features were pitched as “adding structured concurrency” to the language, so for a long time i thought all of the new features (including Task) counted as “structured concurrency”.

i think a lot of the early docs also fixated a lot on the difference between Task.init and Task.detached, which made it seem like “structuredness” had something to do with “detachedness”.

2 Likes

We have to improve the docs around these definitely. Thanks for the reminder

1 Like

Exactly this - I thought that .detached was what broke it out the structure, not to mention that there was some _inherentContext or something similar in the Task initialization, which I took to mean that it was inheriting structure from the parent.

1 Like

I'll also add that I've watched that video more than once. Perhaps it's my fault for not letting that point sink in, but maybe it could have been clearer in the video? I'll have to watch it again with new context to provide better feedback.