Python 3.7中多线程的GIL行为

8
我正在研究并试图理解Python GIL以及在Python中使用多线程的最佳实践。我找到了这个演示文稿这个视频
我尝试复制演示文稿前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的更改导致了这些差异? 提前感谢。


3
我不是Python和GIL的专家,但我理解Python线程的调度尽管有GIL仍然是_抢占式_的。count -= 1不是原子操作,GIL不能防止系统在执行它时从一个线程切换到另一个线程。我认为GIL的主要影响是它从不允许线程真正并行运行。 - Solomon Slow
1
我刚刚看了这个视频!真是一次很棒的演讲,很高兴看到你正在尝试复现结果。由于这个演讲是在2010年的PyCon上进行的,我想David Beazly的结果可能是使用Python版本<=2.6完成的,根据此链接中版本日期的信息:https://www.python.org/doc/versions/。我相信GIL在Python 3.2和随后的版本中都经历了重大改进。 - OfLettersAndNumbers
你尝试过增加大数值吗? - pippo1980
2个回答

7

Python不是直接执行的,它首先被编译成所谓的Python字节码。这个字节码在其思想上类似于原始汇编语言。然后执行这个字节码。

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倍。我不知道造成这种情况的原因。后台必须发生其他事情。


1
你和@aaron,感谢你们的澄清。我明白了。但是我仍在等待第一个问题的答案。 为什么这两种情况的执行时间相同?但在演示和视频中,执行时间完全不同(使用线程时比视频中的另一种情况运行得更慢)。他在Python 3.2和Mac上运行它,而我在Python 3.7和Windows上运行代码。从Python 3.2到3.7是否有任何变化,或者是因为操作系统具有不同的调度程序,还是其他原因? - Hamid Reza Arzaghi
@HamidRezaArzaghi 我不知道关于演示方面的事情。但由于GIL锁定了所有线程,因此它们只能一个接一个地前进。因此,尽管有线程,执行是串行的。因此,顺序代码应该与线程代码运行得几乎相同。由于上下文切换,线程将稍微慢一些。但我认为差异不会达到2倍,那看起来像是严重夸张。在两个线程的情况下,可能无法检测到差异。但我稍后会查看它。 - freakish
1
你说得对,在 Python 和 GIL 的世界中它应该会变慢。但是当我在实验代码中测试多线程情况时,有时候运行速度甚至更快了! - Hamid Reza Arzaghi
1
@HamidRezaArzaghi 现代计算机非常复杂。最终结果不仅取决于您的代码,还取决于整个计算机发生的许多事情。执行速度会有所变化,如果两个算法理论上执行相同,则它们可能会相差甚远,其中一个有时会超越另一个。话虽如此,至少从 Python 2.7 到 3.6,似乎进行了一些优化。在 2.7 中,线程执行速度似乎慢了 1.5 倍。但我在 3.6 中没有看到区别。 - freakish

2
关于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值用于减法运算,并将结果覆盖其他线程所做的任何修改。像所罗门一样,我不是Python底层内部的专家,但我确信GIL确保字节码指令是原子的,但没有更进一步的保证。"最初的回答"

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接