python執行緒筆記

發表於2017-03-19

引言&動機

考慮一下這個場景,我們有10000條資料需要處理,處理每條資料需要花費1秒,但讀取資料只需要0.1秒,每條資料互不干擾。該如何執行才能花費時間最短呢?

在多執行緒(MT)程式設計出現之前,電腦程式的執行由一個執行序列組成,執行序列按順序在主機的中央處理器(CPU)中執行。無論是任務本身要求順序執行還是整個程式是由多個子任務組成,程式都是按這種方式執行的。即使子任務相互獨立,互相無關(即,一個子任務的結果不影響其它子 任務的結果)時也是這樣。

對於上邊的問題,如果使用一個執行序列來完成,我們大約需要花費 10000*0.1 + 10000 = 11000 秒。這個時間顯然是太長了。

那我們有沒有可能在執行計算的同時取資料呢?或者是同時處理幾條資料呢?如果可以,這樣就能大幅提高任務的效率。這就是多執行緒程式設計的目的。

對於本質上就是非同步的, 需要有多個併發事務,各個事務的執行順序可以是不確定的,隨機的,不可預測的問題,多執行緒是最理想的解決方案。這樣的任務可以被分成多個執行流,每個流都有一個要完成的目標,然後將得到的結果合併,得到最終的結果。

執行緒和程式

什麼是程式

程式(有時被稱為重量級程式)是程式的一次 執行。每個程式都有自己的地址空間,記憶體,資料棧以及其它記錄其執行軌跡的輔助資料。操作系 統管理在其上執行的所有程式,併為這些程式公平地分配時間。程式也可以通過 fork 和 spawn 操作 來完成其它的任務。不過各個程式有自己的記憶體空間,資料棧等,所以只能使用程式間通訊(IPC), 而不能直接共享資訊。

什麼是執行緒

執行緒(有時被稱為輕量級程式)跟程式有些相似,不同的是,所有的執行緒執行在同一個程式中, 共享相同的執行環境。它們可以想像成是在主程式或“主執行緒”中並行執行的“迷你程式”。

執行緒狀態如圖

python執行緒筆記

執行緒有開始,順序執行和結束三部分。它有一個自己的指令指標,記錄自己執行到什麼地方。 執行緒的執行可能被搶佔(中斷),或暫時的被掛起(也叫睡眠),讓其它的執行緒執行,這叫做讓步。 一個程式中的各個執行緒之間共享同一片資料空間,所以執行緒之間可以比程式之間更方便地共享資料以及相互通訊。

當然,這樣的共享並不是完全沒有危險的。如果多個執行緒共同訪問同一片資料,則由於資料訪 問的順序不一樣,有可能導致資料結果的不一致的問題。這叫做競態條件(race condition)。

執行緒一般都是併發執行的,不過在單 CPU 的系統中,真正的併發是不可能的,每個執行緒會被安排成每次只執行一小會,然後就把 CPU 讓出來,讓其它的執行緒去執行。由於有的函式會在完成之前阻塞住,在沒有特別為多執行緒做修改的情 況下,這種“貪婪”的函式會讓 CPU 的時間分配有所傾斜。導致各個執行緒分配到的執行時間可能不 盡相同,不盡公平。

Python、執行緒和全域性直譯器鎖

全域性直譯器鎖(GIL)

首先需要明確的一點是GIL並不是Python的特性,它是在實現Python解析器(CPython)時所引入的一個概念。就好比C++是一套語言(語法)標準,但是可以用不同的編譯器來編譯成可執行程式碼。同樣一段程式碼可以通過CPython,PyPy,Psyco等不同的Python執行環境來執行(其中的JPython就沒有GIL)。

那麼CPython實現中的GIL又是什麼呢?GIL全稱Global Interpreter Lock為了避免誤導,我們還是來看一下官方給出的解釋:

In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary mainly because CPython’s memory management is not thread-safe. (However, since the GIL exists, other features have grown to depend on the guarantees that it enforces.)

儘管Python完全支援多執行緒程式設計, 但是直譯器的C語言實現部分在完全並行執行時並不是執行緒安全的。 實際上,直譯器被一個全域性直譯器鎖保護著,它確保任何時候都只有一個Python執行緒執行。

在多執行緒環境中,Python 虛擬機器按以下方式執行:

  1. 設定GIL
  2. 切換到一個執行緒去執行
  3. 執行
    • 指定數量的位元組碼指令
    • 執行緒主動讓出控制(可以呼叫time.sleep(0))
  4. 把執行緒設定完睡眠狀態
  5. 解鎖GIL
  6. 再次重複以上步驟

對所有面向 I/O 的(會呼叫內建的作業系統 C 程式碼的)程式來說,GIL 會在這個 I/O 呼叫之 前被釋放,以允許其它的執行緒在這個執行緒等待 I/O 的時候執行。如果某執行緒並未使用很多 I/O 操作, 它會在自己的時間片內一直佔用處理器(和 GIL)。也就是說,I/O 密集型的 Python 程式比計算密集 型的程式更能充分利用多執行緒環境的好處。

退出執行緒

當一個執行緒結束計算,它就退出了。執行緒可以呼叫 thread.exit()之類的退出函式,也可以使用 Python 退出程式的標準方法,如 sys.exit()或丟擲一個 SystemExit 異常等。不過,你不可以直接 “殺掉”(“kill”)一個執行緒。

在 Python 中使用執行緒

在 Win32 和 Linux, Solaris, MacOS, *BSD 等大多數類 Unix 系統上執行時,Python 支援多執行緒 程式設計。Python 使用 POSIX 相容的執行緒,即 pthreads。

預設情況下,只要在直譯器中

如果沒有報錯,則說明執行緒可用。

Python 的 threading 模組

Python 供了幾個用於多執行緒程式設計的模組,包括 thread, threading 和 Queue 等。thread 和 threading 模組允許程式設計師建立和管理執行緒。thread 模組 供了基本的執行緒和鎖的支援,而 threading 供了更高階別,功能更強的執行緒管理的功能。Queue 模組允許使用者建立一個可以用於多個執行緒之間 共享資料的佇列資料結構。

核心 示:避免使用 thread 模組

出於以下幾點考慮,我們不建議您使用 thread 模組。

  1. 更高階別的 threading 模組更為先 進,對執行緒的支援更為完善,而且使用 thread 模組裡的屬性有可能會與 threading 出現衝突。其次, 低階別的 thread 模組的同步原語很少(實際上只有一個),而 threading 模組則有很多。
  2. 對於你的程式什麼時候應該結束完全沒有控制,當主執行緒結束 時,所有的執行緒都會被強制結束掉,沒有警告也不會有正常的清除工作。我們之前說過,至少 threading 模組能確保重要的子執行緒退出後程式才退出。

thread 模組

除了產生執行緒外,thread 模組也提供了基本的同步數 據結構鎖物件(lock object,也叫原語鎖,簡單鎖,互斥鎖,互斥量,二值訊號量)。

thread 模組函式

  • start_new_thread(function, args, kwargs=None):產生一個新的執行緒,在新執行緒中用指定的引數和可選的 kwargs 來呼叫這個函式。
  • allocate_lock():分配一個 LockType 型別的鎖物件
  • exit():讓執行緒退出
  • acquire(wait=None):嘗試獲取鎖物件
  • locked():如果獲取了鎖物件返回 True,否則返回 False
  • release():釋放鎖

下面是一個使用 thread 的例子:

start_new_thread()要求一定要有前兩個引數。所以,就算我們想要執行的函式不要引數,也要傳一個空的元組。
為什麼要加上sleep(6)這一句呢? 因為,如果我們沒有讓主執行緒停下來,那主執行緒就會執行下一條語句,顯示 “all done”,然後就關閉執行著 loop()和 loop1()的兩個執行緒,退出了。

我們有沒有更好的辦法替換使用sleep() 這種不靠譜的同步方式呢?答案是使用鎖,使用了鎖,我們就可以在兩個執行緒都退出之後馬上退出。

為什麼我們不在建立鎖的迴圈裡建立執行緒呢?有以下幾個原因:

  1. 我們想到實現執行緒的同步,所以要讓“所有的馬同時衝出柵欄”。
  2. 獲取鎖要花一些時間,如果你的 執行緒退出得“太快”,可能會導致還沒有獲得鎖,執行緒就已經結束了的情況。

threading 模組

threading 模組不僅提供了 Thread 類,還 供了各 種非常好用的同步機制。

下面是threading 模組裡所有的物件:

  1. Thread: 表示一個執行緒的執行的物件
  2. Lock: 鎖原語物件(跟 thread 模組裡的鎖物件相同)
  3. RLock: 可重入鎖物件。使單執行緒可以再次獲得已經獲得了的鎖(遞迴鎖定)。
  4. Condition: 條件變數物件能讓一個執行緒停下來,等待其它執行緒滿足了某個“條件”。 如,狀態的改變或值的改變。
  5. Event: 通用的條件變數。多個執行緒可以等待某個事件的發生,在事件發生後, 所有的執行緒都會被啟用。
  6. Semaphore: 為等待鎖的執行緒 供一個類似“等候室”的結構
  7. BoundedSemaphore: 與 Semaphore 類似,只是它不允許超過初始值
  8. Timer: 與 Thread 相似,只是,它要等待一段時間後才開始執行。
守護執行緒

另一個避免使用 thread 模組的原因是,它不支援守護執行緒。當主執行緒退出時,所有的子執行緒不 論它們是否還在工作,都會被強行退出。有時,我們並不期望這種行為,這時,就引入了守護執行緒 的概念
threading 模組支援守護執行緒,它們是這樣工作的:守護執行緒一般是一個等待客戶請求的伺服器, 如果沒有客戶 出請求,它就在那等著。如果你設定一個執行緒為守護執行緒,就表示你在說這個執行緒 是不重要的,在程式退出的時候,不用等待這個執行緒退出。
如果你的主執行緒要退出的時候,不用等待那些子執行緒完成,那就設定這些執行緒的 daemon 屬性。 即,線上程開始(呼叫 thread.start())之前,呼叫 setDaemon()函式設定執行緒的 daemon 標誌 (thread.setDaemon(True))就表示這個執行緒“不重要”
如果你想要等待子執行緒完成再退出,那就什麼都不用做,或者顯式地呼叫 thread.setDaemon(False)以保證其 daemon 標誌為 False。你可以呼叫 thread.isDaemon()函式來判 斷其 daemon 標誌的值。新的子執行緒會繼承其父執行緒的 daemon 標誌。整個 Python 會在所有的非守護 執行緒退出後才會結束,即程式中沒有非守護執行緒存在的時候才結束。

Thread 類

Thread類提供了以下方法:

  • run(): 用以表示執行緒活動的方法。
  • start():啟動執行緒活動。
  • join([time]): 等待至執行緒中止。這阻塞呼叫執行緒直至執行緒的join() 方法被呼叫中止-正常退出或者丟擲未處理的異常-或者是可選的超時發生。
  • is_alive(): 返回執行緒是否活動的。
  • name(): 設定/返回執行緒名。
  • daemon(): 返回/設定執行緒的 daemon 標誌,一定要在呼叫 start()函式前設定

用 Thread 類,你可以用多種方法來建立執行緒。我們在這裡介紹三種比較相像的方法。

  • 建立一個Thread的例項,傳給它一個函式
  • 建立一個Thread的例項,傳給它一個可呼叫的類物件
  • 從Thread派生出一個子類,建立一個這個子類的例項

下邊是三種不同方式的建立執行緒的示例:

與傳一個函式很相似的另一個方法是在建立執行緒的時候,傳一個可呼叫的類的例項供執行緒啟動 的時候執行——這是多執行緒程式設計的一個更為物件導向的方法。相對於一個或幾個函式來說,由於類 物件裡可以使用類的強大的功能,可以儲存更多的資訊,這種方法更為靈活

最後一個例子介紹如何子類化 Thread 類,這與上一個例子中的建立一個可呼叫的類非常像。使 用子類化建立執行緒(第 29-30 行)使程式碼看上去更清晰明瞭。

除了各種同步物件和執行緒物件外,threading 模組還 供了一些函式。

  • active_count(): 當前活動的執行緒物件的數量
  • current_thread(): 返回當前執行緒物件
  • enumerate(): 返回當前活動執行緒的列表
  • settrace(func): 為所有執行緒設定一個跟蹤函式
  • setprofile(func): 為所有執行緒設定一個 profile 函式

Lock & RLock

原語鎖定是一個同步原語,狀態是鎖定或未鎖定。兩個方法acquire()和release() 用於加鎖和釋放鎖。
RLock 可重入鎖是一個類似於Lock物件的同步原語,但同一個執行緒可以多次呼叫。

Lock 不支援遞迴加鎖,也就是說即便在同 執行緒中,也必須等待鎖釋放。通常建議改 RLock, 它會處理 “owning thread” 和 “recursion level” 狀態,對於同 執行緒的多次請求鎖 為,只累加
計數器。每次調 release() 將遞減該計數器,直到 0 時釋放鎖,因此 acquire() 和 release() 必須 要成對出現。

Event

事件用於線上程間通訊。一個執行緒發出一個訊號,其他一個或多個執行緒等待。
Event 通過通過 個內部標記來協調多執行緒運 。 法 wait() 阻塞執行緒執 ,直到標記為 True。 set() 將標記設為 True,clear() 更改標記為 False。isSet() 用於判斷標記狀態。

Condition

條件變數和 Lock 引數一樣,也是一個,也是一個同步原語,當需要執行緒關注特定的狀態變化或事件的發生時使用這個鎖定。

可以認為,除了Lock帶有的鎖定池外,Condition還包含一個等待池,池中的執行緒處於狀態圖中的等待阻塞狀態,直到另一個執行緒呼叫notify()/notifyAll()通知;得到通知後執行緒進入鎖定池等待鎖定。

構造方法:
Condition([lock/rlock])

Condition 有以下這些方法:

  • acquire([timeout])/release(): 呼叫關聯的鎖的相應方法。
  • wait([timeout]): 呼叫這個方法將使執行緒進入Condition的等待池等待通知,並釋放鎖。使用前執行緒必須已獲得鎖定,否則將丟擲異常。
  • notify(): 呼叫這個方法將從等待池挑選一個執行緒並通知,收到通知的執行緒將自動呼叫acquire()嘗試獲得鎖定(進入鎖定池);其他執行緒仍然在等待池中。呼叫這個方法不會釋放鎖定。使用前執行緒必須已獲得鎖定,否則將丟擲異常。
  • notifyAll(): 呼叫這個方法將通知等待池中所有的執行緒,這些執行緒都將進入鎖定池嘗試獲得鎖定。呼叫這個方法不會釋放鎖定。使用前執行緒必須已獲得鎖定,否則將丟擲異常。

只有獲取鎖的執行緒才能呼叫 wait() 和 notify(),因此必須在鎖釋放前呼叫。
當 wait() 釋放鎖後,其他執行緒也可進入 wait 狀態。notifyAll() 啟用所有等待執行緒,讓它們去搶鎖然後完成後續執行。

生產者-消費者問題和 Queue 模組

現在我們用一個經典的(生產者消費者)例子來介紹一下 Queue模組。

生產者消費者的場景是: 生產者生產貨物,然後把貨物放到一個佇列之類的資料結構中,生產貨物所要花費的時間無法預先確定。消費者消耗生產者生產的貨物的時間也是不確定的。

常用的 Queue 模組的屬性:

  • queue(size): 建立一個大小為size的Queue物件。
  • qsize(): 返回佇列的大小(由於在返回的時候,佇列可能會被其它執行緒修改,所以這個值是近似值)
  • empty(): 如果佇列為空返回 True,否則返回 False
  • full(): 如果佇列已滿返回 True,否則返回 False
  • put(item,block=0): 把item放到佇列中,如果給了block(不為0),函式會一直阻塞到佇列中有空間為止
  • get(block=0): 從佇列中取一個物件,如果給了 block(不為 0),函式會一直阻塞到佇列中有物件為止

Queue 模組可以用來進行執行緒間通訊,讓各個執行緒之間共享資料。

現在,我們建立一個佇列,讓 生產者(執行緒)把新生產的貨物放進去供消費者(執行緒)使用。

FAQ

程式與執行緒。執行緒與程式的區別是什麼?

程式(有時被稱為重量級程式)是程式的一次 執行。每個程式都有自己的地址空間,記憶體,資料棧以及其它記錄其執行軌跡的輔助資料。
執行緒(有時被稱為輕量級程式)跟程式有些相似,不同的是,所有的執行緒執行在同一個程式中, 共享相同的執行環境。它們可以想像成是在主程式或“主執行緒”中並行執行的“迷你程式”。

這篇文章很好的解釋了 執行緒和程式的區別,推薦閱讀: http://www.ruanyifeng.com/blo…

Python 的執行緒。在 Python 中,哪一種多執行緒的程式表現得更好,I/O 密集型的還是計算 密集型的?

由於GIL的緣故,對所有面向 I/O 的(會呼叫內建的作業系統 C 程式碼的)程式來說,GIL 會在這個 I/O 呼叫之 前被釋放,以允許其它的執行緒在這個執行緒等待 I/O 的時候執行。如果某執行緒並未使用很多 I/O 操作, 它會在自己的時間片內一直佔用處理器(和 GIL)。也就是說,I/O 密集型的 Python 程式比計算密集 型的程式更能充分利用多執行緒環境的好處。

執行緒。你認為,多 CPU 的系統與一般的系統有什麼大的不同?多執行緒的程式在這種系統上的表現會怎麼樣?

Python的執行緒就是C語言的一個pthread,並通過作業系統排程演算法進行排程(例如linux是CFS)。為了讓各個執行緒能夠平均利用CPU時間,python會計算當前已執行的微程式碼數量,達到一定閾值後就強制釋放GIL。而這時也會觸發一次作業系統的執行緒排程(當然是否真正進行上下文切換由作業系統自主決定)。
虛擬碼

這種模式在只有一個CPU核心的情況下毫無問題。任何一個執行緒被喚起時都能成功獲得到GIL(因為只有釋放了GIL才會引發執行緒排程)。
但當CPU有多個核心的時候,問題就來了。從虛擬碼可以看到,從release GIL到acquire GIL之間幾乎是沒有間隙的。所以當其他在其他核心上的執行緒被喚醒時,大部分情況下主執行緒已經又再一次獲取到GIL了。這個時候被喚醒執行的執行緒只能白白的浪費CPU時間,看著另一個執行緒拿著GIL歡快的執行著。然後達到切換時間後進入待排程狀態,再被喚醒,再等待,以此往復惡性迴圈。
簡單的總結下就是:Python的多執行緒在多核CPU上,只對於IO密集型計算產生正面效果;而當有至少有一個CPU密集型執行緒存在,那麼多執行緒效率會由於GIL而大幅下降。

執行緒池。修改 生成者消費者 的程式碼,不再是一個生產者和一個消費者,而是可以有任意個 消費者執行緒(一個執行緒池),每個執行緒可以在任意時刻處理或消耗任意多個產品。

參考文章

相關文章