In the early days Trio had a concept of “task local variables”, that was basically just a dict attached to each task. When you spawned a new task, the child’s dict was initialized to be a shallow copy of the parent’s dict. The user level API was modelled after Python’s API for thread-local variables.
More recently, Python gained a built-in concept of “context variables” (spec, interpreter docs, trio docs). The motivation for moving this into the interpreter itself was that there are libraries that currently use thread-local state, and they were doing very weird things when mixed with user-space scheduling. For example, numpy doesn’t know anything about networking or concurrency or Trio or asyncio, but it lets you set how IEEE 754 errors like overflow/divide-by-zero/etc. should be handled. And this is supposed to be thread-local, but the settings were leaking between different Trio tasks / asyncio callbacks / etc.
So one important constraint was that the new thing had to be about as fast as thread-locals to access (because target users like numpy are extremely speed-sensitive), and it had to support all the operations that classic thread locals supported, so that we could port these legacy APIs over. In particular, this meant that in addition to a dynamic-scoping-style API like with variable_handle.temporarily_set_to(new_value): ...
, we also needed a traditional-style API like variable_handle.set(new_value)
.
And if you have a pure setting API like this, you have to define the scope of where the new value is visible. I think this is the biggest choice to make: do you only allow creating new dynamic bindings at the level of the current scope, or do you allow updating existing bindings, and if the latter, then were are the bindings created?
For Python we basically kept the concept of task-local variables, that are inherited from the spawning task as a shallow copy. If you’re using the with
based API you can’t really tell (it sets the new value on entry, then restore the old value on exit). But if you use the set
API, then that sets the value in the current task.
Also, with Python context vars, we can’t quite emulate dynamic scoping, because of a wrinkle in how Python generators and context vars interact. The issue is, what happens if you set a context var inside a generator, and then suspend the generator. With a real dynamic scope, then the setting should apply inside the generator, then go away when the generator is suspended, then come back again when the generator is resumed… this requires a significantly more complicated implementation, with the interpreter runtime maintaining a stack of values that are automatically pushed/popped as generators are entered/exited.
Also, it turns out there are some cases in Python where you really want the generator body to be treated as if it was part of the surrounding scope – in particular when defining new context managers. Like, take numpy.errstate
, that I linked to above. Maybe I want to define a new abstraction based on errstate
, like:
# shorthand for 'with errstate(overflow="error")'
with error_on_overflow():
...
The standard way to define error_on_overflow
would be:
@contextmanager
def error_on_overflow():
with errstate(overflow="error"):
yield
So what’s going on here is that we define error_on_overflow
as a generator (!), and then the @contextmanager
decorator wraps that generator into an object that implements the context manager protocol that with
uses. When I write with error_on_overflow(): ...
, it creates the generator, steps it once to get to the yield
, then executes the body of the with
statement, then steps the generator again to do any cleanup. So this is a really different use case for generators than the classic one of defining an iterator. And this is what makes dynamic scoping tricky: for the iterator use case, dynamic scoping should isolate the body of the generator from the caller. But in the context manager use case, the entire purpose is to change the value of a context variable in the caller’s context!
Sorry, I hope that summary made sense – it’s pretty complicated
Anyway we did come up with some schemes for how we could make the full dynamic-scoping-style version work (PEP 550, PEP 568), but Guido was very skeptical about whether it was worth the complexity, and pushed us to leave it out. So for now, context var settings can leak out of generators.
I’m sure a lot of the considerations here are specific to Python. But I’m guessing you have similar issues around backwards compatibility, and I guess you’ll want to think hard about all the ways your new delimited continuations might be used beyond just to implement lightweight-threads, and how they should interact with scoped variables.