1. 程式人生 > >Python高階程式設計-多執行緒

Python高階程式設計-多執行緒

https://www.cnblogs.com/z-joshua/p/6409362.html

(一)程序執行緒概述:

很多同學都聽說過,現代作業系統比如Mac OS X,UNIX,Linux,Windows等,都是支援“多工”的作業系統。

什麼叫“多工”呢?簡單地說,就是作業系統可以同時執行多個任務。打個比方,你一邊在用瀏覽器上網,一邊在聽MP3,一邊在用Word趕作業,這就是多工,至少同時有3個任務正在執行。還有很多工悄悄地在後臺同時執行著,只是桌面上沒有顯示而已。

現在,多核CPU已經非常普及了,但是,即使過去的單核CPU,也可以執行多工。由於CPU執行程式碼都是順序執行的,那麼,單核CPU是怎麼執行多工的呢?

答案就是作業系統輪流讓各個任務交替執行,任務1執行0.01秒,切換到任務2,任務2執行0.01秒,再切換到任務3,執行0.01秒……這樣反覆執行下去。表面上看,每個任務都是交替執行的,但是,由於CPU的執行速度實在是太快了,我們感覺就像所有任務都在同時執行一樣。

真正的並行執行多工只能在多核CPU上實現,但是,由於任務數量遠遠多於CPU的核心數量,所以,作業系統也會自動把很多工輪流排程到每個核心上執行。

對於作業系統來說,一個任務就是一個程序(Process),比如開啟一個瀏覽器就是啟動一個瀏覽器程序,開啟一個記事本就啟動了一個記事本程序,開啟兩個記事本就啟動了兩個記事本程序,開啟一個Word就啟動了一個Word程序。

有些程序還不止同時幹一件事,比如Word,它可以同時進行打字、拼寫檢查、列印等事情。在一個程序內部,要同時幹多件事,就需要同時執行多個“子任務”,我們把程序內的這些“子任務”稱為執行緒(Thread)。

由於每個程序至少要幹一件事,所以,一個程序至少有一個執行緒。當然,像Word這種複雜的程序可以有多個執行緒,多個執行緒可以同時執行,多執行緒的執行方式和多程序是一樣的,也是由作業系統在多個執行緒之間快速切換,讓每個執行緒都短暫地交替執行,看起來就像同時執行一樣。當然,真正地同時執行多執行緒需要多核CPU才可能實現。

我們前面編寫的所有的Python程式,都是執行單任務的程序,也就是隻有一個執行緒。如果我們要同時執行多個任務怎麼辦?

有三種解決方案:

一種是啟動多個程序,每個程序雖然只有一個執行緒,但多個程序可以一塊執行多個任務。

還有一種方法是啟動一個程序,在一個程序內啟動多個執行緒,這樣,多個執行緒也可以一塊執行多個任務。

當然還有第三種方法,就是啟動多個程序,每個程序再啟動多個執行緒,這樣同時執行的任務就更多了

總結一下就是,多工的實現有3種方式:

  • 多程序模式;
  • 多執行緒模式;
  • 多程序+多執行緒模式。

執行緒是最小的執行單元,而程序由至少一個執行緒組成。如何排程程序和執行緒,完全由作業系統決定,程式自己不能決定什麼時候執行,執行多長時間。

多程序和多執行緒的程式涉及到同步、資料共享的問題,編寫起來更復雜。

關於程序和執行緒,大家總結一句話是“程序是作業系統分配資源的最小單元,執行緒是作業系統排程的最小單元”。

程序是具有一定獨立功能的程式關於某個資料集合上的一次執行活動,程序是系統進行資源分配和排程的一個獨立單位.
執行緒是程序的一個實體,是CPU排程和分派的基本單位,它是比程序更小的能獨立執行的基本單位.執行緒自己基本上不擁有系統資源,只擁有一點在執行中必不可少的資源(如程式計數器,一組暫存器和棧),但是它可與同屬一個程序的其他的執行緒共享程序所擁有的全部資源.
一個執行緒可以建立和撤銷另一個執行緒;同一個程序中的多個執行緒之間可以併發執行.

程序和執行緒的主要差別在於它們是不同的作業系統資源管理方式。程序有獨立的地址空間,一個程序崩潰後,在保護模式下不會對其它程序產生影響,而執行緒只是一個程序中的不同執行路徑。執行緒有自己的堆疊和區域性變數,但執行緒之間沒有單獨的地址空間,一個執行緒死掉就等於整個程序死掉,所以多程序的程式要比多執行緒的程式健壯,但在程序切換時,耗費資源較大,效率要差一些。但對於一些要求同時進行並且又要共享某些變數的併發操作,只能用執行緒,不能用程序。

 

(二)多執行緒程式設計

多工可以由多程序完成,也可以由一個程序內的多執行緒完成。

我們前面提到了程序是由若干執行緒組成的,一個程序至少有一個執行緒。

由於執行緒是作業系統直接支援的執行單元,因此,高階語言通常都內建多執行緒的支援,Python也不例外,並且,Python的執行緒是真正的Posix Thread,而不是模擬出來的執行緒。

Python的標準庫提供了兩個模組:_threadthreading_thread是低階模組,threading是高階模組,對_thread進行了封裝。絕大多數情況下,我們只需要使用threading這個高階模組。

(1)啟動一個執行緒就是把一個函式傳入並建立Thread例項,然後呼叫start()開始執行:

複製程式碼
 1 def loop(x):
 2     print("%s start" % threading.current_thread().name)
 3     for i in range(x):
 4         time.sleep(1)
 5         print("%s:%d" % (threading.current_thread().name, i))
 6     print("%s stop" % threading.current_thread().name)
 7 
 8 
 9 print("%s start" % threading.current_thread().name)
10 t1 = threading.Thread(target=loop, args=(6,))
11 t1.start()
12 print("%s stop" % threading.current_thread().name)
複製程式碼

程式中為Thread類建立了一個例項t1,傳入的引數是函式名loop以及loop函式的引數列表,利用threading.current_thread()返回當前執行的執行緒例項,程式執行的結果如下:

 

複製程式碼
 1 MainThread start
 2 Thread-1 start
 3 MainThread stop
 4 Thread-1:0
 5 Thread-1:1
 6 Thread-1:2
 7 Thread-1:3
 8 Thread-1:4
 9 Thread-1:5
10 Thread-1 stop
複製程式碼

 

我們從執行結果看到,主執行緒MainThread先於子執行緒Thread-1退出

多執行緒執行過程如下圖:

如果我們希望主執行緒等待子執行緒呢?下面看看join()方法的效果:

(2)join()

複製程式碼
 1 import threading
 2 import time
 3 
 4 
 5 def loop(x):
 6     print("%s start" % threading.current_thread().name)
 7     for i in range(x):
 8         time.sleep(1)
 9         print("%s:%d" % (threading.current_thread().name, i))
10     print("%s stop" % threading.current_thread().name)
11 
12 
13 print("%s start" % threading.current_thread().name)
14 t1 = threading.Thread(target=loop, args=(6,))
15 t1.start()
16 t1.join()
17 print("%s stop" % threading.current_thread().name)
複製程式碼

我們在上面的程式碼段內加入了 t1.join() ,看看執行效果:

複製程式碼
 1 MainThread start
 2 Thread-1 start
 3 Thread-1:0
 4 Thread-1:1
 5 Thread-1:2
 6 Thread-1:3
 7 Thread-1:4
 8 Thread-1:5
 9 Thread-1 stop
10 MainThread stop
複製程式碼

從上面的執行結果看,MainThread在join之後一直停在join的地方,等待子執行緒Thread-1退出後才繼續執行下去。

 

假如我們希望主執行緒退出的時候,不管子執行緒執行到哪裡,強行讓子執行緒退出呢?我們有 setDaemon(True) 方法:

(3)setDaemon()

複製程式碼
 1 import threading
 2 import time
 3 
 4 
 5 def loop(x):
 6     print("%s start" % threading.current_thread().name)
 7     for i in range(x):
 8         time.sleep(1)
 9         print("%s:%d" % (threading.current_thread().name, i))
10     print("%s stop" % threading.current_thread().name)
11 
12 
13 print("%s start" % threading.current_thread().name)
14 t1 = threading.Thread(target=loop, args=(6,))
15 t1.setDaemon(True)
16 t1.start()
17 print("%s stop" % threading.current_thread().name)
複製程式碼

程式執行結果:

1 MainThread start
2 Thread-1 start
3 MainThread stop

我們看到主執行緒一旦退出,子執行緒也停止了,需要注意的是 setDaemon(True) 在 start() 之前

 

 (4)lock

多執行緒和多程序最大的不同在於,多程序中,同一個變數,各自有一份拷貝存在於每個程序中,互不影響,而多執行緒中,所有變數都由所有執行緒共享,所以,任何一個變數都可以被任何一個執行緒修改,因此,執行緒之間共享資料最大的危險在於多個執行緒同時改一個變數,把內容給改亂了。

來看看多個執行緒同時操作一個變數怎麼把內容給改亂了:

複製程式碼
 1 import threading
 2 
 3 deposit = 0 # 銀行存款
 4 
 5 
 6 def change_it(n):
 7     global deposit
 8     deposit = deposit + n  #
 9     deposit = deposit - n  #
10 
11 
12 def loop(n):
13     for i in range(100000):
14         change_it(n)
15 
16 t1 = threading.Thread(target=loop, args=(5,))
17 t2 = threading.Thread(target=loop, args=(8,))
18 t1.start()
19 t2.start()
20 t1.join()
21 t2.join()
22 print(deposit)
複製程式碼

我們定義了一個共享變數deposit,初始值為0,並且啟動兩個執行緒,先存後取,理論上結果應該為0,但是,由於執行緒的排程是由作業系統決定的,當t1、t2交替執行時,只要迴圈次數足夠多,deposit的結果就不一定是0了(執行的結果不定,有時候是0,有時候是5,8,-8,-3等),deposit值的偏差隨著loop裡迴圈的次數增加。

原因是因為高階語言的一條語句在CPU執行時是若干條語句,即使一個簡單的計算:

 deposit = deposit + n 

也分兩步:

  1. 計算deposit + n,存入臨時變數中;
  2. 將臨時變數的值賦給deposit。

上面的語句等價於:

temp = deposit + n
deposit = temp

(嘗試將語句改為 deposit += n,發現結果總是0,說明 +=是一個原子操作)

由於temp是區域性變數,兩個執行緒各自都有自己的temp,當代碼正常執行時:

複製程式碼
1 # 正常執行過程:
2 # t1: temp1 = deposit + 5  # temp1 = 0 + 5 = 5
3 # t1: deposit = temp1      # deposit = 5
4 # t1: temp1 = deposit - 5  # temp1 = 5 - 5 = 0
5 # t1: deposit =  temp1     # deposit = 0
6 # t2: temp2 = deposit + 8  # temp2 = 0 + 8 = 8
7 # t2: deposit = temp2      # deposit = 8
8 # t2: temp2 = deposit - 8  # temp2 = 8 - 8 = 0
9 # t2: deposit =  temp2     # deposit = 0
複製程式碼

但是t1和t2是交替執行的,如果作業系統以下面的順序執行t1、t2:

複製程式碼
1 # 多執行緒沒有加鎖可能的情況:
2 # t1: temp1 = deposit + 5  # temp1 = 0 + 5 = 5
3 # t2: temp2 = deposit + 8  # temp2 = 0 + 8 = 8
4 # t2: deposit = temp2      # deposit = 8
5 # t1: deposit = temp1      # deposit = 5
6 # t1: temp1 = deposit - 5  # temp1 = 5 - 5 = 0
7 # t1: deposit =  temp1     # deposit = 0
8 # t2: temp2 = deposit - 8  # temp2 = 0 - 8 = -8
9 # t2: deposit =  temp2     # deposit = -8
複製程式碼

究其原因,是因為修改deposit需要多條語句,而執行這幾條語句時,執行緒可能中斷,從而導致多個執行緒把同一個物件的內容改亂了。

兩個執行緒同時一存一取,就可能導致餘額不對,你肯定不希望你的銀行存款莫名其妙地變成了負數,所以,我們必須確保一個執行緒在修改deposit的時候,別的執行緒一定不能改

如果我們要確保deposit計算正確,就要給change_it()上一把鎖,當某個執行緒開始執行change_it()時,我們說,該執行緒因為獲得了鎖,因此其他執行緒不能同時執行change_it(),只能等待,直到鎖被釋放後,獲得該鎖以後才能改。由於鎖只有一個,無論多少執行緒,同一時刻最多隻有一個執行緒持有該鎖,所以,不會造成修改的衝突。建立一個鎖就是通過threading.Lock()來實現:

複製程式碼
 1 import threading
 2 
 3 deposit = 0 # 銀行存款
 4 lock_deposit = threading.Lock()
 5 
 6 
 7 def change_it(n):
 8     global deposit
 9     deposit += deposit  #
10     deposit -= deposit  #
11 
12 
13 def loop(n):
14     for i in range(1000000):
15         lock_deposit.acquire()  # 先獲取鎖
16         try:
17             change_it(n)
18         finally:
19             lock_deposit.release()  # 確保釋放鎖
20 
21 t1 = threading.Thread(target=loop, args=(5,))
22 t2 = threading.Thread(target=loop, args=(8,))
23 t1.start()
24 t2.start()
25 t1.join()
26 t2.join()
27 print(deposit)
複製程式碼

當多個執行緒同時執行lock.acquire()時,只有一個執行緒能成功地獲取鎖,然後繼續執行程式碼,其他執行緒就繼續等待直到獲得鎖為止。

獲得鎖的執行緒用完後一定要釋放鎖,否則那些苦苦等待鎖的執行緒將永遠等待下去,成為死執行緒。所以我們用try...finally來確保鎖一定會被釋放。

鎖的好處就是確保了某段關鍵程式碼只能由一個執行緒從頭到尾完整地執行,壞處當然也很多,首先是阻止了多執行緒併發執行,包含鎖的某段程式碼實際上只能以單執行緒模式執行,效率就大大地下降了。其次,由於可以存在多個鎖,不同的執行緒持有不同的鎖,並試圖獲取對方持有的鎖時,可能會造成死鎖,導致多個執行緒全部掛起,既不能執行,也無法結束,只能靠作業系統強制終止。

 

(5)全域性直譯器鎖(Global Interpreter Lock):

如果你不幸擁有一個多核CPU,你肯定在想,多核應該可以同時執行多個執行緒。

如果寫一個死迴圈的話,會出現什麼情況呢?

開啟Mac OS X的Activity Monitor,或者Windows的Task Manager,都可以監控某個程序的CPU使用率。

我們可以監控到一個死迴圈執行緒會100%佔用一個CPU。

如果有兩個死迴圈執行緒,在多核CPU中,可以監控到會佔用200%的CPU,也就是佔用兩個CPU核心。

要想把N核CPU的核心全部跑滿,就必須啟動N個死迴圈執行緒。

試試用Python寫個死迴圈,啟動與CPU核心數量相同的N個執行緒,在4核CPU上可以監控到CPU佔用率僅有102%,也就是僅使用了一核。

複製程式碼
 1 import multiprocessing
 2 
 3 
 4 def loop():
 5     x = 0
 6     while True:
 7         x = x ^ 1
 8 
 9 for i in range(multiprocessing.cpu_count()):
10     t = threading.Thread(target=loop)
11     t.start()
複製程式碼

但是用C、C++或Java來改寫相同的死迴圈,直接可以把全部核心跑滿,4核就跑到400%,8核就跑到800%,為什麼Python不行呢?

因為Python的執行緒雖然是真正的執行緒,但直譯器執行程式碼時,有一個GIL鎖:Global Interpreter Lock,任何Python執行緒執行前,必須先獲得GIL鎖,然後,每執行100條位元組碼,直譯器就自動釋放GIL鎖,讓別的執行緒有機會執行。這個GIL全域性鎖實際上把所有執行緒的執行程式碼都給上了鎖,所以,多執行緒在Python中只能交替執行,即使100個執行緒跑在100核CPU上,也只能用到1個核。

GIL是Python直譯器設計的歷史遺留問題,通常我們用的直譯器是官方實現的CPython,要真正利用多核,除非重寫一個不帶GIL的直譯器。

所以,在Python中,可以使用多執行緒,但不要指望能有效利用多核。如果一定要通過多執行緒利用多核,那隻能通過C擴充套件來實現,不過這樣就失去了Python簡單易用的特點。

不過,也不用過於擔心,Python雖然不能利用多執行緒實現多核任務,但可以通過多程序實現多核任務。多個Python程序有各自獨立的GIL鎖,互不影響。

 

(6)自定義Thread類: