python 3.7 多线程中的 GIL 行为

GIL behavior in python 3.7 multithreading

我正在研究并试图理解 python GIL 和在 python 中使用多线程的最佳实践。我发现 this presentation and this video

我试图重现演示文稿前 4 张幻灯片中提到的奇怪和疯狂的问题。讲师在视频中也提到了这个问题(前4分钟)。 我写了这个简单的代码来重现问题

from threading import Thread
from time import time

BIG_NUMBER = 100000
count = BIG_NUMBER


def countdown(n):
    global count
    for i in range(n):
        count -= 1


start = time()
countdown(count)
end = time()
print('Without Threading: Final count = {final_n}, Execution Time = {exec_time}'.format(final_n=count, exec_time=end - start))

count = BIG_NUMBER
a = Thread(target=countdown, args=(BIG_NUMBER//2,))
b = Thread(target=countdown, args=(BIG_NUMBER//2,))
start = time()
a.start()
b.start()
a.join()
b.join()
end = time()
print('With Threading: Final count = {final_n}, Execution Time = {exec_time}'.format(final_n=count, exec_time=end - start))

但是结果和论文视频完全不一样! 使用线程和不使用线程的执行时间几乎相同。有时两种情况中的一种比另一种快一点。

这是我在 Windows 10 下使用多核架构处理器使用 CPython 3.7.3 得到的结果。

Without Threading: Final count = 0, Execution Time = 0.02498459815979004
With Threading: Final count = 21, Execution Time = 0.023985862731933594

另外我根据视频和论文理解的是 GIL 阻止两个线程同时在两个核心中真正并行执行。所以如果这是真的,为什么最终计数变量(在多线程情况下)不是预期的零,并且在每次执行结束时会是一个不同的数字,可能是因为同时操作线程? 在比视频和论文(使用 python 3.2)更新的 python 中,GIL 是否发生了任何变化导致这些不同? 提前致谢

关于 Solomon 的评论,您编写的代码给出不一致结果的原因是 python 没有原子就地运算符。 GIL 确实保护 python 的内部结构不被混淆,但您的用户代码仍然必须保护自己。如果我们使用 dis 模块查看您的 countdown 函数,我们可以看到可能发生故障的位置。

>>> print(dis(countdown))
  3           0 SETUP_LOOP              24 (to 26)
              2 LOAD_GLOBAL              0 (range)
              4 LOAD_FAST                0 (n)
              6 CALL_FUNCTION            1
              8 GET_ITER
        >>   10 FOR_ITER                12 (to 24)
             12 STORE_FAST               1 (i)

  <strong>4          14 LOAD_GLOBAL              1 (count)
             16 LOAD_CONST               1 (1)
             18 INPLACE_SUBTRACT
             20 STORE_GLOBAL             1 (count)</strong>
             22 JUMP_ABSOLUTE           10
        >>   24 POP_BLOCK
        >>   26 LOAD_CONST               0 (None)
             28 RETURN_VALUE
None

循环内的减法运算实际上需要4条指令才能完成。如果线程在 14 LOAD_GLOBAL 1 (count) 之后但行 20 STORE_GLOBAL 1 (count) 之前被中断,则其他线程可能会进入并修改 count。然后,当执行返回到第一个线程时,count 的先前值用于减法,结果将覆盖另一个线程所做的任何修改。和 Solomon 一样,我不是 python 底层内部结构的专家,但我相信 GIL 确保字节码指令是原子的,但不会进一步。

Python不直接执行。它首先被编译成所谓的Python bytecode。该字节码在其思想上类似于原始汇编。字节码被执行。

GIL 的作用是不允许两个字节码指令并行 运行。虽然一些操作(例如io)确实在内部发布了GIL以允许真正的并发,当它可以证明它不会破坏任何东西时。

现在您只需要知道 count -= 1 不会 编译成单个字节码指令。它实际上编译成4条指令

LOAD_GLOBAL              1 (count)
LOAD_CONST               1 (1)
INPLACE_SUBTRACT
STORE_GLOBAL             1 (count)

大致意思是

load global variable into local variable
load 1 into local variable
subtract 1 from local variable
set global to the current local variable

这些指令中的每一个 都是 原子的。但是顺序可以被线程混合,这就是为什么你看到你所看到的。

那么 GIL 做了什么使得执行流程串行化。意味着指令一个接一个地发生,没有什么是平行的。因此,理论上,当您 运行 多个线程时,它们将执行与单线程相同的操作,减去在(所谓的)上下文切换上花费的一些时间。我在 Python3.6 中的测试确认执行时间相似。

但是在 Python2.7 中,我的测试显示线程的性能显着下降,大约 1.5 倍。我不知道这是为什么。除了 GIL 之外的其他事情必须在后台发生。