Even in a world of interrupts, it’s possible and indeed necessary to temporarily mask them. The big difference between cooperative and preemptive multitasking is in the timescale during which interrupts are ignored—in cooperative multitasking systems, that duration is allowed to be infinite, and is in fact infinite by default unless the cooperating tasks manually check for them.
We’ve had a similar implementation in our codebase for a while. It would be nice to remove it, but unfortunately it’s not worth pulling in AsyncAlgorithms just to replace a single home-grown solution with this one.
Thanks everyone for the great feedback so far and sorry for the delay in getting back. I wanna do my best to try and address everything that was brought up starting with the larger points.
I think @Philippe_Hausler correctly captured my thinking here already. In the fullness of time a deadline implementation inside the _Concurrency module would be the best; however, there are a few open technical questions that we need to explore further such as the propagation of deadlines and if deadlines should be exposed to the underlying executors. I personally think we need a package where we can incubate such algorithms and AsyncAlgorithms felt like the right place to me if we were to expand its scope beyond just AsyncSequence based operations. FWIW, there are other PRs such as the retry operation that would fall into a similar bucket.
This feedback came up multiple times across the pitch and review thread now. I understand the concerns about the semantics of a "deadline" implying that the operation will not finish later than the deadline. However, when considered in the context of Swift's structured concurrency then there can be no implementation that offers these semantics without resorting to unstructured concurrency. It is my strong believe that we must not offer any APIs that use unstructured concurrency without them clearly communicating this through their name since it easily breaks all assumptions that developers have.
I am open to consider other names that have been brought up here but I personally think that withDeadline is reasonable. Furthermore, I think a potential withDetachedDeadline method might be something to consider in a future proposal.
I explored propagation by exposing a TaskLocal with the deadline; however, due to being generic over the clock this isn't as straight forward. In a given call stack there might be multiple withDeadline methods using different clocks. I think this can be explored as a future direction in a source stable way.
Initial implementations just re-threw the operations error. However, in real world usage this became very confusing since it made it unclear why the operation threw. Wrapping the error makes it very clear that a deadline passed. I think there is some room for additional ergonomics to just un-wrap the underlying error. What if we moved the OperationError from the Cause enum to a property on DeadlineError directly so you could just write deadlineError.underlying or deadlineError.operationError?
Since SE-0413 was accepted and more typed throws usage spread across the ecosystem, it became apparent that primitive constructs must support typed throws otherwise they won't work in embedded mode nor do they allow to compose higher-level methods that want to use typed throws. As such, adoption of typed throws on various Concurrency APIs such as withTaskCancellationHandler is happening.
For how to catch the DeadlineError this works:
do {
try await withDeadline(
until: .now.advanced(by: .milliseconds(1)),
clock: .continuous
) { () async throws(SomeError) in
...
}
} catch {
switch error.cause {
case .deadlineExceeded:
...
case .operationFailed:
...
}
}
This is not a new problem that the proposed withDeadline method creates. Any non-throwing asynchronous method that handles cancellation and returns partial results needs to make a decision how to communicate it. Some methods just return the partial result others return a concrete type that indicates that this is only a partial result. It is up to the method author to decide what to return here.
Thanks for the follow-up. I think there are two kinds of errors to unwrap: the original error thrown before the deadline, and a new concrete error DeadlineExceededError that the proposal is currently missing. This would greatly help people who uses untyped throw and need to keep throwing the original operation errors for backward compatibility (and possibly throw a new error for deadline exceeded).
For example, usage could read as below:
do {
try await withDeadline(...) { ... }
} catch {
switch error.cause {
case .deadlineExceeded(let deadlineError):
// Throw a concrete `DeadlineExceededError` (new type)
throw deadlineError
// Alternatively: throw the error thrown by the cancelled job, if relevant.
throw deadlineError.operationFailure
case .operationFailed(let error):
// The original error, important for backward compatibility
throw error
}
}
The above sample is code is not the end of the story, though, because if DeadlineExceededError is generic on the clock, it is not impossible to catch it unless the client has knowledge of that clock, which can no longer remain a private implementation detail. We'd need a way to type-erase it. If this is not provided by the proposal, then many users of withDeadline will have to 1. understand they need to do it by hand (not as trivial as it seems), and 2. do it by hand (duplicated boilerplate + bad interoperability between multiple packages).
Since SE-0413 was accepted and more typed throws usage spread across the ecosystem, it became apparent that primitive constructs must support typed throws otherwise they won't work in embedded mode nor do they allow to compose higher-level methods that want to use typed throws.
Yes. But the relationship with untyped throws, backward compatibility of thrown errors, and the catchability of generic errors, must be considered with more care, as described above. There should be some guidelines for APIs that add one or several new cases of errors, such as withDeadline, so that this topic does not have to be rehashed multiple times.
As is, the proposed API is usable in the untyped throws world, but it is not very ergonomic, is not very good at package interoperability (no shared concrete type for deadline exceeded error that can be universally caught regardless of the clock), and does not follow the spirit of SE-0413.
non-throwing asynchronous method
The proposed DeadlineError type is uninhabited when OperationError is Never. This means that withDeadline function can not throw when the deadline is expired. It just never returns (crash or infinite loop I don't know), or lets the wrapped operation complete, ignoring the deadline. I'm not sure this has been considered.
Agree that withDeadline is unhelpful for understanding what the feature does; I think Kyle proposed something like withAutomaticCancellation(at:tolerance:clock:body:), but I could go even simpler, to like cancelling(at:tolerance:clock:body:).
I think this reads pretty well:
try await cancelling(at: .now() + .seconds(3)) {
// work
}
I think the documentation needs to be very explicit about cases that this won't work, including async APIs automatically bridged from ObjC completion handlers, which don't respect cancellation.
Does this code example in the doc comment actually work?
/// do {
/// let result = try await withDeadline(until: deadline, clock: clock) {
/// try await fetchDataFromServer()
/// }
/// print("Data received: \(result)")
/// } catch {
/// switch error.cause {
/// case .deadlineExceeded(let operationError):
/// print("Deadline exceeded and operation threw: \(operationError)")
/// case .operationFailed(let operationError):
/// print("Operation failed before deadline: \(operationError)")
/// }
/// }
Normally you'd have to specify do throws(ErrorType) { to get a typed throw, which is surely required for the pattern match in the catch? And if we do have to write do throws(DeadlineError<MyActualError, ContinuousClock>) { will the language at least let us write do throws(DeadlineError<_, _>) { in this case?
It seems that the main goal of this function is to cancel something after delay, while withAutomaticCancellation / withCancellation express semantics clearly.
I agree that the connotations around the name should focus on cancellation and not deadlines. Cancellation in Swift concurrency already has the well-known (or at least easily searchable) behavior of being cooperative, whereas one might naturally assume that a “deadline” is a new kind of thing that is more forceful than standard cancellation. While I like the Automatic name on an emotional level, it does feel like it isn’t right to use here — every single API operates automatically.
I think it is important to expose both instant- and duration-based overloads of this API. The Swift interface for dispatch_after only offers an instant-based API and that means that in practice the vast majority of calls do .now() + .seconds(3) or similar to specify a duration. In my opinion, adding the duration overload will make call sites more natural to read while also making it far more clear which places are actually using a specific instant as the cancellation time.
inspired by the above reference, may I present: ![]()
withCancellation (after: .now () + .seconds (3)) {...}
I also think withCancellation(at: deadline) { ... } or withCancellation(after: deadline) { ... } would best communicate at the callsite what it does.
The less precise label after could give more emphasis on the natural delay expectable when the child task responds to the cancellation in structured concurrency.
The label after also neatly highlights that the cancellation is immediate if the deadline already passed.
I’d like to suggest generalizing this pitch. Instead of providing a specialized withDeadline directly, I believe it would be more valuable to introduce a generic withTimer (or withHeartbeat or something else) primitive.
This approach would treat withDeadline as just one specific use case of a "periodic side-car task," while unlocking many other patterns that are currently cumbersome to implement in structured concurrency.
For example, withDeadline could simply be implemented using this generic timer:
try await withTimer(interval: .seconds(5)) {
try await fetchDataFromServer()
} onTick: { task in // The abstract task of fetchDataFromServer
// Do something like logs
task.cancel()
// The timer will automatically stop if the task ends.
}
But withTimer enables much more. A common pain point is decoupling UI/progress updates from heavy data processing loops. Currently, logic often gets cluttered with manual time checks inside for loops. With a timer scope, we can move that logic out:
// Decoupling progress reporting for stable UI updates
try await withTimer(interval: .seconds(0.1)) {
// The heavy lifting (running on a background thread/actor)
try await heavyDataProcessing()
} onTick: { _ in
await MainActor.run {
// e.g., reading a shared atomic progress value
self.progressBar.value = await self.getProgress()
}
}
Right now, achieving this requires mixing in non-async concepts (like NSTimer / Timer) or spawning an unstructured Task with an infinite while loop and Task.sleep, which feels like boilerplate that the standard library could abstract away.
I think "Composition over Specialization" fits Swift's philosophy better here. A generic timer primitive gives us timeout capabilities for free while solving a broader class of concurrency synchronization problems.
I think the concerns about the name are valid, as I’m uncertain that the majority of Swift users actually understand Swift’s fundamental concurrency model. However, with this API, we are in a somewhat unique position, as we plan to eventually replace it with a version in the _Concurrency module. That’s why I think the name withDeadline is good enough for now. If, however, those concerns prove valid and we see misunderstanding or confusion around the semantics or the name, I think we should go with something like: withTaskCancellationAfter(deadline:tolerance:clock:body:).
So this has gleaned a ton of really good feedback; particularly the naming needs to be looked at a bit more to make sure it is in congruence to the behavior but more aptly there is an outstanding part that needs to be determined if this really deserves runtime level behavior as a forward evolving set of functionality. To those ends I am going to pull this proposal back for revisions so that it can be figured out if living in _Concurrency with runtime level support brings it to the simpler name of withDeadline.
Thank you everyone for your time and feedback on this.
One idea I didn't see in this thread was that withDeadline is effectively a specialization of a more general race primitive, eg.
enum RaceOutcome<Left, LeftFailure: Error, Right, RightFailure: Error> {
case leftWins(value: Left, right: Result<Right, RightFailure>)
case rightWins(value: Right, left: Result<Left, LeftFailure>)
case nobodyWins(left: LeftFailure, right: RightFailure)
}
/// runs the `left` and `right` closures concurrently.
/// If either of the closures returns, the first to do so
/// will be designated the "winner". The other closure's
/// task will be cancelled. This function does not return
/// until both closures have exited.
func race<Left: Sendable, LeftFailure: Error, Right: Sendable, RightFailure: Error>(
_ left: @Sendable () throws(LeftFailure) -> Left,
_ right: @Sendable () throws(RightFailure) -> Right
) async throws -> RaceOutcome<Left, Right>
Then withDeadline is trivially implementable in terms of this.
Always call the closure
@FranzBusch please make sure to call the closure even if we are already past the deadline. We will cancel this Task as soon as it starts (which may seem weird), but this is what the users expect.
Timeout without time
Even then, the preemptive scheduler would have to be aware of both the priority and the deadline, and then it would boost the priority of a near-deadline task. We can break it even more if we flood the scheduler with a lot of tasks with the same deadline, to the point that it is just not possible to satisfy all of them (CPU speed limitation, etc.). The preemption alone does not solve the problem. (And obviously Swift is not preemptive anyway.)
In general, ANY timeout expressed using Clock derivative (duration/instant) can be wonky:
- The scheduler can starve the task, making things unreliable.
- There is a big difference between “5 seconds” on the latest Apple silicon and some old Intel CPU.
Very often it is better to express the deadline in a manner that does not depend on any external resources (like time or CPU speed). For example, in combinatorics you may set the upper limit of tested permutations, etc. When we hit this number it is still a timeout, just not a “time” related timeout. Similar to how the Swift compiler sometimes says: “Expression was too complex to be solved in reasonable time” - AFAIK this message is not based on time, but on the number of tested permutations.
Another example is when the timeout means “try a bunch of different options and return the best”. This is how query optimizers work in databases - at some point they give up and use the best plan they came up with. For example, Microsoft SQL Server documentation says:
It's important to understand that this threshold isn't based on clock time but on the number of possibilities considered by the optimizer. In current SQL Server QO versions, over a half million tasks are considered before a timeout is reached. (…) QO will stop at the threshold and consider the least-cost query plan at that point, even though there may be better, unexplored options.
But still, clock-based timeouts are very useful, and they are extremely easy for programmers to understand: “Throw an error if we do not get the response within 15 seconds”.
@FranzBusch @ktoso Not every timeout/deadline is “time” related. I don't know how common that would be, but would it be possible to throw a DeadlineError without an instant? Just so that we have a single DeadlineError without differentiating on how the deadline was expressed. Otherwise we would have DeadlineErrorWithInstant and DeadlineErrorWithoutInstant and changing how the deadline is expressed (arguably an implementation detail) would change the error type (breaking change).
Removing the clock from the DeadlineError could also solve the ergonomics problem posted by @gwendal.roue:
User error on timeout
The pitch says:
/// ## Behavior
///
/// The function exhibits the following behavior based on deadline and operation completion:
/// - If deadline expires and operation throws an error: Throws ``DeadlineError`` with cause
/// ``DeadlineError/Cause/deadlineExceeded(_:).
Let's say we have the following sequence of events:
- We start a database update with deadline.
- Timeout occurs.
- We cancel the update.
- Database throws a constraint error.
The pitch will throw a DeadlineError.Cause.deadlineExceeded, but we also have a DatabaseError. Which one is the correct one to throw?
Race generalization
But the code readability would suffer:
withDeadlineimmediately communicates the use caseraceforces the user to read the code and then arrive at the “this is timeout” conclusion
I think in practice most of the people would create their own withDeadline function using the race primitive. If a lot of people do the same thing, then that thing should be provided by the library already. That said, race would be a fine addition anyway. (I can't express how much I hate reading the try await group.next()! pattern.)
Cancellation
I see people arguing against waiting for the cancellation. In cooperative multitasking it HAS to work this way, for the following reasons:
Side effects
Let's assume that we are in the middle of a database update when the timeout occurs. Under the “no cancellation” model we return DeadlineError and cancel the the update, but we do not wait for it to finish.
After the withDeadline call returns we can be in 1 of the 4 possible states:
- Timeout occurred, and the update was cancelled (database rollbacks to the state before the update)
- Timeout occurred, and the update succeeded (race condition with cancellation)
- Timeout occurred, and the update failed because of a db constraint (race condition with cancellation)
- Timeout occurred, and the update is still in progress - cancellation is still “propagating” (unlikely, but technically possible)
We do not have enough information to make any further business decisions. It is not safe to retry. In general, it is not safe to do anything before we know what the current state is - which is another race condition.
The same situation is handled by the proposal like this:
/// ## Behavior
///
/// The function exhibits the following behavior based on deadline and operation completion:
/// - If deadline expires and operation completes successfully: Returns the operation's result.
One of the underpinnings of the whole Swift concurrency is that all of the Tasks “behave”. A situation where Task does not respond to a cancellation indicates that the Task is not correctly implemented. It does not invalidate the proposed withDeadline design.
Timeout as an upper bound
It is also important to mention that not all of the timeouts are “bad” and should result in an error. The database query optimizer case I mentioned in the post above can be an example of that. Sometimes we run a timeout just to limit the upper bound, once we reach it we just return the best solution that we came up with. For this use case it is crucial that we wait for the task to respond to cancellation.
with mental model
Also, if we look at it from the caller side:
let result = try await withDeadline(until: deadline, clock: clock) {
// …
}
This is a with pattern used for resource management in Swift:
- Create a resource
- Call the closure providing the resource as an argument
- Clean the resource
- Return/rethrow the closure result
For example Array.withUnsafeBufferPointer(_:):
let numbers = [1, 2, 3, 4, 5]
let sum = numbers.withUnsafeBufferPointer { bufferPtr in
// …
}
Anything that happens within the braces ({}) is isolated, in the code above we are not allowed to escape the bufferPtr variable outside of the closure (quote from the docs):
The pointer argument is valid only for the duration of the method’s execution.
When the withDeadline finishes and the operation is still running we break the mental model of the with pattern, as the resource is still “alive”. If this is the goal, then I suggest a different name - something what does not start with with.
We tried that a while back: that in particular is actually quite problematic - it ends up causing the two to run for the full duration. It caused severe enough leaks (rate of creation of Task versus completion) that we removed it from AsyncAlgorithms.
Other languages have that fundamental, but either a) ignore said leaks or b) have the runtime machinery to do it. There is a remote possibility that the runtime functions backing task group could be modified to do that but it would likely take a ton of work to figure out how to make that remotely robust.
It shouldn't; so long as it's specified to cancel the losing task, it isn't particularly any different from the original proposal for withDeadline here.
I didn't intend to say that withDeadline shouldn't exist in the stdlib :) Only that maybe it should be layered on the more general functionality.
True, race is effectively
await withThrowingDiscardingTaskGroup { group in
group.addTask { try left() }
group.addTask { try right() }
defer { group.cancelAll() }
return try await group.next()!
}
(my suggestion has a more complicated return value than this to allow left and right to have different return types and to report the result of the losing task, and not to finish the race if the first task to exit throws, but the simpler implementation would work in many situations)
So that isn't per-se the design of how race would need to exist as (comparing it to other implementations like .net etc). Ideally the design is that you want two tasks to run, and one to not only cancel the other but cause it to avoid extra execution; because else wise you are limited to the execution time of the total between the implementations since there is no guarantee that one has a fixed return from cancellation.
As it currently stand the swift concurrency design does not have facilities for cleaning up any execution mid suspension so any approximation (done by detached tasks) would end up piling up the slower of the two if the operation of racing between those sides was done with greater frequency. Deadlines have the advantage that we can cancel the sleep immediately.
Now that being said: if we either a) design a system in which that is not an issue, or b) figure out some very impressive system that somehow deals with that potential cleanup phase and early return, it is its own proposal imho - we would still want a deadline system for folks to use. Given that future state we would likely migrate deadlines on top of that race api.
I agree that the definition of race I gave doesn't necessarily match other systems, but I think it's fundamental to the "structured" nature of Swift's concurrency. Cooperative cancellation means that "cancel and hope it responds promptly" is always the best we can do.
As we've seen in this thread, the withTimeout name already caused people to assume behavior that is simply not possible in structured concurrency. So if we think that race is confusing in that respect, we could rename the primitive. I still think it's generally useful.