Saturday, April 19, 2025
HomeiOS DevelopmentImplementing Activity timeout with Swift Concurrency – Donny Wals

Implementing Activity timeout with Swift Concurrency – Donny Wals


Swift Concurrency supplies us with a great deal of cool and attention-grabbing capabilities. For instance, Structured Concurrency permits us to jot down a hierarchy of duties that at all times ensures all youngster duties are accomplished earlier than the father or mother process can full. We even have options like cooperative cancellation in Swift Concurrency which implies that every time we need to cancel a process, that process should proactively examine 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 typically talking, we do not have an API that enables us to “race” two or extra duties.

On this put up, I might wish to discover how we will implement a characteristic like this utilizing Swift’s Activity Group. Should you’re in search of a full-blown implementation of timeouts in Swift Concurrency, I’ve discovered this bundle to deal with it properly, and in a manner 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 process with the work you are trying to carry out
  2. A process that handles the timeout

whichever process completes first is the duty that dictates the end 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 might 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 might summary this right into a operate that has a signature that appears a bit bit like this:

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

Our race operate take two asynchronous closures which might be sending which implies that these closures intently mimic the API offered by, for instance, Activity and TaskGroup. To study extra about sending, you may learn my put up the place I evaluate sending and @Sendable.

The implementation of our race technique may be comparatively easy:

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. Which means that each closures will begin making progress as quickly as attainable (normally instantly). Then, I wrote return attempt await group.subsequent()!. This line will watch for the subsequent lead to our group. In different phrases, the primary process to finish (both by returning one thing or throwing an error) is the duty that “wins”.

The opposite process, the one which’s nonetheless operating, can be me marked as cancelled and we ignore its consequence.

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

Implementing timeout

Utilizing our race operate 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 will name race as follows:

let consequence = 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(consequence)

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 operate to wrap up our timeout sample, and we will enhance our Activity.sleep by setting a deadline as an alternative of period. A deadline will make sure that our process by no means sleeps longer than we supposed.

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

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

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

func performWithTimeout(
  of timeout: Period,
  _ 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 verify we set a deadline for our timeout.

Operating the identical operation as prior to now seems to be as follows:

let consequence = 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 a bit bit nicer this manner since we do not have to go two closures anymore.

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

Respecting cancellation

Taks cancellation in Swift Concurrency is cooperative. Which means that any process 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. Which means that a TaskGroup can not 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 operate, the TaskGroup receives this error on attempt await group.subsequent() line. Which means that the we need to throw an error from our TaskGroup closure which alerts that our work is completed. Nevertheless, we will not do that till the different process has additionally ended.

As quickly as we wish our error to be thrown, the group cancels all its youngster duties. Inbuilt strategies like URLSession‘s information and Activity.sleep respect cancellation and exit early. Nevertheless, as an example 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 mechanically. This might imply that although your work timed out, you will not obtain a timeout till after your heavy processing has accomplished.

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

If you’re implementing timeout habits, you will need to pay attention to this. And when you’re performing costly processing in a loop, you may need 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 achieved...

Observe that including a examine after the work is already achieved does not actually do a lot. You have already paid the value and also you may as properly use the outcomes.

In Abstract

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

On this put up, we carried out a easy race operate that we then used to implement a timeout mechanism. You noticed how we will use Activity.sleep to set a deadline for when our timeout ought to happen, and the way we will use a process group to race two duties.

We ended this put up with a short overview of process cancellation, and the way not dealing with cancellation can result in a much less efficient timeout mechanism. Cooperative cancellation is nice however, in my view, it makes implementing options like process racing and timeouts lots more durable because of the ensures made by Structured Concurrency.

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

- Advertisment -
Google search engine

Most Popular

Recent Comments