-
-
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" | |
} |
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.
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()
}
}
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.
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.
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.
A Swift 6 compatible version is available within the micro package swhitty/swift-timeout
Brilliant!