Implementing Activity timeout with Swift Concurrency – Donny Wals


Swift Concurrency offers us with a great deal of cool and attention-grabbing capabilities. For instance, Structured Concurrency permits us to write down a hierarchy of duties that at all times ensures all youngster duties are accomplished earlier than the guardian activity can full. We even have options like cooperative cancellation in Swift Concurrency which implies that at any time when we wish to cancel a activity, that activity should proactively test for cancellation, and exit when wanted.

One API that Swift Concurrency does not present out of the field is an API to have duties that timeout once they take too lengthy. Extra usually talking, we do not have an API that permits us to “race” two or extra duties.

On this put up, I might wish to discover how we are able to implement a function like this utilizing Swift’s Activity Group. Should you’re searching for a full-blown implementation of timeouts in Swift Concurrency, I’ve discovered this bundle to deal with it nicely, and in a approach that covers most (if not all edge instances).

Racing two duties with a Activity Group

On the core of implementing a timeout mechanism is the flexibility to race two duties:

  1. A activity with the work you are trying to carry out
  2. A activity that handles the timeout

whichever activity completes first is the duty that dictates the result of our operation. If the duty with the work completes first, we return the results of that work. If the duty with the timeout completes first, then we’d throw an error or return some default worth.

We may additionally say that we do not implement a timeout however we implement a race mechanism the place we both take information from one supply or the opposite, whichever one comes again quickest.

We may summary this right into a perform that has a signature that appears slightly bit like this:

func race(
  _ lhs: sending @escaping () async throws -> T,
  _ rhs: sending @escaping () async throws -> T
) async throws -> T {
  // ...
}

Our race perform take two asynchronous closures which can be sending which implies that these closures carefully mimic the API offered by, for instance, Activity and TaskGroup. To study extra about sending, you’ll be able to learn my put up the place I examine sending and @Sendable.

The implementation of our race methodology could be comparatively simple:

func race(
  _ lhs: sending @escaping () async throws -> T,
  _ rhs: sending @escaping () async throws -> T
) async throws -> T {
  return attempt await withThrowingTaskGroup(of: T.self) { group in
    group.addTask { attempt await lhs() }
    group.addTask { attempt await rhs() }

    return attempt await group.subsequent()!
  }
}

We’re making a TaskGroup and add each closures to it. Because of this each closures will begin making progress as quickly as doable (often instantly). Then, I wrote return attempt await group.subsequent()!. This line will look forward to the subsequent lead to our group. In different phrases, the primary activity to finish (both by returning one thing or throwing an error) is the duty that “wins”.

The opposite activity, the one which’s nonetheless operating, might be me marked as cancelled and we ignore its outcome.

There are some caveats round cancellation that I will get to in a second. First, I might like to point out you ways we are able to use this race perform to implement a timeout.

Implementing timeout

Utilizing our race perform to implement a timeout implies that we must always go two closures to race that do the next:

  1. One closure ought to carry out our work (for instance load a URL)
  2. The opposite closure ought to throw an error after a specified period of time

We’ll outline our personal TimeoutError for the second closure:

enum TimeoutError: Error {
  case timeout
}

Subsequent, we are able to name race as follows:

let outcome = attempt await race({ () -> String in
  let url = URL(string: "https://www.donnywals.com")!
  let (information, _) = attempt await URLSession.shared.information(from: url)
  return String(information: information, encoding: .utf8)!
}, {
  attempt await Activity.sleep(for: .seconds(0.3))
  throw TimeoutError.timeout
})

print(outcome)

On this case, we both load content material from the net, or we throw a TimeoutError after 0.3 seconds.

This wait of implementing a timeout does not look very good. We will outline one other perform to wrap up our timeout sample, and we are able to enhance our Activity.sleep by setting a deadline as a substitute of length. A deadline will make sure that our activity by no means sleeps longer than we meant.

The important thing distinction right here is that if our timeout activity begins operating “late”, it is going to nonetheless sleep for 0.3 seconds which implies it’d take a however longer than 0.3 second for the timeout to hit. After we specify a deadline, we are going to make it possible for the timeout hits 0.3 seconds from now, which implies the duty would possibly successfully sleep a bit shorter than 0.3 seconds if it began late.

It is a refined distinction, but it surely’s one price mentioning.

Let’s wrap our name to race and replace our timeout logic:

func performWithTimeout(
  of timeout: Length,
  _ work: sending @escaping () async throws -> T
) async throws -> T {
  return attempt await race(work, {
    attempt await Activity.sleep(till: .now + timeout)
    throw TimeoutError.timeout
  })
}

We’re now utilizing Activity.sleep(till:) to ensure we set a deadline for our timeout.

Operating the identical operation as prior to now appears as follows:

let outcome = attempt await performWithTimeout(of: .seconds(0.5)) {
  let url = URL(string: "https://www.donnywals.com")!
  let (information, _) = attempt await URLSession.shared.information(from: url)
  return String(information: information, encoding: .utf8)!
}

It is slightly bit nicer this manner since we do not have to go two closures anymore.

There’s one very last thing to have in mind right here, and that is cancellation.

Respecting cancellation

Taks cancellation in Swift Concurrency is cooperative. Because of this any activity that will get cancelled should “settle for” that cancellation by actively checking for cancellation, after which exiting early when cancellation has occured.

On the identical time, TaskGroup leverages Structured Concurrency. Because of this a TaskGroup can’t return till all of its youngster duties have accomplished.

After we attain a timeout situation within the code above, we make the closure that runs our timeout an error. In our race perform, the TaskGroup receives this error on attempt await group.subsequent() line. Because of this the we wish to throw an error from our TaskGroup closure which alerts that our work is finished. Nevertheless, we will not do that till the different activity has additionally ended.

As quickly as we would like our error to be thrown, the group cancels all its youngster duties. In-built strategies like URLSession‘s information and Activity.sleep respect cancellation and exit early. Nevertheless, as an instance you have already loaded information from the community and the CPU is crunching an enormous quantity of JSON, that course of is not going to be aborted routinely. This might imply that despite the fact that your work timed out, you will not obtain a timeout till after your heavy processing has accomplished.

And at that time you may need nonetheless waited for a very long time, and also you’re throwing out the results of that gradual work. That may be fairly wasteful.

Whenever you’re implementing timeout habits, you will need to concentrate on this. And for those who’re performing costly processing in a loop, you would possibly wish to sprinkle some calls to attempt Activity.checkCancellation() all through your loop:

for merchandise in veryLongList {
  await course of(merchandise)
  // cease doing the work if we're cancelled
  attempt Activity.checkCancellation()
}

// no level in checking right here, the work is already performed...

Notice that including a test after the work is already performed does not actually do a lot. You have already paid the value and also you would possibly as nicely use the outcomes.

In Abstract

Swift Concurrency comes with loads of built-in mechanisms but it surely’s lacking a timeout or activity racing API.

On this put up, we applied a easy race perform that we then used to implement a timeout mechanism. You noticed how we are able to use Activity.sleep to set a deadline for when our timeout ought to happen, and the way we are able to use a activity group to race two duties.

We ended this put up with a quick overview of activity cancellation, and the way not dealing with cancellation can result in a much less efficient timeout mechanism. Cooperative cancellation is nice however, for my part, it makes implementing options like activity racing and timeouts quite a bit tougher because of the ensures made by Structured Concurrency.

Related Articles

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Latest Articles