由于GIL的存在,多线程Python代码中是否需要锁?

83

如果您使用的是实现了全局解释器锁(例如CPython)并编写多线程代码的Python,那么您真的需要锁吗?

如果GIL不允许多条指令并行执行,那么共享数据是否就不必要进行保护了呢?

如果这是一个愚蠢的问题,我很抱歉,但我一直想知道在多处理器/核心机器上运行Python是否也一样。

对于任何其他实现了GIL的语言实现,同样适用。


2
请注意,GIL 是一项实现细节。例如 IronPython 和 Jython 并没有 GIL。 - L̲̳o̲̳̳n̲̳̳g̲̳̳p̲̳o̲̳̳k̲̳̳e̲̳̳
9个回答

76
如果在不同线程之间共享状态,你仍然需要锁。GIL只是保护解释器内部,你的代码仍然可能存在不一致的更新。
例如:
#!/usr/bin/env python
import threading

shared_balance = 0

class Deposit(threading.Thread):
    def run(self):
        for _ in xrange(1000000):
            global shared_balance
            balance = shared_balance
            balance += 100
            shared_balance = balance

class Withdraw(threading.Thread):
    def run(self):
        for _ in xrange(1000000):
            global shared_balance
            balance = shared_balance
            balance -= 100
            shared_balance = balance

threads = [Deposit(), Withdraw()]

for thread in threads:
    thread.start()

for thread in threads:
    thread.join()

print shared_balance

在这里,您的代码可以在读取共享状态(balance = shared_balance)和写回更改后(shared_balance = balance)之间被中断,导致丢失更新。结果是共享状态的随机值。
为了使更新一致,运行方法需要在读取-修改-写入部分(循环内部)周围锁定共享状态,或者有某种方式检测自上次读取以来共享状态是否已更改

代码示例清晰易懂,视觉效果很好!哈里斯写得不错!我希望我能点赞两次! - RayLuo
如果只有一行代码 shared_balance += 100shared_balance -= 100,这样安全吗? - mrgloom

25
不会 - GIL只是保护Python内部不受多个线程改变状态的影响。这是一种非常低级别的锁定方式,仅足以使Python自身的结构保持一致性。它不涵盖你在代码中需要进行的应用程序级锁定以确保线程安全。
锁定的要点是确保特定的代码块仅由一个线程执行。GIL对于单个字节码大小的块强制执行此操作,但通常您希望锁定跨越比此更大的代码块。

12

10

本文介绍了GIL并提供了一些引人注目的引用:

其中特别值得关注的是以下引用:

每隔十个指令(可以更改此默认值),核心都会释放当前线程的GIL。此时,操作系统会从所有竞争锁的线程中选择一个线程(可能选择刚刚释放GIL的同一个线程-您无法控制选择哪个线程);然后该线程获取GIL,并再次运行十个字节码。

以及

请注意,GIL仅针对纯Python代码限制。可以编写扩展(通常用C编写的外部Python库)来释放锁定,这将允许Python解释器在扩展重新获取锁定之前独立运行。

听起来GIL仅提供了更少的上下文切换实例,并使多核/处理器系统在每个Python解释器实例方面表现为单个核心,因此,是的,您仍然需要使用同步机制。


3
注意,sys.getcheckinterval()会告诉你在“GIL释放”之间执行了多少个字节码指令(至少从2.5开始是100而不是10)。在3.2中,它可能会切换到基于时间的间隔(大约5毫秒),而不是基于指令计数。虽然这仍然是一个正在进行中的工作,但这种改变也可能被应用到2.7中。 - Peter Hansen

8
全局解释器锁防止多个线程同时访问解释器(因此CPython只使用一个核心)。然而,据我所知,线程仍然会被中断和预定,这意味着你仍然需要在共享数据结构上使用锁,否则你的线程将互相干扰。
我一次又一次地遇到的答案是,由于这个原因,在Python中进行多线程很少有价值。我听说过PyProcessing 项目非常好,它使得运行多个进程与多线程一样“简单”,具有共享数据结构、队列等(PyProcessing将作为multiprocessing 模块引入即将推出的Python 2.6 的标准库)。这可以绕过GIL,因为每个进程都有自己的解释器。

4

这样想:

在单处理器计算机上,多线程是通过暂停一个线程并启动另一个线程来实现的,速度足够快,使其看起来像是同时运行。这就像GIL下的Python:只有一个线程实际上在运行。

问题在于,线程可以在任何地方被暂停,例如,如果我想计算b =(a + b)* 3,这可能会产生类似以下的指令:

1    a += b
2    a *= 3
3    b = a

现在,假设程序运行在一个线程中,该线程在第一行或第二行被挂起,然后另一个线程启动并运行:

b = 5

那么当另一个线程恢复时,变量 b 会被旧的计算值覆盖,这可能不是预期的结果。

因此,即使它们实际上没有同时运行,你仍然需要加锁。


3

锁仍然是必需的。我将尝试解释为什么它们是必需的。

任何操作/指令都是在解释器中执行的。GIL确保解释器在特定时间被单个线程持有。而您的具有多个线程的程序在单个解释器中工作。在任何特定时间,该解释器只由一个线程持有。这意味着只有持有解释器的线程在任何时刻运行

假设有两个线程,比如t1和t2,两者都想要执行读取全局变量值并将其增加的两个指令。

#increment value
global var
read_var = var
var = read_var + 1

如上所述,GIL仅确保两个线程不能同时执行一条指令,这意味着两个线程不能在任何特定时刻同时执行read_var = var。但是它们可以依次执行指令,你仍然可能会遇到问题。考虑以下情况:
  • 假设read_var为0。
  • GIL由线程t1持有。
  • t1执行read_var = var。因此,t1中的read_var为0。GIL仅确保不会在此时执行此读取操作的任何其他线程。
  • GIL交给线程t2。
  • t2执行read_var = var。但是read_var仍然是0。因此,t2中的read_var也为0。
  • GIL交回t1。
  • t1执行var = read_var+1,var变成1。
  • GIL交给t2。
  • t2认为read_var=0,因为它读取的值是这样的。
  • t2执行var = read_var+1,var变成1。
  • 我们的期望是var应该变成2。
  • 因此,必须使用锁将读取和递增作为原子操作。
  • Will Harris的答案通过一个代码示例解释了这一点。

1

你仍然需要使用锁定(因为你的代码可能随时被中断以执行另一个线程,这可能会导致数据不一致)。 GIL 的问题在于它防止 Python 代码同时使用更多核心(或者如果有多个处理器可用,则无法使用它们)。


0
Will Harris 的例子稍作更新:
class Withdraw(threading.Thread):  
def run(self):            
    for _ in xrange(1000000):  
        global shared_balance  
        if shared_balance >= 100:
          balance = shared_balance
          balance -= 100  
          shared_balance = balance

在提款中放置一个值检查语句,我不再看到负数了,并且更新似乎是一致的。我的问题是:
如果GIL只防止一个线程在任何原子时间执行,那么哪里会有陈旧的值?如果没有陈旧的值,为什么我们需要锁定?(假设我们只谈论纯Python代码)
如果我理解正确,上述条件检查在真正的线程环境中将无法工作。当多个线程同时执行时,可能会创建陈旧的值,因此共享状态的不一致性,然后您确实需要锁定。但是,如果Python确实只允许一次只有一个线程(时间分片线程),那么不存在陈旧值的可能性,对吗?

好的,看起来GIL并没有一直锁定线程,上下文切换仍然可能发生。所以我错了,还是需要锁定。 - jimx

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