8 ms·
Data races in Python, despite the Global Interpreter Lock
- deleted 5y ago[deleted]
- samwillis 5y agoInterestingly if you change the line to `counter += 1` it still has the race condition. I'm not sure how the byte code is different for the two options but it doesn't make a difference, I had hoped it would.
- miohtama 5y agoIn the Python VM there is no atomic increment bytecode. So `counter += 1` should be exactly same as `counter = counter + 1`. Here is an example what thread safe increment looks like in Python: https://julien.danjou.info/atomic-lock-free-counters-in-python/ https://julien.danjou.info/atomic-lock-free-counters-in-pyth... You need to lock it explicitly. Note that `INC` instructor for x86 architecture needs explicit hints/locks as well, so this should suprise anyone: https://stackoverflow.com/q/10109679 https://stackoverflow.com/q/10109679
- jasonhansel 5y agoIn general, "+=" probably needs to be non-atomic to support "__add__" overloading; Python wouldn't be able to call arbitrary "__add__" methods in a way that could guarantee atomicity.
- nneonneo 5y agoA bit more detail: there is an atomic addition bytecode, called INPLACE_ADD, which is why something like “a_list += [1]” is actually atomic. The problem is that integers are immutable, so “counter += 1” involves a (non-atomic) load and store of counter, and that’s where the race is. Hypothetically, if Python had a built-in mutable integer type, the increment could be made atomic.
- tomp 5y agoPython’s GIL does exactly nothing to prevent data races (or any other concurrency issues); it merely protects the runtime from memory corruption stemming from concurrency. Obviously, the fundamental issue with concurrency is programmer’s intent. This statement: x += 1; y -= 1 can be interpreted in two ways: atomic { x += 1 y -= 1 } or atomic { x += 1 } atomic { y -= 1 } The best the compiler can do would be to alert the programmer of the ambiguity; I know of no compiler that does that.
- deleted 5y ago[deleted]
- dzqhz 5y agoI don't see how it could ever be interpreted the first way.
- verdagon 5y agoThe article isn't trying to claim either interpretation, it's just using it as an example to show that the GIL doesn't actually help protect users from concurrency problems. You'd be surprised how many people think that! I used to think that it did as well, but then my C/Java brain kicked in and realized that couldn't be correct. I wrote this article to help others see it in action.
- matheusmoreira 5y ago> I used to think that it did as well I did too. I even wrote multithreaded test programs to see if it was true: they would always give me consistent results on Python and Ruby but on Java and C data got clobbered as expected.
- tomp 5y agofor example: "move money from one bank account to another bank account" essentially the same operations in the same order, that need to be isolated (i.e. transactional / atomic) from concurrent threads performing the same operations.
- jondgoodwin 5y agoYou claim that a language can guarantee completely deterministic runs. How is that possible in Vale?
- verdagon 5y agoIt's tricky but it is possible, if we: 1. Don't allow any undefined behavior or `unsafe` code in the language. 2. Record all inputs from FFI. 3. Carefully track the orderings of interactions across threads. The article goes into the first two, but the third one is the most interesting IMO: When we unlock a mutex or send a message, we assign a "sequence number" (similar to what we see in TCP packets). Whenever we lock a mutex or receive a message, we read the sequence number and record it to this thread's "recording". When replaying, we use that sequence number and that file to make sure we're reading in the same order as the previous execution.
- bb88 5y agoInteresting, but how do you know if you've captured all the potential states for all possible inputs to a program? An unexpected state would seem to break the memory model, and lead to corrupted data, wouldn't it?
- verdagon 5y agoThe feature just makes it run identically to the last run. It's useful for reproducing bugs; if you encounter a bug, just start the program in "replay" mode, supply the recording from the last run, and fire up the debugger, and watch as the bug is reproduced perfectly. It's kind of like a time-traveling debugger, but with stronger guarantees. I'm particularly excited about combining this with concurrency, we'll never have to spend hours hunting down mysterious race conditions ever again!
- bb88 5y agoThe GIL is for internal python state. Not for atomic preservation of python data types. I learned very early on that python threads should be treated like C threads, and therefore should be avoided. Also there really isn't a performance gain to using threads (other than maybe waiting for IO completion).
- nneonneo 5y agoIf you use C code that releases the GIL, you can see big speed ups with threads. A lot of C extension modules will drop the GIL. For instance, NumPy drops the GIL for many array operations, so threads can significantly speed up numeric workloads.
- _ache_ 5y agoI'm unable to reproduce. Increasing the number of iterations to `1000000` and using a temporary variable `c = counter + 1; counter = c` doesn't help either. Why ? I'm on Linux, using Python 3.10. It's only happening using Python2.7.
- Aperocky 5y agoPython 2.x is unsupported and anyone using it should operate on the assumption that whatever bug its got is final unless they're the one who is going to fix it.
- verdagon 5y agoAuthor here, u/skeeto from https://www.reddit.com/r/programming/comments/sxy5q4/pythons_data_races_despite_the_global_interpreter/hxvaou0/?utm_source=reddit&utm_medium=web2x&context=3 https://www.reddit.com/r/programming/comments/sxy5q4/pythons... has some good insight into the 3.10 difference: > Ironically, CPython 3.10 has gone the opposite direction and made thread scheduling much more deterministic. It now only releases the GIL on backwards edges in the byte code The example in the article always prints 40000000 in CPython 3.10! I expect this will ultimately make Python code less reliable in the future as many programs will accidentally depend on this behavior.
- andreareina 5y agoDoes this mean loops/function call/return?
- rwbhn 5y agoYes. A comment in the referenced commit says "when returning from a call or on the back edges of loops."
- nyanpasu64 5y agoIf only one thread is reading or writing the counter at a time, and holds the GIL while doing so, it's not a data race, but a mutex which fails to ensure the atomicity of the read-modify-write operation: with GIL: x = counter + 1 with GIL: counter = x
- dehrmann 5y agoAnd there's this: https://web.archive.org/web/20201108091210/http://effbot.org/pyfaq/what-kinds-of-global-value-mutation-are-thread-safe.htm https://web.archive.org/web/20201108091210/http://effbot.org...
- jasonhansel 5y agoThat's a logical race, not a data race, right? Technically, "counter = counter + 1" accesses counter twice (once to read and once to write). The fact that "counter" gets modified between the read and the write doesn't imply that multiple accesses were happening simultaneously; it just means that accesses from different threads were getting interleaved. The GIL would only prevent the former but not the latter, since a thread can give up the GIL at any point between operations.
- chrisseaton 5y agoThe semantic operation is increment. It being read, add, write is an implementation detail. The increments happen simultaneously, meeting the definition for 'data race' according to Padua, which is the definition most people would use. https://link.springer.com/referenceworkentry/10.1007/978-0-387-09766-4_36 https://link.springer.com/referenceworkentry/10.1007/978-0-3...
- Thorrez 5y ago>The semantic operation is increment. Why? Who defines that?
- chrisseaton 5y agoBecause that's what the user wrote. You can reason about the operation in terms of smaller operations (such as read, add, store) but not more coarse operations, because the user could have just written the single operation.
- Thorrez 5y agoConsider the situation "counter = counter + get_value()". That also can be reasoned about in terms of smaller operations, but not more coarse operations, because the user could have just written the single operation. We wouldn't expect this to be atomic, because the get_value() function could do tons of things, and we don't expect all that to be atomic. It doesn't make sense to call something atomic just because the user wrote it.
- Kranar 5y agoThe article mixes up data races and race conditions, which is understandable since it's a common mistake. Race conditions are a fairly general class of bugs involving some non-deterministic sequence of operations, but a data race is a very specific scenario (within a given memory model) that occurs when there are two or more concurrent accesses to a memory location and at least one of them is a write. Ignoring C extensions, the GIL absolutely does protect against this and data races are not possible in Python. The bug is due to the fact that an operation such as "x = x + 1" is not atomic, it can be interrupted at several points to allow for another thread to read and write to x. Each individual read and write is atomic, but the order of such reads/writes is non-deterministic. A data race would be if the memory location that x represents is written to simultaneously as it's being read from which could result in a form of clobbering. To make this more concrete, given the following code: print(x) x = x + 1 print(x) In Python it will always be the case that the first print statement prints a number that is less than the second print statement, guaranteed. However if data races were possible, it would be possible that the second print statement displays a number less than the first print statement. For example the first print statement could print 65535, and the second print statement could print out 0. This can happen if for example, two simultaneous additions occur where one addition is in the process of clearing out the lower bits at the same time that another thread's addition is in the process of clearing out the higher bits so that at the completion of both threads, x's bits are reset to 0. Such a scenario is not possible in Python. Whether one considers data races to be race conditions is a matter of debate, but at any rate no one familiar with the difference between the two should consider the behavior in this article to be a data race.
- verdagon 5y agoEveryone seems to have their own definition of data race! The article uses the one from the Rustonomicon, and by that definition, the Python example does appear to have a data race. (Edit: Apologies for misreading; our definitions are indeed similar, continued below)
- Kranar 5y agoThe definition I gave is exactly the one given by Rust [1], by C++ [2], by Java [3] and the one found on Wikipedia [4]. In safe Rust data races are not possible and yet it is still possible for a Rust program to exhibit the behavior in your article, because your article is not an example of a data race but rather a race condition. Note that in the source you reference, it explicitly states that while data races are protected, race conditions are not. The scenario I gave, where performing an operation such as "x = x + 1" can result in x decreasing in value can only be caused by an overflow, or a data race. No interpretation of a Python or safe Rust program will ever produce a decreasing value of x when evaluating "x = x + 1" (ignoring Rust's overflow behavior) since data races are not possible. In Java or C++, since data races are possible it is also possible that "x = x + 1" produces a decreasing value of x (that is not due to overflow). Finally here is a good article that lays out the differences between data races and race conditions: https://blog.regehr.org/archives/490 https://blog.regehr.org/archives/490 [1] https://doc.rust-lang.org/nomicon/races.html https://doc.rust-lang.org/nomicon/races.html [2] https://docs.oracle.com/cd/E19205-01/820-0619/geojs/index.html https://docs.oracle.com/cd/E19205-01/820-0619/geojs/index.ht... [3] https://en.cppreference.com/w/cpp/language/memory_model https://en.cppreference.com/w/cpp/language/memory_model [4] https://en.wikipedia.org/wiki/Race_condition#Data_race https://en.wikipedia.org/wiki/Race_condition#Data_race
- zitterbewegung 5y agoI would have an issue with having Python in some sort of development mode that would change behavior versus deployed mode ? (I don’t even think how this would occur other than adding an argument to the interpreter itself). Mainly because of having two separate modes of operations.
- pdonis 5y agoThe author evidently does not understand how threaded programming works in Python. The reason for the "data races" he describes is that, even with the GIL in Python, you have no control over the order in which different threads execute particular operations that are operating on the same objects, unless you use synchronization primitives like mutexes. Python has never claimed that having the GIL allows you to avoid that. The Python documentation on threading and the GIL [1] makes it clear that the reason for the GIL is to make operations on Python objects inside the interpreter thread-safe, by ensuring that the C structures that store the states of Python objects can only be mutated by one thread at a time. All of those C-level operations happen "inside" a single bytecode. Python operations that require multiple bytecodes do not have any of the same guarantees. [1] https://docs.python.org/3/c-api/init.html#thread-state-and-the-global-interpreter-lock https://docs.python.org/3/c-api/init.html#thread-state-and-t...
- hedora 5y agoThanks for the link. It's too bad they exposed the GIL to C extension authors. Now people will rely on it, making it impractical to remove!
- pdonis 5y ago> It's too bad they exposed the GIL to C extension authors. As I understand it, yes, that has been one of the main obstacles to removing the GIL. The current way forward seems to be to make all the interpreter state per-thread, so that each thread could have a separate interpreter; this would make the GIL unnnecessary, but of course one would still need synchronization primitives like mutexes to control access to data shared between threads.
- nneonneo 5y agoFor what it’s worth, Python does have an atomic add instruction, but because integers are immutable, the load/store operations to the global variable are unsynchronized (the add produces a new object which must be stored to the global). If you replace the Python integer with a 0D NumPy array, and use a real in-place operation, the race disappears: import numpy as np counter = np.array(0, dtype=np.uint32) one = np.uint32(1) def increase(): global counter for i in range(0, 100000): counter += one Of course, the lesson still holds: accessing shared state from multiple threads still requires care; you don’t get to ignore these things just because Python has a GIL.
- dureuill 5y agoThis is not a data race in the sense that Rust prevents. Case in point, here is the same race condition in Rust: - playground[0] (see for yourself) - inline code (not that long): use std::thread; use std::sync::atomic::AtomicU32; use std::sync::atomic::Ordering; static COUNTER : AtomicU32 = AtomicU32::new(0); fn increase() { for _i in 0..100000 { let a = COUNTER.load(Ordering::SeqCst); COUNTER.store(a + 1, Ordering::SeqCst); } } fn main() { let mut threads = Vec::new(); for _i in 0..400 { threads.push(thread::spawn(increase)) } for thread in threads { thread.join().unwrap(); } println!("Final counter: {}", COUNTER.load(Ordering::SeqCst)) } The data races that Rust prevents are "instruction-level data races". They occur when a single, non atomic, architectural instruction is executed in parallel by multiple threads. In that case we can really get corrupted values, in that "partially written" value in the architectural sense (depending on architectural details) can be produced. Think a double word with one word rewritten by one thread and the other word rewritten by the other thread. In the given code example that is not an instruction-level data race, the produced value will strictly be in the range <max_number_of_increase>..<max_number_of_increase>*<number_of_threads> (plus or minus the usual off-by-one error), and no architectural artifacts can ever manifest. I guess the insight of the article could be that `counter = counter + 1` is closer in Python from `a = atomicLoad(counter); atomicStore(counter, a + 1)` than from `atomicIncrement(counter)`? I wonder if using `counter += 1` changes anything? In any case, I can see how it is easier to trigger accidental race conditions of that kind in Python than in Rust, with the atomic operations being implicit in the former, and explicit in the latter. [0]: https://play.rust-lang.org/?version=stable&mode=release&edition=2021&gist=13c380a005dd43056eba8f63c9563403 https://play.rust-lang.org/?version=stable&mode=release&edit...