Skip to content

Commit c0abcbb

Browse files
committed
Add support for threadlocal caches
Doesn't seem super useful, as the GIL means a cache lock might not get to contend that much. However it might find utility with the free threaded interpreter eventually. Anyway it's not huge and it's not very complex, although the contextvars API is not great for lazy initialisation. Still even though the initialisation looks like it could lead to redundant inits (similar to the clearing cache which can get multi-cleared) it should be safe: different threads hitting `cache` concurrently will each hit their own lookup failure, and initialise their local cache, and set their personal contextvar. For a var to get double-init would require the same thread to be concurrent with itself, which is not possible. Fixes #180
1 parent 670fdf6 commit c0abcbb

2 files changed

Lines changed: 35 additions & 4 deletions

File tree

src/ua_parser/__main__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
PartialParseResult,
2323
Resolver,
2424
)
25-
from .caching import Cache
25+
from .caching import Cache, Local
2626
from .loaders import load_builtins, load_yaml
2727
from .re2 import Resolver as Re2Resolver
2828
from .user_agent_parser import Parse
@@ -243,11 +243,12 @@ def run_threaded(args: argparse.Namespace) -> None:
243243
basic = BasicResolver(load_builtins())
244244
resolvers: List[Tuple[str, Resolver]] = [
245245
("clearing", CachingResolver(basic, Clearing(CACHESIZE))),
246-
("LRU", CachingResolver(basic, Locking(LRU(CACHESIZE)))),
246+
("locking-lru", CachingResolver(basic, Locking(LRU(CACHESIZE)))),
247+
("local-lru", CachingResolver(basic, Local(lambda: LRU(CACHESIZE)))),
247248
("re2", Re2Resolver(load_builtins())),
248249
]
249250
for name, resolver in resolvers:
250-
print(f"{name:10}: ", end="", flush=True)
251+
print(f"{name:11}: ", end="", flush=True)
251252
# randomize the dataset for each thread, predictably, to
252253
# simulate distributed load (not great but better than
253254
# nothing, and probably better than reusing the exact same

src/ua_parser/caching.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import abc
22
import threading
33
from collections import OrderedDict
4-
from typing import Dict, Optional, Protocol
4+
from contextvars import ContextVar
5+
from typing import Callable, Dict, Optional, Protocol
56

67
from .core import Domain, PartialParseResult, Resolver
78

@@ -122,6 +123,35 @@ def __setitem__(self, key: str, value: PartialParseResult) -> None:
122123
self.cache[key] = value
123124

124125

126+
class Local:
127+
"""Thread local cache decorator. Takes a cache factory and lazily
128+
instantiates a cache for each thread it's accessed from.
129+
130+
This means the cache capacity and memory consumption is
131+
figuratively multiplied by however many threads the cache is used
132+
from, but those threads don't share their caching.
133+
134+
"""
135+
136+
def __init__(self, factory: Callable[[], Cache]) -> None:
137+
self.cv: ContextVar[Cache] = ContextVar("local-cache")
138+
self.factory = factory
139+
140+
@property
141+
def cache(self) -> Cache:
142+
c = self.cv.get(None)
143+
if c is None:
144+
c = self.factory()
145+
self.cv.set(c)
146+
return c
147+
148+
def __getitem__(self, key: str) -> Optional[PartialParseResult]:
149+
return self.cache[key]
150+
151+
def __setitem__(self, key: str, value: PartialParseResult) -> None:
152+
self.cache[key] = value
153+
154+
125155
class CachingResolver:
126156
"""A wrapping parser which takes an underlying concrete :class:`Cache`
127157
for the actual caching and cache strategy.

0 commit comments

Comments
 (0)