從僞並行的 Python 多線程提及

本文首發於 本人博客轉載請註明出處

寫在前面

  • 做者電腦有 4 個 CPU,所以使用 4 個線程測試是合理的
  • 本文使用的 cpython 版本爲 3.6.4
  • 本文使用的 pypy 版本爲 5.9.0-beta0,兼容 Python 3.5 語法
  • 本文使用的 jython 版本爲 2.7.0,兼容 Python 2.7 語法
  • 若無特殊說明,做語言解時,python 指 Python 語言;做解釋器解時,pythoncpython

本文使用的測速函數代碼以下:html

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))

並行?僞並行?

學過操做系統的同窗都知道,線程是現代操做系統底層一種輕量級的多任務機制。一個進程空間中能夠存在多個線程,每一個線程表明一條控制流,共享全局進程空間的變量,又有本身私有的內存空間。python

多個線程能夠同時執行。此處的「同時」,在較早的單核架構中表現爲「僞並行」,即讓線程以極短的時間間隔交替執行,從人的感受上看它們就像在同時執行同樣。但因爲僅有一個運算單元,當線程皆執行計算密集型任務時,多線程可能會出現 1 + 1 > 2 的反效果。編程

而「真正的並行」只能在多核架構上實現。對於計算密集型任務,巧妙地使用多線程或多進程將其分配至多個 CPU 上,一般能夠成倍地縮短運算時間。api

做爲一門優秀的語言,python 爲咱們提供了操縱線程的庫 threading。使用 threading,咱們能夠很方便地進行並行編程。但下面的例子可能會讓你對「並行」的真實性產生懷疑。數組

假設咱們有一個計算斐波那契數列的函數:安全

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
相關文章
相關標籤/搜索