Python高級編程-多線程


(一)進程線程概述:

很多同學都聽說過,現代操作系統比如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類:

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM