Skip to content

Instantly share code, notes, and snippets.

@swhitty
Last active November 7, 2024 06:43
Show Gist options
  • Save swhitty/9be89dfe97dbb55c6ef0f916273bbb97 to your computer and use it in GitHub Desktop.
Save swhitty/9be89dfe97dbb55c6ef0f916273bbb97 to your computer and use it in GitHub Desktop.
extension Task where Failure == Error {
// Start a new Task with a timeout. If the timeout expires before the operation is
// completed then the task is cancelled and an error is thrown.
init(priority: TaskPriority? = nil, timeout: TimeInterval, operation: @escaping @Sendable () async throws -> Success) {
self = Task(priority: priority) {
try await withThrowingTaskGroup(of: Success.self) { group -> Success in
group.addTask(operation: operation)
group.addTask {
try await _Concurrency.Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
throw TimeoutError()
}
guard let success = try await group.next() else {
throw _Concurrency.CancellationError()
}
group.cancelAll()
return success
}
}
}
}
private struct TimeoutError: LocalizedError {
var errorDescription: String? = "Task timed out before completion"
}
@priyans05
Copy link

Brilliant!

@MrDzmitry
Copy link

This code is cool, but it demands support cancellation for each task and timeout can be processed only after each task is cancelled. I got incorrect behaviour because of it, so here you can find alternative code that does opposite staff: cancel nothing and just throws error if timeout occurs, task continues to work.

@swhitty
Copy link
Author

swhitty commented Mar 20, 2023

Oh nice alternative @MrDzmitry. I will point out one of the problems spawning unstructured tasks like that is that now not even the timeout can be cancelled anymore which withThrowingTaskGroup avoids.

As a general rule I try not to spawn unstructured tasks if I can avoid it and almost never spawn detached tasks. Within FlyingFox I have refined this API to use the with nomenclature used within Swift Concurrency in withThrowingTimeout.

The issue you have discovered highlights Swifts cooperative cancellation model which requires functions to detect and either throw or return to unwind the call stack — this is critical part of releasing resources and if skipped can result in an ever increasing number of tasks that never complete.

If a function ignores cancellation and continues executing it could be a bug that probably should be fixed. That said I realise that fixing some of this code may be difficult or impossible if the bug lies in an external library so your library could be explicit about skipping the cooperative cancellation and using something like CancellatingContinuation you could create something like this:

try await withThrowingTimeout(seconds: 1) {
  try await withIgnoringCooperativeCancellation {
    try await doWorkThatDoesNotCancelProperly()
  }
}

@MrDzmitry
Copy link

now not even the timeout can be cancelled anymore which withThrowingTaskGroup avoids.

You're right, but it's not a problem. Each tool has rules how to use it. This one isn't exception. This tool meant to run short timestamps as 5 seconds or some minutes (but I use it for 5 seconds). There is no problem if task, that just sleeps, will stay alive for 5 extra seconds.

As a general rule I try not to spawn unstructured tasks if I can avoid it and almost never spawn detached tasks.

imho you're not right here, Task.detached logically is the same as DispatchQueue.global().async. If you never use it you wirte single thread code, that don't use whole cpu power. In my application I use Task.detached in 90% of code and in result I have app, that uses main thread only in 65% cases (I measured it). You need use Task.detached at least for root task. Use child tasks or not it's up to your architecture. In my case I have no dependent tasks, I just don't need them.

if skipped can result in an ever increasing number of tasks that never complete.

It will be truth only if you run tasks that never ends. That is why I said your code is cool. In case if you have tasks that never stops but can easily support cancellation your code is better than mine. But in opposite situation it's visa versa :)

If a function ignores cancellation and continues executing it could be a bug that probably should be fixed.

fifty fifty. It might be bug and might be logic. In my case it's business logic.

your library could be explicit about skipping the cooperative cancellation and using something like CancellatingContinuation

it can be a good idea to give exact name as withIgnoringCooperativeCancellation, I really though about it, but it's not so easy to do and not so cool looks in code that I decided not to use naming like this. As an example I used this

Swifts cooperative cancellation model

that never explains this trick about TaskGroup. It was really hard to find that TaskGroup will wait till each task ends. I even found that forum discussion where apple developer didn't know about this behavior in first time. So...

I'm not insists, if you like use only one thread, instead of timeout wait till work cancels and so on - you are on a right way. But I have need to finish work after UI passed this part (cause I will reuse results in future) and prefer to use all threads that I can.

@swhitty
Copy link
Author

swhitty commented Mar 31, 2023

It can be confusing because Swift's concurrency design is intentionally vague about the details of how code is actually run and does not have a construct for threads and instead uses high-level semantic properties, expressed in terms of actor isolation.

When actor isolation is not specified, tasks are performed on the default concurrent executor. John McCall's commentary on its relationship with threads is enlightening

The default concurrent executor is used to run jobs that don't need to run somewhere more specific. It is based on a fixed-width thread pool that scales to the number of available cores.

@akaraatanasov
Copy link

Apple documentation is shit as usual. Thank you @swhitty & @MrDzmitry for the great discussion! It was a great learning resource for me on async/await.

@swhitty
Copy link
Author

swhitty commented Sep 1, 2024

A Swift 6 compatible version is available within the micro package swhitty/swift-timeout

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment