Skip to content

ThreadPoolExecutor tasks see the context of whichever submit() call started their worker thread #159964

Open

Description

@ngoldbaum

Bug report

Bug description:

When sys.flags.thread_inherit_context is true (the default on the free-threaded build), ThreadPoolExecutor workers copy the context of the submit() call that happened to start them, and every later task on that worker sees that copy:

import contextlib
import contextvars
from concurrent.futures import ThreadPoolExecutor

precision = contextvars.ContextVar("precision", default=8)


@contextlib.contextmanager
def printoptions(p):
    token = precision.set(p)
    try:
        yield
    finally:
        precision.reset(token)


def fmt(x):
    return f"{x:.{precision.get()}f}"


pool = ThreadPoolExecutor(max_workers=1)

with printoptions(2):
    print("with precision=2: ", fmt(3.14159), pool.submit(fmt, 3.14159).result())

print("no with block:     ", fmt(3.14159), pool.submit(fmt, 3.14159).result())

with printoptions(5):
    print("with precision=5: ", fmt(3.14159), pool.submit(fmt, 3.14159).result())

On 3.15.0t:

with precision=2:  3.14 3.14
no with block:      3.14159000 3.14
with precision=5:  3.14159 3.14

The pool column is stuck at precision=2 because its only worker was started inside the first with block. With more than one worker, the value a task sees depends on which worker runs it.

sentry-sdk, opentelemetry-instrumentation-threading, and dask all patch or subclass ThreadPoolExecutor to get this behavior.

See also https://discuss.python.org/t/enabling-thread-inherit-context-and-context-aware-warnings-by-default-on-both-builds/108205 and gh-154562.

CPython versions tested on:

3.14, 3.15, CPython main branch

Operating systems tested on:

macOS

Linked PRs

Activity

  1. added
    type-bugAn unexpected behavior, bug, or error
    stdlibStandard Library Python modules in the Lib/ directory
    on Oct 20, 2026
  2. ngoldbaum commented on Oct 20, 2026

    @ngoldbaum
    ContributorAuthor
  3. nascheme commented on Oct 20, 2026

    @nascheme
    Member

    I think you could argue that this is a bug. For each task to see the submitting thread’s context, the pool would need to capture that context with contextvars.copy_context() at submission time and execute the task using that copy’s run() method. Currently, when thread_inherit_context is true, contextvar bindings are copied when the pool starts a worker thread, and that worker keeps using its context for subsequent tasks.

    Edit: also note that copy_context() and using Context.run() would add a little overhead. However the ContextVars mechanism is designed specifically to be efficient to be used like this. Since asyncio already uses run() similarly, I think it's hard to argue it's too expensive for thread based work.

  4. added a commit that references this issue on Oct 21, 2026
  5. self-assigned this
    on Oct 21, 2026
  6. ngoldbaum commented on Oct 21, 2026

    @ngoldbaum
    ContributorAuthor

    I’d like to fix this during the core dev sprint.

  7. ngoldbaum commented on Oct 21, 2026

    @ngoldbaum
    ContributorAuthor

    Unfortunately there are significant downstream uses that making this change would break. In particular:

        from concurrent.futures import ThreadPoolExecutor
        from asgiref.local import Local
    
        local = Local()
    
        def task():
            if not hasattr(local, "connection"):
                local.connection = object()
                print("opened a connection")
    
        pool = ThreadPoolExecutor(max_workers=1)
        for _ in range(3):
            pool.submit(task).result()

    Today this prints "opened a connection" once, because each worker keeps a single context across tasks. With a naive fix for the reproducer in the issue, each task runs in a fresh copy of the submitter's context, and it prints three times. asgiref.local.Local is documented as thread-local in sync threads, and asgiref already works around thread_inherit_context for new threads (django/asgiref#564), but that can't help here. Django keeps its cache backends in a Local, so with this change Django would create a new cache backend for every pool task on the free-threaded build (or with -X thread_inherit_context=1).

    So sadly the easy fix I think requires Guido's time machine. It's possible to fix this in asgiref but I'm not sure if they'd be open to that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

sprintstdlibStandard Library Python modules in the Lib/ directorytopic-free-threadingtype-bugAn unexpected behavior, bug, or error

Projects

  • Status
    Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions