grok_Grok the GIL:如何编写快速且线程安全的Python

grok

我六岁那年,我有一个音乐盒。 我把它收拾起来,一个芭蕾舞演员绕着盒子旋转,而里面的一个机制发出了“眨眼,眨眼,小星星”的声音。 这东西一定是俗气的,但是我喜欢那个音乐盒,我想知道它是如何工作的。 我以某种方式把它打开了,并看到了一个简单的装置,这是我的拇指的回报,这是我的拇指大小的金属圆柱,钉有钉子,以便随着旋转,它拔出钢梳子的牙齿并做笔记。

music box parts

在程序员的所有特征中,对事物如何工作的好奇心是必要的。 当我打开音乐盒看里面的时候,我表明我可以成长为一个优秀的程序员,如果不是一个伟大的程序员,那么至少是一个好奇的程序员。

奇怪的是,多年以来我在编写Python程序的同时对全局解释器锁(GIL)持有错误的看法,因为我从来没有好奇过它的工作原理。 我遇到了同样的犹豫和无知的人。 现在是我们撬开箱子的时候了。 让我们阅读CPython解释器的源代码,确切地找出GIL是什么,为什么Python拥有GIL以及它如何影响您的多线程程序。 我将展示一些示例来帮助您理解GIL。 您将学习编写快速且线程安全的Python,以及如何在线程和进程之间进行选择。

(出于重点考虑,在此仅描述CPython,而不是JythonPyPyIronPython。CPython是工作的程序员绝大多数使用的Python实现。)

看,全局解释器锁

这里是:


static PyThread_type_lock interpreter_lock = 0 ; /* This is the GIL */ 

这行代码在CPython 2.7解释器的源代码ceval.c中 。 Guido van Rossum在2003年添加了注释“这是GIL”,但是该锁本身的历史可追溯到1997年他的第一个多线程Python解释器。在Unix系统上, PyThread_type_lock是标准C锁Mutex_t的别名。 在Python解释器开始时将其初始化:



   
   
void
PyEval_InitThreads ( void )
{
    interpreter_lock = PyThread_allocate_lock ( ) ;
    PyThread_acquire_lock ( interpreter_lock ) ;
}

执行Python时,解释器中的所有C代码都必须持有此锁。 Guido首先以这种方式构建Python,因为它很简单,并且每次从CPython中删除GIL的尝试都使单线程程序付出了过多的性能,以致于不值得多线程获得。

GIL对程序中的线程的影响非常简单,您可以在手背上写下该原理:“一个线程运行Python,而其他N个线程睡眠或等待I / O。” Python线程也可以等待线程模块中的threading.Lock或其他同步对象; 认为处于该状态的线程也处于“睡眠状态”。

hand with writing

线程何时切换? 每当线程开始Hibernate或等待网络I / O时,另一个线程就有机会采用GIL并执行Python代码。 这是协作式多任务处理 。 CPython还具有抢占式多任务处理能力 :如果一个线程在Python 2中连续运行1000个字节码指令,或者在Python 3中运行15毫秒,则它放弃GIL,并且另一个线程可能会运行。 可以其想像成过去有很多线程但只有一个CPU的时间分割 。 我将详细讨论这两种多任务。

将Python视为旧的大型机; 许多任务共享一个CPU。

合作多任务

当它开始一个任务(例如网络I / O)时,它的持续时间很长或不确定,并且不需要运行任何Python代码,因此一个线程会放弃GIL,以便另一个线程可以接管它并运行Python。 这种礼貌的行为称为合作多任务,它允许并发。 许多线程可以同时等待不同的事件。

假设两个线程分别连接一个套接字:



   
   
def do_connect ( ) :
    s = socket . socket ( )
    s. connect ( ( 'python.org' , 80 ) )   # drop the GIL

for i in range ( 2 ) :
    t = threading . Thread ( target = do_connect )
    t. start ( )

这两个线程中的一个只能一次执行Python,但是一旦线程开始连接,它将删除GIL,以便另一个线程可以运行。 这意味着两个线程都可以等待它们的套接字同时连接,这是一件好事。 他们可以在相同的时间内完成更多工作。

让我们撬开盒子,看看在socketmodule.c中,Python线程在等待建立连接时实际上是如何删除GIL的:



   
   
/* s.connect((host, port)) method */
static PyObject *
sock_connect ( PySocketSockObject * s, PyObject * addro )
{
    sock_addr_t addrbuf ;
    int addrlen ;
    int res ;

    /* convert (host, port) tuple to C address */
    getsockaddrarg ( s, addro, SAS2SA ( & addrbuf ) , & addrlen ) ;

    Py_BEGIN_ALLOW_THREADS
    res = connect ( s - > sock_fd, addr, addrlen ) ;
    Py_END_ALLOW_THREADS

    /* error handling and so on .... */
}

Py_BEGIN_ALLOW_THREADS宏是线程删除GIL的位置; 它的简单定义为:


PyThread_release_lock ( interpreter_lock ) ; 

当然, Py_END_ALLOW_THREADS会重新获得锁。 一个线程可能会在此位置阻塞,等待另一个线程释放锁; 一旦发生这种情况,等待线程将GIL收回并继续执行您的Python代码。 简而言之:当N个线程在网络I / O上被阻塞或等待重新获取GIL时,一个线程可以运行Python。

下面,看到一个完整的示例,该示例使用协作式多任务处理来快速获取许多URL。 但是在此之前,让我们将协作式多任务与另一种多任务进行对比。

抢先式多任务处理

Python线程可以自愿释放GIL,但也可以抢先从中夺取GIL。

让我们备份并讨论如何执行Python。 您的程序分两个阶段运行。 首先,将您的Python文本编译成一种更简单的二进制格式,称为bytecode 。 其次,Python解释器的主循环,一个名为PyEval_EvalFrameEx()的函数,读取字节码并字节执行其中的指令。

解释器逐步检查您的字节码时,会定期删除GIL,而无需征求正在执行其代码的线程的许可,因此其他线程可以运行:



   
   
for ( ;; ) {
    if ( -- ticker < 0 ) {
        ticker = check_interval ;
   
        /* Give another thread a chance */
        PyThread_release_lock ( interpreter_lock ) ;
   
        /* Other threads may run now */
   
        PyThread_acquire_lock ( interpreter_lock, 1 ) ;
    }

    bytecode = * next_instr ++ ;
    switch ( bytecode ) {
        /* execute the next instruction ... */
    }
}

默认情况下,检查间隔为1000个字节码。 所有线程都运行相同的代码,并以相同的方式定期从其获取锁。 在Python 3中,GIL的实现更为复杂,并且检查间隔不是固定数量的字节码,而是15毫秒。 但是,对于您的代码,这些差异并不重要。

Python中的线程安全

编织多个线程需要技巧。

如果线程随时可能丢失GIL,则必须使代码安全。 但是,由于许多Python操作是原子操作,因此Python程序员对线程安全的看法与C或Java程序员不同。

原子操作的一个示例是在列表上调用sort() 。 线程不能在排序过程中被打断,其他线程也永远不会看到部分排序的列表,也不会看到列表排序之前的陈旧数据。 原子操作简化了我们的生活,但是有惊喜。 例如, + =似乎比sort()更简单,但是+ =不是原子的。 您如何知道哪些操作是原子操作,哪些不是原子操作?

考虑以下代码:



   
   
n = 0

def foo ( ) :
    global n
    n + = 1

我们可以使用Python的标准dis模块查看此函数编译到的字节码:



   
   
>>> import dis
>>> dis . dis ( foo )
LOAD_GLOBAL               0 ( n )
LOAD_CONST               1 ( 1 )
INPLACE_ADD
STORE_GLOBAL             0 ( n )

一行代码n + = 1已被编译为四个字节码,它们执行四个基本操作:

  1. 将n的值加载到堆栈上
  2. 将常量1加载到堆栈上
  3. 将堆栈顶部的两个值相加
  4. 将总和存回n

请记住,解释器将GIL移走后,每1000个字节代码都会中断一个线程。 如果线程不是很幸运,则可能在将n的值加载到堆栈上和将其存储回堆栈之间发生。 这很容易导致更新丢失,请参阅:



   
   
threads = [ ]
for i in range ( 100 ) :
    t = threading . Thread ( target = foo )
    threads. append ( t )

for t in threads:
    t. start ( )

for t in threads:
    t. join ( )

print ( n )

通常,此代码打印100 ,因为100个线程中的每个线程都增加了n 。 但是,如果其中一个线程的更新被另一个覆盖,则有时会看到99或98。

因此,尽管有GIL,您仍然需要锁来保护共享的可变状态:



   
   
n = 0
lock = threading . Lock ( )

def foo ( ) :
    global n
    with lock:
        n + = 1

如果我们改用诸如sort()之类的原子操作怎么办?:



   
   
lst = [ 4 , 1 , 3 , 2 ]

def foo ( ) :
    lst. sort ( )

该函数的字节码表明sort()无法中断,因为它是原子的:



   
   
>>> dis . dis ( foo )
LOAD_GLOBAL               0 ( lst )
LOAD_ATTR                 1 ( sort )
CALL_FUNCTION             0

一行编译为三个字节码:

  1. lst的值加载到堆栈上
  2. 将其排序方法加载到堆栈上
  3. 调用sort方法

即使lst.sort()行采取了多个步骤, sort调用本身也是一个字节码,因此线程在调用过程中没有机会从中获取GIL。 我们可以得出结论,我们不需要锁定sort() 。 或者,为避免担心哪些操作是原子操作,请遵循一个简单的规则:始终锁定共享可变状态的读取和写入。 毕竟,获得一个线程 .Python锁很便宜。

尽管GIL并没有为我们提供锁的借口,但这确实意味着没有必要进行细粒度的锁定。 在像Java这样的自由线程语言中,程序员努力在尽可能短的时间内锁定共享数据,以减少线程争用并允许最大的并行度。 但是,由于线程无法并行运行Python,因此细粒度锁定没有任何优势。 只要没有线程在Hibernate,I / O或其他一些GIL丢弃操作时都持有锁,就应该使用最粗,最简单的锁。 无论如何,其他线程不可能并行运行。

并发更快地完成

我押注您真正追求的是使用多线程优化程序。 如果您的任务通过一次等待许多网络操作而更快地完成,那么即使多个线程一次只能执行Python,也可以使用多个线程。 这是并发性 ,在这种情况下线程可以很好地工作。

这段代码在线程中运行更快:



   
   
import threading
import requests

urls = [ ... ]

def worker ( ) :
    while True :
        try :
            url = urls. pop ( )
        except IndexError :
            break   # Done.

        requests. get ( url )

for _ in range ( 10 ) :
    t = threading . Thread ( target = worker )
    t. start ( )

正如我们在上面看到的,这些线程在等待通过HTTP获取URL所涉及的每个套接字操作时会丢弃GIL,因此它们比单个线程能更快地完成工作。

平行性

如果您的任务只有同时运行Python代码才能更快完成,该怎么办? 这种缩放称为并行性 ,而GIL禁止并行化 。 您必须使用多个进程,这可能比线程处理更为复杂并且需要更多的内存,但是它将利用多个CPU。

该示例通过分叉10个进程比仅用一个进程完成分叉更快,因为这些进程在多个内核上并行运行。 但是使用10个线程运行的速度不会比使用1个线程快,因为一次只能有一个线程可以执行Python:



   
   
import os
import sys

nums = [ 1 for _ in range ( 1000000 ) ]
chunk_size = len ( nums ) // 10
readers = [ ]

while nums:
    chunk , nums = nums [ :chunk_size ] , nums [ chunk_size: ]
    reader , writer = os . pipe ( )
    if os . fork ( ) :
        readers. append ( reader )   # Parent.
    else :
        subtotal = 0
        for i in chunk : # Intentionally slow code.
            subtotal + = i

        print ( 'subtotal %d' % subtotal )
        os . write ( writer , str ( subtotal ) . encode ( ) )
        sys . exit ( 0 )

# Parent.
total = 0
for reader in readers:
    subtotal = int ( os . read ( reader , 1000 ) . decode ( ) )
    total + = subtotal

print ( "Total: %d" % total )

因为每个分叉的进程都有一个单独的GIL,所以该程序可以打包工作并立即运行多个计算。

(Jython和IronPython提供单进程并行性,但它们与CPython完全不兼容。带有软件事务存储的 PyPy可能有一天会很快。如果您感到好奇,请尝试使用这些解释器。)

结论

现在,您已经打开音乐盒并看到了简单的机制,您已经知道编写快速,线程安全的Python所需的全部知识。 使用线程进行并发I / O,使用进程进行并行计算。 该原理很简单,您甚至不需要手写。

A. Jesse Jiryu Davis将在5月17日至25日在俄勒冈州波特兰举行的PyCon 2017上发表演讲。 请在5月19日(星期五) 收听他的演讲“ Grok the GIL:编写快速且线程安全的Python”

翻译自: https://opensource.com/article/17/4/grok-gil

grok

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值