This article was first published on Zhihu
Keywords: thread safety, GIL, atomic operation, storage data types (List, queue.queue, collections.deque)
When multiple threads are working at the same time to modify the same resource, we must ensure that the changes do not conflict and that the data changes do not go wrong. In other words, we must ensure that the threads are thread-safe.
At the same time, we know that in Python, only one thread is executing at a time even if multiple threads are enabled due to GIL.
So does this mean that multiple threads executing in Python don’t collide? The answer is no.
The thread under GIL is not safe
Take a look at this code
import threading
import time
zero = 0
def change_zero():
global zero
for i in range(3000000):
zero += 1
zero -= 1
th1 = threading.Thread(target = change_zero)
th2 = threading.Thread(target = change_zero)
th1.start()
th2.start()
th1.join()
th2.join()
print(zero)
Copy the code
The two threads work together to modify the zero variable, adding 1 and then subtracting 1 each time. Normally, the zero result should still be zero after 300,000 executions. However, after running this code, it is found that the result is often not zero, and the result is different each time.
The fundamental reason is that the operation of zero += 1 is not a simple step, but can be regarded as the combination of two steps, as follows
x = zero + 1
zero = x
Copy the code
The GIL lock is given to another thread. By the time the GIL lock is returned to the first thread, zero has changed. Then zero = x will not add one to zero. The complete simulation of an error is as follows
Th1: x1 = zero + 1# x1 = 1
th2: x2 = zero + 1 # x2 = 1
th2: zero = x2 # zero = 1
th1: zero = x1 # zero = 1 # zero = 1 # zero = 1 # zero = 1
th1: x1 = zero - 1 # x1 = 0
th1: zero = x1 # zero = 0
th2: x2 = zero - 1 # x2 = -1
th2: zero = x2 # zero = -1Result: Zero = -1Copy the code
To better illustrate why Python still has thread insecurity under the GIL, we need to introduce a concept here: atomic operations.
Atomic operation
Atomic operations are operations that are not interrupted by the thread scheduling mechanism and that, once started, continue to finish without switching to another thread.
A program like Zero += 1, which can be split into multiple steps in one step, is not an atomic operation. As a direct consequence of the non-atomic operation, it is possible to switch to another thread before it is completely finished, and if the other thread is modifying the same variable, resource modification conflicts may occur.
One solution is to change the above definition of the change_zero function by locking it
def change_zero():
global zero
for i in range(1000000):
with lock:
zero += 1
zero -= 1
Copy the code
After the lock is added, the program inside the lock either does not execute, and will switch to other threads after the execution. This is actually equivalent to the realization of an “artificial atomic operation”, a whole block of code as a whole operation, will not be interrupted. This prevents conflicts with resource modifications. The reader can try to re-run the program after locking and find that the zero variable always outputs 0.
Let’s consider a question: if the program itself is atomic, is it automatically thread-safe and does not need to be locked? The answer is yes.
As an example, we now want to maintain a queue and start multiple threads, with one thread adding elements to the queue and another thread extracting elements for subsequent processing. In this case, the queue is an object shared between multiple threads, and we must ensure that there are no conflicts between the add and fetch processes, which is to keep thread-safe. If the operation process is more complex, we can use locks to keep multiple operations uninterrupted. But if the program itself is atomic, no additional safeguards need to be added.
For example, we use the queue object in the queue module to maintain the queue, fill in the elements with queue. Put and fetch the elements with queue. Get. So there’s no need to add extra protection here.
So the question naturally arises: how do we know which operations are atomic and which are not? There’s a list on the website
These are all atomic operations
L.append(x)
L1.extend(L2)
x = L[i]
x = L.pop()
L1[i:j] = L2
L.sort()
x = y
x.field = y
D[x] = y
D1.update(D2)
D.keys()
Copy the code
These are not atomic operations
i = i+1
L.append(L[-1])
L[i] = L[j]
D[x] = D[x] + 1
Copy the code
One thing to note here is that we sometimes hear that lists in Python are not thread-safe. This is a loose statement, because thread-safe is not about objects, it’s about operations. If we refer to an operation such as L[0] = L[0] + 1, it is of course not an atomic operation and would be thread unsafe if left unprotected, whereas an operation such as L.append(I) is thread safe.
So lists can be used as storage objects in multiple threads. But instead of lists, we use queue.Queue because the latter implements the Condition lock communication mechanism internally, as detailed in this article
Here we go back to atomic operations, although the official website lists some common operations, but sometimes we also need to be able to judge their own methods, can use the DIS module dis function, for example as shown below
from dis import dis
a = 0
def fun():
global a
a = a + 1
dis(fun)
Copy the code
The results are as follows
5 0 LOAD_GLOBAL 0 (a)
3 LOAD_CONST 1 (1)
6 BINARY_ADD
7 STORE_GLOBAL 0 (a)
10 LOAD_CONST 0 (None)
13 RETURN_VALUE
Copy the code
We just need to focus on each line. Each line represents the process of executing the fun function, which can be broken down into steps like importing global variables > importing constants > performing addition > storing variables… Where each step is an instruction bytecode, which can be viewed as an atomic operation. If the thread is switched after the execution of the former (operation plus sum) and before the execution of the latter (assignment), the thread is switched. The change resource conflict in our example above occurs.
Let’s look at the bytecode for the L.apend (I) procedure
from dis import dis
l = []
def fun():
global l
l.append(1)
dis(fun)
Copy the code
results
5 0 LOAD_GLOBAL 0 (l)
3 LOAD_ATTR 1 (append)
6 LOAD_CONST 1 (1)
9 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
12 POP_TOP
13 LOAD_CONST 0 (None)
16 RETURN_VALUE
Copy the code
As you can see, append actually only has POP_TOP step, which is either executed or not executed, and will not be interrupted.
Atomic operations leave us there, and let’s compare the queue data structures we use in Python
Common queue comparison in Python
List VS Queue.Queue VS collections.deque
First, we need to distinguish Queue.queue from the other two, since it is used primarily for communication between threads, while the other two are used primarily for storing data. Queue.Queue is used when we need to implement Condition locks, and the latter two are used when storing data only.
Collections. deque differs from list mainly in data insertion and extraction. If you want to insert data into the head of a list, or extract data from the head, the former is much more efficient than the latter, because of the bidirectional queuing feature of the former, the advantage is undoubted. If you don’t care about the order of extraction, you don’t have to use collections.deque
This section mainly refers to the following two answers
- Queue.Queue vs. collections.deque
- The collections. The efficiency of the deque
Welcome to my zhihu column
Column home: Programming in Python
Table of contents: table of contents
Version description: Software and package version description