Skip to content

GH-141148: Ensure decimal.getcontext() returns fresh copy.#151953

Open
nascheme wants to merge 2 commits into
python:mainfrom
nascheme:decimal_ctx_depth
Open

GH-141148: Ensure decimal.getcontext() returns fresh copy.#151953
nascheme wants to merge 2 commits into
python:mainfrom
nascheme:decimal_ctx_depth

Conversation

@nascheme

@nascheme nascheme commented Jun 22, 2026

Copy link
Copy Markdown
Member

Ensure that decimal.getcontext() returns a per-task copy of the decimal.Context so that mutations are isolated between asyncio tasks and threads when sys.flags.thread_inherit_context is set.

This change is required because decimal.Context instances are mutable. The contextvars.Context object uses an immutable data structure (HAMT) to store variable bindings. That ensures each task has its own set of contextvar bindings. However, it doesn't help if the contextvar value itself is mutated.

This is an alternative to GH-146482. In this PR, we add a "depth" counter to PyContext, incremented every time we copy the HAMT. Then, the decimal.Context() instance can use this to determine that getcontext() needs to copy the instance. This ensures each thread or asyncio task has it's own copy of the decimal.Context() instance.

This differs from GH-146482 as follows:

  • it avoids the potential problem of keeping context vars alive longer, via the ctx_vars_origin reference. Instead, we are just using a 64-bit integer counter here to track depth.
  • it avoids copying decimal.Context in the case that the context is reused (entered) many times, e.g. for asyncio tasks
  • it creates slightly more work for the _pydecimal and _decimal.c code, since it has to track the depth on its Context instance

We need a per-task copy of decimal.Context() so that mutations are
isolated between asyncio tasks and threads using
sys.flags.thread_inherit_context.  This is done by adding a "depth"
counter to PyContext and copying the decimal.Context() instance whenever
it doesn't match the depth of the current PyContext.
@nascheme

Copy link
Copy Markdown
Member Author

Checking the performance impact of this, for asyncio tasks. I'm not concerned about threads since starting a thread should be much more expensive than copying decimal.Context. Here is an analysis from GPT 5.6:

Clang -O3, normal GIL build:

Case Before After
Repeated getcontext() 36.5 ns 42.4 ns
First access in copied context, initially unbound 259.7 ns 260.1 ns
First access after inheriting bound context 47.0 ns 271.1 ns
Tiny task using inherited decimal context 2.02 µs/task 2.31 µs/task

So the incremental first-use isolation cost was about 220–300 ns per task here. A deliberately tiny task doing nothing except accessing decimal slowed by about 14%; realistic tasks should amortize that quickly.

There is also a steady-state cost of roughly 6 ns per current-context lookup from checking the depth. A basic decimal-addition benchmark did not show a measurable slowdown, although code-layout noise makes such tiny differences difficult to isolate.

The C decimal Context grew from 120 to 128 bytes, and each contextvars.Context from 80 to 88 bytes on this build.

Generalizing beyond tasks: the copy occurs once for each distinct copied contextvars.Context that first accesses an inherited decimal context. Arbitrary callbacks that capture separate contexts can therefore also each incur one copy, but asyncio task resumptions explicitly reuse the task’s existing context.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant