Either the asynchronous function runs in the current task.
Either the asynchronous function runs in the new top-level task created with Task { ... }.
Sometimes, this makes no difference. For example, in both cases the function runs in the priority and actor context of the caller. Task locals are the same as well.
It looks like the intent of new top-level tasks is indeed to make no difference, as this sentence in the documentation of Task.init seems to imply: "Use this function when creating asynchronous work that operates on behalf of the synchronous function that calls it."
But some differences exist, and some of them can be observed by the user (which means that the choice has consequences regarding semver and Hyrum's law).
For example, in the context of cancellation:
If the operation runs in a new top-level task, then it is not automatically cancelled when the current task is cancelled. It can inherit cancellation with withTaskCancellationHandler, though, so that difference can be erased if desired.
I'm not sure the difference regarding cancellation can be fully erased, though, because it can be observed with withUnsafeCurrentTask:
perform {
// Cancels the current task, or a new top-level task,
// depending on the implementation.
withUnsafeCurrentTask { currentTask in
currentTask?.cancel()
}
}
Another difference is that if the async function runs in a new top-level task, then it must be declared @escaping in the declaration of perform. I suppose it can no longer use non-escapable values, among other consequences.
Are you aware of other differences? It's an open and unbounded question, which might interest other members of the forum as well. Please share your knowledge and experience!
(This question is asked in the context of this GitHub issue, where I'm wondering if there are undesired consequences if I switch from an implementation that runs in the current task to an implementation that creates a new top-level task.)
Then apart from behavioral differences the first thing that comes to mind is that Task requires Success to be Sendable which is not the case without tasks.
Yes, and good catch I assume we can avoid the Sendable requirement of Task.Success because the task is discarded and we just return its result. I mean that perform and its closure argument can both return asending Result. Internally we'duse an @unchecked Sendable wrapper in order to comply with the Task requirement.
one possibility that comes to mind is if a Task executor preference was set in an ancestor scope of perform, switching to using an unstructured Task would not inherit it i believe.
One should generally avoid introducing unstructured concurrency that doesn’t support cancellation. And the obvious benefit of structured concurrency is that it does all of this for you.
To the OP’s question, I think the question is why wouldn’t one remain within structured concurrency? As TSPL says, unstructured concurrency gives a greater level of control, but it comes at a price:
We generally would only impose the overhead of unstructured concurrency if we needed to do something idiosyncratic/special (e.g., you needed to mitigate actor reentrancy with some task dependencies; you wanted to keep a reference to prior tasks to support some cancelAll sort of functionality; etc.). But I see nothing in the question (thus far) that calls for needing to go beyond simple structured concurrency.