1. 程式人生 > >Python併發程式設計理論篇

Python併發程式設計理論篇

Python併發程式設計理論篇

前言

  其實關於Python的併發程式設計是比較難寫的一章,因為涉及到的知識很複雜並且理論偏多,所以在這裡我儘量的用一些非常簡明的語言來儘可能的將它描述清楚,在學習之前首先要記住一個點:併發程式設計永遠的宗旨就是提高程式的執行效率,也是圍繞CPU來進行優化的一種技術手段。

  像我們之前學習過的網路程式設計中,我們只是基於socketserver模組讓我們的Server端有了處理多工的能力,但是我們並不瞭解它的底層是怎麼做到的,在學習完併發程式設計後,嘗試自己閱讀一下socketserver原始碼,你將會大有收穫。

  併發程式設計很重要嗎?是的,非常重要,如果你想進入PythonWeb

領域那麼著名的框架如DjangoTornadoFlask等等底層都是有基於本章節的知識點,如果你想進入爬蟲領域那就更不用說了,非常強大的scrapy框架也是基於我們所學的這些東西累積起來的。

  好了廢話不多說,讓我們開始進入併發程式設計的學習吧。

 

從任務處理角度看待作業系統發展史

  這一節主要是理論知識,瞭解計算機任務處理方式的演變過程,能夠讓我們更快的理解和學習併發程式設計。

  首先,我們先來回顧一下作業系統方面的一些知識。

 

  作業系統的作用:管理硬體,目的就是讓使用者更加方便的來操控計算機底層的硬體。

 

  可以看到作業系統對於人們操控計算機進行作業有著不可小覷的功勞,那麼在早期沒有作業系統的時候你能想象是什麼樣子嗎?現在我們來看一看。

 

無作業系統時任務的處理方式


  早期的計算機並沒有作業系統,而是通過紙帶來進行程式的編輯,它有三臺裝置分別是:輸入機,計算機,輸出機。

 

 

  那個時候的程式設計師需要一起約定好時間,來輪流的對自己的程式進行優化,因為那個時候的計算機在同一時刻下只能由一個人去執行和掌控,我們來看一下它的計算流程:

 

 

 

  這個時代的計算機一次只能跑一個人的程式,沒有其他干擾,那麼它的缺點也很明顯,一次只能一個人使用而後面想要使用的人必須得等待前一個人用完之後才行。其執行效率非常低下,最關鍵的就是人在進行與計算機互動的時候計算機的運算器是沒有任何工作的,這就造成了資源上極大的浪費,那麼這種浪費可以理解為I/O阻塞

  為了解決這個問題,批處理系統橫空出世了。

 

批處理系統的誕生


  相比於前一代計算機處理任務的方式,批處系統的誕生讓這一代計算機有了極大的進步,並且輸入也不再使用紙帶,而是採用磁帶,批處理作業系統可以將多個使用者的任務同時提交(但是不能同時執行)。

  假設有三個程式設計師需要使用這臺計算機,他們將自己的程式全部交由一個程式設計師讓其進行人機之間的互動,那麼這樣就節省了三倍的時間。但是這樣的缺點也很明顯,只能等待三個人的程式全部處理完後大家才能拿到各自的結果,這個等待過程是十分漫長的。

 

 

  在這裡,出現了一種自動化的工作方式,計算機也就是中間的7094機器能夠去區分出每個程式設計師自己的程式,那麼其內部肯定是由一種程式碼支援它有了這種功能,那個這個就是批處理系統。

  單處理的批處理系統最大的缺點依然還是擁有I/O阻塞,能不能把中間的兩個小人全部幹掉讓計算機來做他們做的事兒呢?當然可以,但是....當時的人還沒那麼聰明。

  我們再來想一個問題,如果程式設計師A的程式出錯了,它第一時間拿不到,返回會一直卡在那,程式設計師B和程式設計師C也不用拿了,反正都出不來。是不是很蛋疼?

  後來慢慢的經過時間的積累與技術的成熟,針對這一代的批處理系統的缺點,又出現了新一代作業系統。

 

多道程式設計與分時作業系統的誕生


  在這一代作業系統中最先出現了一種技術,名叫SPOOLING技術,這個技術的出現讓上圖的兩個小人下崗了。SPOOLING技術的出現極大的減少了I/O阻塞的時間,除此之外,該代作業系統還提出了一個非常重要的思想,即多道程式設計的思想,這個技術思想目前在我們的程序中依然存在,它的主要功能就是解決了順序執行(序列)的問題。

  儘管這樣做的確讓程式效率提高了,但是我們還有一個問題。計算機中依然是批處理系統,還是要等A,B,C的叅櫊程式同時出結果才能拿到最終結果,這個時間太長了,就想上面說的如果程式設計師A的程式出錯了卡住了程式設計師B和C的正常程式也取不出來。

  有的人開始懷念最早的無作業系統時代的計算機了,太懷念了,我一個人的程式十分鐘我就出來了,三個人的我要等三十分鐘,如果有一個出錯了我的等在久也出不來,我太難了...

 

  為了解決這種問題,出現了極為牛逼的分時作業系統。

  分時很形象的一個比喻就是一臺電腦給A,B,C每個程式設計師一個鍵盤滑鼠和顯示器,大家共有一個主機各玩各的互不影響,都認為自己的程式是獨享的並且馬上就能看到自己程式的執行結果,你說牛不牛逼?大家都很開心,但是實際上大家還是共用的同一個CPU...(多使用者多工)。

 

  分時作業系統到現在依然存在,並且還十分常見,比如許多人去操作同一臺伺服器。

  這時候大家就在考慮,你丫鍵盤滑鼠顯示器啥都給我了,為啥不再給我一個主機呢?這其實還是受限於當時的成本條件,但是到了如今計算機的成本以及體積都下來後,這些都不是問題了。

 

個人作業系統的誕生


  現在咱們大家都是用的個人作業系統,已經挺熟練了吧,這個玩意兒每個人都在玩,但是雖然大家不共有一個CPU了,其實在系統內部依然存在著切換,它就是程序或者執行緒之間的切換。

 

應用程式與系統之間的關係

  現在咱們聊一聊應用程式與系統之間的關係,其實對於開發者而已,我們與作業系統之間是隔了很多層的。如圖所示:

  所以,我們自己寫的程式要想執行,必須從上至下的依次經過這些關卡。

  為什麼要聊這個,因為聊完這個之後我們才能接著往下看。

 

併發並行阻塞非阻塞同步非同步

  這幾個概念將貫穿接下來的所有學習。

 

  併發和並行是指作業系統處理任務的能力:(一個一個處理?一次處理多個?)

    併發:作業系統具有處理多個任務的能力。

    並行:作業系統具有 同時 處理多個任務的能力。

  PS:併發包含並行。這裡再提一個偽並行,就是看起來像是同時處理,但是實際上並不是同時處理。

 

  同步和非同步是指任務的提交方式:(任務提交完後等你結果我再進行下一步操作?或者不等你的結果我接著幹我的其他事?)

    同步:任務提交之後,原地等待任務的返回結果,等待的過程中不做任何事。(乾等),程式上面表現出來的感覺就是卡住了。

    非同步:任務提交之後,不原地等待任務的返回結果,直接去做其他事情,等待任務的返回結果自動提交給呼叫者。

  Ps: 對於非同步來說,那麼我們提交任務後的返回結果如何獲取?

  提交任務後的返回結果會有一個非同步回撥機制自動處理,可以理解為當該任務有結果就會自動返回回來。給你打電話告訴你一聲我這邊完成了,你別忙了,看我一眼。

 

  阻塞和非阻塞是指程式的執行狀態:(程式現在卡住了嗎?卡住了就是阻塞,沒卡就是非阻塞)

    阻塞:是指呼叫某個函式的時候被卡住不動了,比如input()函式會導致阻塞

    非阻塞:是指呼叫某個函式的時候不會卡住,而是立即返回的一種形式

 

 

程序理論

程序的定義


  大白話版本:

  程序你可以把它當做一件屋子,裡面放了很多物件(資源),所以程序就是最小的資源單元。另外我們要注意一點,程式只有在執行狀態時才會產生程序,而不執行的時候就是一堆死程式碼。

 

  程式是一堆躺在硬碟上的程式碼,是"死"的

  程序則是表示程式正在執行的過程,是"活的"

 

  所以說,程序這玩意兒就是在程式執行過程中產生的,它會有一些資源狀態放在這個屋子裡。

 

  並且一定要注意,程序這玩意兒是一個系統級別的概念,程序是由作業系統創建出來的。程式執行的時候我們就會有一個程序,當然一個程式執行中也可以產生多個程序。

 

  專業版本

  詳細定義:

    程序就是一個程式在一個數據集上的一次動態執行過程。

    程序一般由程式、資料集、程序控制塊三部分組成。

    我們編寫的程式用來描述程序要完成哪些功能以及如何完成;

    資料集則是程式在執行過程中所需要使用的資源;

    程序控制塊用來記錄程序的外部特徵,描述程序的執行變化過程,系統可以利用它來控制和管理程序,它是系統感知程序存在的唯一標誌。

 

  資料集提供所有程式執行時需要的資源,程序控制塊用來記錄程式的狀態,比如說掛起被切換狀態還是執行狀態等等...

 

程序間的資料互動


  程序之間按理說是不應該允許彼此之間資料互動的,因為每個程序都是一間獨立的小房子,每個小房子的資源都是自己獨享的。但是我們之前學過socket模組,這玩意兒最早就是用來解決程序間資料互動(程序間通訊)問題的。

  所以,程序之間雖然預設不支援資料互動,但是我們可以使用某些特殊手段讓兩個程序之間支援資料互動,但是這不是很容易就能完成的,需要付出一些代價。

 

程序切換


  一個CPU核心同一時刻最多隻能執行一個程序,而多個CPU核心同一時刻可以執行多個程序,這個就是併發的體現。我們說過,多道技術的產生解決了程式序列的問題,那麼就必然涉及到程序切換。程序切換實際上是由作業系統說了算,除了我們的I/O操作切換外,它還有以下控制程序切換的手段,PS:程序的切換代價也是比較巨大的,因為一旦切換就要保證當前程序中的資源資料,而切換回來時又要將程序的狀態復原:

 

  1.先來先服務演算法

    誰先開闢了一個小屋子,那麼就先執行你。這個說白了對一個存活時間很短的程序是相當不利的,如果一個存活時間很長的程序佔用了一個CPU核心,那麼恰巧這個CPU又是單核的,其他存活時間短的程序永遠也得不到CPU的眷顧了。所以單一的這種策略不行。

 

  2.短作業優先排程演算法

    誰的程序作業時間短(即存活時間短)就先執行誰,顯然,單一的這種演算法會讓長作業程序得不到CPU眷顧,故也不能一直採取這種策略。

 

  3.時間片輪轉(時間輪詢)

  什麼意思呢?就是說假如有多個程序,我每個程序讓你執行個三五秒就切換到另一個程序執行,如此來回切換就是時間片輪轉。即將時間切成一段。

 

  4.多級反饋佇列

  這個其實是基於時間片輪轉做的,它會將當前所有的活動程序送入一個佇列中,根據存活時間來為其分配到不同的佇列中,程序存活時間越久,其得到CPU眷顧的次數越低。如圖:

  其實在Linux系統中,我們可以為一個程序分配更多的時間片與更高的優先順序,這裡暫且先不提。

 

執行緒理論

執行緒的定義


  大白話版:

  每個程序存在的時候都預設會有一個執行緒,如果把程序比喻做房子,那麼執行緒就是房子裡的人(可以有一個也可以有多個,預設一個)。執行緒才是真正幹活的單元,因此執行緒是最小的執行單元,執行緒共享程序中所有資料(程序資源集)。

 

  程序和執行緒是一個包含關係:必須有程序才有執行緒,就像執行緒這個人必須住在程序的房子裡。

 

 

  專業版本:

 

  執行緒詳細定義:

    1 一個程式至少有一個程序,一個程序至少有一個執行緒.(程序可以理解成執行緒的容器)

    2 程序在執行過程中擁有獨立的記憶體單元,而多個執行緒共享記憶體,從而極大地提高了程式的執行效率。

    3 執行緒在執行過程中與程序還是有區別的。每個獨立的執行緒有一個程式執行的入口、順序執行序列和程式的出口。但是執行緒不能夠獨立執行,必須依存在應用程式中,由應用程式提供多個執行緒執行控制。

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

 

執行緒間的資料互動


  執行緒必須存在於程序中,我們上面說過一個程序可以有多個執行緒,那麼想當然的該程序裡的所有資源都可以被位於該程序中的執行緒所拿到。

  而跨程序之間的執行緒就是屬於程序間的資料互動了。

 

  但是我們一定要注意一點,就是執行緒安全。這句話怎麼說呢?就是這個房子裡有一顆糖,被一個小人吃了,那麼其他的小人也應該知道這顆糖沒了才行。雖然聽起來很符合邏輯,但是別忘了執行緒不是真正的人,它是傻的,所以當我們進行執行緒之間資料互動的時候一定要注意執行緒安全。

  執行緒安全的問題還是由於執行緒切換導致的,比如這個房間一共有10顆糖,一個小人吃了3顆糖被CPU通知歇息一會,那麼他會認為還剩下7顆糖,另一個幹活的小人又吃了3顆糖後去休息了,那麼現在第一個休息的小人上線了,但是真正的糖果數量只剩下了4顆,他還傻乎乎的任務是7顆。

 

執行緒切換


  執行緒切換與程序切換如出一轍,看上面的就行了。

 

Python中的GIL鎖

  終於聊到這個話題了,GIL鎖被稱為全域性直譯器鎖。這玩意兒直接讓Python的多執行緒殘了,我們用圖來解釋這個鎖是幹嘛用的(Ps:這裡的Python代指CPython):

 

 

  我們再來看一眼諸如C#或者Java中的多執行緒執行是怎麼樣的。

 

  所以!Python中的多執行緒沒有並行操作!同時處理多個事對於Python裡的單程序下的多執行緒來說是做不到的,那麼我們可以怎麼辦呢?

import sys

print(sys.getcheckinterval())  # 100  代表CPU接收100個指令後切換另一條執行緒。Cpython
檢視Cpython的GIL鎖釋放時機

 

  自己在學GIL鎖時作的筆記:

 

  Python中的一個執行緒對應於C語言中的一個執行緒( 基於CPython ),而 CPython前面也已經說過了。會將函式轉變為可執行的位元組碼,而多個執行緒同時執行一段位元組碼是很有可能出錯的,為了避免這個錯誤所以Python使用了GIL鎖限制了多執行緒技術。 具體如下:

    GIL 使得同一個時刻只能有一個執行緒在 CPU 上執行位元組碼( 一般情況下 ),無法將多個執行緒對映到多個CPU 上去執行。 ​ 因此 Python多執行緒的GIL鎖 註定了其在多執行緒任務處理並沒有太大優勢

    當GIL 鎖死一個執行緒之後,並不是非要等這個執行緒執行完後才會釋放。而是會在適當的時候就進行釋放 :

      1:時間輪詢機制

      2:I/O操作

 

  所以Python中執行緒的並行操作是不被支援的(Cpython),Python並不適合做多執行緒的大量計算。這樣的時間遠不如序列來的簡單,因為線上程切換之中會導致執行速度的減緩。

 

  Python中的執行緒不能並行,但是程序是存在並行的。所以,Python的執行緒更加適用於密集型I/O操作比如網路爬蟲方面,Python的GIL鎖在某種程度上來說是保護了執行緒安全,但是更多的被人詬病。開發團隊曾經嘗試過去GIL鎖但是發現去掉GIL鎖之後實現執行緒並行的這種方式讓執行速度更加慢了下來,具體原因是因為CPython中的大量模組第三方庫在設計之初都是在有GIL鎖的情況下設計的,所以一旦改版後果不能被人預料。

 

  但是也不用悲觀,Python的GIL鎖只是直譯器層面和語言本身並無關係,比如PYPY就是沒有GIL鎖的一種直譯器。並在在Python1.9的時候確實推出了沒有GIL鎖的直譯器,但是執行的效率反而更低下(可能是這版的直譯器重寫不太好 - -)

   

  

  摘自知乎:為什麼CPython需要GIL

 

  因為90年代是單核的世界,單核中,多執行緒主要為了一邊做IO,一邊做CPU intensive job 設計的,GIL設計簡單,並不會影響效能。進入10年以後,變成了多核的世界,可以同時做多個cpu bound的job, GIL才真正變成問題。但是因為python 實在太火了,這些年無數的優秀庫是base on CPython(也就是我們現在見到的最主流的python 實現)的。所以無數的嘗試發現,要在不break 90年代延續下來的C API的前提下去除GIL基本不可能。所以我們看到了一次次的嘗試的失敗。

  另外兩個版本的python 實現 Jython 和IronPython都是沒有GIL的,但是有人為他們寫庫嗎?

 

總結

  1. 應用程式/程序/執行緒的關係?

      應用程式是死的一堆程式碼,在其執行的時候預設會建立一個程序,該程序可以理解為一個房子,故程序是最小的資源單元,並且該程序下還會預設建立一條執行緒,該執行緒被稱為主執行緒,可以理解為一個小人,是具體幹活的,故執行緒是最小的CPU執行單元

     

  2. 為什麼要建立多執行緒?

      對於其他語言如Java/C#來說,單程序下的多執行緒可以充分利用多核優勢,讓每個執行緒都會被一個CPU核心排程從而提高工作效率。

     

  3. 程序的作用是什麼?

      程序的最大作用就是做資料隔離,因為作業系統上有許許多多的程序,如果沒有資料隔離則會發生很多不安全的現象。

     

  4. Python的GIL鎖是什麼?它的作用和劣勢是什麼?

      Python裡的GIL鎖中文名稱是全域性直譯器鎖,它規定了一個程序下的多條執行緒同一時刻只能有一條執行緒能在一個CPU核心上工作。

      它的作用在於防止了多執行緒在執行期間位元組碼出錯的問題。

      但是GIL鎖的劣勢也很明顯,直接讓Python的一個程序下的多執行緒沒有了並行操作,簡而言之就是殘廢了。但是我們仍然可以使用多程序的操作方式來實現並行,但是這樣並不是完美的解決方案,因為一個程序的建立和切換代價相比於一個執行緒的建立和切換要大得多。

 

擴充套件:程序切換與程式計數器

  不同的程序之間能進行切換那麼不同的執行緒之間也必定能進行切換,既然執行緒是最小的執行單元那麼同一程序中的執行緒切換的代價必然是少於程序間的切換的。

 

  程序切換

  為了控制程序的執行,核心必須有能力掛起正在CPU上執行的程序,並恢復以前掛起的某個程序的執行。這種行為被稱為程序切換,這種切換是由作業系統來完成的。因此可以說,任何程序都是在作業系統核心的支援下執行的,是與核心緊密相關的。

  從一個程序的執行轉到另一個程序上執行,這個過程中經過下面這些變化:

    1. 儲存處理機上下文,包括程式計數器和其他暫存器。

    2. 更新PCB資訊。

    3. 把程序的PCB移入相應的佇列,如就緒、在某事件阻塞等佇列。

    4. 選擇另一個程序執行,並更新其PCB。

    5. 更新記憶體管理的資料結構。

    6.恢復處理機上下文。

    注:總而言之就是很耗資源的

 

  程式計數器

  我們都知道軟體的資料是儲存在硬碟上的,這個呼叫的過程十分緩慢,但是在記憶體中就會快很多。同時,一個執行緒或者程序的切換掛起狀態如果是存放在記憶體中那麼是肯定不行的,這個速度對於切換毫秒級別的執行緒或者程序來說速度依舊不夠快。所以在CPU旁邊有了一個程式計數器的存在,由於距離CPU比較近傳輸狀態的時間也會相應縮短。它的大小並不是很大隻有小小的12kb,主要功能就是儲存了這些程序或者執行緒切換狀態的資料。儲存的其實都是--->記憶體地址。

 

 

&n