从伪并行的 Python 多线程提及

本文首发于本人博客转载请注明出处html

写在前面

  • 做者电脑为 4 核架构,所以使用 4 个线程测试是合理的
  • 本文使用的 cpython 版本为 3.6.4
  • 本文使用的 pypy 版本为 5.9.0-beta0,兼容 Python 3.5 语法
  • 本文使用的 jython 版本为 2.7.0,兼容 Python 2.7 语法
  • 若无特殊说明,做语言解时,python 指 Python 语言;做解释器解时,pythoncpython

本文使用的测速函数代码以下:python

from __future__ import print_function

import sys
PY2 = sys.version_info[0] == 2

# 由于 Jython 不兼容 Python 3 语法,此处必须 hack 掉 range 以保证都是迭代器版本
if PY2:
    range = xrange  # noqa

from time import time
from threading import Thread


def spawn_n_threads(n, target):
    """ 启动 n 个线程并行执行 target 函数 """

    threads = []

    for _ in range(n):
        thread = Thread(target=target)
        thread.start()
        threads.append(thread)

    for thread in threads:
        thread.join()


def test(target, number=10, spawner=spawn_n_threads):
    """ 分别启动 1, 2, 3, 4 个控制流,重复 number 次,计算运行耗时 """

    for n in (1, 2, 3, 4, ):

        start_time = time()
        for _ in range(number):  # 执行 number 次以减小偶然偏差
            spawner(n, target)
        end_time = time()

        print('Time elapsed with {} branch(es): {:.6f} sec(s)'.format(n, end_time - start_time))
复制代码

并行?伪并行?

学过操做系统的同窗都知道,线程是现代操做系统底层一种轻量级的多任务机制。一个进程空间中能够存在多个线程,每一个线程表明一条控制流,共享全局进程空间的变量,又有本身私有的内存空间。编程

多个线程能够同时执行。此处的“同时”,在较早的单核架构中表现为“伪并行”,即让线程以极短的时间间隔交替执行,从人的感受上看它们就像在同时执行同样。但因为仅有一个运算单元,当线程皆执行计算密集型任务时,多线程可能会出现 1 + 1 > 2 的反效果。api

而“真正的并行”只能在多核架构上实现。对于计算密集型任务,巧妙地使用多线程或多进程将其分配至多个 CPU 上,一般能够成倍地缩短运算时间。数组

做为一门优秀的语言,python 为咱们提供了操纵线程的库 threading。使用 threading,咱们能够很方便地进行并行编程。但下面的例子可能会让你对“并行”的真实性产生怀疑。安全

假设咱们有一个计算斐波那契数列的函数:bash

def fib():

    a = b = 1

    for i in range(100000):
        a, b = b, a + b
复制代码

此处咱们不记录其结果,只是为了让它产生必定的计算量,使运算时间开销远大于线程建立、切换的时间开销。如今咱们执行 test(fib),尝试在不一样数量的线程中执行这个函数。若是线程是“真并行”,时间开销应该不会随线程数大幅上涨。但执行结果却让咱们大跌眼镜:服务器

# CPython,fib
Time elapsed with 1 branch(es): 1.246095 sec(s)
Time elapsed with 2 branch(es): 2.535884 sec(s)
Time elapsed with 3 branch(es): 3.837506 sec(s)
Time elapsed with 4 branch(es): 5.107638 sec(s)
复制代码

从结果中能够发现:时间开销几乎是正比于线程数的!这明显和多核架构的“真并行”相矛盾。这是为何呢?网络

一切的罪魁祸首都是一个叫 GIL 的东西。多线程

GIL

GIL 是什么

GIL 的全名是 the Global Interpreter Lock (全局解释锁),是常规 python 解释器(固然,有些解释器没有)的核心部件。咱们看看官方的解释:

The Python interpreter is not fully thread-safe. In order to support multi-threaded Python programs, there’s a global lock, called the global interpreter lock or GIL, that must be held by the current thread before it can safely access Python objects.

-- via Python 3.6.4 Documentation

可见,这是一个用于保护 Python 内部对象的全局锁(在进程空间中惟一),保障了解释器的线程安全。

这里用一个形象的例子来讲明 GIL 的必要性(对资源抢占问题很是熟悉的能够跳过不看):

咱们把整个进程空间看作一个车间,把线程当作是多条不相交的流水线,把线程控制流中的字节码看做是流水线上待处理的物品。Python 解释器是工人,整个车间仅此一名。操做系统是一只上帝之手,会随时把工人从一条流水线调到另外一条——这种“随时”是不禁分说的,即无论处理完当前物品与否。

若没有 GIL。假设工人正在流水线 A 处理 A1 物品,根据 A1 的须要将房间温度(一个全局对象)调到了 20 度。这时上帝之手发动了,工人被调到流水线 B 处理 B1 物品,根据 B1 的须要又将房间温度调到了 50 度。这时上帝之手又发动了,工人又调回 A 继续处理 A1。但此时 A1 暴露在了 50 度的环境中,安全问题就此产生了。

而 GIL 至关于一条锁链,一旦工人开始处理某条流水线上的物品,GIL 便会将工人和该流水线锁在一块儿。而被锁住的工人只会处理该流水线上的物品。就算忽然被调到另外一条流水线,他也不会干活,而是干等至从新调回原来的流水线。这样每一个物品在被处理的过程当中便老是能保证全局环境不会突变。

GIL 保证了线程安全性,但很显然也带来了一个问题:每一个时刻只有一条线程在执行,即便在多核架构中也是如此——毕竟,解释器只有一个。如此一来,单进程的 Python 程序便没法利用到多核的优点了。

验证

为了验证确实是 GIL 搞的鬼,咱们能够用不一样的解释器再执行一次。这里使用 pypy(有 GIL)和 jython (无 GIL)做测试:

# PyPy, fib
Time elapsed with 1 branch(es): 0.868052 sec(s)
Time elapsed with 2 branch(es): 1.706454 sec(s)
Time elapsed with 3 branch(es): 2.594260 sec(s)
Time elapsed with 4 branch(es): 3.449946 sec(s)
复制代码
# Jython, fib
Time elapsed with 1 branch(es): 2.984000 sec(s)
Time elapsed with 2 branch(es): 3.058000 sec(s)
Time elapsed with 3 branch(es): 4.404000 sec(s)
Time elapsed with 4 branch(es): 5.357000 sec(s)
复制代码

从结果能够看出,用 pypy 执行时,时间开销和线程数也是几乎成正比的;而 jython 的时间开销则是以较为缓慢的速度增加的。jython 因为下面还有一层 JVM,单线程的执行速度很慢,但在线程数达到 4 时,时间开销只有单线程的两倍不到,仅仅稍逊于 cpython 的 4 线程运行结果(5.10 secs)。因而可知,GIL 确实是形成伪并行现象的主要因素

如何解决?

GIL 是 Python 解释器正确运行的保证,Python 语言自己没有提供任何机制访问它。但在特定场合,咱们仍有办法下降它对效率的影响。

使用多进程

线程间会竞争资源是由于它们共享同一个进程空间,但进程的内存空间是独立的,天然也就没有必要使用解释锁了。

许多人很是忌讳使用多进程,理由是进程操做(建立、切换)的时间开销太大了,并且会占用更多的内存。这种担忧其实没有必要——除非是对并发量要求很高的应用(如服务器),多进程增长的时空开销其实都在能够接受的范围中。更况且,咱们可使用进程池减小频繁建立进程带来的开销。

下面新建一个 spawner,以演示多进程带来的性能提高:

from multiprocessing import Process


def spawn_n_processes(n, target):

    threads = []

    for _ in range(n):
        thread = Process(target=target)
        thread.start()
        threads.append(thread)

    for thread in threads:
        thread.join()
复制代码

使用 cpython 执行 test(fib, spawner=spawn_n_processes),结果以下:

# CPython, fib, multi-processing
Time elapsed with 1 branch(es): 1.260981 sec(s)
Time elapsed with 2 branch(es): 1.343570 sec(s)
Time elapsed with 3 branch(es): 2.183770 sec(s)
Time elapsed with 4 branch(es): 2.732911 sec(s)
复制代码

可见这里出现了“真正的并行”,程序效率获得了提高。

使用 C 扩展

GIL 并非彻底的黑箱,CPython 在解释器层提供了控制 GIL 的开关——这就是 Py_BEGIN_ALLOW_THREADSPy_END_ALLOW_THREADS 宏。这一对宏容许你在自定义的 C 扩展中释放 GIL,从而能够从新利用多核的优点。

沿用上面的例子,自定义的 C 扩展函数比如是流水线上一个特殊的物品。这个物品承诺本身不依赖全局环境,同时也不会要求工人去改变全局环境。同时它带有 Py_BEGIN_ALLOW_THREADSPy_END_ALLOW_THREADS 两个机关,前者能砍断 GIL 锁链,这样工人被调度走后不须要干等,而是能够直接干活;后者则将锁链从新锁上,保证操做的一致性。

这里一样用一个 C 扩展作演示。因为 C 实现的斐波那契数列计算过快,此处采用另外一个计算 PI 的函数:

// cfib.c
#include <python3.6m/Python.h>

static PyObject* fib(PyObject* self, PyObject* args) {
    Py_BEGIN_ALLOW_THREADS
    double n = 90000000, i;
    double s = 1;
    double pi = 3;

    for (i = 2; i <= n * 2; i += 2) {
        pi = pi + s * (4 / (i * (i + 1) * (i + 2)));
        s = -s;
    }
    Py_END_ALLOW_THREADS
    return Py_None;
}

// 模块初始化代码略去
复制代码

使用 cpython 执行 test(cfib.fib),结果以下:

# CPython, cfib, non-GIL
Time elapsed with 1 branch(es): 1.334247 sec(s)
Time elapsed with 2 branch(es): 1.439759 sec(s)
Time elapsed with 3 branch(es): 1.603779 sec(s)
Time elapsed with 4 branch(es): 1.689330 sec(s)
复制代码

若注释掉以上两个宏,则结果以下:

# CPython, cfib, with-GIL
Time elapsed with 1 branch(es): 1.331415 sec(s)
Time elapsed with 2 branch(es): 2.671651 sec(s)
Time elapsed with 3 branch(es): 4.022696 sec(s)
Time elapsed with 4 branch(es): 5.337917 sec(s)
复制代码

可见其中的性能差别。所以当你想作一些计算密集型任务时,不妨尝试用 C 实现,以此规避 GIL。

值得注意的是,一些著名的科学计算库(如 numpy)为了提高性能,其底层也是用 C 实现的,而且会在作一些线程安全操做(如 numpy 的数组操做)时释放 GIL。所以对于这些库,咱们能够放心地使用多线程。如下是一个例子:

import numpy


def np_example():
    ones = numpy.ones(10000000)
    numpy.exp(ones)
复制代码

用 CPython 执行 test(np_example) 结果以下:

# CPython, np_example
Time elapsed with 1 branch(es): 3.708392 sec(s)
Time elapsed with 2 branch(es): 2.462703 sec(s)
Time elapsed with 3 branch(es): 3.578331 sec(s)
Time elapsed with 4 branch(es): 4.276800 sec(s)
复制代码

让线程作该作的事

读到这,有同窗可能会奇怪了:我在使用 python 多线程写爬虫时可历来没有这种问题啊——用 4 个线程下载 4 个页面的时间与单线程下载一个页面的时间相差无几。

这里就要谈到 GIL 的第二种释放时机了。除了调用 Py_BEGIN_ALLOW_THREADS,解释器还会在发生阻塞 IO(如网络、文件)时释放 GIL。发生阻塞 IO 时,调用方线程会被挂起,没法进行任何操做,直至内核返回;IO 函数通常是原子性的,这确保了调用的线程安全性。所以在大多数阻塞 IO 发生时,解释器没有理由加锁。

以爬虫为例:当 Thread1 发起对 Page1 的请求后,Thread1 会被挂起,此时 GIL 释放。当控制流切换至 Thread2 时,因为没有 GIL,没必要干等,而是能够直接请求 Page2……如此一来,四个请求能够认为是几乎同时发起的。时间开销便与单线程请求一次同样。

有人反对使用阻塞 IO,由于若想更好利用阻塞时的时间,必须使用多线程或进程,这样会有很大的上下文切换开销,而非阻塞 IO + 协程显然是更经济的方式。但当若干任务之间没有偏序关系时,一个任务阻塞是能够接受的(毕竟不会影响到其余任务的执行),同时也会简化程序的设计。而在一些通讯模型(如 Publisher-Subscriber)中,“阻塞”是必要的语义。

多个阻塞 IO 须要多条非抢占式的控制流来承载,这些工做交给线程再合适不过了。

小结

  1. 因为 GIL 的存在,大多数状况下 Python 多线程没法利用多核优点。
  2. C 扩展中能够接触到 GIL 的开关,从而规避 GIL,从新得到多核优点。
  3. IO 阻塞时,GIL 会被释放。

相关连接

  1. GlobalInterpreterLock - Python Wiki
  2. Blocking(computing) - Wikipedia
  3. Extending Python with C or C++
  4. PyPy
  5. Jython
相关文章
相关标签/搜索