博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
grok_Grok the GIL:如何编写快速且线程安全的Python
阅读量:2525 次
发布时间:2019-05-11

本文共 11235 字,大约阅读时间需要 37 分钟。

grok

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

music box parts

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

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

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

看,全局解释器锁

这里是:

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

这行代码在 。 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,因为它很简单,并且每次单线程程序付出了过多的性能,以致于不值得多线程获得。

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日在俄勒冈州波特兰举行的上发表演讲。 请在5月19日(星期五) 他的演讲“ 。

翻译自:

grok

转载地址:http://kzdzd.baihongyu.com/

你可能感兴趣的文章
阶段1 语言基础+高级_1-3-Java语言高级_08-JDK8新特性_第1节 常用函数接口_16_常用的函数式接口_Function接口中练习-自定义函数模型拼接...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_08-JDK8新特性_第3节 两种获取Stream流的方式_2_Stream流中的常用方法_forEach...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_08-JDK8新特性_第3节 两种获取Stream流的方式_1_两种获取Stream流的方式...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_08-JDK8新特性_第3节 两种获取Stream流的方式_4_Stream流的特点_只能使用一次...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_08-JDK8新特性_第3节 两种获取Stream流的方式_5_Stream流中的常用方法_map...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_08-JDK8新特性_第3节 两种获取Stream流的方式_6_Stream流中的常用方法_count...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_08-JDK8新特性_第3节 两种获取Stream流的方式_7_Stream流中的常用方法_limit...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_08-JDK8新特性_第3节 两种获取Stream流的方式_8_Stream流中的常用方法_skip...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_08-JDK8新特性_第3节 两种获取Stream流的方式_3_Stream流中的常用方法_filter...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_08-JDK8新特性_第3节 两种获取Stream流的方式_10_练习:集合元素处理(传统方式)...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_08-JDK8新特性_第3节 两种获取Stream流的方式_9_Stream流中的常用方法_concat...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_08-JDK8新特性_第3节 两种获取Stream流的方式_11_练习:集合元素处理(Stream方式)...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_08-JDK8新特性_第4节 方法引用_1_方法引用基本介绍...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_08-JDK8新特性_第4节 方法引用_2_方法引用_通过对象名引用成员方法...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_08-JDK8新特性_第4节 方法引用_5_方法引用_通过this引用本类的成员...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_08-JDK8新特性_第4节 方法引用_7方法引用_数组的构造器引用...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_08-JDK8新特性_第4节 方法引用_3_方法引用_通过类名引用静态成员...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_09-基础加强_第1节 基础加强_3_Junit_使用步骤...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_08-JDK8新特性_第4节 方法引用_6_方法引用_类的构造器(构造方法)引用...
查看>>
阶段1 语言基础+高级_1-3-Java语言高级_09-基础加强_第2节 反射_5_反射_概述
查看>>