今天看啥  ›  专栏  ›  高手

【译文】了解GIL 如何编写快速和线程安全的Python

高手  · 掘金  ·  · 2021-01-29 15:38
阅读 135

【译文】了解GIL 如何编写快速和线程安全的Python

我们将探索Python的全局解释器锁,并了解它如何影响多线程程序。

全局解释器锁

它的定义

static PyThread_type_lock interpreter_lock = 0; /* This is the GIL */
复制代码

这行代码是ceval格式的,在CPython 2.7解释器的源代码中。Guido van Rossum的评论“这就是GIL”是在2003年添加的,但是锁本身可以追溯到1997年他的第一个多线程Python解释器。在Unix系统上,PyThread_type_lock是标准C锁互斥锁mutex_t的别名。

它在Python解释器开始时被初始化:

void PyEval_InitThreads(void)
{
    interpreter_lock = PyThread_allocate_lock();
    PyThread_acquire_lock(interpreter_lock);
}
复制代码

解释器中的所有C代码在执行Python时都必须持有这个锁。Guido最初以这种方式构建Python是因为它很简单,而且每次试图从CPython中删除GIL都会让单线程程序损失太多的性能,而不值得为多线程所做的努力。GIL对程序中线程的影响非常直观,你可以在手背上写下这样的原则:”一个线程运行Python,而其他N个线程休眠或等待I/O”。Python线程也可以等待threading.Lock或来自threading模块的其他同步对象,我们也可以将处于该状态的线程视为“睡眠”。

线程何时切换呢?

每当一个线程开始休眠或等待网络I/O时,就有机会让另一个线程获取GIL并执行Python代码。这就是协作多任务处理。CPython也有抢占式多任务:如果一个线程在Python 2中不间断地运行1000个字节码指令,或者在Python 3中运行15ms,那么它就会放弃GIL,另一个线程可能会运行。这就像以前的时间切片,当时我们有很多线程,但只有一个CPU。我们将详细讨论这两种多任务处理。

协作多任务

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

看看下面这个实例,实例中创建了两个线程,每个线程分别去创建socket连接:

def do_connect():
    s = socket.socket()
    s.connect(('[python.org](http://python.org/)', 80))  # 这里线程会主动释放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文本被编译成一种更简单的二进制格式,称为字节码。第二,Python解释器的主循环,一个名为PyEval_EvalFrameEx()的函数,读取字节码并逐个执行其中的指令。

当解释器一步一步地遍历你的字节码时,它会周期性地删除GIL,而不需要请求它正在执行代码的线程的许可,这样其他线程就可以运行:

for (;;) {
    if (--ticker < 0) {
        ticker = check_interval;
   
        /* 释放GIL,给其他线程提供运行机会 */
        PyThread_release_lock(interpreter_lock);
   
        /* 其他线程运行 */
   
        PyThread_acquire_lock(interpreter_lock, 1);
    }

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

默认情况下,检查时间间隔为1000字节。所有线程都运行相同的代码,并以相同的方式定期从它们那里获得锁。在Python 3中,GIL的实现更为复杂,并且检查间隔不是固定的字节码数,而是15毫秒。然而,对于您的代码,这些差异并不显著。

Python中的线程安全

如果一个线程可能在任何时候丢失GIL,那么您必须使代码是线程安全的。然而,Python程序员对线程安全的看法与C或Java程序员不同,因为许多Python操作是原子的。

原子操作的一个例子是在列表上调用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,已经被编译成四个字节码,执行四种基本操作:

  • 将n的值加载到堆栈中
  • 将常量1加载到堆栈中
  • 将栈顶的两个值相加
  • 把和存储回n中

请记住,每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
复制代码

一行代码编译成三个字节码:

  • 将lst的值加载到堆栈中
  • 将其sort方法加载到堆栈上
  • 调用sort方法

尽管lst.sort()行需要几个步骤,但sort调用本身是一个字节码,因此线程在调用期间没有机会从它那里获取GIL。我们可以得出结论,我们不需要锁定sort()。或者,为了避免担心哪些操作是原子的,可以遵循一个简单的规则:总是锁定共享可变状态的读写。毕竟在Python中去获取线程锁成本是很低的。

尽管GIL并没有免除我们对锁的需求,但它确实意味着不需要细粒度的锁。在像Java这样的自由线程语言中,程序员努力在尽可能短的时间内锁定共享数据,以减少线程争用并允许最大限度的并行。但是,因为线程不能并行运行Python,所以细粒度锁定没有任何优势。只要没有线程在休眠、I/O或其他释放锁操作时持有锁,就应该使用尽可能粗的、最简单的锁。其他线程无论如何都不能并行运行。

使用并发性更快地完成

我敢打赌,您真正的目的是用多线程优化您的程序。如果您的任务可以通过同时等待多个网络操作而更快地完成,那么多个线程会有帮助,即使一次只能有一个线程执行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。

这个例子fork 10个进程比fork 1个进程更快地完成,因为这些进程在多个核上并行运行。但是10个线程的运行速度不会比一个线程的运行速度快,因为一次只有一个线程可以执行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,使用进程进行并行计算。这个原理很简单,你甚至不需要把它写在手上。

原文链接:opensource.com/article/17/…




原文地址:访问原文地址
快照地址: 访问文章快照