Changing Interpreters
00:00 In the previous lesson, I stopped beating around the bush and talked specifically about the GIL. In this lesson, I’ll show you how you shouldn’t write code that is dependent on the GIL’s behavior as it can change over time.
00:12 As I mentioned before, the GIL is a CPython-specific solution to the concurrency and reference counting problem. Other interpreters besides CPython exist.
00:21 Some use a GIL and some do not. Since it is an internal mechanism, it isn’t part of the language spec, which means the language doesn’t dictate how it’s supposed to work.
00:31 The consequence of that is the core development team is free to change how it works and where and when it activates. If you write code that makes assumptions about the GIL, you might be in for a surprise in a future version of the interpreter.
00:45
I ran into this problem myself with the race.py
code that I showed you earlier. It was originally written for a course on concurrency and a few months ago someone posted the comments that they couldn’t get it to fail.
00:55 It was always showing the correct answer. After some digging, I discovered that some performance optimizations in Python 3.10 changed how the GIL worked, and now the GIL was locking during the race condition, stopping it from happening.
01:08
This is why I was very particular about demoing race.py
in Python 3.9. Let’s dip back into the REPL just briefly.
01:18 In the top window, I’ve got the results from the earlier lesson where I ran the race function in a Python 3.9 REPL. Now in the bottom, let me fire up 3.12.
01:27
Importing race
, running two threads. Ooh, it worked, and again
01:36 and again, it’s working consistently.
01:39 One of those optimizations I told you about has caused the race condition to stop triggering.
01:46 I want to show you a little more information about the differences between the 3.9 and 3.12 code but first, a quick tangent. Python technically is a compiled language depending on your definition of compiled.
01:58 It doesn’t get compiled down to machine code, it gets compiled down to bytecode. Bytecode is a machine code-like thing that Python interpreters know how to run.
02:08
This is how Python is cross-platform without recompiling. A bytecode is the same, the interpreter is what is platform-specific. You can examine the bytecode for an object by using the dis()
function from the dis
module. Calling it isn’t complicated.
02:25 You import the function, call it with the object you want disassembled, and then read the results. Results, on the other hand, are a whole other language.
02:34
On the left-hand side, I have part of the results of dis
running on the race function in Python 3.9. The results are rather lengthy, so to give you just a taste, I’m only showing the disassembly of the change_counter
function.
02:48 On the right side, I have the same code disassembled in Python 3.12.
02:53 I’ve added blank lines on the left so that the code that is the same lines up with the code on the right. The first column is like an address while the second column is the bytecode operation.
03:04 The rest of it are the arguments to the operation.
03:08 The four lines in red on the left are operations that are different in 3.12. On the right side, I’ve highlighted in green the things that replace the ops on the left as well as the one new line of code that’s there at the top.
03:22
Without getting too much into details, you can kind of see the relationship between this code and the original. LOAD_GLOBAL
is used to get the range
function and later the global counter
. LOAD_CONST
is used to get the 10000
that’s used as an argument to range
. INPLACE_ADD
on the left and BINARY_OP
on the right are doing the addition, while the two jump calls are about loops causing the left to jump back to line 8
and the right to jump back to 24
, both of which are at the top of the loop’s iteration.
03:55 None of this is GIL-specific. The effect the GIL is having is underneath even these covers. But you can see that 3.9 and 3.12 result in different bytecode, so even if the GIL behavior was the same, it might affect how your race condition happened.
04:11 So you’ve seen how the output of the compiler might change between Python versions and likewise the behavior of the GIL might change as well. Prior to 3.10, the GIL activated on all bytecode operations just in case they would affect the reference count.
04:27 This is a speedier way of doing things as it means fewer if-then-else blocks, but has the consequence of being a little aggressive blocking when it might not be necessary.
04:38 The last few releases of Python have spent a lot of time on doing speedup. In 3.10, they changed the bytecode operations, merging some together and splitting others apart in a quest to get better performance. In doing this, another thing they changed was on which operations the GIL gets activated.
04:56 It now doesn’t necessarily happen on each operation,
05:00 but, you might say, if it’s locking less, doesn’t that mean the race condition should be there? Not necessarily. The lock is locking and unlocking less frequently, but that doesn’t mean the duration of the lock is the same.
05:13 I suspect what’s happening here is that one of the entry operations is locking and it’s staying locked for longer until the function leaves. Whereas in Python 3.9, the lock was being released and reacquired, giving a chance for the race condition to trigger.
05:28 Let’s be clear, the race condition has not gone away. The change in the GIL has stopped the race condition from triggering, but the code is still written in a fashion that risks a race condition.
05:40 Future changes to the GIL are coming, so it’s possible that in a later version of Python, this very same code will start to fail once more. Don’t rely on the GIL to avoid your race conditions.
05:53 Use the locking mechanisms in the threading libraries to write properly atomic code.
05:59 I should have said, “spoiler alert” when I said future changes to the GIL are coming. Next up, let’s talk about future changes to the GIL.
Become a Member to join the conversation.