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


76

如果您依赖具有全局解释器锁(即CPython)的Python实现并编写多线程代码,那么您真的需要锁吗?

如果GIL不允许并行执行多个指令,那么共享数据是否有必要保护吗?

抱歉,这是一个愚蠢的问题,但这是我一直想知道的关于多处理器/核心计算机上的Python的东西。

同样的情况也适用于具有GIL的任何其他语言实现。


1
还要注意,GIL是和实现细节。例如,IronPython和Jython没有GIL。
L̲̳o̲̳̳n̲̳̳g̲̳̳p̲̳o̲̳̳k̲̳̳e̲̳̳

Answers:


72

如果您在线程之间共享状态,则仍然需要锁。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)之间中断,从而导致更新丢失。结果是共享状态的随机值。

为了使更新一致,运行方法需要将共享状态锁定在read-modify-write部分周围(循环内部),或者需要某种方法来检测共享状态自被读取以来何时发生了变化


该代码示例提供了清晰直观的理解!哈里斯好帖子!我希望我可以投票两次!
RayLuo

如果只有一条线shared_balance += 100,会安全shared_balance -= 100吗?
mrgloom

24

否-GIL只是保护python内部结构免受多个线程更改其状态的影响。这是一个非常低级的锁定,仅足以使python自己的结构保持一致状态。它没有涵盖您需要执行的应用程序级别锁定,以覆盖您自己的代码中的线程安全性。

锁定的本质是确保特定代码仅由一个线程执行。GIL对单个字节码大小的块强制执行此操作,但是通常您希望锁跨越比此更大的代码块。



9

这篇文章从较高的层次描述了GIL:

这些引号特别引人注意:

每十个指令(可以更改此默认值),内核将释放当前线程的GIL。那时,操作系统从所有争夺锁的线程中选择一个线程(可能选择了刚刚释放GIL的相同线程–您无法控制选择哪个线程);该线程获取GIL,然后运行另外十个字节码。

请注意,GIL仅限制纯Python代码。可以编写扩展(释放通常是用C语言编写的外部Python库)来释放锁,然后允许Python解释器与扩展分开运行,直到扩展重新获取锁为止。

听起来,GIL只是为上下文切换提供了更少的可能实例,并使多核/处理器系统对于每个python解释器实例而言都表现为单个核,因此,是的,您仍然需要使用同步机制。


2
请注意,它sys.getcheckinterval()告诉您在“ GIL版本”之间执行了多少字节码指令(从2.5开始至少为100(而不是10))。在3.2中,它可能会切换到基于时间的间隔(大约5毫秒),而不是指令计数。该更改可能仍适用于2.7,尽管仍在进行中。
彼得·汉森

8

全局解释器锁定可防止线程同时访问解释器(因此CPython只能使用一个内核)。但是,据我了解,线程仍然被抢先中断和调度,这意味着您仍然需要在共享数据结构上加锁,以免线程踩到对方的脚趾。

我一次又一次遇到的答案是,因此,Python中的多线程很少值得开销。我听说过PyProcessing项目的好处,该项目使多个进程像共享线程一样,以多线程的方式“简单”运行。(PyProcessing将作为多处理模块引入即将到来的Python 2.6的标准库中。)这使您绕过GIL,因为每个进程都有自己的解释器。


4

这样想:

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

问题是线程可以挂在任何地方,例如,如果我要计算b =(a + b)* 3,则可能会产生类似以下的指令:

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

现在,假设该线程在一个线程中运行,并且该线程在第1行或第2行之后挂起,然后另一个线程进入并运行:

b = 5

然后,当另一个线程恢复时,b被旧的计算值覆盖,这可能不是预期的。

因此,您可以看到,即使它们实际上并没有同时运行,您仍然需要锁定。


1

您仍然需要使用锁(您的代码可能随时被中断以执行另一个线程,这可能会导致数据不一致)。GIL的问题在于,它阻止了Python代码同时使用更多的内核(或多个处理器,如果可用)。


1

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

任何操作/指令都在解释器中执行。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)的答案通过一个代码示例对其进行了解释。

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
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.