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.