從偽並行的 Python 多執行緒說起

hsfzxjy發表於2019-02-19

本文首發於本人部落格轉載請註明出處

寫在前面

  • 作者電腦為 4 核架構,因此使用 4 個執行緒測試是合理的
  • 本文使用的 cpython 版本為 3.6.4
  • 本文使用的 pypy 版本為 5.9.0-beta0,相容 Python 3.5 語法
  • 本文使用的 jython 版本為 2.7.0,相容 Python 2.7 語法
  • 若無特殊說明,作語言解時,python 指 Python 語言;作直譯器解時,pythoncpython

本文使用的測速函式程式碼如下:

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 的反效果。

而“真正的並行”只能在多核架構上實現。對於計算密集型任務,巧妙地使用多執行緒或多程式將其分配至多個 CPU 上,通常可以成倍地縮短運算時間。

作為一門優秀的語言,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

相關文章