Python GIL和并发

什么是Python全局锁GIL

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

上面这行代码位于CPython 2.7源码的ceval.c文件中。Guido van Rossum在2003年添加了这个注释:“这就是那个GIL鬼”,而这行代码则要追溯到1997年了。在Unix系统上PyThread_type_lock是标准C语言库的锁mutex_t的别名。它在Python解释器运行时初始化:

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

Python解释器的C代码必须在运行Python代码时获得该锁。Guildo在设计Python时采用这个方案是因为这样实现非常简单。所有试图取消GIL的尝试都让单线程的运行效率大大的降低了,使得实现真正的并行多线程运算变得得不偿失。 多线程运行程序中GIL的影响可以用一句话来说明:“只有唯一一个线程执行Python代码,其它的N个线程或处于休眠或等待I/O”。Python的线程也可以等待获取threading.Lock锁或threading模块中的其它同步对象;这些等待中的线程也可以理解为“休眠”状态。

那么什么时候切换线程呢?当运行线程转为“休眠”状态或等待I/O时,其它线程中的一个就有机会获得GIL锁,并执行Python代码。这就是所谓的多任务协同,Python还有一个抢占式多任务机制,就是一个线程在Python2中不中断运行1000个字节码指令或者在Python3中不中断运行15毫秒后,其主动放弃GIL,这样其它线程就能运行了。这跟古老的mainframe主机的分时系统是一个道理,这样就能在一个CPU上处理多个线程任务了。

多任务协同

当执行一个比如网络的I/O请求任务时,该任务需要较长或不确定时间的I/O相应,而在此期间并不执行任何Python代码时,该线程就会主动释放GIL,从而其它线程有机会获取GIL并执行Python代码。这种礼貌的行为就是所谓的多任务协同,它使得并发任务成为可能;多个线程可以同时等待不同的触发信号。

比如两个线程都要连接socket:

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代码,但是只要该线程开始连接socket,它就会释放GIL锁,所以另一个线程就可以运行了。这意味着两个线程现在可以同时等待socket连接,从而实现在同一段时间里可以做更多的任务。

让我们来看看Python线程是如何在等待连接时放弃GIL锁的,以下代码来自socketmodule.c:

/* 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被释放,该等待中的线程获取GIL并继续执行Python代码。简言之,当N个线程因为等待网络连接而阻塞或在等待重新获得GIL时,只有一个线程在执行Python代码。

下面有一个运用多任务协同机制实现抓取多个URL的完整例子。在我们看这个例子之前,先来看看Python的另一个多任务机制:

抢占式多任务

一个Python线程可以主动释放GIL,但也有另外一种抢占式获得GIL的机制。

我们先来了解一下Python代码是怎样被执行的。Python程序的执行分两个阶段。首先Python源码被转换为简单的二进制格式的字节码,然后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 ... */
    }
}

默认情况下check_interval的周期是1000条字节码指令。所有的线程都会运行上面的代码,并且同样的周期性的释放锁。在Python3中GIL的实现方式会更复杂些,它不再检查固定数量的字节码指令而是以15毫秒为周期释放GIL。一般上来说,这两种实现方式对你的程序运行没有什么差别。

Python中的线程安全

如果一个线程随时都可能丢掉GIL锁,那么我们就必须设法保障自己的代码线程安全。Python程序员对于线程安全的处理与C和JAVA程序员不同,因为毕竟大多数的Python操作是原子操作。

原子操作的一个例子就是对列表调用sort()函数。线程在排序过程中是不会被中断的,因此其它线程不会看到一个部分排序的列表,也不会在函数返回时看到未经排序的数据。原子操作使事情变得简单了,但是也会有偶尔的例外。比如+=操作看起来比sort()更简单,但是+=却不是原子操作。比如:

n = 0

def foo():
    global n
    n += 1
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()

如果不知道什么时候该使用锁,什么时候不使用,那么遵循一个简单的规则:总是对共享可变数据加锁。要知道Python获取threading.Lock锁的开销是很小的。

虽然GIL本身并没有让我们完全不用考虑线程锁,但是它的存在的确让我们不必考虑细颗粒的线程锁。而象Java这样的自由线程的语言就必须花费精力来保证对共享数据施加线程锁的时间尽可能的小以减少线程拥塞,尽可能实现最大的平行运算。Python线程是无法做到平行运算的,所以我们也就无需去考虑细颗粒的线程锁了。只要没有线程在休眠,IO等待,或其它该释放GIL锁的操作时不释放其所获得的锁,你就完全可以使用这个粗糙简单的锁,反正其它线程这时无法做平行运算。

并发处理

如果目的是实现并发处理多个网络请求,那么使用多线程是最优的。虽然在同一时间里,只有一个线程在运行,但是Python的多线程模式最适合处理这种大量需要等待IO的应用场景。

下面的代码运行在多线程模式下。这样的运行速度也是最快的:

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资源从而等待套接字的IO操作时,其会自动释放GIL锁,这样其它线程可以继续运行。多线程的使用,使得程序要比单线程程序运行的快得多。

平行运算

在同一时间能同时运行多条Python代码,这叫平行运算(Parallelism)。GIL锁的存在使得单个Python进程无法实现平行运算。这时就必须使用多进程了。多进程要比多线程复杂也会消耗更多的内存,但我们可以利用到多CPU的运算能力。

在下面的例子中,代码复制出了10个Python运行进程。由于这些进程同时运行在CPU的多个内核上,所以计算速度要远远快于单进程的程序。反之,如果用10个线程来执行同样的运算任务,则不但不比单线程运算更快,甚至会慢很多。

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)