Python之執行緒、程式和協程

發表於2016-09-13

目錄:

引言

一、執行緒

1.1 普通的多執行緒
1.2 自定義執行緒類
1.3 執行緒鎖
1.3.1 未使用鎖
1.3.2 普通鎖Lock和RLock
1.3.3 訊號量(Semaphore)
1.3.4 事件(Event)
1.3.5 條件(condition)
1.3 全域性直譯器鎖(GIL)
1.4 定時器(Timer)
1.5 佇列
1.5.1 Queue:先進先出佇列
1.5.2 LifoQueue:後進先出佇列
1.5.3 PriorityQueue:優先順序佇列
1.5.4 deque:雙向佇列
1.6 生產者消費者模型
1.7 執行緒池

二、程式

2.1 程式的資料共享
2.1.1 使用Array共享資料
2.1.2 使用Manager共享資料
2.1.3 使用queues的Queue類共享資料
2.2 程式鎖
2.3 程式池

三、協程

3.1 greenlet
3.2 gevent

引言

直譯器環境:python3.5.1
我們都知道python網路程式設計的兩大必學模組socket和socketserver,其中的socketserver是一個支援IO多路複用和多執行緒、多程式的模組。一般我們在socketserver服務端程式碼中都會寫這麼一句:
server = socketserver.ThreadingTCPServer(settings.IP_PORT, MyServer)
ThreadingTCPServer這個類是一個支援多執行緒和TCP協議的socketserver,它的繼承關係是這樣的:
class ThreadingTCPServer(ThreadingMixIn, TCPServer): pass
右邊的TCPServer實際上是它主要的功能父類,而左邊的ThreadingMixIn則是實現了多執行緒的類,它自己本身則沒有任何程式碼。
MixIn在python的類命名中,很常見,一般被稱為“混入”,戲稱“亂入”,通常為了某種重要功能被子類繼承。

在ThreadingMixIn類中,其實就定義了一個屬性,兩個方法。在process_request方法中實際呼叫的正是python內建的多執行緒模組threading。這個模組是python中所有多執行緒的基礎,socketserver本質上也是利用了這個模組。

一、執行緒

執行緒,有時被稱為輕量級程式(Lightweight Process,LWP),是程式執行流的最小單元。一個標準的執行緒由執行緒ID,當前指令指標(PC),暫存器集合和堆疊組成。另外,執行緒是程式中的一個實體,是被系統獨立排程和分派的基本單位,執行緒自己不獨立擁有系統資源,但它可與同屬一個程式的其它執行緒共享該程式所擁有的全部資源。一個執行緒可以建立和撤消另一個執行緒,同一程式中的多個執行緒之間可以併發執行。由於執行緒之間的相互制約,致使執行緒在執行中呈現出間斷性。執行緒也有就緒、阻塞和執行三種基本狀態。就緒狀態是指執行緒具備執行的所有條件,邏輯上可以執行,在等待處理機;執行狀態是指執行緒佔有處理機正在執行;阻塞狀態是指執行緒在等待一個事件(如某個訊號量),邏輯上不可執行。每一個應用程式都至少有一個程式和一個執行緒。執行緒是程式中一個單一的順序控制流程。在單個程式中同時執行多個執行緒完成不同的被劃分成一塊一塊的工作,稱為多執行緒。
以上那一段,可以不用看!舉個例子,廠家要生產某個產品,在它的生產基地建設了很多廠房,每個廠房內又有多條流水生產線。所有廠房配合將整個產品生產出來,某個廠房內的所有流水線將這個廠房負責的產品部分生產出來。每個廠房擁有自己的材料庫,廠房內的生產線共享這些材料。而每一個廠家要實現生產必須擁有至少一個廠房一條生產線。那麼這個廠家就是某個應用程式;每個廠房就是一個程式;每條生產線都是一個執行緒。

1.1 普通的多執行緒

在python中,threading模組提供執行緒的功能。通過它,我們可以輕易的在程式中建立多個執行緒。下面是個例子:

上述程式碼建立了10個“前臺”執行緒,然後控制器就交給了CPU,CPU根據指定演算法進行排程,分片執行指令。
下面是Thread類的主要方法:

  • start 執行緒準備就緒,等待CPU排程
  • setName 為執行緒設定名稱
  • getName 獲取執行緒名稱
  • setDaemon 設定為後臺執行緒或前臺執行緒(預設是False,前臺執行緒)
    如果是後臺執行緒,主執行緒執行過程中,後臺執行緒也在進行,主執行緒執行完畢後,後臺執行緒不論成功與否,均停止。如果是前臺執行緒,主執行緒執行過程中,前臺執行緒也在進行,主執行緒執行完畢後,等待前臺執行緒也執行完成後,程式停止。
  • join 該方法非常重要。它的存在是告訴主執行緒,必須在這個位置等待子執行緒執行完畢後,才繼續進行主執行緒的後面的程式碼。但是當setDaemon為True時,join方法是無效的。
  • run 執行緒被cpu排程後自動執行執行緒物件的run方法

1.2 自定義執行緒類

對於threading模組中的Thread類,本質上是執行了它的run方法。因此可以自定義執行緒類,讓它繼承Thread類,然後重寫run方法。

1.3 執行緒鎖

CPU執行任務時,線上程之間是進行隨機排程的,並且每個執行緒可能只執行n條程式碼後就轉而執行另外一條執行緒。由於在一個程式中的多個執行緒之間是共享資源和資料的,這就容易造成資源搶奪或髒資料,於是就有了鎖的概念,限制某一時刻只有一個執行緒能訪問某個指定的資料。

1.3.1 未使用鎖

上述程式碼執行後,結果如下:

由此可見,由於執行緒同時訪問一個資料,產生了錯誤的結果。為了解決這個問題,python在threading模組中定義了幾種執行緒鎖類,分別是:

  • Lock 普通鎖(不可巢狀)
  • RLock 普通鎖(可巢狀)常用
  • Semaphore 訊號量
  • event 事件
  • condition 條件
1.3.2 普通鎖Lock和RLock

類名:Lock或RLock
普通鎖,也叫互斥鎖,是獨佔的,同一時刻只有一個執行緒被放行。

以上是threading模組的Lock類,它不支援巢狀鎖。RLcok類的用法和Lock一模一樣,但它支援巢狀,因此我們一般直接使用RLcok類。

1.3.3 訊號量(Semaphore)

類名:BoundedSemaphore
這種鎖允許一定數量的執行緒同時更改資料,它不是互斥鎖。比如地鐵安檢,排隊人很多,工作人員只允許一定數量的人進入安檢區,其它的人繼續排隊。

1.3.4 事件(Event)

類名:Event
事件主要提供了三個方法 set、wait、clear。
事件機制:全域性定義了一個“Flag”,如果“Flag”的值為False,那麼當程式執行wait方法時就會阻塞,如果“Flag”值為True,那麼wait方法時便不再阻塞。這種鎖,類似交通紅綠燈(預設是紅燈),它屬於在紅燈的時候一次性阻擋所有執行緒,在綠燈的時候,一次性放行所有的排隊中的執行緒。
clear:將“Flag”設定為False
set:將“Flag”設定為True

1.3.5 條件(condition)

類名:Condition
該機制會使得執行緒等待,只有滿足某條件時,才釋放n個執行緒。

上面的例子,每輸入一次“yes”放行了一個執行緒。下面這個,可以選擇一次放行幾個執行緒。

1.3 全域性直譯器鎖(GIL)

既然介紹了多執行緒和執行緒鎖,那就不得不提及python的GIL,也就是全域性直譯器鎖。在程式語言的世界,python因為GIL的問題廣受詬病,因為它在直譯器的層面限制了程式在同一時間只有一個執行緒被CPU實際執行,而不管你的程式裡實際開了多少條執行緒。所以我們經常能發現,python中的多執行緒程式設計有時候效率還不如單執行緒,就是因為這個原因。那麼,對於這個GIL,一些普遍的問題如下:

  • 每種程式語言都有GIL嗎?

    以python官方Cpython直譯器為代表….其他語言好像未見。

  • 為什麼要有GIL?

    作為解釋型語言,Python的直譯器必須做到既安全又高效。我們都知道多執行緒程式設計會遇到的問題。直譯器要留意的是避免在不同的執行緒操作內部共享的資料。同時它還要保證在管理使用者執行緒時總是有最大化的計算資源。那麼,不同執行緒同時訪問時,資料的保護機制是怎樣的呢?答案是直譯器全域性鎖GIL。GIL對諸如當前執行緒狀態和為垃圾回收而用的堆分配物件這樣的東西的訪問提供著保護。

  • 為什麼不能去掉GIL?

    首先,在早期的python直譯器依賴較多的全域性狀態,傳承下來,使得想要移除當今的GIL變得更加困難。其次,對於程式設計師而言,僅僅是想要理解它的實現就需要對作業系統設計、多執行緒程式設計、C語言、直譯器設計和CPython直譯器的實現有著非常徹底的理解。
    在1999年,針對Python1.5,一個“freethreading”補丁已經嘗試移除GIL,用細粒度的鎖來代替。然而,GIL的移除給單執行緒程式的執行速度帶來了一定的負面影響。當用單執行緒執行時,速度大約降低了40%。雖然使用兩個執行緒時在速度上得到了提高,但這個提高並沒有隨著核數的增加而線性增長。因此這個補丁沒有被採納。
    另外,在python的不同直譯器實現中,如PyPy就移除了GIL,其執行速度更快(不單單是去除GIL的原因)。然而,我們通常使用的CPython佔有著統治地位的使用量,所以,你懂的。
    在Python 3.2中實現了一個新的GIL,並且帶著一些積極的結果。這是自1992年以來,GIL的一次最主要改變。舊的GIL通過對Python指令進行計數來確定何時放棄GIL。在新的GIL實現中,用一個固定的超時時間來指示當前的執行緒以放棄這個鎖。在當前執行緒保持這個鎖,且當第二個執行緒請求這個鎖的時候,當前執行緒就會在5ms後被強制釋放掉這個鎖(這就是說,當前執行緒每5ms就要檢查其是否需要釋放這個鎖)。當任務是可行的時候,這會使得執行緒間的切換更加可預測。

  • GIL對我們有什麼影響?

    最大的影響是我們不能隨意使用多執行緒。要區分任務場景。
    在單核cpu情況下對效能的影響可以忽略不計,多執行緒多程式都差不多。在多核CPU時,多執行緒效率較低。GIL對單程式和多程式沒有影響。

  • 在實際使用中有什麼好的建議?

    建議在IO密集型任務中使用多執行緒,在計算密集型任務中使用多程式。深入研究python的協程機制,你會有驚喜的。

更多的詳細介紹和說明請參考下面的文獻:
原文:Python’s Hardest Problem
譯文:Python 最難的問題

1.4 定時器(Timer)

定時器,指定n秒後執行某操作。很簡單但很使用的東西。

1.5 佇列

通常而言,佇列是一種先進先出的資料結構,與之對應的是堆疊這種後進先出的結構。但是在python中,它內建了一個queue模組,它不但提供普通的佇列,還提供一些特殊的佇列。具體如下:

  • queue.Queue :先進先出佇列
  • queue.LifoQueue :後進先出佇列
  • queue.PriorityQueue :優先順序佇列
  • queue.deque :雙向佇列
1.5.1 Queue:先進先出佇列

這是最常用也是最普遍的佇列,先看一個例子。

Queue類的引數和方法:

  • maxsize 佇列的最大元素個數,也就是queue.Queue(5)中的5。當佇列內的元素達到這個值時,後來的元素預設會阻塞,等待佇列騰出位置。
  • qsize() 獲取當前佇列中元素的個數,也就是佇列的大小
  • empty() 判斷當前佇列是否為空,返回True或者False
  • full() 判斷當前佇列是否已滿,返回True或者False
  • put(self, block=True, timeout=None)

    往佇列裡放一個元素,預設是阻塞和無時間限制的。如果,block設定為False,則不阻塞,這時,如果佇列是滿的,放不進去,就會彈出異常。如果timeout設定為n秒,則會等待這個秒數後才put,如果put不進去則彈出異常。

  • get(self, block=True, timeout=None)
    從佇列裡獲取一個元素。引數和put是一樣的意思。
  • join() 阻塞程式,直到所有任務完成,需要配合另一個方法task_done。
  • task_done() 表示某個任務完成。每一條get語句後需要一條task_done。
1.5.2 LifoQueue:後進先出佇列

類似於“堆疊”,後進先出。也較常用。

上述程式碼執行結果是:456

1.5.3 PriorityQueue:優先順序佇列

帶有權重的佇列,每個元素都是一個元組,前面的數字表示它的優先順序,數字越小優先順序越高,同樣的優先順序先進先出

1.5.4 deque:雙向佇列

Queue和LifoQueue的“綜合體”,雙向進出。方法較多,使用複雜,慎用!

1.6 生產者消費者模型

利用多執行緒和佇列可以搭建一個生產者消費者模型,用於處理大併發的服務。

在併發程式設計中使用生產者和消費者模式能夠解決絕大多數併發問題。該模式通過平衡生產執行緒和消費執行緒的工作能力來提高程式的整體處理資料的速度。

為什麼要使用生產者和消費者模式

線上程世界裡,生產者就是生產資料的執行緒,消費者就是消費資料的執行緒。在多執行緒開發當中,如果生產者處理速度很快,而消費者處理速度很慢,那麼生產者就必須等待消費者處理完,才能繼續生產資料。同樣的道理,如果消費者的處理能力大於生產者,那麼消費者就必須等待生產者。為了解決這個問題於是引入了生產者和消費者模式。

什麼是生產者消費者模式

生產者消費者模式是通過一個容器來解決生產者和消費者的強耦合問題。生產者和消費者彼此之間不直接通訊,而通過阻塞佇列來進行通訊,所以生產者生產完資料之後不用等待消費者處理,直接扔給阻塞佇列,消費者不找生產者要資料,而是直接從阻塞佇列裡取,阻塞佇列就相當於一個緩衝區,平衡了生產者和消費者的處理能力。

這個阻塞佇列就是用來給生產者和消費者解耦的。縱觀大多數設計模式,都會找一個第三者出來進行解耦,如工廠模式的第三者是工廠類,模板模式的第三者是模板類。在學習一些設計模式的過程中,如果先找到這個模式的第三者,能幫助我們快速熟悉一個設計模式。

以上摘自方騰飛的《聊聊併發——生產者消費者模式》

下面是一個簡單的廚師做包子,顧客吃包子的例子。

1.7 執行緒池

在使用多執行緒處理任務時也不是執行緒越多越好,由於在切換執行緒的時候,需要切換上下文環境,依然會造成cpu的大量開銷。為解決這個問題,執行緒池的概念被提出來了。預先建立好一個較為優化的數量的執行緒,讓過來的任務立刻能夠使用,就形成了執行緒池。在python中,沒有內建的較好的執行緒池模組,需要自己實現或使用第三方模組。下面是一個簡單的執行緒池:

上面的例子是把執行緒類當做元素新增到佇列內。實現方法比較糙,每個執行緒使用後就被拋棄,一開始就將執行緒開到滿,因此效能較差。下面是一個相對好一點的例子,在這個例子中,佇列裡存放的不再是執行緒物件,而是任務物件,執行緒池也不是一開始就直接開闢所有執行緒,而是根據需要,逐步建立,直至池滿。通過詳細的程式碼註釋,應該會有個清晰的理解。

二、程式

在python中multiprocess模組提供了Process類,實現程式相關的功能。但是,由於它是基於fork機制的,因此不被windows平臺支援。想要在windows中執行,必須使用if __name__ == '__main__:的方式,顯然這隻能用於除錯和學習,不能用於實際環境。
(PS:在這裡我必須吐槽一下python的包、模組和類的組織結構。在multiprocess中你既可以import大寫的Process,也可以import小寫的process,這兩者是完全不同的東西。這種情況在python中很多,新手容易傻傻分不清。)
下面是一個簡單的多程式例子,你會發現Process的用法和Thread的用法幾乎一模一樣。

2.1 程式的資料共享

每個程式都有自己獨立的資料空間,不同程式之間通常是不能共享資料,建立一個程式需要非常大的開銷。

執行上面的程式碼,你會發現列表list_1在各個程式中只有自己的資料,完全無法共享。想要程式之間進行資源共享可以使用queues/Array/Manager這三個multiprocess模組提供的類。

2.1.1 使用Array共享資料

對於Array陣列類,括號內的“i”表示它內部的元素全部是int型別,而不是指字元i,列表內的元素可以預先指定,也可以指定列表長度。概括的來說就是Array類在例項化的時候就必須指定陣列的資料型別和陣列的大小,類似temp = Array('i', 5)。對於資料型別有下面的表格對應:

‘c’: ctypes.c_char, ‘u’: ctypes.c_wchar,
‘b’: ctypes.c_byte, ‘B’: ctypes.c_ubyte,
‘h’: ctypes.c_short, ‘H’: ctypes.c_ushort,
‘i’: ctypes.c_int, ‘I’: ctypes.c_uint,
‘l’: ctypes.c_long, ‘L’: ctypes.c_ulong,
‘f’: ctypes.c_float, ‘d’: ctypes.c_double

2.1.2 使用Manager共享資料

Manager比Array要好用一點,因為它可以同時儲存多種型別的資料格式。

2.1.3 使用queues的Queue類共享資料

這裡就有點類似上面的佇列了。從執行結果裡,你還能發現資料共享中存在的髒資料問題。另外,比較悲催的是multiprocessing裡還有一個Queue,一樣能實現這個功能。

2.2 程式鎖

為了防止和多執行緒一樣的出現資料搶奪和髒資料的問題,同樣需要設定程式鎖。與threading類似,在multiprocessing裡也有同名的鎖類RLock, Lock, Event, Condition, Semaphore,連用法都是一樣樣的!(這個我喜歡)

2.3 程式池

既然有執行緒池,那必然也有程式池。但是,python給我們內建了一個程式池,不需要像執行緒池那樣需要自定義,你只需要簡單的from multiprocessing import Pool

程式池內部維護一個程式序列,當使用時,去程式池中獲取一個程式,如果程式池序列中沒有可供使用的程式,那麼程式就會等待,直到程式池中有可用程式為止。
程式池中有以下幾個主要方法:
apply:從程式池裡取一個程式並執行
apply_async:apply的非同步版本
terminate:立刻關閉程式池
join:主程式等待所有子程式執行完畢。必須在close或terminate之後。
close:等待所有程式結束後,才關閉程式池。

三、協程

執行緒和程式的操作是由程式觸發系統介面,最後的執行者是系統,它本質上是作業系統提供的功能。而協程的操作則是程式設計師指定的,在python中通過yield,人為的實現併發處理。
協程存在的意義:對於多執行緒應用,CPU通過切片的方式來切換執行緒間的執行,執行緒切換時需要耗時。協程,則只使用一個執行緒,分解一個執行緒成為多個“微執行緒”,在一個執行緒中規定某個程式碼塊的執行順序。
協程的適用場景:當程式中存在大量不需要CPU的操作時(IO)。
在不需要自己“造輪子”的年代,同樣有第三方模組為我們提供了高效的協程,這裡介紹一下greenlet和gevent。本質上,gevent是對greenlet的高階封裝,因此一般用它就行,這是一個相當高效的模組。
在使用它們之前,需要先安裝,可以通過原始碼,也可以通過pip。

3.1 greenlet

實際上,greenlet就是通過switch方法在不同的任務之間進行切換。

3.2 gevent

通過joinall將任務f和它的引數進行統一排程,實現單執行緒中的協程。程式碼封裝層次很高,實際使用只需要瞭解它的幾個主要方法即可。

相關文章