Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions Lib/test/test_free_threading/test_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,98 @@ def read_set():
for t in threads:
t.join()

def test_intersection_update_concurrent(self):
"""Test one-operand intersection updates of one shared set."""
NUM_ITERS = 10
BLOCK_SIZE = self.SET_SIZE * 100

sources = [
set(range(4 * BLOCK_SIZE)),
set(range(3 * BLOCK_SIZE)),
set(range(2 * BLOCK_SIZE)),
set(range(BLOCK_SIZE)),
]
expected = set(range(BLOCK_SIZE))

for _ in range(NUM_ITERS):
target = set(range(5 * BLOCK_SIZE))
barrier = Barrier(len(sources), timeout=2)

def intersect(source):
barrier.wait()
target.intersection_update(source)

threads = [Thread(target=intersect, args=(source,))
for source in sources]
for thread in threads:
thread.start()
for thread in threads:
thread.join()

self.assertEqual(target, expected)

def test_intersection_update_multiple_concurrent(self):
"""Test multi-operand intersection updates of one shared set."""
NUM_ITERS = 10
BLOCK_SIZE = self.SET_SIZE * 100

evens = set(range(0, 4 * BLOCK_SIZE, 2))
below_three_blocks = set(range(3 * BLOCK_SIZE))
multiples_of_three = set(range(0, 2 * BLOCK_SIZE, 3))
below_one_block = set(range(BLOCK_SIZE))
expected = set(range(0, BLOCK_SIZE, 6))

for _ in range(NUM_ITERS):
target = set(range(5 * BLOCK_SIZE))
barrier = Barrier(2, timeout=2)

def intersect(first, second):
barrier.wait()
target.intersection_update(first, second)

threads = [
Thread(target=intersect,
args=(evens, below_three_blocks)),
Thread(target=intersect,
args=(multiples_of_three, below_one_block)),
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()

self.assertEqual(target, expected)

def test_iand_concurrent(self):
"""Test concurrent &= operations on one shared set."""
NUM_ITERS = 10
BLOCK_SIZE = self.SET_SIZE * 100

sources = [
set(range(4 * BLOCK_SIZE)),
set(range(3 * BLOCK_SIZE)),
set(range(2 * BLOCK_SIZE)),
set(range(BLOCK_SIZE)),
]
expected = set(range(BLOCK_SIZE))

for _ in range(NUM_ITERS):
target = set(range(5 * BLOCK_SIZE))
barrier = Barrier(len(sources), timeout=2)

def intersect(source):
barrier.wait()
target.__iand__(source)

threads = [Thread(target=intersect, args=(source,))
for source in sources]
for thread in threads:
thread.start()
for thread in threads:
thread.join()

self.assertEqual(target, expected)


@threading_helper.requires_working_threading()
class SmallSetTest(RaceTestBase, unittest.TestCase):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix a race condition in :meth:`set.intersection_update` on free-threaded builds that could cause concurrent updates to the same set to be lost.
40 changes: 33 additions & 7 deletions Objects/setobject.c
Comment thread
CaQtiml marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -1861,15 +1861,41 @@ set_intersection_update_multi_impl(PySetObject *so, PyObject * const *others,
Py_ssize_t others_length)
/*[clinic end generated code: output=d768b5584675b48d input=782e422fc370e4fc]*/
{
PyObject *tmp;
Py_ssize_t i;
PyObject *other;
PyObject *result;

tmp = set_intersection_multi_impl(so, others, others_length);
if (tmp == NULL)
if (others_length == 0) {
Py_RETURN_NONE;
}

other = others[0];
Py_BEGIN_CRITICAL_SECTION2(so, other);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Py_BEGIN_CRITICAL_SECTION2(so, other) without checking so == other will trigger assert(c1 != c2) in debug builds and cause a self-deadlock in release builds on s.intersection_update(s). If so == other, a single Py_BEGIN_CRITICAL_SECTION(so) must be used instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this shouldn't be a big deal for the main branch because we already check that inside Py_BEGIN_CRITICAL_SECTION2. But I am not sure about versions before.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, you're right

result = set_intersection(so, other);
if (result != NULL) {
for (i = 1; i < others_length; i++) {
PyObject *newresult;

other = others[i];
Py_BEGIN_CRITICAL_SECTION(other);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nesting Py_BEGIN_CRITICAL_SECTION(other) inside Py_BEGIN_CRITICAL_SECTION2 introduces an lock-ordering inversion (ABBA deadlock) when concurrent threads call intersection_update with swapped arguments (e.g., a.intersection_update(b, c) vs c.intersection_update(b, a)). We should only hold a critical section on the mutated set so.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly (please correct me if I am wrong), the ABBA deadlock will not happen because _PyCriticalSection_BeginMutex has a slow path _PyCriticalSection_BeginSlow for waiting on a lock.

For example, suppose Thread 1 has completed its outer critical section on a and b and then waits for c. Thread 2 may have acquired c while trying to acquire c and b, but it cannot acquire b because Thread 1 holds it. When Thread 1 goes to sleep, it temporarily releases a and b. Thread 2 can then acquire b, finish its operation, and release its locks. Thread 1 can then acquire c and later reacquire a and b.

The reverse execution is also possible, depending on the execution order.

But this leads to a problem of lost update (again)! Another thread can modify the target set while its lock is temporarily released. I will fix this.

newresult = set_intersection((PySetObject *)result, other);
Py_END_CRITICAL_SECTION();
if (newresult == NULL) {
Py_CLEAR(result);
break;
}
Py_SETREF(result, newresult);
}
if (result != NULL) {
set_swap_bodies(so, (PySetObject *)result);
}
}
Py_END_CRITICAL_SECTION2();

if (result == NULL) {
return NULL;
Py_BEGIN_CRITICAL_SECTION(so);
set_swap_bodies(so, (PySetObject *)tmp);
Py_END_CRITICAL_SECTION();
Py_DECREF(tmp);
}
Py_DECREF(result);
Py_RETURN_NONE;
}

Expand Down
Loading