1. 程式人生 > >Python | 面試必問,執行緒與程序的區別,Python中如何建立多執行緒?

Python | 面試必問,執行緒與程序的區別,Python中如何建立多執行緒?

本文始發於個人公眾號:**TechFlow**,原創不易,求個關注

今天是Python專題第20篇文章,我們來聊聊Python當中的多執行緒。

其實關於元類還有很多種用法,比如說如何在元類當中設定引數啦,以及一些規約的用法等等。只不過這些用法比較小眾,使用頻率非常低,所以我們不過多闡述了,可以在用到的時候再去詳細瞭解。我想只要大家理解了元類的原理以及使用方法,再去學習那些具體的用法應該會很容易。所以我們今天開始了一個新的話題——多執行緒和併發。

程序和執行緒

為了照顧小白,我們來簡單聊聊程序和執行緒這兩個概念。這兩個概念屬於作業系統,我們經常聽說,但是可能很少有人會細究它們的含義。對於工程師而言,兩者的定義和區別還是很有必要了解清楚的。

首先說程序,程序可以看成是CPU執行的具體的任務。在作業系統當中,由於CPU的執行速度非常快,要比計算機當中的其他裝置要快得多。比如記憶體、磁碟等等,所以如果CPU一次只執行一個任務,那麼會導致CPU大量時間在等待這些裝置,這樣操作效率很低。為了提升計算機的執行效率,把機器的技能儘可能壓榨出來,CPU是輪詢工作的。也就是說它一次只執行一個任務,執行一小段碎片時間之後立即切換,去執行其他任務。

所以在早期的單核機器的時候,看起來電腦也是併發工作的。我們可以一邊聽歌一邊上網,也不會覺得卡頓。但實際上,這是CPU輪詢的結果。在這個例子當中,聽歌的軟體和上網的軟體對於CPU而言都是獨立的程序。我們可以把程序簡單地理解成執行的應用,比如在安卓手機裡面,一個app啟動的時候就會對應系統中的一個程序。當然這種說法不完全準確,一個應用也是可以啟動多個程序的。

程序是對應CPU而言的,執行緒則更多針對的是程式。即使是CPU在執行當前程序的時候,程式執行的任務其實也是有分工的。舉個例子,比如聽歌軟體當中,我們需要顯示歌詞的字幕,需要播放聲音,需要監聽使用者的行為,比如是否發生了切歌、調節音量等等。所以,我們需要進一步拆分CPU的工作,讓它在執行當前程序的時候,繼續通過輪詢的方式來同時做多件事情。

程序中的任務就是執行緒,所以從這點上來說,程序和執行緒是包含關係。一個程序當中可以包含多個執行緒,對於CPU而言,不能直接執行執行緒,一個執行緒一定屬於一個程序。所以我們知道,CPU程序切換切換的是執行的應用程式或者是軟體,而程序內部的執行緒切換,切換的是軟體當中具體的執行任務。

關於程序和執行緒有一個經典的模型可以說明它們之間的關係,假設CPU是一家工廠,工廠當中有多個車間。不同的車間對應不同的生產任務,有的車間生產汽車輪胎,有的車間生產汽車骨架。但是工廠的電力是有限的,同時只能滿足一個廠房的使用。

為了讓大家的進度協調,所以工廠個需要輪流提供各個車間的供電。這裡的車間對應的就是程序。

一個車間雖然只生產一種產品,但是其中的工序卻不止一個。一個車間可能會有好幾條流水線,具體的生產任務其實是流水線完成的,每一條流水線對應一個具體執行的任務。但是同樣的,車間同一時刻也只能執行一條流水線,所以我們需要車間在這些流水線之間切換供電,讓各個流水線生產進度統一。

這裡車間裡的流水線自然對應的就是執行緒的概念,這個模型很好地詮釋了CPU、程序和執行緒之間的關係。實際的原理也的確如此,不過CPU中的情況要比現實中的車間複雜得多。因為對於程序和CPU來說,它們面臨的局面都是實時變化的。車間當中的流水線是x個,下一刻可能就成了y個。

瞭解完了執行緒和程序的概念之後,對於理解電腦的配置也有幫助。比如我們買電腦,經常會碰到一個術語,就是這個電腦的CPU是某某核某某執行緒的。比如我當年買的第一臺筆記本是4核8執行緒的,這其實是在說這臺電腦的CPU有4個計算核心,但是使用了超執行緒技術,使得可以把一個物理核心模擬成兩個邏輯核心。相當於我們可以用4個核心同時執行8個執行緒,相當於8個核心同時執行,但其實有4個核心是模擬出來的虛擬核心。

有一個問題是為什麼是4核8執行緒而不是4核8程序呢?因為CPU並不會直接執行程序,而是執行的是程序當中的某一個執行緒。就好像車間並不能直接生產零件,只有流水線才能生產零件。車間負責的更多是資源的調配,所以教科書裡有一句非常經典的話來詮釋:程序是資源分配的最小單元,執行緒是CPU排程的最小單元。

啟動執行緒

Python當中為我們提供了完善的threading庫,通過它,我們可以非常方便地建立執行緒來執行多執行緒。

首先,我們引入threading中的Thread,這是一個執行緒的類,我們可以通過建立一個執行緒的例項來執行多執行緒。

from threading import Thread
t = Thread(target=func, name='therad', args=(x, y))
t.start()

簡單解釋一下它的用法,我們傳入了三個引數,分別是target,name和args,從名字上我們就可以猜測出它們的含義。首先是target,它傳入的是一個方法,也就是我們希望多執行緒執行的方法。name是我們為這個新建立的執行緒起的名字,這個引數可以省略,如果省略的話,系統會為它起一個系統名。當我們執行Python的時候啟動的執行緒名叫MainThread,通過執行緒的名字我們可以做區分。args是會傳遞給target這個函式的引數。

我們來舉個經典的例子:

import time, threading

# 新執行緒執行的程式碼:
def loop(n):
    print('thread %s is running...' % threading.current_thread().name)
    for i in range(n):
        print('thread %s >>> %s' % (threading.current_thread().name, i))
        time.sleep(5)
    print('thread %s ended.' % threading.current_thread().name)

print('thread %s is running...' % threading.current_thread().name)
t = threading.Thread(target=loop, name='LoopThread', args=(10, ))
t.start()
print('thread %s ended.' % threading.current_thread().name)

我們建立了一個非常簡單的loop函式,用來執行一個迴圈來列印數字,我們每次列印一個數字之後這個執行緒會睡眠5秒鐘,所以我們看到的結果應該是每過5秒鐘螢幕上多出一行數字。

我們在Jupyter裡執行一下:

表面上看這個結果沒毛病,但是其實有一個問題,什麼問題呢?輸出的順序不太對,為什麼我們在列印了第一個數字0之後,主執行緒就結束了呢?另外一個問題是,既然主執行緒已經結束了,為什麼Python程序沒有結束, 還在向外列印結果呢?

因為執行緒之間是獨立的,對於主執行緒而言,它在執行了t.start()之後,並不會停留,而是會一直往下執行一直到結束。如果我們不希望主執行緒在這個時候結束,而是阻塞等待子執行緒執行結束之後再繼續執行,我們可以在程式碼當中加上t.join()這一行來實現這點。

t.start()
t.join()
print('thread %s ended.' % threading.current_thread().name)

join操作可以讓主執行緒在join處掛起等待,直到子執行緒執行結束之後,再繼續往下執行。我們加上了join之後的執行結果是這樣的:

這個就是我們預期的樣子了,等待子執行緒執行結束之後再繼續。

我們再來看第二個問題,為什麼主執行緒結束的時候,子執行緒還在繼續執行,Python程序沒有退出呢?這是因為預設情況下我們建立的都是使用者級執行緒,對於程序而言,會等待所有使用者級執行緒執行結束之後才退出。這裡就有了一個問題,那假如我們建立了一個執行緒嘗試從一個介面當中獲取資料,由於介面一直沒有返回,當前程序豈不是會永遠等待下去?

這顯然是不合理的,所以為了解決這個問題,我們可以把創建出來的執行緒設定成守護執行緒。

守護執行緒

守護執行緒即daemon執行緒,它的英文直譯其實是後臺駐留程式,所以我們也可以理解成後臺執行緒,這樣更方便理解。daemon執行緒和使用者執行緒級別不同,程序不會主動等待daemon執行緒的執行,當所有使用者級執行緒執行結束之後即會退出。程序退出時會kill掉所有守護執行緒。

我們傳入daemon=True引數來將創建出來的執行緒設定成後臺執行緒:

t = threading.Thread(target=loop, name='LoopThread', args=(10, ), daemon=True)

這樣我們再執行看到的結果就是這樣了:

這裡有一點需要注意,如果你在jupyter當中執行是看不到這樣的結果的。因為jupyter自身是一個程序,對於jupyter當中的cell而言,它一直是有使用者級執行緒存活的,所以程序不會退出。所以想要看到這樣的效果,只能通過命令列執行Python檔案。

如果我們想要等待這個子執行緒結束,就必須通過join方法。另外,為了預防子執行緒鎖死一直無法退出的情況, 我們還可以在joih當中設定timeout,即最長等待時間,當等待時間到達之後,將不再等待。

比如我在join當中設定的timeout等於5時,螢幕上就只會輸出5個數字。

另外,如果沒有設定成後臺執行緒的話,設定timeout雖然也有用,但是程序仍然會等待所有子執行緒結束。所以螢幕上的輸出結果會是這樣的:

雖然主執行緒繼續往下執行並且結束了,但是子執行緒仍然一直執行,直到子執行緒也執行結束。

關於join設定timeout這裡有一個坑,如果我們只有一個執行緒要等待還好,如果有多個執行緒,我們用一個迴圈將它們設定等待的話。那麼主執行緒一共會等待N * timeout的時間,這裡的N是執行緒的數量。因為每個執行緒計算是否超時的開始時間是上一個執行緒超時結束的時間,它會等待所有執行緒都超時,才會一起終止它們。

比如我這樣建立3個執行緒:

ths = []
for i in range(3):
    t = threading.Thread(target=loop, name='LoopThread' + str(i), args=(10, ), daemon=True)
    ths.append(t)


for t in ths:
    t.start()


for t in ths:
    t.join(2)

最後螢幕上輸出的結果是這樣的:

所有執行緒都存活了6秒,不得不說,這個設計有點坑,和我們預想的完全不一樣。

總結

在今天的文章當中,我們一起簡單瞭解了作業系統當中執行緒和程序的概念,以及Python當中如何建立一個執行緒,以及關於建立執行緒之後的相關使用。今天介紹的只是最基礎的使用和概念,關於執行緒還有很多高階的用法,我們將在後續的文章當中和大家分享。

多執行緒在許多語言當中都是至關重要的,許多場景下必定會使用到多執行緒。比如web後端,比如爬蟲,再比如遊戲開發以及其他所有需要涉及開發ui介面的領域。因為凡是涉及到ui,必然會需要一個執行緒單獨渲染頁面,另外的執行緒負責準備資料和執行邏輯。因此,多執行緒是專業程式設計師繞不開的一個話題,也是一定要掌握的內容之一。

今天的文章就到這裡,如果喜歡本文,可以的話,請點個關注,給我一點鼓勵,也方便獲取更多文章。

本文使用 mdnice 排版

![](https://user-gold-cdn.xitu.io/2020/7/6/173223c4a0fa40d1?w=258&h=258&f=png&