Skip to content

Commit 571b705

Browse files
committed
Reduce context stack contention under free-threaded Python
1 parent 0051a97 commit 571b705

7 files changed

Lines changed: 800 additions & 315 deletions

File tree

Cargo.lock

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ rust-version = "1.83"
1313
license = "BSD-3-Clause"
1414

1515
[workspace.dependencies]
16+
arc-swap = "1.7"
1617
pyo3 = "0.29"

src/logbook/_fallback.py

Lines changed: 90 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,15 @@
1111
from __future__ import annotations
1212

1313
import threading
14-
from collections.abc import Callable, Iterable, Iterator, Sequence
14+
from collections.abc import Callable, Iterator
1515
from contextvars import ContextVar
1616
from itertools import chain, count
17-
from typing import Any, Generic, SupportsIndex, TypeVar, overload
18-
from weakref import WeakKeyDictionary
19-
20-
from typing_extensions import TypeAliasType
17+
from typing import Any, Generic, TypeVar
2118

2219
from logbook.helpers import get_iterator_next_method
2320

2421
_missing = object()
25-
_MAX_CONTEXT_OBJECT_CACHE = 256
2622

27-
T_co = TypeVar("T_co", covariant=True)
2823
T = TypeVar("T")
2924

3025

@@ -105,57 +100,36 @@ def applicationbound(self):
105100
return ApplicationBound(self)
106101

107102

108-
class FrozenSequence(Sequence[T_co]):
109-
__slots__ = ("__weakref__", "_hash", "_items")
110-
111-
def __init__(self, iterable: Iterable[T_co] = ()) -> None:
112-
self._items = tuple(iterable)
113-
self._hash: int | None = None
114-
115-
@overload
116-
def __getitem__(self, index: SupportsIndex) -> T_co: ...
117-
118-
@overload
119-
def __getitem__(self, index: slice) -> FrozenSequence[T_co]: ...
120-
121-
def __getitem__(self, index: SupportsIndex | slice) -> T_co | FrozenSequence[T_co]:
122-
if isinstance(index, slice):
123-
return FrozenSequence(self._items[index])
124-
return self._items[index]
125-
126-
def __len__(self) -> int:
127-
return len(self._items)
103+
class _StackState(Generic[T]):
104+
"""A state of the persistent context stack.
128105
129-
def __iter__(self) -> Iterator[T_co]:
130-
return iter(self._items)
131-
132-
def __reversed__(self) -> Iterator[T_co]:
133-
return reversed(self._items)
134-
135-
def __contains__(self, item: object) -> bool:
136-
return item in self._items
137-
138-
def __eq__(self, other: object) -> bool:
139-
if isinstance(other, FrozenSequence):
140-
return self._items == other._items
141-
return NotImplemented
106+
Each state is a node in a parent-linked chain: pushing creates a child
107+
node and popping restores the parent, so a stack state keeps its
108+
identity (and its ``merged`` memo) across push/pop cycles. The root
109+
sentinel has ``item = parent = None``.
110+
"""
142111

143-
def __hash__(self) -> int:
144-
if self._hash is None:
145-
self._hash = hash(self._items)
146-
return self._hash
112+
__slots__ = ("item", "merged", "parent", "size")
113+
114+
def __init__(
115+
self, item: tuple[int, T] | None, parent: _StackState[T] | None
116+
) -> None:
117+
self.item = item
118+
self.parent = parent
119+
self.size = 0 if parent is None else parent.size + 1
120+
self.merged: tuple[tuple[tuple[int, T], ...], tuple[T, ...]] | None = None
121+
122+
def __iter__(self) -> Iterator[tuple[int, T]]:
123+
items = []
124+
node = self
125+
while node.parent is not None:
126+
assert node.item is not None
127+
items.append(node.item)
128+
node = node.parent
129+
return iter(reversed(items))
147130

148131
def __repr__(self) -> str:
149-
if self._items:
150-
items = repr(self._items)
151-
else:
152-
items = ""
153-
return f"{self.__class__.__name__}({items})"
154-
155-
156-
FrozenStack = TypeAliasType(
157-
"FrozenStack", FrozenSequence[tuple[int, T]], type_params=(T,)
158-
)
132+
return f"<_StackState len={self.size}>"
159133

160134

161135
class ContextStackManager(Generic[T]):
@@ -164,57 +138,82 @@ class ContextStackManager(Generic[T]):
164138
"""
165139

166140
def __init__(self) -> None:
167-
self._global: list[T] = []
168-
self._context_stack: ContextVar[FrozenStack[T]] = ContextVar(
169-
"stack", default=FrozenSequence()
141+
self._write_lock = threading.Lock()
142+
# () is a singleton, so empty globals from different eras are
143+
# identity-equal; harmless for the memo check because equal contents
144+
# produce equal merges.
145+
self._global: tuple[tuple[int, T], ...] = ()
146+
# All contexts share the root node; safe because nodes are immutable
147+
# apart from the atomically-published memo slot.
148+
self._root: _StackState[T] = _StackState(None, None)
149+
self._context_stack: ContextVar[_StackState[T]] = ContextVar(
150+
"stack", default=self._root
170151
)
171-
self._cache: WeakKeyDictionary[FrozenStack[T], list[T]] = WeakKeyDictionary()
172152
self._stackop: Callable[[], int] = get_iterator_next_method(count())
173-
self._lock = threading.Lock()
174153

175154
def iter_context_objects(self) -> Iterator[T]:
176155
"""Returns an iterator over all objects for the combined
177156
application and context cache.
178157
"""
158+
node = self._context_stack.get()
159+
current_global = self._global
160+
161+
memo = node.merged
162+
# The memo holds a strong reference to the global it was computed
163+
# against, so the identity check cannot be fooled by id reuse.
164+
if memo is not None and memo[0] is current_global:
165+
return iter(memo[1])
166+
167+
stack_objects = sorted(chain(current_global, node), reverse=True)
168+
objects = tuple(x[1] for x in stack_objects)
169+
# Nodes are shared across threads via copy_context(); the tuple is
170+
# built in full before this single store, so concurrent readers see
171+
# either None or a complete memo.
172+
node.merged = (current_global, objects)
173+
174+
# A live chain deliberately keeps a memo per node — each is the warm
175+
# cache for a depth the context may pop back to — but memos computed
176+
# against an older application stack would pin its popped handlers
177+
# until then; drop those now. Clearing races benignly with
178+
# concurrent stores.
179+
ancestor = node.parent
180+
while ancestor is not None:
181+
memo = ancestor.merged
182+
if memo is not None and memo[0] is not current_global:
183+
ancestor.merged = None
184+
ancestor = ancestor.parent
179185

180-
with self._lock:
181-
stack = self._context_stack.get()
182-
objects = self._cache.get(stack)
183-
if objects is None:
184-
if len(self._cache) >= _MAX_CONTEXT_OBJECT_CACHE:
185-
self._cache.clear()
186-
stack_objects = sorted(
187-
chain(
188-
self._global,
189-
stack,
190-
),
191-
reverse=True,
192-
)
193-
objects = [x[1] for x in stack_objects]
194-
self._cache[stack] = objects
195186
return iter(objects)
196187

197188
def push_context(self, obj: T) -> None:
198-
item = (self._stackop(), obj)
199-
stack = self._context_stack.get()
200-
self._context_stack.set(FrozenSequence((*stack, item)))
189+
node = self._context_stack.get()
190+
self._context_stack.set(_StackState((self._stackop(), obj), node))
201191

202192
def pop_context(self) -> T:
203-
stack = self._context_stack.get()
204-
assert stack, "no objects on stack"
205-
*remaining, poppped = stack
206-
self._context_stack.set(FrozenSequence(remaining))
207-
return poppped[1]
193+
node = self._context_stack.get()
194+
if node.parent is None:
195+
raise AssertionError("no objects on stack")
196+
assert node.item is not None
197+
self._context_stack.set(node.parent)
198+
return node.item[1]
208199

209200
def push_application(self, obj: T) -> None:
210-
item = (self._stackop(), obj)
211-
with self._lock:
212-
self._global.append(item)
213-
self._cache.clear()
201+
with self._write_lock:
202+
item = (self._stackop(), obj)
203+
self._global = (*self._global, item)
204+
# Best-effort lifetime hygiene, not needed for correctness: the
205+
# root memo may reference the just-replaced global, and the root
206+
# never dies, so drop the memo now rather than waiting for a
207+
# future empty-stack iteration to replace it.
208+
self._root.merged = None
214209

215210
def pop_application(self) -> T:
216-
with self._lock:
217-
assert self._global, "no objects on application stack"
218-
popped = self._global.pop()
219-
self._cache.clear()
220-
return popped[1]
211+
with self._write_lock:
212+
current = self._global
213+
if not current:
214+
raise AssertionError("no objects on application stack")
215+
self._global = current[:-1]
216+
# See push_application: keep the immortal root memo from pinning
217+
# the popped handler.
218+
self._root.merged = None
219+
return current[-1][1]

src/rust/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ rust-version = { workspace = true }
88
license = { workspace = true }
99

1010
[dependencies]
11+
arc-swap = { workspace = true }
1112
pyo3 = { workspace = true }
1213

1314
[features]

0 commit comments

Comments
 (0)