摘要:多個線程可以同時執行?,F在我們執行,嘗試在不同數量的線程中執行這個函數。如果線程是真并行,時間開銷應該不會隨線程數大幅上漲。由此可見,確實是造成偽并行現象的主要因素。小結由于的存在,大多數情況下多線程無法利用多核優勢。
本文首發于本人博客,轉載請注明出處寫在前面
作者電腦有 4 個 CPU,因此使用 4 個線程測試是合理的
本文使用的 cpython 版本為 3.6.4
本文使用的 pypy 版本為 5.9.0-beta0,兼容 Python 3.5 語法
本文使用的 jython 版本為 2.7.0,兼容 Python 2.7 語法
若無特殊說明,作語言解時,python 指 Python 語言;作解釋器解時,python 指 cpython
本文使用的測速函數代碼如下:
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 上,通??梢猿杀兜乜s短運算時間。
作為一門優秀的語言,python 為我們提供了操縱線程的庫 threading。使用 threading,我們可以很方便地進行并行編程。但下面的例子可能會讓你對“并行”的真實性產生懷疑。
假設我們有一個計算斐波那契數列的函數:
def fib(): a = b = 1 for i in range(100000): a, b = b, a + b
此處我們不記錄其結果,只是為了讓它產生一定的計算量,使運算時間開銷遠大于線程創建、切換的時間開銷?,F在我們執行 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_THREADS 和 Py_END_ALLOW_THREADS 宏。這一對宏允許你在自定義的 C 擴展中釋放 GIL,從而可以重新利用多核的優勢。
沿用上面的例子,自定義的 C 擴展函數好比是流水線上一個特殊的物品。這個物品承諾自己不依賴全局環境,同時也不會要求工人去改變全局環境。同時它帶有 Py_BEGIN_ALLOW_THREADS 和 Py_END_ALLOW_THREADS 兩個機關,前者能砍斷 GIL 鎖鏈,這樣工人被調度走后不需要干等,而是可以直接干活;后者則將鎖鏈重新鎖上,保證操作的一致性。
這里同樣用一個 C 擴展做演示。由于 C 實現的斐波那契數列計算過快,此處采用另一個計算 PI 的函數:
// cfib.c #includestatic 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 需要多條非搶占式的控制流來承載,這些工作交給線程再合適不過了。
小結由于 GIL 的存在,大多數情況下 Python 多線程無法利用多核優勢。
C 擴展中可以接觸到 GIL 的開關,從而規避 GIL,重新獲得多核優勢。
IO 阻塞時,GIL 會被釋放。
相關鏈接GlobalInterpreterLock - Python Wiki
Blocking(computing) - Wikipedia
Extending Python with C or C++
PyPy
Jython
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/44583.html
摘要:引用自可迭代對象和迭代器不以規矩,不成方圓為了使某個對象成為可迭代對象象,它必須實現方法,也就是說,它得有一個是的屬性。的遍歷,絕對應該用。 pseudo 英 [sju:d??] 美 [su:do?]adj.假的,虛偽的n.[口]假冒的人,偽君子 pseudo-array 英 [sju:d???re?] 美 [sju:d???re?][計] 偽數組 jQuery 對象是偽數組 兩個...
摘要:如果某線程并未使用很多操作,它會在自己的時間片內一直占用處理器和。在中使用線程在和等大多數類系統上運行時,支持多線程編程。守護線程另一個避免使用模塊的原因是,它不支持守護線程。 這一篇是Python并發的第四篇,主要介紹進程和線程的定義,Python線程和全局解釋器鎖以及Python如何使用thread模塊處理并發 引言&動機 考慮一下這個場景,我們有10000條數據需要處理,處理每條...
摘要:每個在同一時間只能執行一個線程在單核下的多線程其實都只是并發,不是并行,并發和并行從宏觀上來講都是同時處理多路請求的概念。在多線程下,每個線程的執行方式獲取執行代碼直到或者是虛擬機將其掛起。拿不到通行證的線程,就不允許進入執行。 進程與線程 并發與并行 進程與線程 首先要理解的是,我們的軟件都是運行在操作系統之上,操作系統再控制硬件,比如 處理器、內存、IO設備等。操作系統為了向上...
摘要:語言誕生于谷歌,由計算機領域的三位宗師級大牛和寫成。作者華為云技術宅基地鏈接谷歌前員工認為,比起大家熟悉的,語言其實有很多優良特性,很多時候都可以代替,他已經在很多任務中使用語言替代了。 Go 語言誕生于谷歌,由計算機領域的三位宗師級大牛 Rob Pike、Ken Thompson 和 Robert Griesemer 寫成。由于出身名門,Go 在誕生之初就吸引了大批開發者的關注。誕生...
閱讀 2934·2021-10-14 09:43
閱讀 2873·2021-10-14 09:42
閱讀 4653·2021-09-22 15:56
閱讀 2363·2019-08-30 10:49
閱讀 1592·2019-08-26 13:34
閱讀 2377·2019-08-26 10:35
閱讀 598·2019-08-23 17:57
閱讀 2026·2019-08-23 17:15