python線程筆記
考慮一下這個場景,我們有10000條數據需要處理,處理每條數據需要花費1秒,但讀取數據只需要0.1秒,每條數據互不干擾。該如何執行才能花費時間最短呢?
在多線程(MT)編程出現之前,電腦程序的運行由一個執行序列組成,執行序列按順序在主機的中央處理器(CPU)中運行。無論是任務本身要求順序執行還是整個程序是由多個子任務組成,程序都是按這種方式執行的。即使子任務相互獨立,互相無關(即,一個子任務的結果不影響其它子 任務的結果)時也是這樣。
對于上邊的問題,如果使用一個執行序列來完成,我們大約需要花費 10000*0.1 + 10000 = 11000 秒。這個時間顯然是太長了。
那我們有沒有可能在執行計算的同時取數據呢?或者是同時處理幾條數據呢?如果可以,這樣就能大幅提高任務的效率。這就是多線程編程的目的。
對于本質上就是異步的, 需要有多個并發事務,各個事務的運行順序可以是不確定的,隨機的,不可預測的問題,多線程是最理想的解決方案。這樣的任務可以被分成多個執行流,每個流都有一個要完成的目標,然后將得到的結果合并,得到最終的結果。
什么是進程
進程(有時被稱為重量級進程)是程序的一次 執行。每個進程都有自己的地址空間,內存,數據棧以及其它記錄其運行軌跡的輔助數據。操作系 統管理在其上運行的所有進程,并為這些進程公平地分配時間。進程也可以通過 fork 和 spawn 操作 來完成其它的任務。不過各個進程有自己的內存空間,數據棧等,所以只能使用進程間通訊(IPC), 而不能直接共享信息。
什么是線程
線程(有時被稱為輕量級進程)跟進程有些相似,不同的是,所有的線程運行在同一個進程中, 共享相同的運行環境。它們可以想像成是在主進程或“主線程”中并行運行的“迷你進程”。
線程狀態如圖
線程有開始,順序執行和結束三部分。它有一個自己的指令指針,記錄自己運行到什么地方。 線程的運行可能被搶占(中斷),或暫時的被掛起(也叫睡眠),讓其它的線程運行,這叫做讓步。 一個進程中的各個線程之間共享同一片數據空間,所以線程之間可以比進程之間更方便地共享數據以及相互通訊。
當然,這樣的共享并不是完全沒有危險的。如果多個線程共同訪問同一片數據,則由于數據訪 問的順序不一樣,有可能導致數據結果的不一致的問題。這叫做競態條件(race condition)。
線程一般都是并發執行的,不過在單 CPU 的系統中,真正的并發是不可能的,每個線程會被安排成每次只運行一小會,然后就把 CPU 讓出來,讓其它的線程去運行。由于有的函數會在完成之前阻塞住,在沒有特別為多線程做修改的情 況下,這種“貪婪”的函數會讓 CPU 的時間分配有所傾斜。導致各個線程分配到的運行時間可能不 盡相同,不盡公平。
全局解釋器鎖(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 模塊也提供了基本的同步數 據結構鎖對象(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 模塊不僅提供了 Thread 類,還 供了各 種非常好用的同步機制。
下面是threading 模塊里所有的對象:
- Thread: 表示一個線程的執行的對象
- Lock: 鎖原語對象(跟 thread 模塊里的鎖對象相同)
- RLock: 可重入鎖對象。使單線程可以再次獲得已經獲得了的鎖(遞歸鎖定)。
- Condition: 條件變量對象能讓一個線程停下來,等待其它線程滿足了某個“條件”。 如,狀態的改變或值的改變。
- Event: 通用的條件變量。多個線程可以等待某個事件的發生,在事件發生后, 所有的線程都會被激活。
- Semaphore: 為等待鎖的線程 供一個類似“等候室”的結構
- BoundedSemaphore: 與 Semaphore 類似,只是它不允許超過初始值
- 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 函數
原語鎖定是一個同步原語,狀態是鎖定或未鎖定。兩個方法acquire()和release() 用于加鎖和釋放鎖。
RLock 可重入鎖是一個類似于Lock對象的同步原語,但同一個線程可以多次調用。
Lock 不支持遞歸加鎖,也就是說即便在同 線程中,也必須等待鎖釋放。通常建議改 RLock, 它會處理 “owning thread” 和 “recursion level” 狀態,對于同 線程的多次請求鎖 為,只累加
計數器。每次調 release() 將遞減該計數器,直到 0 時釋放鎖,因此 acquire() 和 release() 必須 要成對出現。
事件用于在線程間通信。一個線程發出一個信號,其他一個或多個線程等待。
Event 通過通過 個內部標記來協調多線程運 。 法 wait() 阻塞線程執 ,直到標記為 True。 set() 將標記設為 True,clear() 更改標記為 False。isSet() 用于判斷標記狀態。
條件變量和 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(size): 創建一個大小為size的Queue對象。
- qsize(): 返回隊列的大小(由于在返回的時候,隊列可能會被其它線程修改,所以這個值是近似值)
- empty(): 如果隊列為空返回 True,否則返回 False
- full(): 如果隊列已滿返回 True,否則返回 False
- put(item,block=0): 把item放到隊列中,如果給了block(不為0),函數會一直阻塞到隊列中有空間為止
- get(block=0): 從隊列中取一個對象,如果給了 block(不為 0),函數會一直阻塞到隊列中有對象為止
Queue 模塊可以用來進行線程間通訊,讓各個線程之間共享數據。
現在,我們創建一個隊列,讓 生產者(線程)把新生產的貨物放進去供消費者(線程)使用。
1.進程與線程。線程與進程的區別是什么?
進程(有時被稱為重量級進程)是程序的一次 執行。每個進程都有自己的地址空間,內存,數據棧以及其它記錄其運行軌跡的輔助數據。
線程(有時被稱為輕量級進程)跟進程有些相似,不同的是,所有的線程運行在同一個進程中, 共享相同的運行環境。它們可以想像成是在主進程或“主線程”中并行運行的“迷你進程”。
2.Python 的線程。在 Python 中,哪一種多線程的程序表現得更好,I/O 密集型的還是計算 密集型的?
由于GIL的緣故,對所有面向 I/O 的(會調用內建的操作系統 C 代碼的)程序來說,GIL 會在這個 I/O 調用之 前被釋放,以允許其它的線程在這個線程等待 I/O 的時候運行。如果某線程并未使用很多 I/O 操作, 它會在自己的時間片內一直占用處理器(和 GIL)。也就是說,I/O 密集型的 Python 程序比計算密集 型的程序更能充分利用多線程環境的好處。
3.線程。你認為,多 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而大幅下降。
4.線程池。修改 生成者消費者 的代碼,不再是一個生產者和一個消費者,而是可以有任意個 消費者線程(一個線程池),每個線程可以在任意時刻處理或消耗任意多個產品。