天天看點

萬字長文,帶你了解多線程與多程序

全局解釋器鎖 (英語:global interpreter lock,縮寫 gil) 是 計算機程式設計語言解釋器 用于 同步線程 的一種機制,它使得任何時刻僅有 一個線程 在執行,即便在 多核心處理器 上,使用 gil 的解釋器也隻允許同一時間執行一個線程。常見的使用 gil 的解釋器有 cpython 與 ruby mri。

如果,你對上面的不了解,也沒有問題。通俗的解釋就是:你電腦是 一核或者多核 ,還是你得代碼寫了了多個線程,但因為 gil 鎖的存在你也就隻能運作一個線程,無法同時運作多個線程。

接下來,我們來用個圖檔來解釋一下:

萬字長文,帶你了解多線程與多程式

比如圖中,假如你開了兩個線程(py thread1 、py tread2),

當我們線程一(py thread1)開始執行時,這個線程會去我們的解釋器中申請到一個鎖。也就是我們的 gil 鎖;

然後,解釋器接收到一個請求的時候呢,它就會到我們的 os 裡面,申請我們的系統線程;

系統統一你的線程執行的時候,就會在你的 cpu 上面執行。(假設你現在是四核cpu);

而我們的另一個線程二(py thread2)也在同步運作。

而線程二在向這個解釋器申請 gil 的時候線程二會卡在這裡(python 解釋器),因為它的 gil 鎖已經被線程一給拿走了(也就是說:他要進去執行,必須拿到這把鎖);

線程二要運作的話,就必須等我們的線程一運作完成之後(也就是把我們的 gil 釋放之後(圖檔中的第5步)線程二才能拿到這把鎖);

當線程二拿到這把鎖之後就和線程一的運作過程一樣。

這個鎖其實是 python 之父想一勞永逸解決線程的安全問題(也就是禁止多線程同時運作)

為了更加直覺,我這裡使用把每種線程代碼單獨寫出來并做對比:

單線程裸奔:(這也是一個主線程(main thread))

輸出:

注意:因為每台電腦的性能不一樣,所運作的結果也相對不同(請按實際情況分析)

接下來我們寫一個多線程

我們先建立個字典 (thread_name_time) 來存儲我們每個線程的名稱與對應的時間

輸出

我們可以看到,速度上的差別不大。 多線程并發不如單線程順序執行快 這是得不償失的 造成這種情況的原因就是 gil 這裡是計算密集型,是以不适用

在我們執行加減乘除或者圖像處理的時候,都是在從 cpu 上面執行才可以。python 因為 gil 存在,同一時期肯定隻有一個線程在執行,這樣這樣就是造成我們開是個線程和一個線程沒有太大差別的原因。

而我們的網絡爬蟲大多時候是屬于 io 密集與計算機密集

[外鍊圖檔轉存失敗,源站可能有防盜鍊機制,建議将圖檔儲存下來直接上傳(img-iciacnqy-1583392394098)(assets/1571801967486.png)]

bios:b:base、i:input、o:output、s:system

也就是你電腦一開機的時候就會啟動。

1. 計算密集型

在上面的時候,我們開啟了兩個線程,如果這兩個線程要同時執行,那同一時期 cpu 上隻有一個線程在執行。 那從上圖可知,那這兩個線程就需要頻繁的在上下文切換。 ps:我們這個綠色表示我們這個線程正在執行,紅色代表阻塞。 是以,我們可以明顯的觀察到,線程的上下文切換也是需要消耗資源的(時間-ms)不斷的歸還和拿取 gil 等,切換上下文。明顯造成很大的資源浪費。

2. io 密集型

我們現在假設,有個伺服器程式(socket)也就是我們新開的一個程式(也就是我們網絡爬蟲的最底層)開始爬取目标網頁了,我們那個網頁呢,有兩個線程同時運作,我們線程二已經請求成功開始運作了,也就是上圖的 (thread 2)綠色一條路過去。 而我們的線程一(thread 1)- datagram(這裡它開啟了一個 udp),然後等待資料建立(也就是等待哪些 html、css 等資料傳回)也就是說,在 **ready to receive(recvfrom)**之間都是準備階段。這樣就是有一段時間一直阻塞,而我們的線程二可以一直無停歇也不用切換上下文就一直在運作。這樣的 io 密集型就有很大的好處。

這裡我們需要注意的是,我們的多線程是運作在 io 密集型上的,我們得區厘清楚。

還有就是,資源等待,比如有時候我們使用浏覽器發起了一個 get 請求,那浏覽器圖示上面在轉圈圈的時候就是我們請求資源等待的時間,(也就是圖上面的 datagram 到 ready to receive )資料建立到資料接收(就是轉圈圈的時間)。我們完全就不需要執行它,就讓它等待就好。這個時候讓另一個線程去執行就好

換言之就是:第一個線程,我們爬取那個網頁轉圈圈的時候讓另一個線程繼續爬取。這樣就避免了資源浪費。(把時間都利用起來)

注意: 請求資源是不需要 cpu 進行計算的,cpu 參與是很少的,而我們第一個例子,計算數字的 for 循環中,是需要 cpu 進行計算的。

[外鍊圖檔轉存失敗,源站可能有防盜鍊機制,建議将圖檔儲存下來直接上傳(img-g9ah2mzv-1583392394099)(assets/1571888520939.png)]

前面開頭已經提到:因為 gil 的存在,是以不管我們開了多少線程,同一時間始終隻有一個線程在執行。那我們該如何避免 gil 呢?

那這樣的話,我們不開線程不就行,(它的的存在已經無法避免,那我們選擇不使用它不就相當于不存在嘛)。那這是,你會想:那不開線程我們開啥呢?

問的好!

我們來開:程序,那怎麼說?别急!請聽我細細道來。

比方你有 3 個 cpu(當然,你可能有更多,這裡就按 3 個 cpu來為例子),那我們就開 3 個程序就好。一個 cpu 上運作就好。

ps:我們的程序是可以同時運作的。

我們可以看一下下面的圖檔:

任務管理器

[外鍊圖檔轉存失敗,源站可能有防盜鍊機制,建議将圖檔儲存下來直接上傳(img-6ad47aid-1583392394100)(assets/1571966626435.png)]

我們 任務管理 上的每一項都是一個程序。

多程序比多線程不好的地方是什麼呢?

多程序的建立和銷毀開銷也會更大,成本高。 你可能線程可以開許多的線程,但你的程序就是看你的 cpu 數量。
程序間無法看到對方資料,需要使用棧或者隊列進行擷取。 每個程序之間都是獨立的。 就好像我們上面的谷歌浏覽器和我們的 pycharm 是沒有任何關系的,谷歌浏覽器上面的資料肯定不可能讓 pycharm 看到。這就是我們所說的程序之間的獨立性。 如果你想要一個進行抓取資料,一個進行調用資料,那這時是不能直接調用的,需要你自己定義個結構才能使用。>>> 程式設計複雜度提升。

前面的基礎講完了,接下來我們繼續來正式進入主題。

如果有參數的話,我們就對多線程參數進行傳參數。代碼示例:

解析:

我認認真看一下我們的運作結果,

start

stop

my first thread

true

2968

我們會發現并不是按我們正常的邏輯執行這一系列的代碼。

而是,先執行完 start 然後就直接 stop 然後才會執行我們函數的其他三項。

一個線程它就直接貫穿到底了。也就是先把我們主線程裡面的代碼運作完,然後才會運作它裡面的代碼。

我們的代碼并不是當代碼執行到 thread.start() 等它執行完再執行 print(‘stop’) 。而是,我們線程執行到thread.start() 繼續向下執行,同時再執行裡面的代碼(也就是**start()**函數裡面的代碼)。(不會卡在 thread.start() 那裡) 也不會随着主線程結束而結束

因為,程式在執行到 print(‘stop’) 之後就是主線程結束,而裡面開的線程是我們自己開的。當我們主線程執行這個 stop 就已經結束了。 這種不會随着主線程結束而銷毀的,這種線程它叫做:非守護線程

主線程會跳過建立的線程繼續執行;

直到建立線程運作完畢;

程式結束;

既然,有非守護線程。那就還有守護線程。

如果要修改成守護線程,那你就得在 thread.start() 前面加一個:

需要在我們啟動之前設定。

我們來看看運作的結果

我們可以看見,程式直接運作:start、stop,執行到 **print(‘stop’) 它就結束了。**也就随着我們的主線程結束而結束。并不管它裡面還有什麼沒有執行完。(也不會管他裡面的 time.sleep())我們的主線程一結束,我們的守護線程就會随着主線程一起銷毀。

我們日常啟動的是非守護線程,守護線程用的較少。

守護線程會伴随主線程一起結束,setdaemon 設定為 true 即可。

學員問題:任務管理器上面超過五六個程序。都是程序的話,怎麼能開那麼多呢? 答:我們一個 cpu 不止能執行一個程序,就比如我的一個 cpu 裡面密麻麻有許多程序。(比方我現在開六個程序)并發執行的。隻不過計算機執行的速度非常快,這裡我簡單講一下哈。這是計算機原理的課。 不管是任何作業系統,現在就拿單核作業系統來說:我們假設現在隻有一個 cpu ,一個 cpu 裡面六個程序,同一時間它隻有一個程序在運作。不過我們計算執行速度非常快,這個程式執行完,它就會執行一個上下文切換,執行下一個。(因為,它執行的速度非常快,你就會感覺是并發執行一樣。) 實際上,一個 cpu 同一時間隻有一個程序在執行,一個程序裡面它隻有一個線程在執行。(當然,這個單核是五六年前了。現在肯定至少有雙核。 那就說有第二個 cpu 了。 而第二個和 cpu 上面又有許多個 程序,兩個 cpu 是互不相幹。 那這時候,第一個 cpu 上面運作一個程序,而我們的第二個 cpu 上面也有一個程序,兩個是互補相幹。 (就相當于你開了兩台電腦。) 但是同一個 cpu 在同一時間隻有一個就程序。(不管你(電腦)速度多麼快,實際上本質上(在那一秒)隻有一個程序在執行。如果你是雙核,那就有兩個程序。(四核就有四個程序)

python 有個不好的地方,剛剛上面講到,如果我們有兩個 cpu 那就有兩個程序在執行(那四個 cpu 就是四個程序在執行),**但是因為 python 當中存在着 gil,它即使有四個 cpu 每次也隻有一個線程能進去,**也就是說:同一時間當中,一個 cpu 上的一個程序中的一個線程在執行。剩下的都不能運作,我們的 python 不能利用多核。

如果,大家用的是 c、java、go 這種的就沒有這個說法了。

接下來是比較難的知識點,比方說我們現在有兩個線程,一個是求加一千萬次,另一個是減一千萬次。按原本得計劃來說,一個加一千萬一個減一千萬結果應該還是零。可是最終得結果并不是等于零,我們多運作幾次會發現幾次得出來得結果并不相同。多線程代碼如下:

就算單線程也會出現兩個值:1000000 與 -1000000,兩個函數誰先運作就是輸出誰的結果,為什麼呢?因為兩個函數調用的是全局變量 number 是以,如果先運作加法函數,加法得到的結果是 1000000 ,那全局下的 number 的值也會變成:1000000 ,那減法的操作亦然就是 0。反過來也是一個意思。

由上面的多線程代碼,我可以發現結果:兩個線程操作同一個數字,最後得到的數字是混亂的。為什麼說是混亂的呢?

我們現在所要做的是一個指派,number += 1 其實也就是 number = number + 1,的這個操作。而在我們的 python 當中,我們是先:計算右邊的,然後指派給左邊的,一共兩步。

我先來看一下正确的運作流程:

上面的過成是正确的流程,可在多線程裡面呢?

上面就是我們剛才結果錯亂得原因,也就是說:我們計算和指派是兩部,但是該多線程它沒有順序執行,這也就是我們所說的線程不安全。

因為,執行太快了,兩個線程互動交織在一起,最終得到我們這個錯誤結果。以上就是線程不安全的問題。

這就是需要 lock 鎖,給它上一把鎖,來達到我們 number 的效果,這個時候為了避免錯誤,我們要給他上一把鎖了。

在代碼:lock.acquire() 與 lock.release() 中間的這個過程讓它強制有這個計算和指派的過程,也就是讓他執行完這兩個操作,後再切換。這樣就不會完成計算後,還沒來的及指派就跑到下一個去了。這樣也就防止了線程不安全的情況。

然後,就是我們第一個線程拿到這把鎖的 lock.acquire() 了,那另一個線程就會在 lock.acquire() 阻塞了,直到我們另一個線程把 lock.release() 鎖釋放,然後拿到鎖執行,就這樣不斷地切換拿鎖執行。

**死鎖:**就是前面的線程拿到鎖之後,運作完卻不釋放鎖,下一個線程在等待前一個線程釋放鎖,這種就是死鎖。說的直白一點就是,互相等待。就像照鏡子一樣,你中有我,我中有你。也就是在沒有 release 的這種情況。(你等我表白,我等你表白)

再次複用,一個鎖可以再嵌套一個鎖。向我們上面的普通鎖,一個線程裡面,你隻能擷取一次。如果擷取第二次就會報錯。

遞歸鎖什麼時候用呢?需要更低精度的,力度更小,為了更小的力度。

我們會發現這個遞歸鎖是比較耗費時間的,也就死我們擷取鎖與釋放鎖都是進行上下文切換導緻資源消耗的,是以說開啟的鎖越多,所耗費的資源也就越多,程式的運作速度也就越慢。一些大的工程很少上這麼多的鎖,因為這個鎖的速度會拖慢你整個程式的運作速度。是以得思考好,用不用這些東西。

多線程在 io 密集型用的比較多,也就是在爬蟲方面用的比較多。而 cpu 密集型根本就不用多線程。

我們一般的政策是,多程序加多線程,這樣的結合是最好。我需要用到這個庫:

pid(程序控制符)英文全稱為process identifier,它也屬于電工電子類技術術語。

pid就是各程序的身份辨別,程式一運作系統就會自動配置設定給程序一個獨一無二的pid。程序中止後pid被系統回收,可能會被繼續配置設定給新運作的程式。

pid一列代表了各程序的程序id,也就是說,pid就是各程序的身份辨別。

在實際調試中,隻能先大緻設定一個經驗值,然後根據調節效果修改。

[外鍊圖檔轉存失敗,源站可能有防盜鍊機制,建議将圖檔儲存下來直接上傳(img-bqoutcxb-1583392394101)(08-多線程與多程序.assets/image-20200229140520623.png)]

python 多程序之間是預設無法通信的,因為是并發執行的。是以需要借助其他資料結構。

舉個例子:

你一個程序抓取到資料,要給另一個程序用,就需要程序通信。

隊列:就像排隊一樣,先進先出。也就是你先放進去的資料,也就先取出資料。

棧:主要用在 c 和 c++ 上的資料結構。主要存儲使用者自定義的資料。它是後進先出。先進去的墊在底層,後進的在上面。

舉個實操的小例子:

為什麼需要程序池與線程池呢,我就用前面我們在進行上下文切換的時候會有資源消耗,而在這個基礎上,建立線程與删除線程都是需要消耗更多的資源。而這個池就節省了資源消耗,這樣我們就不用進行建立和銷毀了,隻要擷取裡面的使用即可。

第一種方法(多任務):

第二種方法(單任務):

使用 from multiprocessing import pool:引入程序池 ,那這個程序池,它是可以可以提供指定數量程序池,如果有新的請求送出到程序池,如果這個程序池還沒有滿的話,就建立新的程序來執行請求。 如果池滿的話,就會先等待。

我找了許多包,這個包還是不錯的:pip install threadpool

将你原先寫過的任何一個爬蟲程式改為多線程或者多程序。