Running an async task with a timeout

I wrote a function async(timeoutAfter:work:). Its goal is to run an async task with a timeout. If the timeout expires and the work hasn't completed, it should cancel the task and throw a TimedOutError.

Here’s my code. It seems to work, but I’m not sure if it’s correct. Am I on the right track here or is there a better way to achieve this?

import Foundation.NSDate // for TimeInterval

struct TimedOutError: Error, Equatable {}

/// Runs an async task with a timeout.
///
/// - Parameters:
///   - maxDuration: The duration in seconds `work` is allowed to run before timing out.
///   - work: The async operation to perform.
/// - Returns: Returns the result of `work` if it completed in time.
/// - Throws: Throws ``TimedOutError`` if the timeout expires before `work` completes.
///   If `work` throws an error before the timeout expires, that error is propagated to the caller.
func `async`<R>(
  timeoutAfter maxDuration: TimeInterval,
  do work: @escaping () async throws -> R
) async throws -> R {
  return try await withThrowingTaskGroup(of: R.self) { group in
    // Start actual work.
    group.async {
      return try await work()
    }
    // Start timeout child task.
    group.async {
      await Task.sleep(UInt64(maxDuration * 1_000_000_000))
      try Task.checkCancellation()
      // We’ve reached the timeout.
      throw TimedOutError()
    }
    // First finished child task wins, cancel the other task.
    let result = try await group.next()!
    group.cancelAll()
    return result
  }
}

Here’s a usage example. You can set the sleep amount in the await Task.sleep(100_000_000) to a higher value, e.g. 300_000_000, to see it fail.

(If you want to try this out with Xcode 13.0 beta 1, use this workaround to make Task.sleep work.)

detach {
  do {
    let favoriteNumber: Int = try await async(timeoutAfter: 0.25) {
      await Task.sleep(100_000_000)
      return 42
    }
    print("Favorite number: \(favoriteNumber)")
  } catch {
    print("Error: \(error)")
  }
}

(Edit: Modified the code slightly to accommodate tasks that can throw.)

6 Likes

I wonder about the cost of all those coroutines being suspended in the sleep call, especially given a low timeout probability…

Is that timeout task guaranteed to start executing immediately? All this stuff is new to me obviously, but I don’t really see how that could be guaranteed.

1 Like

Right I don't think it can, tasks are scheduled on the thread pool which could be busy with other things.

1 Like

Very similar to what I did and seems to be alright. The problem is that right now sleep doesn’t stop early when cancelled, which makes this a bit worthless.

I mentioned that in the structured concurrency review. SE-0304 (3rd review): Structured Concurrency - #36 by Alejandro_Martinez

1 Like

Couldn’t you just put the timer in a while loop that checks every second if it’s cancelled or time’s up and break accordingly?

Thank you everyone for your input. I also received good feedback on Twitter, which I wanted to link here.

Importantly, @John_McCall pointed out that a timeout API should be expressed as a deadline (a specific point in time at which the timeout occurs) rather than a duration because (1) it’s not guaranteed when the timeout task will start (as @jjoelson also pointed out), and (2) deadlines compose better when propagated to child tasks.

@jrose suggested to get rid of the explicit TimedOutError and model a timeout as a plain cancellation. I think I agree with this.

2 Likes

Though using a deadline does not really solve the problem by itself, because if you're unlucky the timeout task might be severely delayed and you might just set up the deadline too late. It seems like the timeout would need to be set up in the parent task of the task you want to have the timeout for (and have it fire outside of the tread pool?).

I’m not sure I understand what you mean. Something like this

True, but I don’t know if we should be concerned about that. It’s the nature of a cooperative system that tasks may be delayed. It’s no different in that regard than Timer in Foundation, which is also not guaranteed to fire on time.

With a deadline check, even if the timeout task gets scheduled for the first time after the deadline has passed, it would immediately cancel and thus trigger the cancellation of the "work task" at the earliest time the system was able to accommodate. I think this is as good an outcome as we can expect.

I’m not sure if I understand what you mean, but you gave me the idea to write my own sleep function with cancellation support. Thanks! This implementation sleeps for short intervals and performs manual cancellation checks in between:

import Foundation

extension Task {
  /// Like `Task.sleep` but with cancellation support.
  ///
  /// - Parameter deadline: Sleep at least until this time. The actual time the sleep ends can be later.
  /// - Parameter cancellationCheckInterval: The interval in nanoseconds between cancellation checks.
  static func sleepCancellable(
    until deadline: Date,
    cancellationCheckInterval: UInt64 = 100_000
  ) async {
    while Date.now < deadline {
      guard !Task.isCancelled else {
        break
      }
      // Sleep for a while between cancellation checks.
      await Task.sleep(cancellationCheckInterval)
    }
  }
}

Using this instead of Task.sleep in my timeout implementation makes it behave correctly.

2 Likes

This is what I meant and I’m glad it works like you wanted it to. I think I’d have made it more generalized to have a cancelIf: @autoclosure () -> Bool to be able to trigger cancellation off of other things but also include an init that takes a cancelingAfter: Date parameter and sets cancelIf to a closure that returns true if it’s past the time specified.

1 Like
Terms of Service

Privacy Policy

Cookie Policy