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