Python's Swap is Not Atomic
Python's Swap is Not Atomic
Join the DZone community and get the full member experience.Join For Free
Learn how error monitoring with Sentry closes the gap between the product team and your customers. With Sentry, you can focus on what you do best: building and scaling software that makes your users’ lives better.
I rewrote PyMongo’s connection pool over the last few months. Among the concurrency issues I had to nail down was, if a thread is resetting the connection pool as another thread is using the pool, how do I keep them from stepping on each other?
I thought I nailed this, but of course I didn’t. There’s a race condition in here:
I thought that the swap would be atomic: the first thread to enter reset() would replace self.sockets with an empty set, then close all the old sockets, and all subsequent threads would find that self.sockets was empty. That turns out not to be the case.
The race condition was occasionally revealed in runs of PyMongo’s huge test suite. One of the tests spins up 40 concurrent threads. Each thread queries MongoDB, calls reset(), and queries MongoDB again. Here’s how the test fails:
As I said, I’d thought the swap was atomic, but in fact it takes half a dozen bytecode instructions. That one swap line:
Say that Thread 1 is executing this function. Thread 1 loads self.sockets and the empty set onto its stack and swaps them, and before it gets to STORE_ATTR (where self.sockets is actually replaced), it gets interrupted by Thread 2. Thread 2 runs some other part of the connection pool’s code, e.g.:
This disassembles to:
Let’s say Thread 2 reaches the LOAD_ATTR 1 bytecode. Now it has self.sockets on its stack, and it gets interrupted by Thread 1, which is still in reset(). Thread 1 replaces self.sockets with the empty set. But alas, Thread 1′s “old” list of sockets and Thread 2′s “self.sockets” are the same set. Thread 1 starts iterating over the old list of sockets, closing them:
…but it gets interrupted again by Thread 2, which does self.sockets.add(sock_info), increasing the set’s size by one. When Thread 1 is next resumed, it tries to continue iterating, and raises the “Set changed size during iteration” exception.
Let’s dive deeper for a minute. You may be thinking that in practice two Python threads wouldn’t interrupt each other this often. Indeed, the interpreter executes 100 bytecodes at a time before it even thinks of switching threads. But in our case, Thread 1 is repeatedly calling socket.close(), which is written in socketmodule.c like this:
That Py_BEGIN_ALLOW_THREADS macro releases the Global Interpreter Lock and Py_END_ALLOW_THREADS waits to reacquire it. In a multithreaded Python program, releasing the GIL makes it very likely that another thread which is waiting for the GIL will immediately acquire it. (Notwithstanding David Beazley’s talk on the GIL—he demonstrates that CPU-bound and IO-bound threads competing for the GIL on a multicore system interrupt each other too rarely, but in this case I’m only dealing with IO-bound threads.)
So calling socket.close() in a loop ensures that this thread will be constantly interrupted. The probability that some thread in return_socket() gets a reference to the set, and modifies it, interleaved with some other thread in reset() getting a reference to the same set and iterating it, is high enough to break PyMongo’s unittest about 1% of the time.
The solution was obvious once I understood the problem:
Single-bytecode instructions in Python are atomic, and if you can use this atomicity to avoid mutexes then I believe you should—not only is your code faster and simpler, but you avoid the risk of deadlocks, which are the worst concurrency bugs. But not everything that looks atomic is. When in doubt, use the dis module to examine your bytecode and find out for sure.
Opinions expressed by DZone contributors are their own.