Skip to content

Commit f0ec3e2

Browse files
committed
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.
1 parent f62050d commit f0ec3e2

13 files changed

Lines changed: 387 additions & 16 deletions

File tree

Doc/library/threading.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,16 @@ since it is impossible to detect the termination of alien threads.
545545
current context, pass the value from :func:`~contextvars.copy_context`. The
546546
flag defaults true on free-threaded builds and false otherwise.
547547

548+
On GIL-enabled builds, when the flag has its default value of false and
549+
*context* is ``None``, looking up a context variable that was set in the
550+
caller of :meth:`~Thread.start` but is absent from the new thread's context
551+
emits a :exc:`DeprecationWarning`. In a future release, the flag will
552+
default to true on all builds, so threads will inherit context by default.
553+
Set
554+
:option:`-X thread_inherit_context <-X>` or
555+
:envvar:`PYTHON_THREAD_INHERIT_CONTEXT`, or pass an explicit *context*, to
556+
select the behavior now and suppress this warning.
557+
548558
If the subclass overrides the constructor, it must make sure to invoke the
549559
base class constructor (``Thread.__init__()``) before doing anything else to
550560
the thread.

Doc/using/cmdline.rst

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -678,8 +678,14 @@ Miscellaneous options
678678
to, by default, use a copy of context of the caller of
679679
``Thread.start()`` when starting. Otherwise, threads will start
680680
with an empty context. If unset, the value of this option defaults
681-
to ``1`` on free-threaded builds and to ``0`` otherwise. See also
682-
:envvar:`PYTHON_THREAD_INHERIT_CONTEXT`.
681+
to ``1`` on free-threaded builds and to ``0`` otherwise. On GIL-enabled
682+
builds, leaving the option and :envvar:`PYTHON_THREAD_INHERIT_CONTEXT`
683+
unset can cause an implicitly empty thread context to emit a
684+
:exc:`DeprecationWarning` when a context variable lookup would differ
685+
under inheritance. In a future release, this option will default to
686+
``1`` on all builds. Setting either configuration option, or passing an explicit
687+
``context=`` to :class:`~threading.Thread`, selects the behavior without a
688+
warning.
683689

684690
.. versionadded:: 3.14
685691

Include/internal/pycore_context.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ struct _pycontextobject {
2626
PyHamtObject *ctx_vars;
2727
PyObject *ctx_weakreflist;
2828
int ctx_entered;
29+
// used to emit warnings about thread_inherit_context
30+
PyHamtObject *ctx_starter_vars;
2931
};
3032

3133

@@ -54,6 +56,7 @@ struct _pycontexttokenobject {
5456
// _testinternalcapi.hamt() used by tests.
5557
// Export for '_testcapi' shared extension
5658
PyAPI_FUNC(PyObject*) _PyContext_NewHamtForTests(void);
59+
PyAPI_FUNC(PyObject*) _PyContext_NewForThread(void);
5760

5861
PyAPI_FUNC(int) _PyContext_Enter(PyThreadState *ts, PyObject *octx);
5962
PyAPI_FUNC(int) _PyContext_Exit(PyThreadState *ts, PyObject *octx);

Include/internal/pycore_initconfig.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ extern PyStatus _PyConfig_SetPyArgv(
179179
PyConfig *config,
180180
const _PyArgv *args);
181181
extern PyObject* _PyConfig_CreateXOptionsDict(const PyConfig *config);
182+
extern int _PyConfig_ThreadInheritContextWarn(const PyConfig *config);
182183

183184
extern void _Py_DumpPathConfig(PyThreadState *tstate);
184185

Include/internal/pycore_interp_structs.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -998,6 +998,9 @@ struct _is {
998998
// One bit is set for each non-NULL entry in code_watchers
999999
uint8_t active_code_watchers;
10001000
uint8_t active_context_watchers;
1001+
// True if we should warn about threads not inheriting context from the
1002+
// starting thread.
1003+
bool thread_inherit_context_warn;
10011004

10021005
struct _py_object_state object_state;
10031006
struct _Py_unicode_state unicode;

Lib/test/test_context.py

Lines changed: 238 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,31 @@
44
import contextvars
55
import functools
66
import gc
7+
import os
78
import random
89
import time
910
import unittest
11+
import warnings
1012
import weakref
1113
from test import support
12-
from test.support import threading_helper
14+
from test.support import script_helper, threading_helper
1315

1416
try:
1517
from _testinternalcapi import hamt
1618
except ImportError:
1719
hamt = None
1820

1921

22+
THREAD_INHERIT_CONTEXT_WARNING = (
23+
not support.Py_GIL_DISABLED
24+
and not sys.flags.thread_inherit_context
25+
and "thread_inherit_context" not in sys._xoptions
26+
and (sys.flags.ignore_environment
27+
# An empty value is treated as unset, as in Python/initconfig.c.
28+
or not os.environ.get("PYTHON_THREAD_INHERIT_CONTEXT"))
29+
)
30+
31+
2032
def isolated_context(func):
2133
"""Needed to make reftracking test mode work."""
2234
@functools.wraps(func)
@@ -400,12 +412,12 @@ def test_context_thread_inherit(self):
400412

401413
cvar = contextvars.ContextVar('cvar')
402414

415+
results = []
416+
403417
def run_context_none():
404-
if sys.flags.thread_inherit_context:
405-
expected = 1
406-
else:
407-
expected = None
408-
self.assertEqual(cvar.get(None), expected)
418+
with warnings.catch_warnings(record=True) as caught:
419+
warnings.simplefilter("always", DeprecationWarning)
420+
results.append((cvar.get(None), len(caught)))
409421

410422
# By default, context is inherited based on the
411423
# sys.flags.thread_inherit_context option.
@@ -420,6 +432,12 @@ def run_context_none():
420432
thread.start()
421433
thread.join()
422434

435+
if sys.flags.thread_inherit_context:
436+
self.assertEqual(results, [(1, 0), (1, 0)])
437+
else:
438+
warning_count = int(THREAD_INHERIT_CONTEXT_WARNING)
439+
self.assertEqual(results, [(None, warning_count)] * 2)
440+
423441
# An explicit Context value can also be passed
424442
custom_ctx = contextvars.Context()
425443
custom_var = None
@@ -447,6 +465,220 @@ def run_empty():
447465
thread.start()
448466
thread.join()
449467

468+
@isolated_context
469+
@threading_helper.requires_working_threading()
470+
@unittest.skipUnless(THREAD_INHERIT_CONTEXT_WARNING,
471+
"requires the warning-enabled GIL-build default")
472+
def test_context_thread_inherit_warning(self):
473+
import threading
474+
475+
call_default = contextvars.ContextVar("call_default")
476+
var_default = contextvars.ContextVar("var_default", default="variable")
477+
missing = contextvars.ContextVar("missing")
478+
other = contextvars.ContextVar("other")
479+
for var in (call_default, var_default, missing, other):
480+
var.set("starter")
481+
482+
results = []
483+
484+
def target():
485+
with warnings.catch_warnings(record=True) as caught:
486+
warnings.simplefilter("always", DeprecationWarning)
487+
# Explicit nested contexts and copies of the child context do
488+
# not retain the compatibility metadata.
489+
values = [
490+
contextvars.Context().run(call_default.get, "nested"),
491+
contextvars.copy_context().run(call_default.get, "copy"),
492+
call_default.get("argument"),
493+
var_default.get(),
494+
]
495+
try:
496+
missing.get()
497+
except LookupError:
498+
values.append("LookupError")
499+
# The snapshot is detached after the first mismatch, including
500+
# for repeated lookups and other variables.
501+
values.extend((call_default.get("again"), other.get(None)))
502+
results.append((values, caught))
503+
504+
thread = threading.Thread(target=target)
505+
thread.start()
506+
thread.join()
507+
508+
values, caught = results[0]
509+
self.assertEqual(values, [
510+
"nested", "copy", "argument", "variable", "LookupError",
511+
"again", None,
512+
])
513+
self.assertEqual(len(caught), 1)
514+
self.assertIs(caught[0].category, DeprecationWarning)
515+
message = str(caught[0].message)
516+
self.assertIn(
517+
"threads will inherit context by default", message)
518+
519+
@isolated_context
520+
@threading_helper.requires_working_threading()
521+
@unittest.skipIf(sys.flags.thread_inherit_context,
522+
"requires the non-inheriting thread default")
523+
def test_context_thread_inherit_warning_not_emitted(self):
524+
import threading
525+
526+
unbound = contextvars.ContextVar("unbound")
527+
bound = contextvars.ContextVar("bound")
528+
bound.set("starter")
529+
results = []
530+
531+
def no_starter_binding():
532+
with warnings.catch_warnings(record=True) as caught:
533+
warnings.simplefilter("always", DeprecationWarning)
534+
results.append((unbound.get(None), len(caught)))
535+
536+
def child_binding():
537+
with warnings.catch_warnings(record=True) as caught:
538+
warnings.simplefilter("always", DeprecationWarning)
539+
bound.set("child")
540+
results.append((bound.get(), len(caught)))
541+
542+
def explicit_empty():
543+
with warnings.catch_warnings(record=True) as caught:
544+
warnings.simplefilter("always", DeprecationWarning)
545+
results.append((bound.get(None), len(caught)))
546+
547+
threads = [
548+
threading.Thread(target=no_starter_binding),
549+
threading.Thread(target=child_binding),
550+
threading.Thread(target=explicit_empty,
551+
context=contextvars.Context()),
552+
]
553+
for thread in threads:
554+
thread.start()
555+
thread.join()
556+
557+
self.assertEqual(results, [(None, 0), ("child", 0), (None, 0)])
558+
559+
@threading_helper.requires_working_threading()
560+
def test_context_thread_inherit_warning_explicitly_disabled(self):
561+
code = """
562+
import contextvars
563+
import threading
564+
import warnings
565+
566+
var = contextvars.ContextVar('var')
567+
var.set('starter')
568+
result = []
569+
570+
def target():
571+
with warnings.catch_warnings(record=True) as caught:
572+
warnings.simplefilter('always', DeprecationWarning)
573+
result.append((var.get(None), len(caught)))
574+
575+
thread = threading.Thread(target=target)
576+
thread.start()
577+
thread.join()
578+
assert result == [(None, 0)], result
579+
"""
580+
script_helper.assert_python_ok(
581+
"-X", "thread_inherit_context=0", "-c", code)
582+
script_helper.assert_python_ok(
583+
"-c", code, PYTHON_THREAD_INHERIT_CONTEXT="0")
584+
585+
@isolated_context
586+
@threading_helper.requires_working_threading()
587+
@unittest.skipIf(sys.flags.thread_inherit_context,
588+
"requires the non-inheriting thread default")
589+
def test_context_thread_warning_snapshot_at_start(self):
590+
import threading
591+
592+
var = contextvars.ContextVar("var")
593+
# Keep another variable bound so the starter context is non-empty at
594+
# start() and a snapshot is retained.
595+
keep = contextvars.ContextVar("keep")
596+
keep.set("starter")
597+
token = var.set("during construction")
598+
results = []
599+
600+
def target():
601+
with warnings.catch_warnings(record=True) as caught:
602+
warnings.simplefilter("always", DeprecationWarning)
603+
results.append((var.get(None), len(caught)))
604+
605+
thread = threading.Thread(target=target)
606+
var.reset(token)
607+
thread.start()
608+
thread.join()
609+
610+
self.assertEqual(results, [(None, 0)])
611+
612+
@isolated_context
613+
@threading_helper.requires_working_threading()
614+
@unittest.skipUnless(THREAD_INHERIT_CONTEXT_WARNING,
615+
"requires the warning-enabled GIL-build default")
616+
def test_context_thread_inherit_warning_as_error(self):
617+
import threading
618+
619+
var = contextvars.ContextVar("var")
620+
var.set("starter")
621+
results = []
622+
623+
def target():
624+
with warnings.catch_warnings():
625+
warnings.simplefilter("error", DeprecationWarning)
626+
try:
627+
var.get()
628+
except DeprecationWarning as exc:
629+
results.append(str(exc))
630+
631+
thread = threading.Thread(target=target)
632+
thread.start()
633+
thread.join()
634+
635+
self.assertEqual(len(results), 1)
636+
self.assertIn(
637+
"threads will inherit context by default",
638+
results[0])
639+
640+
@threading_helper.requires_working_threading()
641+
@unittest.skipIf(support.Py_GIL_DISABLED,
642+
"the free-threaded default inherits context")
643+
def test_context_thread_inherit_warning_context_aware(self):
644+
code = """
645+
import contextvars
646+
import threading
647+
import warnings
648+
649+
var = contextvars.ContextVar('var')
650+
var.set('starter')
651+
result = []
652+
653+
def target():
654+
try:
655+
var.get()
656+
except DeprecationWarning:
657+
result.append('warning')
658+
659+
with warnings.catch_warnings():
660+
warnings.simplefilter('error', DeprecationWarning)
661+
thread = threading.Thread(target=target)
662+
thread.start()
663+
thread.join()
664+
665+
assert result == ['warning'], result
666+
"""
667+
# An empty value is treated as unset; this shields the subprocess from
668+
# any PYTHON_THREAD_INHERIT_CONTEXT in the test environment.
669+
script_helper.assert_python_ok(
670+
"-W", "error::DeprecationWarning",
671+
"-X", "context_aware_warnings=1", "-c", code,
672+
PYTHON_THREAD_INHERIT_CONTEXT="")
673+
674+
def test_private_thread_start_context(self):
675+
import _contextvars
676+
677+
self.assertFalse(hasattr(contextvars, "_thread_start_context"))
678+
ctx = _contextvars._thread_start_context()
679+
self.assertIs(type(ctx), contextvars.Context)
680+
self.assertEqual(ctx.run(lambda: "result"), "result")
681+
450682
def test_token_contextmanager_with_default(self):
451683
ctx = contextvars.Context()
452684
c = contextvars.ContextVar('c', default=42)

Lib/threading.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,8 +1133,10 @@ def start(self):
11331133
# start with a copy of the context of the caller
11341134
self._context = _contextvars.copy_context()
11351135
else:
1136-
# start with an empty context
1137-
self._context = _contextvars.Context()
1136+
# Start with an empty context while retaining the context that
1137+
# would be inherited. This retained context is used to warn
1138+
# about the future default setting of thread_inherit_context.
1139+
self._context = _contextvars._thread_start_context()
11381140

11391141
try:
11401142
# Start joinable thread
@@ -1218,6 +1220,9 @@ def _bootstrap_inner(self):
12181220
self._context.run(self.run)
12191221
except:
12201222
self._invoke_excepthook(self)
1223+
finally:
1224+
# Free context, we don't need it anymore.
1225+
self._context = None
12211226
finally:
12221227
self._delete()
12231228

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
On GIL-enabled builds, emit a :exc:`DeprecationWarning` when the default
2+
implicitly empty :class:`threading.Thread` context causes a context variable
3+
lookup to differ from the context that a future release will inherit by
4+
default. Setting ``thread_inherit_context`` selects the desired behavior
5+
without a warning.

0 commit comments

Comments
 (0)