How to create and use task local values
How to create and use task local values êŽë š
Updated for Xcode 15
Swift lets us attach metadata to a task using task-local values, which are small pieces of information that any code inside a task can read. For example, youâve already seen how we can read Task.isCancelled
to see whether the current task is cancelled or not, but thatâs not a true static property â itâs scoped to the current task, rather than shared across all tasks. This is the power of task-local values: the ability to create static-like properties inside a task.
Important
Most people will not want to use task-local values â if youâre just curious youâre welcome to read on and explore how task-local values work, but honestly they are useful in only a handful of very specific circumstances and if you find them complex I wouldnât worry too much.
Task-local values are analogous to thread-local values in an old-style multithreading environment: we attach some metadata to our task, and any code running inside that task can read that data as needed. Swiftâs implementation is carefully scoped so that you create contexts where the data is available, rather than just injecting it directly into the task, which makes it possible to adjust your metadata over time. However, inside that context all code is able to read your task-local values, regardless of how itâs used.
Using task-local values happens in four steps:
- Creating a type that has one or more properties we want to make into task-local values. This can be an enum, struct, class, or even actor if you want, but Iâd suggest starting with an enum so itâs clear you donât intend to make instances of the type.
- Marking each of your task-local values with the
@TaskLocal
property wrapper. These properties can be any type you want, including optionals, but must be marked asstatic
. - Starting a new task-local scope using
YourType.$yourProperty.withValue(someValue) { ⊠}
.
Inside the task-local scope, any time you read YourType.yourProperty
you will receive the task-local value for that property â itâs not a regular static property that has a single value shared between all parts of your program, but instead it can return a different value depending on which task tries to read it.
To demonstrate task-local values in action, I want to give you two examples: the first is a simple toy example that demonstrates the code required to use them and how they work, but the second is a more real-world example thatâs actually useful.
First, our simple example. This will create a User
enum with a id
property that is marked @TaskLocal
, then it will launch a couple of tasks with different values for that user ID. Each task will do exactly the same thing: print the user ID, sleep for a small amount of time, then print the user ID again, which will allow you to see both tasks running at the same time while having their own unique task-local user ID.
Hereâs the code:
enum User {
@TaskLocal static var id = "Anonymous"
}
@main
struct App {
static func main() async throws {
Task {
try await User.$id.withValue("Piper") {
print("Start of task: \(User.id)")
try await Task.sleep(nanoseconds: 1_000_000)
print("End of task: \(User.id)")
}
}
Task {
try await User.$id.withValue("Alex") {
print("Start of task: \(User.id)")
try await Task.sleep(nanoseconds: 1_000_000)
print("End of task: \(User.id)")
}
}
print("Outside of tasks: \(User.id)")
}
}
When that code runs it will print:
- Start of task: Alex
- Start of task: Piper
- Outside of tasks: Anonymous
- End of task: Alex
- End of task: Piper
Of course, because the two tasks run independently of each other you might also find that the order of Piper and Alex switch. The important thing is that each task has its own value for User.id
even as they overlap, and code outside the task will continue to use the original value.
As you can see, Swift makes it impossible to forget about a task-local value youâve set, because it only exists for the work inside withValue()
. This scoping approach also means itâs possible to nest multiple task locals as needed, and you can even shadow task locals â start a scope for one, do some work, then start another nested scope for that same property. so that it temporarily has a different value.
In real-world code, task-local values are useful for places where you need to repeatedly pass values around inside your tasks â values that need to be shared within the task, but not across your whole program like a singleton might be. For example, the Swift Evolution proposal for task-local values (https://github.com/apple/swift-evolution/blob/main/proposals/0311-task-locals.md) suggests examples such as tracing, mocking, progress monitoring, and more.
As a more complex example, we could create a simple Logger
struct that writes out messages depending on the current level of logging: debug being the lowest log level, then info, warn, error, and finally fatal at the highest level. If we make the log level â which messages to print â be a task-local value, then each of our tasks can have whatever level of logging they want, regardless of what other tasks are doing.
To make this work we need three things:
- An enum to describe the five levels of logging.
- A
Logger
struct that is a singleton. - A task-local property inside
Logger
to store the current log level. (Even though the logger is a singleton, the log level is task-local.)
On top of that, we need a couple more things to actually demonstrate the logger in action: a fetch()
method that downloads data from a URL and creates various logging messages, and a couple of tasks that call fetch()
with different task-local log settings so we can see exactly how it all works.
Hereâs the code:
// Our five log levels, marked Comparable so we can use < and > with them.
enum LogLevel: Comparable {
case debug, info, warn, error, fatal
}
struct Logger {
// The log level for an individual task
@TaskLocal static var logLevel = LogLevel.info
// Make this struct a singleton
private init() { }
static let shared = Logger()
// Print out a message only if it meets or exceeds our log level.
func write(_ message: String, level: LogLevel) {
if level >= Logger.logLevel {
print(message)
}
}
}
@main
struct App {
// Returns data from a URL, writing log messages along the way.
static func fetch(url urlString: String) async throws -> String? {
Logger.shared.write("Preparing request: \(urlString)", level: .debug)
if let url = URL(string: urlString) {
let (data, _) = try await URLSession.shared.data(from: url)
Logger.shared.write("Received \(data.count) bytes", level: .info)
return String(decoding: data, as: UTF8.self)
} else {
Logger.shared.write("URL \(urlString) is invalid", level: .error)
return nil
}
}
// Starts a couple of fire-and-forget tasks with different log levels.
static func main() async throws {
Task {
try await Logger.$logLevel.withValue(.debug) {
try await fetch(url: "https://hws.dev/news-1.json")
}
}
Task {
try await Logger.$logLevel.withValue(.error) {
try await fetch(url: "https:\\hws.dev/news-1.json")
}
}
}
}
When that runs youâll see âPreparing request: https://hws.dev/news-1.jsonâ as the first task starts, then âURL https:\hws.dev/news-1.json is invalidâ as the second task starts (I used a back slash rather than forward slash), then âReceived 8075 bytesâ as the first task finishes downloading its data.
So, here our fetch()
method doesnât even need to know that a task-local value is being used â it just calls the Logger
singleton, which in turn refers to the task-local value.
To finish up, I want to leave you with a few important tips for using task-local values:
- Itâs okay to access a task-local value outside of a
withValue()
scope â youâll just get back whatever default value you gave it. - Although regular tasks inherit task-local values of their parent task, detached tasks do not because they donât have a parent.
- Task-local values are read-only; you can only modify them by calling
withValue()
as shown above.
And finally, one important quote from the Swift Evolution proposal for this feature: âplease be careful with the use of task-locals and don't use them in places where plain-old parameter passing would have done the job.â Put more plainly, if task locals are the answer, thereâs a very good chance youâre asking the wrong question.