Now that we know all about tasks and task groups, it’s time to take it a step further. The code in this lesson will be advanced, but you don’t have to understand all the details. You can also decide to simply use this code throughout your projects.
My goal of this lesson is to demonstrate the usage of Task Groups in a creative manner. At the same time, we’re filling a gap in Swift Concurrency: setting a timeout for tasks. This can be useful for tasks that should be completed quickly, but may occasionally take longer than expected. It can also serve as a tool when writing tests. Let’s dive in!
Note
I’m using Swift 6.2 for the API in this lesson, which allows new task group initializers.
Creating a global task timeout handler
Swift Concurrency offers several global methods like withThrowingTaskGroup or withCheckedContinuation. We can follow this pattern by creating a new withTaskTimeoutHandler method.
This method will take a Duration argument to configure a certain timeout. The concept works as follows:
- Create a new Task Group
- Add the main task as the first child task
- Add another timeout triggering task as the second child task
- Await for the first value to be returned, whether it’s a timeout fallback value or the main task’s return value
In code, this would look as follows:
func withTaskTimeoutHandler<Success: Sendable>(
timeout: Duration,
operation: @Sendable @escaping () async throws -> Success,
onTimeout handler: @Sendable @escaping () throws -> Success,
isolation: isolated (any Actor)? = #isolation
) async rethrows -> Success {
try await withThrowingTaskGroup(returning: Success.self) { group in
/// Add the operation to perform as the first task.
_ = group.addTaskUnlessCancelled {
try await operation()
}
/// Add another task to trigger the timeout handler if it finishes earlier than our first task.
_ = group.addTaskUnlessCancelled { () -> Success in
try await Task<Never, Never>.sleep(for: timeout, clock: .continuous)
return try handler()
}
/// We need to deal with an optional, even though we know it's not optional.
/// This is default for task groups to account for when there aren’t any pending tasks.
/// Awaiting on an empty group immediately returns `nil` without suspending.
guard let result = try await group.next() else {
throw TaskTimeoutError.missingValue
}
/// If we reach this, it means we have a value from either the operation or the fallback handler from the timeout.
/// We cancel the group, which means just cancelling the remaining task, as we only need one of the two outputs.
group.cancelAll()
return result
}
}
The inline comments help you further understand this concept. We use a Task.sleep to configure the timeout and call into the timeout handler for a fallback value. Note that the timeout handler is currently synchronous; however, you could decide to make it asynchronous as well. However, you would need to cancel the main task before running the handler.
Calling this method looks as follows:
/// Using the global defined method `withTaskTimeoutHandler` to trigger a timeout if the inner operation
/// does not complete before our timeout.
let output = try await withTaskTimeoutHandler(timeout: .seconds(1), operation: {
/// Sleep for longer than the timeout to demonstrate the task timing out.
try await Task.sleep(for: .seconds(2))
return "Task completed before timeout"
}, onTimeout: {
return "Timeout fallback message: Task did timeout"
})
print("Result of global timeout function: \(output)")
In this demonstration, the output value will be Timeout fallback message: Task did timeout. You can give this code a try yourself by using the sample code.
Creating a custom Task initializer
I did not want to stop here. The global method is probably the best way to configure a timeout for a task, but we can also write a custom task initializer. The code looks as follows:
extension Task where Failure == any Error {
@discardableResult
init<C>(
name: String? = nil,
priority: TaskPriority? = nil,
/// New: a timeout property to configure how long a task may perform before failing with a timeout error.
timeout: C.Instant.Duration,
clock: C = .continuous,
operation: @Sendable @escaping @isolated(any) () async throws -> Success
) where C : Clock {
self = Task(name: name, priority: priority, operation: {
try await withThrowingTaskGroup { group in
/// Add the operation to perform as the first task.
_ = group.addTaskUnlessCancelled {
try await operation()
}
/// Add another task to trigger the timeout if it finishes earlier than our first task.
_ = group.addTaskUnlessCancelled { () -> Success in
try await Task<Never, Never>.sleep(for: timeout, clock: clock)
throw TaskTimeoutError.timeout
}
/// We need to deal with an optional, even though we know it's not optional.
/// This is default for task groups to account for when there aren’t any pending tasks.
/// Awaiting on an empty group immediately returns `nil` without suspending.
guard let result = try await group.next() else {
throw TaskTimeoutError.missingValue
}
/// If we reach this, it means we have a value before the timeout.
/// We cancel the group, which means just cancelling the timeout task.
group.cancelAll()
return result
}
})
}
}
The concept remains the same, we just wrap it inside a new Task instance. This can be useful if you want to call into an asynchronous method from a synchronous context with a timeout. Note that we don’t have a handler here, but we fail the operation by throwing a TaskTimeoutError.timeout.
Calling this code looks as follows:
do {
/// Create a task with a timeout.
/// Note the new `timeout` property:
let timeoutTask = Task(name: "Task with timeout", timeout: .seconds(1)) {
/// Sleep for longer than the timeout to demonstrate the task timing out.
try await Task.sleep(for: .seconds(2))
return "Did not timeout"
}
let output = try await timeoutTask.value
print(output) // Will never be reached in this demo.
} catch {
print("Task failed with error: \(error)")
}
It’s quite similar to the global method we defined before, but it fails with an error instead of a fallback string.
Summary
Both timeout solutions have their purpose. They demonstrate the flexibility we can create by creatively using task groups. I’ve used this code myself inside RocketSim for a process that should log a certain output. In case it wouldn’t, I have a timeout to guard myself from a never-ending process.
That was it for this module. Time to switch gears and look into the Sendable protocol.