Enabling thread_inherit_context and context_aware_warnings by default on both builds

Currently, context variables and warnings behave differently when used with threads on the free-threaded build and the GIL-enabled build: on the free-threaded build, a new thread inherits the creating thread’s context by default, while on the GIL-enabled build it starts with an empty context. This is linked with warnings because of the warnings.catch_warnings state.

This is controlled via the thread_inherit_context and context_aware_warnings Python startup options, which default to on in free-threaded builds and off in GIL-enabled builds. See the startup option docs and the Python 3.14 release notes for more detail about this.

To make what thread_inherit_context does concrete, consider the following example using numpy.printoptions, which stores state in a context variable. The printoptions context manager gives users control over how NumPy prints arrays.

$ python3.14
>>> import numpy, threading
>>> def print_printoptions():
...    print(numpy.get_printoptions()['precision'])
>>> with numpy.printoptions(precision=2):
...    threading.Thread(target=print_printoptions).start()
8

The spawned thread sees the default 'precision' print option, 8, instead of the value specified in the with statement: 2.

With Python 3.14 and thread_inherit_context enabled, the spawned thread sees the state that was active where it was started, as the structure of the code suggests:

$ python3.14 -X thread_inherit_context=1
>>> import numpy, threading
>>> def print_printoptions():
...    print(numpy.get_printoptions()['precision'])
>>> with numpy.printoptions(precision=2):
...    threading.Thread(target=print_printoptions).start()
2

The current default on the GIL-enabled build is documented but I think it’s a surprising default, and everywhere else we’ve already decided that propagation is the right behavior: asyncio tasks copy the creating context, asyncio.to_thread() explicitly propagates it, and the free-threaded build has thread_inheric_context on by default. Plain threading.Thread is the odd one out.

I want to create a future where thread_inherit_context is turned on by default on both builds.

I spoke with @yselivanov, @thomas, and @hugovk at the CPython sprints on July 18th. They agreed the current behavior is surprising, and liked the idea of introducing the warning in Python 3.15 if we can get it done before rc1 on August 4th. Otherwise we can do it for 3.16. If the warning lands in 3.15, my rough hope would be to flip the defaults in 3.16; if the warning slips to 3.16, then 3.17.

Yury had an idea for implementing the warning without making every Thread(...) call noisy. When a thread is created without the flag being explicitly set (as opposed to explicitly disabled by the user), we’d capture a snapshot of which context variables were set in the creating thread, without making that context active in the new thread. If the new thread later reads a context variable and falls back to its default even though the variable was set in the enclosing scope when the thread was spawned, Python would issue a warning that the value was not inherited from the creating thread. Code that never reads context variables in threads and code that reads the same value as the value defined in the enclosing scope when the tread was spawned would never see the warning.

If a user explicitly sets thread_inherit_context to 0 there would also be no warning, as that indicates an explicit user request to use the current behavior. Code that wants a clean context for a particular thread can opt out explicitly by passing context=contextvars.Context() to threading.Thread.

The context_aware_warnings option makes warnings.catch_warnings() store its state in a context variable instead of mutating process-global state. IMO it should also be turned on by default on both builds: combined with thread_inherit_context, a catch_warnings block around thread creation would finally apply to warnings raised inside that thread, closing a long-standing gap in the warnings module.

One subtlety: with context_aware_warnings enabled, emitting a warning itself reads a context variable to find the active filters, so the check will need to exempt the warnings machinery’s own context variable and guard against re-entrancy.

I’m posting here to raise awareness and to start discussion. If you think such a warning would be unavoidably noisy, or you see other downsides, I’d appreciate hearing about it. Maybe @nas has other ideas — I know he thought about this quite a bit for Python 3.14 last year.

3 Likes

New warnings tend to be more disruptive than people anticipate. Trying to limit it would still have an impact, it means more work the interpreter has to do any time contextvar defaults are hit.

I agree with changing the default behavior here, but is this a case that needs a warning to do so? It was my understanding that the freethreaded build was still expected to be changing details like this over time to keep improving it before that build can later become the only one offered. ( Edit: I misread which one would require changing rest still applies)

This seems like an unambiguous improvement in default behavior to just make the change if the expectation is that those relying on specific details of the freethreaded build are expected to be more actively aware of changes to it.

Libraries can’t rely on the current behavior anyway, only applications could have (it’s an -X option after all)

The 3.15/3.16 timing is tricky at this point if we’re going to have a release with a warning.

The key problem with new warnings in the beta period is that they’re a breaking change for situations where any text appearing on stderr is classified as an error even if the affected Python invocation is otherwise fine.

The change itself seems like a good idea to me, it’s just the more conservative timeline that I would suggest pursuing.

1 Like

That’s fair and it is quite late for a new warning. I also think “just change it” will be too disruptive, or at least it will be hard to gauge how disruptive it will be without a warning phase. I’ll try to talk to Neil about this next week to understand the original motivation for not changing the GIL-enabled build in 3.14.

1 Like

I should clarify that when I said “just change it”, I meant announce that it will be changing in 3.16 without a runtime warning in 3.15, just change it in 3.16, letting release notes do the job of communicating the upcoming change.

Something else that I didn’t consider in the post because I initially read it backward is that there’s also a reasonable argument here to just leave this alone and let the situation resolve itself when freethreaded becomes the only option. I don’t think I’ve ever seen someone intentionally or unintentionally spawn a thread from within a modified context. From observation (anecdotal, not statistical), people modify context as close to where they expect it to apply as possible.

Is the number of cases this might create a warning for, with an unknown percentage of those cases that might be fine in application code worth the churn if we believe in the future of the freethreaded build?

1 Like

I think we should turn on those two options in the default build, not sure in what release. If not too complex or expensive, giving deprecation warnings would be nice.

An easy thing we could do in the 3.15 release: ask people to test their code and apps with those flags, set PYTHON_CONTEXT_AWARE_WARNINGS=1 and PYTHON_THREAD_INHERIT_CONTEXT=1 in the environment. Or, test with the free-threaded build. :wink:

Perhaps this same mechanism could also be used to fix the decimal context bug related to this change. Briefly, because the decimal context object is mutable, inheriting the context causes this mutable object to be shared between threads and asyncio tasks. I suspect other libraries that use contextvar might use a similar pattern in that the object they bind to the contextvar is mutable and the “real” context local settings are inside that object.

Here is the issue that lead to thread_inherit_context. I was initially thinking we could turn it on for all builds. My guess was that it wouldn’t cause many problems. Here’s a comment arguing why we shouldn’t turn it on by default:

The context_aware_warnings flag is likely more disruptive, e.g. I found a number of examples of code in the wild that messes with warnings.filters directly. There are some details in the old thread discussion thread-safe warning filters.

A warning for context_aware_warnings might work as follows. Detect if people are directly modifying warnings.filters. Maintain a context local filters list, same as is done when the flag is on. Then, when checking if a warning is filtered, detect if you get a different filtering result by using the context local filters and, if so, emit a deprecation warning.

That seems fairly complex and expensive though. Maybe it’s better just to have people set those flags in the environment and see what breaks.

2 Likes

My request was motivated by a NumPy issue and subsequent documentation PR.

Before we added free-threaded support in NumPy, we stored a number of configuration options in C global variables. This was horribly thread-unsafe: a remote thread could change a configuration option “underneath” a running thread.

The issue reporter was using the set_printoptions function to adjust the printoptions state globally.

A few releases ago, we fixed the thread safety issues associated with NumPy’s global configuration state by making the state be context-local.

However, there’s one big downside: freshly spawned threads don’t see the configuration done in the main thread, so users need to know that spawned threads need to re-do the configuration or they need to enable thread_inherit_context. In the issue two very experienced Python developers (Georg and Matti) said that they weren’t aware of this issue or the startup option to fix it.

Ideally, IMO, projects would be able to unconditionally replace global state with context-local state, but this one remaining wrinkle is still a downside for projects who want to do that.

2 Likes

Ideally, IMO, projects would be able to unconditionally replace global state with context-local state, but this one remaining wrinkle is still a downside for projects who want to do that.

Just a generall :+1: if it helps to keep things moving. warnings doesn’t seem like an outlier here, almost any global option is ideally context local (at least my understanding is that everyone feels it is the “obvious” thing).
But this is a serious wrinkle and user surprise unfortunately when we use it this way (and I am not sure there is any real alternative).

With free-threading not running into known issues yet, I guess (and indeed showing it is strictly required in practice!), I do wonder if there are cases that will be broken? (There always are, but will those all be places where it feels like a fix that flushed out a bug?)

Having an interpreter global that controls this is not ideal. Instead, it would be nicer to allow individual projects/libraries/modules to opt-in. I’ve come up with a change that adds this API:

  .. classmethod:: ContextVar.thread_inheritable(name, [*, default])

     Return a new context variable whose binding is inherited by new
     threads.  When :meth:`threading.Thread.start` would otherwise start
     the thread with an empty context (that is, when
     :data:`sys.flags.thread_inherit_context` is false and no explicit
     context was passed to the thread), the bindings of all
     thread-inheritable variables are copied from the context of the
     caller of :meth:`~threading.Thread.start` into the new thread's
     context.  This allows individual context variables to opt in to
     thread inheritance on a per-variable basis.

     The copied bindings are ordinary bindings in the new thread's
     context: they are visible to :func:`copy_context`, may be shadowed
     with :meth:`ContextVar.set`, and are in turn inherited by threads
     started from the new thread.  Threads started with an explicitly
     supplied context are unaffected.

     The *name* and *default* parameters have the same meaning as for the
     :class:`ContextVar` constructor.

     Libraries that also support Python versions without this method can
     gracefully fall back to creating an ordinary context variable::

         new_context_var = getattr(
             ContextVar, "thread_inheritable", ContextVar
         )
         var = new_context_var("var")

     .. versionadded:: 3.16

I think the addition of this API would decrease the urgency of making thread_inherit_context default to true.

2 Likes

Neil and I talked about this new constructor for ContextVar offline and we both agreed that thread_inheritable isn’t the best name. Neil is going to give it a name that more clearly indicates it’s a new classmethod constructor. A future Python version could make it an alias for the main ContextVar constructor, if we want to change the behavior of the main constructor in the future.

Have a new constructor like this is a lot nicer to allow writing code that doesn’t depend on Python version. Needing to add a new python version-dependent keyword argument to the ContextVar constructor is a lot more awkward than probing for an attribute with getattr.

Neil is planning to open two PRs against CPython with the warning and this new constructor. Merging both for 3.15 is ideal, IMO, but we’re proposing them as two separate PRs to allow Hugo to make two different decisions if he’d like to do that.

2 Likes

Also I should say that we’re not going to touch the context_aware_warnings default for 3.15, since Neil thinks that will likely cause more breakage. The CPython test suite never hits the new thread_inherit_context warning Neil has a draft implementation for.

Here is the draft PR for ContextVar.thread_inheritable(). I’m now leaning to prefer a class init keyword arg, e.g. ContextVar(thread_inheritable=True). You can “feature detect” on the class descriptor for the instance attribute. I’m sure people who care will share opinions, I don’t feel strongly about that part.

An important question, IMHO, is if we should try to get this feature into the 3.15 release, even though we are in the beta stage. It is quite late to be adding a new API. However, this mechanism really needs to be in Python itself, you can’t work around its absence it in a library. As the issue explains, libraries that want context-local state don’t have good options (i.e. it’s not just inconvenient, there is actually no solution that does the right thing).

IMHO, this new API is more useful than a warning and likely less disruptive. Libraries that want to get the “thread inherit” behavior just start using it. No need to wait for people to see the warning, respond to it, and for Python to finally change the default of the thread_inherit_context flag.

(FWIW, posting for myself, not the SC.)

I don’t think that’s the right consideration here. @yselivanov was very clear at the EuroPython sprints that he considers the current (GIL-build) behaviour a bug and an oversight. I agree with him. The current behaviour is confusing and unexpected and non-obvious, and we should fix it. Fixing it by making all users use this keyword argument (or method, or whatever API is chosen) is not the right approach.

If the current (GIL-build) behaviour is at all relied on by code, changing the behaviour without a warning is a bad idea. Given that one of the ways code can rely on the current behaviour is in order to work around the current broken behaviour, I think it’s safe to say there’s a reasonable likelihood of things relying on the current behaviour.

I think the warning would be too disruptive if there were too many cases that just didn’t care about which behaviour they get. If we only emit warnings when a contextvar is looked up that would otherwise have been different, as Yury suggested, we limit the warning to code that actually gets a different contextvar than in the free-threaded build or 3.16, and might actually be getting a different contextvar than they expected. Those are reasonable warnings to deliver.

I wish we’d considered this earlier in the beta process, but we are still in beta, and we can still very reasonably change things like this. We have the release candidates to flush out obvious issues, and if the warning does turn out to be too disruptive and we don’t realize it until after the 3.15.0 release, we can still disable it in point releases. That’s not something we can do with a change of behaviour.

3 Likes

I agree with this. In a future version of Python, ContextVar() should be inherited both by asyncio tasks and by threads, as Yury intended. It’s the most useful and intuitive behavior.

The keyword/classmethod would exist only to give a way for libraries to opt-in early to what will become the default behavior in a later version. If we don’t give library authors this tool, awkward as it might be, they don’t have any good solutions until thread_inherit_context is turned on (either on by default or someone remembers to manually turn it on).

We can’t go back in time and fix the oversight. So, what are our options going forward? If we decide it’s going to break too much to change thread_inherit_context now, we have to go through a deprecation period.

This is my recommendation:

  • add the ContextVar(thread_inheritable=True) API to the 3.15 release
  • add the deprecation warning to 3.16.

I think it’s a bit too risky to add the deprecation warning at this point. We have the annoying problem that decimal will likely trigger it when you call decimal.getcontext(), e.g. the following code warns:

import threading
import decimal

dc = decimal.getcontext()


def func():
    decimal.getcontext()


t = threading.Thread(target=func)
t.start()
t.join()

This is not a spurious warning and we couldn’t avoid it with smarter warning logic. This is exactly a case where thread_inherit_context=1 does change behavior. It’s why we need something like my PR to make decimal.getcontext() freshly copy the decimal.Context object.

Also, if library authors can use use ContextVar(thread_inheritable=True), there is lower urgency to change the default. Flipping that default is no longer giving them a new capability, it’s just making their code less verbose.

I think changing the default in 3.18 is probably fine. Or, if the deprecation warning reveals little affected code, PEP 387 does allow us to change it early, say in 3.17.

I put together an uber PR that combines all the changes I’m suggesting, along with some commentary on the reasoning and the suggested merge timing of each. We have a bit of a mess but I think we can crawl out of it. :wink:

1 Like

What do you think should go into 3.15? I think it’d be best if we table everything else until after 3.15 ships. I want to make it clear what Hugo needs to make a decision about.

Library code (ie. code that can’t control what options python was built with or launched with) won’t benefit from the new temporary interface unless they are implicitly telling their users “this might be broken if you don’t update to the latest python” I think the best option here is still to just to communicate an upcoming change in the release notes, and provide recipes for this that work on existing python versions for affected libraries (this would effectively be a recipe to copy contextvars into threading locals)

Application authors can just set the flag themselves now.

The suggestion is that a library that wants to convert global state into context-local does something like the following:

if hasattr(ContextVar, "thread_inheritable"):
    # Context-local and inherited by new threads.
    _new_var = ContextVar.thread_inheritable
elif getattr(sys.flags, "thread_inherit_context", False):
    # Threads inherit the whole context, so an ordinary
    # context variable is inherited too.
    _new_var = ContextVar
else:
    # Keep the library's historical global semantics.
    _new_var = _GlobalVar

That means if the user of the library either uses a new enough Python, or they explicitly set thread_inherit_context=1 then they get thread-safe async-compatible behavior. The library can choose to give a warning or error out in the “else” case, if that’s not a supported config.

I think the decimal.Context sharing bug should be fixed in 3.15, it’s just a straight-up bug. I would have merged the PR already but I’m hoping for a second opinion/review of it. Adding the depth counter to PyContext seems a bit ugly but is the best fix I can think of.

I think the ContextVar.thread_inheritable API should go into 3.15 too, because it lets application/library code get the inherited behavior they want, without having to wait for a future version of Python to turn thread_inherit_context on by default. However, if Hugo says too late for a new API, I think that’s a reasonable position. Maybe it’s not so bad, library code can be like this:

lif getattr(sys.flags, "thread_inherit_context", False):
    # Threads inherit the whole context, so an ordinary
    # context variable is inherited too.
    _new_var = ContextVar
else:
    # Keep the library's historical global semantics.
    _new_var = _GlobalVar

Again the library has the option to warn or error out in the “else” case. Maybe say “please set PYTHON_THREAD_INHERIT_CONTEXT=1”.

It would be nice to merge the deprecation warning PR too. However, that one has the issue that decimal.getcontext() will trigger warnings. Maybe other ContextVar users have a similar pattern, where they create a new context var binding when you lookup the value. So I think the careful thing to do is only add the deprecation warning in 3.16.

1 Like

Right now, libraries are going to have to handle the case where it is false (unless they only support the latest python version and this temporary api is added). If the current status quo is broken for their use, they can detect that now and inform users now in a way that doesn’t require a new API that’s only a transitionary measure. If we were to add such a transitionary API in either 3.15 or 3.16, libraries can’t rely on it existing until 3.15/3.16 is their minimum, which means they still have to have a path for prior versions.

I don’t see a benefit to the new API as a result. If I need to either have an implementation that works without contextvars inheriting, or tell users to set the flag for older python versions to begin with, a new temporary api to manually inherit isn’t reducing the needed work.

Suppose the new API lands in 3.15, the warning lands in 3.16, and the default changes in 3.17.

That’s two years in which library authors can make things “just work” on the latest Python version without end users having to change anything.

If the default only changes in 3.18, it’s a full 3 years of benefit (and arguably more given adoption curves for new Python versions).