1. 程式人生 > >python事件驅動與非同步I/O

python事件驅動與非同步I/O

  • 通常,我們寫伺服器處理模型的時候,有以下幾種模型:

    1. 每收到一個請求,建立一個新的程序,來處理該請求
    2. 每收到一個請求,建立一個新的執行緒,來處理該請求
    3. 每收到一個請求,放入一個時間列表,讓主程序通過非阻塞I/O方式來處理請求
  • 以上幾種方式各有優缺點:

    • 第一種方法中,由於建立新的程序開銷比較大,會導致伺服器效能比較差,但是實現比較簡單
    • 第二種方式,由於要設計成執行緒的同步,有可能會棉鈴死鎖等問題。
    • 第三種方式中,在寫應用程式程式碼時,邏輯比前兩種都複雜。

綜合考慮各方面因素,一般普遍認為第三種方式是大多網路伺服器採用的方式

事件驅動模型

  • 在UI程式設計中,常常要對滑鼠點選進行響應,對於獲取滑鼠點選有一下辦法: 
    1. 建立一個執行緒,該執行緒一直迴圈檢測是否有滑鼠點選,但是這個方式有一下缺點: 
      • cpu資源浪費,可能滑鼠的點選頻率相當小,但是掃描程序還是會一直迴圈檢測,這回造成很多的CPU資源浪費。
      • 如果是阻塞的,在等待滑鼠點選的時候,如果使用者想通過按鍵盤來實現,程式會阻塞等待滑鼠點選,不會識別鍵盤的按鍵。
      • 如果一個迴圈掃描的裝置非常多,這又會引發響應時間的問題
      • 所以這個方式是非常不好的
    2. 事件驅動模型 
      目前大部分的UI程式設計都是事件驅動模型 ,如很多UI平臺都會提供onClick()事件,這個事件就代表滑鼠按下事件。事件驅動模型大體思路如下: 
      • 有一個事件(訊息)佇列
      • 滑鼠按下時,往這個佇列中增加一個點選事件(訊息)
      • 有個迴圈,不斷從佇列中取出事件,根據不同的事件,呼叫不同的函式,如onClick()、onKeyDown()等
      • 事件(訊息)一般都各自儲存各自的處理函式指標,這樣,每個訊息都有獨立的處理函式;

這裡寫圖片描述

事件驅動程式設計是一種程式設計正規化,這裡程式的執行流由外部事件來決定。它的特點是包含一個時間迴圈,當外部事件發生時使用回撥機制來觸發相應的處理。另外兩種常見的程式設計正規化是(單執行緒)同步以及多執行緒程式設計。 
讓我們用例子來比較和對比一下單執行緒、多執行緒以及事件驅動模型。下圖展示了隨著時間的推移,這三種模式下程式所做的工作。這個程式有三個任務需要完成,每個任務都在等待I/O操作室阻塞自身。阻塞在I/O操作桑所花的時間已經用灰色表示出來了。 
這裡寫圖片描述

  • 在單執行緒同步模型中,任務按照順序執行。如果某個任務因為I/O而阻塞,其他所有的任務都必須等待,知道它完成之後其他任務才能依次執行。這種明確的執行順序和序列化處理的行為是很容易推斷得出的。如果任務之間沒有相互依賴的關係,但但仍需要相互等待的話這就使得程式降低了執行速度。
  • 在多執行緒版本中,這3個任務分別在獨立的執行緒中執行。這些執行緒有作業系統來管理,在多處理器系統上可以並行處理,或者在單獨的處理器上交錯執行。這使得當某個執行緒阻塞的時候,其他的執行緒可以繼續執行。與完成類似功能的同步程式相比,這種方式等有效率,但程式設計師必須寫程式碼來保護共享資源,防止其被多個執行緒同時訪問。多執行緒程式更加難以推斷,因為這類程式不得不通過執行緒同步機制,如鎖、可重入函式、執行緒區域性儲存或其他機制來處理執行緒安全問題,如果實現不當就會產生BUG。
  • 在事件驅動過程中,三個任務交錯執行,但仍在一個單獨的執行緒控制中。當處理I/O或者其他需要阻塞的操作時,註冊一個回撥事件在迴圈中,然後在I/O操作完成時繼續執行。回撥描述了該如何處理某個事件。實踐迴圈輪訓所有的事件,當事件到來時將他們分配給等待處理事件的回撥函式。這種方式讓程式儘可能的執行而不需要額外的執行緒。事件驅動型程式比對執行緒更容易推斷出行為,因為程式設計師不需要關心執行緒安全問題。
  • 當我們面對如下環境時,事件驅動通常是最好的選擇: 
    1. 程式中有許多工
    2. 任務之間高度獨立(因此他們不需要相互通訊或者等待彼此)
    3. 等待事件到來時,某些任務會阻塞

當應用程式需要在任務間共享可變資料時,這也是個不錯的選擇,因為這不需要採用同步處理。 
網路應用通常都符合上述特點,這使得他們能夠很好的切合事件驅動模型。

select、pool、epool非同步IO

概念說明

在進行解釋之前,首先要說明幾個概念:

  • 使用者空間和核心空間
  • 程序切換
  • 程序的阻塞
  • 快取I/O

使用者空間和核心空間

現在作業系統都是採用虛擬儲存器,那麼對32位作業系統而言,它的定址空間(虛擬儲存空間)位4G(2的32次方)。作業系統的核心是核心,獨立於普通的應用程式,可以訪問受保護的記憶體空間,也有訪問底層硬體裝置的所有許可權。為了保護使用者程序不能直接操作核心(kernel),保證核心的安全,作業系統將虛擬空間劃分為兩個部分,一部分為核心空間,一部分作為使用者空間。針對linux作業系統而言,將最高的1G位元組供核心使用,成為核心空間,而將較低的3G位元組供個程序使用,稱為使用者空間。

程序切換

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

  • 從一個程序的執行到另一個程序的執行,這個過程經過下面這些變化: 
    1. 儲存處理機上下文,包括程式計數器和其他暫存器。
    2. 更新PCB資訊。
    3. 把程序的PCB移入相應的佇列,如就緒、在某事件阻塞等佇列。
    4. 選擇另一個程序執行,並更新其PCB。
    5. 更新記憶體管理的資料結構。
    6. 回覆處理機上下文

總之就是非常消耗資源 
注:程序控制塊,是作業系統核心中的一種資料結構,主要表示程序狀態。其作用是使一個在多道程式環境下不能獨立執行的程式(含資料),成為一個能獨立執行的基本單位或與其他程序併發執行的程序。或者說,作業系統是根據PCB來對併發執行的程序進行控制可管理的。PCB通常是系統記憶體佔用區中的一個連續存區,它存放著作業系統使用者描述程序情況及控制程序執行所需的全部資訊

程序的阻塞

正在執行的程序,由於期待的某些事情未發生,如請求系統資源失敗、等待某種操作的完成、新資料尚未到達或無新工作等,則有系統自動執行阻塞原語(Block),是自己有執行狀態變為阻塞狀態。可見程序的阻塞是程序自身的一種阻塞行為,也因此只有處於執行狀態的程序(獲得CPU),才能將其轉為阻塞狀態。當程序進入阻塞狀態,是不佔CPU資源的。

檔案描述符fd

檔案描述符(File descriptor)是電腦科學中的一個術語。是一個用於表述指向檔案的引用的抽象化概念。 
檔案描述符在形式上是一個非負整數。實際上,他是一個索引值,指向核心為每一個程序所維護的該程序開啟檔案 的記錄表。當程式大愛一個現有檔案或者穿件一個新檔案時,核心向程序返回一個檔案描述符。在程式設計中,一些涉及底層的程式編寫往往會圍繞著檔案描述符展開。但是檔案描述符這一概念往往之適用於UNIX、Linux這樣的作業系統。

快取I/O

快取I/O又被稱作標準I/O,大多數檔案系統的預設I/O操作都是快取I/O。在Linux的快取I/O機制中,作業系統會將I/O的資料快取在檔案系統的頁快取中,也就是說,資料會先被拷貝到作業系統核心的緩衝區中,然後才會從作業系統核心的緩衝區中拷貝到應用程式的地址空間。

快取I/O的缺點:

  • 資料在傳輸過程中需要在應用程式地址空間和核心進行多次資料拷貝操作,這些資料拷貝操作所帶來的CPU以及記憶體的開銷是很大的。

I/O模式

對於一次I/O訪問(以read舉例),資料會先被拷貝到作業系統核心的緩衝區中,然後才會從作業系統核心的緩衝區拷貝到應用程式的地址空間。所以說,當一個read操作發生時,他會經歷兩個階段: 
1. 等待資料準備 
2. 將資料從核心拷貝到程序中

正式因為這兩個階段,linux系統產生了下面五種網路模式的方案: 
- 阻塞I/O(blocking IO) 
- 非阻塞I/O(nonblocking IO) 
- I/O多路複用(IO multiplexing) 
- 訊號驅動I/O(signal driven IO) 
- 非同步I/O(asynchronous IO)

注:訊號驅動IO在實際中並不常用,所以下面只提及剩下的四中IO模型

阻塞I/O(blocking IO)

在linux中,沒預設情況下所有的socket都是blocking,一個典型的讀操作流程是這樣的: 
這裡寫圖片描述

當用戶程序呼叫了recvfrom這個系統呼叫,kernel就開始了IO的第一個階段:準備資料(對於網路IO來說,很多時候資料在一開始還沒有到達。比如,還沒有收到一個完整的UDP包。這個時候kernel就要等待足夠的資料到來)。這個過程需要等待。也就是說資料被拷貝到作業系統核心的緩衝區是需要一個過程的。而使用者在這邊,整個程序會被阻塞(當然,是程序自己選擇的阻塞)。當kernel一直等到資料準備好了,它就會將資料從kernel中拷貝到使用者記憶體,然後kernel返回結果,使用者程序才解除block的狀態,重新執行起來。 
所以,blocking IO的特點就是在IO執行的量的階段都被block了

非阻塞IO(nonblocking IO)

linux下,可以通過設定socket使其變為non-blocking。當對一個non-blocking socket執行度操作是,流程是這個樣子的: 
這裡寫圖片描述

當用戶程序發出read操作時,如果kernel中的資料還沒有準備好,那麼他並不會block使用者程序,而是立刻返回一個error。從使用者的角度講,它發起一個read操作後,並不需要等待,而是馬上得到一個結果。使用者程序判斷結果是個error時,他就知道資料還麼有準備好,於是他可以再次傳送read操作。一旦kernel中的資料轉備好了,並且有再次收到了使用者程序的system call,那麼他馬上就將資料拷貝到了使用者記憶體,然後返回。 
所以, nonblocking IO的特點是使用者程序需要不斷的主動詢問kernel資料好了沒有。

I/O多路複用(IO multiplexing)

I/O 多路複用就是本文講到的select、pool、epool,有些地方也稱這種I/O方式為event driven IO(事件驅動IO)。select、epool的好吃就在於單個程序就可以同時處理多個網路連線的IO。他的基本原理就是select、pool、epool這個function會不斷地輪詢所負責的所有socket,當某個socket有資料到達了,就通知使用者程序。 
這裡寫圖片描述

當用戶程序呼叫了select,那麼整個程序都會被block,而同時,kernel會“監聽”所有select負責的socket,當任何一個socket中的資料準備好了,select就會返回。這個時候使用者程序再呼叫read操作,將資料從kernel拷貝到使用者程序。 
所以,I/O多路複用的特點是通過一種機制,一個程序能同時等待多個檔案描述符,而這些檔案描述符(套接字描述符)其中的任意一個進入讀就緒狀態,select()函式就可以返回。

這個圖跟阻塞IO的圖其實沒有太大的不同,事實上,還等差一些。因為這裡需要使用兩個system call(select和recvfrom),而阻塞IO(blocking IO)只調用了一個system call(recvfrom)。但是,用select的有事在於他可以同時處理多個connection。

所以,如果處理連線數不是很高 的話,使用select、epool的web server不一定比使用multithreading + nlocking IO的web server效能更好,可能延遲還更大。select、epool的優勢並不是對於單個連結能處理的更快,而是在於能處理更多的連結。

在 IO multiplexing Model中,對於每一個socket,一般都設定成為non-blocking,但是,如上圖所示,整個使用者的process其實是一直被block的,只不過process是被select這個函式block,而不是被socket IO給block。

非同步I/O(asynchronous IO)

Linux下的asynchronous IO其實用的很少,流程如下: 
這裡寫圖片描述

使用者程序發起read操作之後,立刻就可以做其他的事情。而另一方面,從kernel的角度,當他收到一個asynchronous read之後,首先他會立刻返回。所以不會對使用者程序產生任何的block。然後,kernel會等待資料準備完成,然後將資料拷貝到使用者記憶體,當這一切完成之後,kernel會給使用者程序傳送一個signal,告訴它read操作完成了

總結

blocking和non-blocking的區別 
呼叫blocking IO會一直block住對應的程序直到操作完成,而non-blockig IO在kernel還準備資料的情況下立刻返回

同步IO(synchronous IO)和非同步IO(asynchronous)的區別 
在說明同步IO和非同步IO的區別之前,需要先給出兩個定義。POSIX的定義是這個樣子的:

  • 同步的輸入/輸出操作導致請求程序被阻塞,直到該輸入/輸出操作完成;
  • 非同步的輸入/輸出操作不會導致請求程序被阻塞;

二者的區別就在於同步IO做IO操作的時候會將process阻塞,安裝這個定義,之前所述的阻塞IO(blocking IO),非阻塞IO(non-blocking IO),IO多路複用(IO multiiplexing)都屬於同步IO。 
注:雖然非阻塞IO沒有被阻塞,但是也是同步IO,因為定義中提高的IO操作是指真實的IO操作,就是例子中的recvfrom這個system call。非阻塞IO在執行recvform這個system call的時候,如果kernel的資料沒有準備好,這時候不會block程序。但是當kernel準備好資料的時候,recvform會將資料從kernel拷貝到 使用者記憶體中,這時候程序是被block了,在這段時間內程序是被block的。

而非同步IO不一樣,當程序發起IO操作後,就直接返回,再也不理睬了,直到kernel傳送一個訊號,告訴程序說IO完成。在這整體過程中,程序完全沒有被block。

各個IO模型的比較示意圖: 
這裡寫圖片描述

通過上面的圖片,可以發現非阻塞IO和非同步IO的區別還是很明顯的。在非阻塞IO中,雖然勁曾大部分時間不會被block,但是他仍要求程序去主動的check,並且當資料準備完成後,越要程序主動的再次呼叫recvfrom來將資料拷貝到使用者記憶體。而非同步IO則完全同。他就像是使用者程序將整個IO操作交給了其他人(kernel)完成,然後別人做完之後發訊號通知。在此期間,使用者程序不需要檢查IO的操作狀態,也不需要主動的拷貝資料。