From f0ec3e2a36a9a6de309515bba520043ec7767ca5 Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Wed, 22 Jul 2026 14:29:49 -0700 Subject: [PATCH] gh-154549: warning for thread_inherit_context change On GIL-enabled builds, emit a DeprecationWarning when the default implicitly empty threading.Thread() context causes a context variable lookup to differ from the context that a future release will inherit by default. --- Doc/library/threading.rst | 10 + Doc/using/cmdline.rst | 10 +- Include/internal/pycore_context.h | 3 + Include/internal/pycore_initconfig.h | 1 + Include/internal/pycore_interp_structs.h | 3 + Lib/test/test_context.py | 244 +++++++++++++++++- Lib/threading.py | 9 +- ...-07-23-10-16-59.gh-issue-154549.Ulgmsd.rst | 5 + Python/_contextvars.c | 14 + Python/clinic/_contextvars.c.h | 19 +- Python/context.c | 54 +++- Python/initconfig.c | 15 ++ Python/pylifecycle.c | 16 +- 13 files changed, 387 insertions(+), 16 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-23-10-16-59.gh-issue-154549.Ulgmsd.rst diff --git a/Doc/library/threading.rst b/Doc/library/threading.rst index 5d9a7b6314b1668..24900ede15b3213 100644 --- a/Doc/library/threading.rst +++ b/Doc/library/threading.rst @@ -545,6 +545,16 @@ since it is impossible to detect the termination of alien threads. current context, pass the value from :func:`~contextvars.copy_context`. The flag defaults true on free-threaded builds and false otherwise. + On GIL-enabled builds, when the flag has its default value of false and + *context* is ``None``, looking up a context variable that was set in the + caller of :meth:`~Thread.start` but is absent from the new thread's context + emits a :exc:`DeprecationWarning`. In a future release, the flag will + default to true on all builds, so threads will inherit context by default. + Set + :option:`-X thread_inherit_context <-X>` or + :envvar:`PYTHON_THREAD_INHERIT_CONTEXT`, or pass an explicit *context*, to + select the behavior now and suppress this warning. + If the subclass overrides the constructor, it must make sure to invoke the base class constructor (``Thread.__init__()``) before doing anything else to the thread. diff --git a/Doc/using/cmdline.rst b/Doc/using/cmdline.rst index 677fbbae3f4219a..d6f1d19acf95633 100644 --- a/Doc/using/cmdline.rst +++ b/Doc/using/cmdline.rst @@ -678,8 +678,14 @@ Miscellaneous options to, by default, use a copy of context of the caller of ``Thread.start()`` when starting. Otherwise, threads will start with an empty context. If unset, the value of this option defaults - to ``1`` on free-threaded builds and to ``0`` otherwise. See also - :envvar:`PYTHON_THREAD_INHERIT_CONTEXT`. + to ``1`` on free-threaded builds and to ``0`` otherwise. On GIL-enabled + builds, leaving the option and :envvar:`PYTHON_THREAD_INHERIT_CONTEXT` + unset can cause an implicitly empty thread context to emit a + :exc:`DeprecationWarning` when a context variable lookup would differ + under inheritance. In a future release, this option will default to + ``1`` on all builds. Setting either configuration option, or passing an explicit + ``context=`` to :class:`~threading.Thread`, selects the behavior without a + warning. .. versionadded:: 3.14 diff --git a/Include/internal/pycore_context.h b/Include/internal/pycore_context.h index a833f790a621b1d..a5aa83f38e6f157 100644 --- a/Include/internal/pycore_context.h +++ b/Include/internal/pycore_context.h @@ -26,6 +26,8 @@ struct _pycontextobject { PyHamtObject *ctx_vars; PyObject *ctx_weakreflist; int ctx_entered; + // used to emit warnings about thread_inherit_context + PyHamtObject *ctx_starter_vars; }; @@ -54,6 +56,7 @@ struct _pycontexttokenobject { // _testinternalcapi.hamt() used by tests. // Export for '_testcapi' shared extension PyAPI_FUNC(PyObject*) _PyContext_NewHamtForTests(void); +PyAPI_FUNC(PyObject*) _PyContext_NewForThread(void); PyAPI_FUNC(int) _PyContext_Enter(PyThreadState *ts, PyObject *octx); PyAPI_FUNC(int) _PyContext_Exit(PyThreadState *ts, PyObject *octx); diff --git a/Include/internal/pycore_initconfig.h b/Include/internal/pycore_initconfig.h index 183b2d45c5ede1c..0b1cb440ece37ac 100644 --- a/Include/internal/pycore_initconfig.h +++ b/Include/internal/pycore_initconfig.h @@ -179,6 +179,7 @@ extern PyStatus _PyConfig_SetPyArgv( PyConfig *config, const _PyArgv *args); extern PyObject* _PyConfig_CreateXOptionsDict(const PyConfig *config); +extern int _PyConfig_ThreadInheritContextWarn(const PyConfig *config); extern void _Py_DumpPathConfig(PyThreadState *tstate); diff --git a/Include/internal/pycore_interp_structs.h b/Include/internal/pycore_interp_structs.h index d3efac906aeb933..0e28c32d3fed198 100644 --- a/Include/internal/pycore_interp_structs.h +++ b/Include/internal/pycore_interp_structs.h @@ -998,6 +998,9 @@ struct _is { // One bit is set for each non-NULL entry in code_watchers uint8_t active_code_watchers; uint8_t active_context_watchers; + // True if we should warn about threads not inheriting context from the + // starting thread. + bool thread_inherit_context_warn; struct _py_object_state object_state; struct _Py_unicode_state unicode; diff --git a/Lib/test/test_context.py b/Lib/test/test_context.py index ef20495dcc01ea9..501becf6dada049 100644 --- a/Lib/test/test_context.py +++ b/Lib/test/test_context.py @@ -4,12 +4,14 @@ import contextvars import functools import gc +import os import random import time import unittest +import warnings import weakref from test import support -from test.support import threading_helper +from test.support import script_helper, threading_helper try: from _testinternalcapi import hamt @@ -17,6 +19,16 @@ hamt = None +THREAD_INHERIT_CONTEXT_WARNING = ( + not support.Py_GIL_DISABLED + and not sys.flags.thread_inherit_context + and "thread_inherit_context" not in sys._xoptions + and (sys.flags.ignore_environment + # An empty value is treated as unset, as in Python/initconfig.c. + or not os.environ.get("PYTHON_THREAD_INHERIT_CONTEXT")) +) + + def isolated_context(func): """Needed to make reftracking test mode work.""" @functools.wraps(func) @@ -400,12 +412,12 @@ def test_context_thread_inherit(self): cvar = contextvars.ContextVar('cvar') + results = [] + def run_context_none(): - if sys.flags.thread_inherit_context: - expected = 1 - else: - expected = None - self.assertEqual(cvar.get(None), expected) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + results.append((cvar.get(None), len(caught))) # By default, context is inherited based on the # sys.flags.thread_inherit_context option. @@ -420,6 +432,12 @@ def run_context_none(): thread.start() thread.join() + if sys.flags.thread_inherit_context: + self.assertEqual(results, [(1, 0), (1, 0)]) + else: + warning_count = int(THREAD_INHERIT_CONTEXT_WARNING) + self.assertEqual(results, [(None, warning_count)] * 2) + # An explicit Context value can also be passed custom_ctx = contextvars.Context() custom_var = None @@ -447,6 +465,220 @@ def run_empty(): thread.start() thread.join() + @isolated_context + @threading_helper.requires_working_threading() + @unittest.skipUnless(THREAD_INHERIT_CONTEXT_WARNING, + "requires the warning-enabled GIL-build default") + def test_context_thread_inherit_warning(self): + import threading + + call_default = contextvars.ContextVar("call_default") + var_default = contextvars.ContextVar("var_default", default="variable") + missing = contextvars.ContextVar("missing") + other = contextvars.ContextVar("other") + for var in (call_default, var_default, missing, other): + var.set("starter") + + results = [] + + def target(): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + # Explicit nested contexts and copies of the child context do + # not retain the compatibility metadata. + values = [ + contextvars.Context().run(call_default.get, "nested"), + contextvars.copy_context().run(call_default.get, "copy"), + call_default.get("argument"), + var_default.get(), + ] + try: + missing.get() + except LookupError: + values.append("LookupError") + # The snapshot is detached after the first mismatch, including + # for repeated lookups and other variables. + values.extend((call_default.get("again"), other.get(None))) + results.append((values, caught)) + + thread = threading.Thread(target=target) + thread.start() + thread.join() + + values, caught = results[0] + self.assertEqual(values, [ + "nested", "copy", "argument", "variable", "LookupError", + "again", None, + ]) + self.assertEqual(len(caught), 1) + self.assertIs(caught[0].category, DeprecationWarning) + message = str(caught[0].message) + self.assertIn( + "threads will inherit context by default", message) + + @isolated_context + @threading_helper.requires_working_threading() + @unittest.skipIf(sys.flags.thread_inherit_context, + "requires the non-inheriting thread default") + def test_context_thread_inherit_warning_not_emitted(self): + import threading + + unbound = contextvars.ContextVar("unbound") + bound = contextvars.ContextVar("bound") + bound.set("starter") + results = [] + + def no_starter_binding(): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + results.append((unbound.get(None), len(caught))) + + def child_binding(): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + bound.set("child") + results.append((bound.get(), len(caught))) + + def explicit_empty(): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + results.append((bound.get(None), len(caught))) + + threads = [ + threading.Thread(target=no_starter_binding), + threading.Thread(target=child_binding), + threading.Thread(target=explicit_empty, + context=contextvars.Context()), + ] + for thread in threads: + thread.start() + thread.join() + + self.assertEqual(results, [(None, 0), ("child", 0), (None, 0)]) + + @threading_helper.requires_working_threading() + def test_context_thread_inherit_warning_explicitly_disabled(self): + code = """ +import contextvars +import threading +import warnings + +var = contextvars.ContextVar('var') +var.set('starter') +result = [] + +def target(): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always', DeprecationWarning) + result.append((var.get(None), len(caught))) + +thread = threading.Thread(target=target) +thread.start() +thread.join() +assert result == [(None, 0)], result +""" + script_helper.assert_python_ok( + "-X", "thread_inherit_context=0", "-c", code) + script_helper.assert_python_ok( + "-c", code, PYTHON_THREAD_INHERIT_CONTEXT="0") + + @isolated_context + @threading_helper.requires_working_threading() + @unittest.skipIf(sys.flags.thread_inherit_context, + "requires the non-inheriting thread default") + def test_context_thread_warning_snapshot_at_start(self): + import threading + + var = contextvars.ContextVar("var") + # Keep another variable bound so the starter context is non-empty at + # start() and a snapshot is retained. + keep = contextvars.ContextVar("keep") + keep.set("starter") + token = var.set("during construction") + results = [] + + def target(): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + results.append((var.get(None), len(caught))) + + thread = threading.Thread(target=target) + var.reset(token) + thread.start() + thread.join() + + self.assertEqual(results, [(None, 0)]) + + @isolated_context + @threading_helper.requires_working_threading() + @unittest.skipUnless(THREAD_INHERIT_CONTEXT_WARNING, + "requires the warning-enabled GIL-build default") + def test_context_thread_inherit_warning_as_error(self): + import threading + + var = contextvars.ContextVar("var") + var.set("starter") + results = [] + + def target(): + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + try: + var.get() + except DeprecationWarning as exc: + results.append(str(exc)) + + thread = threading.Thread(target=target) + thread.start() + thread.join() + + self.assertEqual(len(results), 1) + self.assertIn( + "threads will inherit context by default", + results[0]) + + @threading_helper.requires_working_threading() + @unittest.skipIf(support.Py_GIL_DISABLED, + "the free-threaded default inherits context") + def test_context_thread_inherit_warning_context_aware(self): + code = """ +import contextvars +import threading +import warnings + +var = contextvars.ContextVar('var') +var.set('starter') +result = [] + +def target(): + try: + var.get() + except DeprecationWarning: + result.append('warning') + +with warnings.catch_warnings(): + warnings.simplefilter('error', DeprecationWarning) + thread = threading.Thread(target=target) + thread.start() + thread.join() + +assert result == ['warning'], result +""" + # An empty value is treated as unset; this shields the subprocess from + # any PYTHON_THREAD_INHERIT_CONTEXT in the test environment. + script_helper.assert_python_ok( + "-W", "error::DeprecationWarning", + "-X", "context_aware_warnings=1", "-c", code, + PYTHON_THREAD_INHERIT_CONTEXT="") + + def test_private_thread_start_context(self): + import _contextvars + + self.assertFalse(hasattr(contextvars, "_thread_start_context")) + ctx = _contextvars._thread_start_context() + self.assertIs(type(ctx), contextvars.Context) + self.assertEqual(ctx.run(lambda: "result"), "result") + def test_token_contextmanager_with_default(self): ctx = contextvars.Context() c = contextvars.ContextVar('c', default=42) diff --git a/Lib/threading.py b/Lib/threading.py index abac31e25886fae..03d29e227bed9aa 100644 --- a/Lib/threading.py +++ b/Lib/threading.py @@ -1133,8 +1133,10 @@ def start(self): # start with a copy of the context of the caller self._context = _contextvars.copy_context() else: - # start with an empty context - self._context = _contextvars.Context() + # Start with an empty context while retaining the context that + # would be inherited. This retained context is used to warn + # about the future default setting of thread_inherit_context. + self._context = _contextvars._thread_start_context() try: # Start joinable thread @@ -1218,6 +1220,9 @@ def _bootstrap_inner(self): self._context.run(self.run) except: self._invoke_excepthook(self) + finally: + # Free context, we don't need it anymore. + self._context = None finally: self._delete() diff --git a/Misc/NEWS.d/next/Library/2026-07-23-10-16-59.gh-issue-154549.Ulgmsd.rst b/Misc/NEWS.d/next/Library/2026-07-23-10-16-59.gh-issue-154549.Ulgmsd.rst new file mode 100644 index 000000000000000..4d82c8feac0fbdc --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-23-10-16-59.gh-issue-154549.Ulgmsd.rst @@ -0,0 +1,5 @@ +On GIL-enabled builds, emit a :exc:`DeprecationWarning` when the default +implicitly empty :class:`threading.Thread` context causes a context variable +lookup to differ from the context that a future release will inherit by +default. Setting ``thread_inherit_context`` selects the desired behavior +without a warning. diff --git a/Python/_contextvars.c b/Python/_contextvars.c index 86acc94fbc79cdc..ecbb163d3bae82e 100644 --- a/Python/_contextvars.c +++ b/Python/_contextvars.c @@ -1,4 +1,5 @@ #include "Python.h" +#include "pycore_context.h" // _PyContext_NewForThread() #include "clinic/_contextvars.c.h" @@ -20,10 +21,23 @@ _contextvars_copy_context_impl(PyObject *module) } +/*[clinic input] +_contextvars._thread_start_context +[clinic start generated code]*/ + +static PyObject * +_contextvars__thread_start_context_impl(PyObject *module) +/*[clinic end generated code: output=7e656d156c385a65 input=4575c5d2223de58d]*/ +{ + return _PyContext_NewForThread(); +} + + PyDoc_STRVAR(module_doc, "Context Variables"); static PyMethodDef _contextvars_methods[] = { _CONTEXTVARS_COPY_CONTEXT_METHODDEF + _CONTEXTVARS__THREAD_START_CONTEXT_METHODDEF {NULL, NULL} }; diff --git a/Python/clinic/_contextvars.c.h b/Python/clinic/_contextvars.c.h index b1885e41c355d27..381d8c159beb4b8 100644 --- a/Python/clinic/_contextvars.c.h +++ b/Python/clinic/_contextvars.c.h @@ -18,4 +18,21 @@ _contextvars_copy_context(PyObject *module, PyObject *Py_UNUSED(ignored)) { return _contextvars_copy_context_impl(module); } -/*[clinic end generated code: output=26e07024451baf52 input=a9049054013a1b77]*/ + +PyDoc_STRVAR(_contextvars__thread_start_context__doc__, +"_thread_start_context($module, /)\n" +"--\n" +"\n"); + +#define _CONTEXTVARS__THREAD_START_CONTEXT_METHODDEF \ + {"_thread_start_context", (PyCFunction)_contextvars__thread_start_context, METH_NOARGS, _contextvars__thread_start_context__doc__}, + +static PyObject * +_contextvars__thread_start_context_impl(PyObject *module); + +static PyObject * +_contextvars__thread_start_context(PyObject *module, PyObject *Py_UNUSED(ignored)) +{ + return _contextvars__thread_start_context_impl(module); +} +/*[clinic end generated code: output=2c6c9d89faff4312 input=a9049054013a1b77]*/ diff --git a/Python/context.c b/Python/context.c index 4678054ff3ad743..506a841947f9757 100644 --- a/Python/context.c +++ b/Python/context.c @@ -79,6 +79,33 @@ PyContext_New(void) } +PyObject * +_PyContext_NewForThread(void) +{ + PyInterpreterState *interp = _PyInterpreterState_GET(); + if (!interp->thread_inherit_context_warn) { + return PyContext_New(); + } + + PyThreadState *ts = _PyThreadState_GET(); + assert(ts != NULL); + PyContext *starter_ctx = (PyContext *)ts->context; + if (starter_ctx == NULL || _PyHamt_Len(starter_ctx->ctx_vars) == 0) { + // No variable is set in the starter context so no lookup in the new + // thread can differ from the inheriting behavior; a snapshot would + // only add overhead to ContextVar.get() misses. + return PyContext_New(); + } + + PyContext *ctx = context_new_empty(); + if (ctx == NULL) { + return NULL; + } + ctx->ctx_starter_vars = (PyHamtObject*)Py_NewRef(starter_ctx->ctx_vars); + return (PyObject *)ctx; +} + + PyObject * PyContext_Copy(PyObject * octx) { @@ -298,7 +325,8 @@ PyContextVar_Get(PyObject *ovar, PyObject *def, PyObject **val) #endif assert(PyContext_CheckExact(ts->context)); - PyHamtObject *vars = ((PyContext *)ts->context)->ctx_vars; + PyContext *ctx = (PyContext *)ts->context; + PyHamtObject *vars = ctx->ctx_vars; PyObject *found = NULL; int res = _PyHamt_Find(vars, (PyObject*)var, &found); @@ -317,6 +345,27 @@ PyContextVar_Get(PyObject *ovar, PyObject *def, PyObject **val) goto found; } + if (ctx->ctx_starter_vars != NULL) { + res = _PyHamt_Find(ctx->ctx_starter_vars, (PyObject *)var, &found); + if (res < 0) { + goto error; + } + if (res == 1) { + // Detach before warning so that warning machinery using context + // variables cannot recursively warn. This also means we warn + // once per thread. + Py_CLEAR(ctx->ctx_starter_vars); + if (PyErr_WarnEx( + PyExc_DeprecationWarning, + "threads will inherit context by default in a future " + "Python release", + 1) < 0) + { + goto error; + } + } + } + not_found: if (def == NULL) { if (var->var_default != NULL) { @@ -437,6 +486,7 @@ _context_alloc(void) ctx->ctx_vars = NULL; ctx->ctx_prev = NULL; + ctx->ctx_starter_vars = NULL; ctx->ctx_entered = 0; ctx->ctx_weakreflist = NULL; @@ -523,6 +573,7 @@ context_tp_clear(PyObject *op) PyContext *self = _PyContext_CAST(op); Py_CLEAR(self->ctx_prev); Py_CLEAR(self->ctx_vars); + Py_CLEAR(self->ctx_starter_vars); return 0; } @@ -532,6 +583,7 @@ context_tp_traverse(PyObject *op, visitproc visit, void *arg) PyContext *self = _PyContext_CAST(op); Py_VISIT(self->ctx_prev); Py_VISIT(self->ctx_vars); + Py_VISIT(self->ctx_starter_vars); return 0; } diff --git a/Python/initconfig.c b/Python/initconfig.c index b9bacb17a66454a..75d87b7fe798c81 100644 --- a/Python/initconfig.c +++ b/Python/initconfig.c @@ -2105,6 +2105,21 @@ config_init_cpu_count(PyConfig *config) "n must be greater than 0"); } +// Return true if we should warn about threads *not* inheriting context +// from the starting thread. +int +_PyConfig_ThreadInheritContextWarn(const PyConfig *config) +{ +#ifdef Py_GIL_DISABLED + (void)config; + return 0; +#else + return (config->thread_inherit_context == 0 + && config_get_env(config, "PYTHON_THREAD_INHERIT_CONTEXT") == NULL + && config_get_xoption(config, L"thread_inherit_context") == NULL); +#endif +} + static PyStatus config_init_thread_inherit_context(PyConfig *config) { diff --git a/Python/pylifecycle.c b/Python/pylifecycle.c index 01ca459b2eb2b8f..a8d15ef3e43f107 100644 --- a/Python/pylifecycle.c +++ b/Python/pylifecycle.c @@ -479,6 +479,8 @@ pyinit_core_reconfigure(_PyRuntimeState *runtime, if (_PyStatus_EXCEPTION(status)) { return status; } + interp->thread_inherit_context_warn = + _PyConfig_ThreadInheritContextWarn(config); config = _PyInterpreterState_GetConfig(interp); if (config->_install_importlib) { @@ -691,6 +693,8 @@ pycore_create_interpreter(_PyRuntimeState *runtime, if (_PyStatus_EXCEPTION(status)) { return status; } + interp->thread_inherit_context_warn = + _PyConfig_ThreadInheritContextWarn(src_config); /* Auto-thread-state API */ status = _PyGILState_Init(interp); @@ -2679,16 +2683,16 @@ new_interpreter(PyThreadState **tstate_p, } /* Copy the current interpreter config into the new interpreter */ - const PyConfig *src_config; + PyInterpreterState *src_interp; if (save_tstate != NULL) { - src_config = _PyInterpreterState_GetConfig(save_tstate->interp); + src_interp = save_tstate->interp; } else { /* No current thread state, copy from the main interpreter */ - PyInterpreterState *main_interp = _PyInterpreterState_Main(); - src_config = _PyInterpreterState_GetConfig(main_interp); + src_interp = _PyInterpreterState_Main(); } + const PyConfig *src_config = _PyInterpreterState_GetConfig(src_interp); /* This does not require that the GIL be held. */ status = _PyConfig_Copy(&interp->config, src_config); @@ -2696,6 +2700,10 @@ new_interpreter(PyThreadState **tstate_p, goto error; } + /* Copy this flag from source interpreter too. */ + interp->thread_inherit_context_warn = + src_interp->thread_inherit_context_warn; + /* This does not require that the GIL be held. */ status = init_interp_settings(interp, config); if (_PyStatus_EXCEPTION(status)) {