Is UnsafeCurrentTask cancellation thread safe?

Is it safe to use UnsafeCurrentTask to concurrently cancel the task, as long as I only do so while the withUnsafeCurrentTask scope it originates from is still active?

To quote the UnsafeCurrentTask documentation:

To get an instance of UnsafeCurrentTask for the current task, call the withUnsafeCurrentTask(body:) method. Don’t store an unsafe task reference for use outside that method’s closure. Storing an unsafe reference doesn’t affect the task’s actual life cycle, and the behavior of accessing an unsafe task reference outside of the withUnsafeCurrentTask(body:) method’s closure isn’t defined.

Only APIs on UnsafeCurrentTask that are also part of Task are safe to invoke from a task other than the task that this UnsafeCurrentTask instance refers to. Calling other APIs from another task is undefined behavior, breaks invariants in other parts of the program running on this task, and may lead to crashes or data loss.

My (quite possibly flawed) interpretation would be that UnsafeCurrentTask is essentially an unowned reference to the current task and as such not safe to use once the withUnsafeCurrentTask closure returns.
However, any API shared between UnsafeCurrentTask and Task is thread-safe, which would include cancel().

On the other hand, UnsafeCurrentTask was explicitly marked as not-Sendable after the initial release.

So my question is, is it safe to escape an UnsafeCurrentTask instance, bypass the sendability restriction and concurrently use it to cancel the task, as long as I only use the UnsafeCurrentTask instance before returning from withUnsafeCurrentTask?

My intended use case

I need to create a task and separately register for a callback that is called (concurrently) when the task needs to be cancelled.

With unstructured tasks I can capture the Task handle and use that to cancel the task in response to the callback without any safety concerns.

However, I would also like to be able to do this with child tasks to leverage structured concurrency. But there is no Task handle for child tasks.

Still, child tasks can be cancelled using UnsafeCurrentTask so I'm wondering if I could safely do the following:

// taskGroup: TaskGroup
// MyState is a thread-safe type managing the callback and UnsafeCurrentTask

// register the callback that is called when we need to cancel
let state = MyState()

taskGroup.addTask {
    await withUnsafeCurrentTask { unsafeTask in
        // if the callback was already called, cancel the task immediately
        // otherwise store the `UnsafeCurrentTask` and use it to cancel the task if the callback is called
        state.setTask(unsafeTask)

        // do the actual task work

        // discard the stored `UnsafeCurrentTask` so it isn't stored or used outside withUnsafeCurrentTask
        state.removeTask()
    }
}

While less important, this pattern would also be a slight improvement for unstructured tasks as I can capture the UnsafeCurrentTask as soon as the task starts running. The Task handle on the other hand might not be immediately available depending on scheduling and for immediate tasks the Task handle only becomes available once the task suspends, potentially delaying cancellation indefinitely.


Should the async variant of `withUnsafeCurrentTask` adopt `nonisolated(nonsending)`?

Obviously withUnsafeCurrentTask predates nonisolated(nonsending) but it also never adopted #isolation to inherit the current isolation.

Should I file GitHub issue for this or is there a reason withUnsafeCurrentTask shouldn't inherit the current isolation that I am missing?

1 Like

Yeah that's basically what it means the unsafe current task doesn't keep the task alive, so if you try to use it after the task object was destroyed, you'll get undefined behavior.

So just calling things inside it the with... is fine from that perspective, because the task could not have been destroyed. If you escape it to some other context though that's not guaranteed anymore, so you have to be very careful with it.


Separately: Yeah lots of APIs need to adopt nonisolated(nonsending), we're going through those one by one and adding that. It involves adding ABI shims so we don't break existing users because adding nonisolated nonsending is an ABI break.

2 Likes

Thank you, that's very helpful!