Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Doc/library/threading.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions Doc/using/cmdline.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions Include/internal/pycore_context.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};


Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions Include/internal/pycore_initconfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
3 changes: 3 additions & 0 deletions Include/internal/pycore_interp_structs.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
244 changes: 238 additions & 6 deletions Lib/test/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,31 @@
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
except ImportError:
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)
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions Lib/threading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading