Terribly sorry about this; I the proposal indeed has been catching up on all the other proposals moving since months and got a bit messy. I did a cleanup pass through the text now, it hopefully does not refer to old wording anymore.
Right, I'm very excited about this aspect actually. We're in a good spot with structured concurrency as a core concept to pull of patterns which are impossible in other runtimes and languages (e.g. the way we're using using efficient allocation the locals, and are also in some cases to allocate even tasks in their parent's task allocator).
Correct, it is possible to read task locals from synchronous functions. It is among the bigger reasons we made the task accessible in those.
It seems I missed a few sentences which still claimed that these APIs only work from asynchronous code - fixed these now. Sorry about this, the proposal should now be consistent.
The current design assumes that reads may be performed from asynchronous or synchronous functions. And if no task is available in the synchronous function, we return the default value.
I will point out that currently the design assumes that writes must be done in an async function:
// initially proposed API
Task.withLocal(\.traceID, boundTo: 1111) {
...
}
// or, following style of Becca's proposed API:
await Lib.traceID.withValue(...) {
...
}
This is because there must be a task available to set a task local value after all. And unlike for reads, there isn't really a good alternative to do when no task is available other than silently do nothing, which I can see as getting very very confusing. For example, it would be very confusing if we allowed bindings in synchronous functions like this:
func log() {
print("value: \(Task.local(\.value))")
// print("value: \(Library.value)") // in property wrapper style
}
// NOT PROPOSED API; this showcases issues with such approach
func sync() {
/*await*/ Task.withLocal(\.value, boundTo: 1) {
log()
}
}
If we allowed such API it would be very confusing, because depending on if sync() was called inside a task it'd work as expected; but if it wasn't, we'd have to no-op and set nothing...
We can definitely discuss if this is just the way it is and relax the restriction on binding task locals even from synchronous contexts. At the risk if this confusing example becoming a reality.
Alternatively, (and which was originally the plan), it would be possible to perform write operations on the UnsafeCurrentTask -- inside a synchronous function if we are able to get a task, we know we can use it to set the value.
I'd love your feedback @Chris_Lattner3 on this aspect of the design: Shall we permit writes (binding values) also from synchronous functions even given the above confusing snippet? One could argue that that's just what one has to deal with when trying to use task specific APIs in a context where a task may not be available...?
As it seems that we're closing on towards using the property wrapper design for declaring keys, the default value aspect becomes rather natural:
@TaskLocal
var traceID: TraceID? = nil
// or
@TaskLocal
var traceID: TraceID = .empty
This feels pretty natural to me; and the complexity isn't as much in the default values (even in the initial proposed design), as it is in the "options" which we need to support for future extensions of this API (the "inheritance modes" etc).
Nice, thanks! I'll admit I was not familiar with the origin of the technique but have seen it in practice in such context types and was very happy that our structured concurrency guarantees make it all work in this context. I'll read up a bit there, perhaps some other tricks exist we could apply here.
Great question! Yes, that indeed is the plan in order to get better lookup performance for lookup performance sensitive keys.
The current implementation does not do this yet but indeed we'll be able to reserve a few words for such in-line storage. I don't know yet the fine details of this, but the general idea was to provide e.g. 3 (or some small n) "slots" that we'd utilize for this.
Then, we'd want to utilize this storage for "privileged keys" which we'd mark using options on the key types. If no privileged keys are present, we could use it for other keys as well.
This would allow us to always store keys which will be "very very often accessed" in the in-line storage, rather than having to perform the search for them. For example this could be used for mach vouchers or traceIDs which are accessed all the time, while other less-privileged keys would use the usual linked list lookup method.
Detaches + carry task locals anyway are sadly quite expensive. They mean we have to copy all values (and +1 reference count them) over to the new task.
There is no way we can implement this without copying sadly. Task local values are optimized for the typical use of setting some values, running child tasks which read them, and then tearing down all that memory. Even tasks themselves we believe we'll be able to allocate using this task local allocator -- this will give noticable performance gains to short-lived tasks, as we will not have to malloc at all for short lived tasks.
So... yes detaching and carrying values is quite heavy, we have to iterate through the task local bindings and copy over the first encounter of any key to the new task.
The verb in the write side is "bind", as in Task.withLocal(_:boundTo:) but I can see your point.
Given that many in this thread would prefer the property wrapper API, what do you think about those?
Library.value // read
Library.$value.withValue(...) { ... }
Thanks, fixed (removed this as it didn't really help the point much).
Thank you, updated to Sendable and also making use of it everywhere where we should be using it.