Skip to content

Commit 9ad0c7b

Browse files
authored
gh-132641: fix race in lru_cache under free-threading (#133787)
Fix race in `lru_cache` by acquiring critical section on the cache object itself and call the lock held variant of dict functions to modify the underlying dict.
1 parent 35f47d0 commit 9ad0c7b

File tree

5 files changed

+94
-5
lines changed

5 files changed

+94
-5
lines changed

Include/internal/pycore_dict.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ extern int _PyDict_Pop_KnownHash(
150150
Py_hash_t hash,
151151
PyObject **result);
152152

153+
extern void _PyDict_Clear_LockHeld(PyObject *op);
154+
153155
#ifdef Py_GIL_DISABLED
154156
PyAPI_FUNC(void) _PyDict_EnsureSharedOnRead(PyDictObject *mp);
155157
#endif
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import random
2+
import unittest
3+
4+
from functools import lru_cache
5+
from threading import Barrier, Thread
6+
7+
from test.support import threading_helper
8+
9+
@threading_helper.requires_working_threading()
10+
class TestLRUCache(unittest.TestCase):
11+
12+
def _test_concurrent_operations(self, maxsize):
13+
num_threads = 10
14+
b = Barrier(num_threads)
15+
@lru_cache(maxsize=maxsize)
16+
def func(arg=0):
17+
return object()
18+
19+
20+
def thread_func():
21+
b.wait()
22+
for i in range(1000):
23+
r = random.randint(0, 1000)
24+
if i < 800:
25+
func(i)
26+
elif i < 900:
27+
func.cache_info()
28+
else:
29+
func.cache_clear()
30+
31+
threads = []
32+
for i in range(num_threads):
33+
t = Thread(target=thread_func)
34+
threads.append(t)
35+
36+
with threading_helper.start_threads(threads):
37+
pass
38+
39+
def test_concurrent_operations_unbounded(self):
40+
self._test_concurrent_operations(maxsize=None)
41+
42+
def test_concurrent_operations_bounded(self):
43+
self._test_concurrent_operations(maxsize=128)
44+
45+
def _test_reentrant_cache_clear(self, maxsize):
46+
num_threads = 10
47+
b = Barrier(num_threads)
48+
@lru_cache(maxsize=maxsize)
49+
def func(arg=0):
50+
func.cache_clear()
51+
return object()
52+
53+
54+
def thread_func():
55+
b.wait()
56+
for i in range(1000):
57+
func(random.randint(0, 10000))
58+
59+
threads = []
60+
for i in range(num_threads):
61+
t = Thread(target=thread_func)
62+
threads.append(t)
63+
64+
with threading_helper.start_threads(threads):
65+
pass
66+
67+
def test_reentrant_cache_clear_unbounded(self):
68+
self._test_reentrant_cache_clear(maxsize=None)
69+
70+
def test_reentrant_cache_clear_bounded(self):
71+
self._test_reentrant_cache_clear(maxsize=128)
72+
73+
74+
if __name__ == "__main__":
75+
unittest.main()
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed a race in :func:`functools.lru_cache` under free-threading.

Modules/_functoolsmodule.c

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1383,8 +1383,8 @@ bounded_lru_cache_update_lock_held(lru_cache_object *self,
13831383
this same key, then this setitem call will update the cache dict
13841384
with this new link, leaving the old link as an orphan (i.e. not
13851385
having a cache dict entry that refers to it). */
1386-
if (_PyDict_SetItem_KnownHash(self->cache, key, (PyObject *)link,
1387-
hash) < 0) {
1386+
if (_PyDict_SetItem_KnownHash_LockHeld((PyDictObject *)self->cache, key,
1387+
(PyObject *)link, hash) < 0) {
13881388
Py_DECREF(link);
13891389
return NULL;
13901390
}
@@ -1453,8 +1453,8 @@ bounded_lru_cache_update_lock_held(lru_cache_object *self,
14531453
for successful insertion in the cache dict before adding the
14541454
link to the linked list. Otherwise, the potentially reentrant
14551455
__eq__ call could cause the then orphan link to be visited. */
1456-
if (_PyDict_SetItem_KnownHash(self->cache, key, (PyObject *)link,
1457-
hash) < 0) {
1456+
if (_PyDict_SetItem_KnownHash_LockHeld((PyDictObject *)self->cache, key,
1457+
(PyObject *)link, hash) < 0) {
14581458
/* Somehow the cache dict update failed. We no longer can
14591459
restore the old link. Let the error propagate upward and
14601460
leave the cache short one link. */
@@ -1689,7 +1689,13 @@ _functools__lru_cache_wrapper_cache_clear_impl(PyObject *self)
16891689
lru_list_elem *list = lru_cache_unlink_list(_self);
16901690
FT_ATOMIC_STORE_SSIZE_RELAXED(_self->hits, 0);
16911691
FT_ATOMIC_STORE_SSIZE_RELAXED(_self->misses, 0);
1692-
PyDict_Clear(_self->cache);
1692+
if (_self->wrapper == bounded_lru_cache_wrapper) {
1693+
/* The critical section on the lru cache itself protects the dictionary
1694+
for bounded_lru_cache instances. */
1695+
_PyDict_Clear_LockHeld(_self->cache);
1696+
} else {
1697+
PyDict_Clear(_self->cache);
1698+
}
16931699
lru_cache_clear_list(list);
16941700
Py_RETURN_NONE;
16951701
}

Objects/dictobject.c

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2915,6 +2915,11 @@ clear_lock_held(PyObject *op)
29152915
ASSERT_CONSISTENT(mp);
29162916
}
29172917

2918+
void
2919+
_PyDict_Clear_LockHeld(PyObject *op) {
2920+
clear_lock_held(op);
2921+
}
2922+
29182923
void
29192924
PyDict_Clear(PyObject *op)
29202925
{

0 commit comments

Comments
 (0)