協程到底有什麼用?6 種 I-O 模式告訴你!

大家好,我是小風哥,今天來聊一聊協程的作用。

假設磁盤上有 10 個文件,你需要讀取的內存,那麼你該怎麼用代碼實現呢?

在接着往下看之前,先自己想一想這個問題,看看自己能想出幾種方法,各自有什麼樣的優缺點。

想清楚了嗎 (還在看嗎),想清楚了我們繼續往下看。

最簡單的方法——串行

這可能是大多數同學都能想到的最簡單方法,那就是一個一個的讀取,讀完一個接着讀下一個。

用代碼表示是這樣的:

for file in files:
  result = file.read()
  process(result)

是不是非常簡單,我們假設每個文件讀取需要 1 分鐘,那麼 10 個文件總共需要 10 分鐘才能讀取完成。

這種方法有什麼問題呢?

實際上這種方法只有一個問題,那就是

除此之外,其它都是優點:

  1. 代碼簡單,容易理解

  2. 可維護性好,這代碼交給誰都能維護的了 (論程序員的核心競爭力在哪裏)

那麼慢的問題該怎麼解決呢?

有的同學可能已經想到了,爲啥要一個一個讀取呢?並行讀取不就可以加快速度了嗎。

稍好的方法,並行

那麼,該怎麼並行讀取文件呢?

顯然,地球人都知道,線程就是用來並行的。

我們可以同時開啓 10 個線程,每個線程中讀取一個文件。

用代碼實現就是這樣的:

def read_and_process(file):
  result = file.read()
  process(result)
def main():
  files = [fileA,fileB,fileC......]
  for file in files:
     create_thread(read_and_process,
                   file).run()
  # 等待這些線程執行完成

怎麼樣,是不是也非常簡單。

那麼這種方法有什麼問題嗎?

在開啓 10 個線程這種問題規模下沒有問題。

現在我們把問題難度加大,假設有 10000 個文件,需要處理該怎麼辦呢?

有的同學可能想 10 個文件和 10000 個文件有什麼區別嗎,直接創建 10000 個線程去讀不可以嗎?

實際上這裏的問題其實是說創建多個線程有沒有什麼問題。

我們知道,雖然線程號稱 “輕量級進程”,雖然是輕量級但當數量足夠可觀時依然會有性能問題。

這裏的問題主要有這樣幾個方面:

  1. 創建線程需要消耗系統資源,像內存等 (想一想爲什麼?)

  2. 調度開銷,尤其是當線程數量較多且都比較繁忙時 (同樣想一想爲什麼?)

  3. 創建多個線程不一定能加快 I/O(如果此時設備處理能力已經飽和)

既然線程有這樣那樣的問題,那麼還有沒有更好的方法?

答案是肯定的,並行編程不一定只能依賴線程這種技術,關於併發編程可以用哪些技術實現的詳細討論請參考《高性能服務器是如何實現的》。

這裏的答案就是基於事件驅動編程技術。

事件驅動 + 異步

沒錯,即使在單個線程中,使用事件驅動 + 異步也可以實現 IO 並行處理,Node.js 就是非常典型的例子。

爲什麼單線程也可以做到並行呢?

這是基於這樣兩個事實:

  1. 相對於 CPU 的處理速度來說,IO 是非常慢的

  2. IO 不怎麼需要計算資源

因此,當我們發起 IO 操作後爲什麼要一直等着 IO 執行完成呢?在 IO 執行完之前的這段時間處理其它 IO 難道不香嗎

這就是爲什麼單線程也可以並行處理多個 IO 的本質所在。

回到我們的例子,該怎樣用事件驅動 + 異步來改造上述程序呢?

實際上非常簡單。

首先我們需要創建一個 event loop,這個非常簡單:

event_loop = EventLoop()

然後,我們需要往 event loop 中加入原材料,也就是需要監控的 event,就像這樣:

def add_to_event_loop(event_loop, file):
   file.asyn_read() # 文件異步讀取
   event_loop.add(file)

注意當執行 file.asyn_read 這行代碼時會立即返回,不會阻塞線程,當這行代碼返回時可能文件還沒有真正開始讀取,這就是所謂的異步。

file.asyn_read 這行代碼的真正目的僅僅是發起 IO,而不是等待 IO 執行完成。

此後我們將該 IO 放到 event loop 中進行監控,也就是 event_loop.add(file) 這行代碼的作用。

一切準備就緒,接下來就可以等待 event 的到來了:

while event_loop:
   file = event_loop.wait_one_IO_ready()
   process(file.result)

我們可以看到,event_loop 會一直等待直到有文件讀取完成(event_loop.wait_one_IO_ready()),這時我們就能得到讀完的文件了,接下來處理即可。

全部代碼如下所示:

 def add_to_event_loop(event_loop, file):
   file.asyn_read() # 文件異步讀取
   event_loop.add(file)
def main():
  files = [fileA,fileB,fileC ...]
  event_loop = EventLoop()
  for file in files:
      add_to_event_loop(event_loop, file)
  while event_loop:
     file = event_loop.wait_one_IO_ready()
     process(file.result)

多線程 VS 單線程 + event loop

接下來我們看下程序執行的效果。

在多線程情況下,假設有 10 個文件,每個文件讀取需要 1 秒,那麼很簡單,並行讀取 10 個文件需要 1 秒。

那麼對於單線程 + event loop 呢?

我們再次看下 event loop + 異步版本的代碼:

def add_to_event_loop(event_loop, file):
   file.asyn_read() # 文件異步讀取
   event_loop.add(file)
def main():
  files = [fileA,fileB,fileC......]
  event_loop = EventLoop()
  for file in files:
      add_to_event_loop(event_loop, file)
  while event_loop:
     file = event_loop.wait_one_IO_ready()
     process(file.result)

對於 add_to_event_loop,由於文件異步讀取,因此該函數可以瞬間執行完成,真正耗時的函數其實就是 event loop 的等待函數,也就是這樣:

file = event_loop.wait_one_IO_ready()

我們知道,一個文件的讀取耗時是 1 秒,因此該函數在 1s 後才能返回,但是,但是,接下來是重點。

但是雖然該函數 wait_one_IO_ready 會等待 1s,不要忘了,我們利用這兩行代碼同時發起了 10 個 IO 操作請求。

for file in files:  add_to_event_loop(event_loop, file)

因此在 event_loop.wait_one_IO_ready 等待的 1s 期間,剩下的 9 個 IO 也完成了,也就是說 event_loop.wait_one_IO_ready 函數只是在第一次循環時會等待 1s,但是此後的 9 次循環會直接返回,原因就在於剩下的 9 個 IO 也完成了

因此整個程序的執行耗時也是 1 秒。

是不是很神奇,我們只用一個線程就達到了 10 個線程的效果。

這就是 event loop + 異步的威力所在。

一個好聽的名字:Reactors 模式

本質上,我們上述給出的 event loop 簡單代碼片段做的事情本質上和生物一樣:

給出刺激,做出反應。

我們這裏的給出 event,然後處理 event。

這本質上就是所謂的 Reactors 模式。

現在你應該明白所謂的 Reactors 模式是怎麼一回事了吧。

所謂的一些看上去複雜的異步框架其核心不過就是這裏給出的代碼片段,只是這些框架可以支持更加複雜的多階段任務處理以及各種類型的 IO。而我們這裏給出的代碼片段只能處理文件讀取這一類 IO。

把回調也加進來

如果我們需要處理各種類型的 IO 上述代碼片段會有什麼問題嗎?

問題就在於上述代碼片段就不會這麼簡單了,針對不同類型會有不同的處理方法,因此上述 process 方法需要判斷 IO 類型然後有針對性的處理,這會使得代碼越來越複雜,越來越難以維護。

幸好我們也有應對策略,這就是回調。關於回調函數,請參考這篇《程序員應如何理解回調函數》。

我們可以把 IO 完成後的處理任務封裝到回調函數中,然後和 IO 一併註冊到 event loop

就像這樣:

def IO_type_1(event_loop, io):
  io.start()
  def callback(result):
    process_IO_type_1(result)
  event_loop.add((io, callback))

這樣,event_loop 在檢測到有 IO 完成後就可以把該 IO 和關聯的 callback 處理函數一併檢索出來,直接調用 callback 函數就可以了。

while event_loop:
   io, callback = event_loop.wait_one_IO_ready()
   callback(io.result)

看到了吧,這樣 event_loop 內部就極其簡潔了,even_loop 根本就不關心該怎麼處理該 IO 結果,這是註冊的 callback 該關心的事情,event_loop 需要做的僅僅就是拿到 event 以及相應的處理函數 callback,然後調用該 callback 函數就可以了。

現在我們可以同單線程來併發編程了,也使用 callback 對 IO 處理進行了抽象,使得代碼更加容易維護,想想看還有沒有什麼問題?

回調函數的問題

雖然回調函數使得 event loop 內部更加簡潔,但依然有其它問題,讓我們來仔細看看回調函數:

def start_IO_type_1(event_loop, io):
  io.start()
  def callback(result):
    process_IO_type_1(result)
  event_loop.add((io, callback))

從上述代碼中你能看到什麼問題嗎?

在上述代碼中,一次 IO 處理過程被分爲了兩部分:

  1. 發起 IO

  2. IO 處理

其中第 2 部分放到了回調函數中,這樣的異步處理天然不容易理解,這和我們熟悉的發起 IO,等待 IO 完成、處理 IO 結果的同步模塊有很大差別。

這裏的給的例子很簡單,所以你可能不以爲意,但是當處理的任務非常複雜時,可能會出現回調函數中嵌套回調函數,也就是回調地獄,這樣的代碼維護起來會讓你懷疑爲什麼要稱爲一名苦逼的碼農。

問題出在哪裏

讓我們再來仔細的看看問題出在了哪裏?

同步編程模式下很簡單,但是同步模式下發起 IO,線程會被阻塞,這樣我們就不得不創建多個線程,但是創建過多線程又會有性能問題。

這樣爲了發起 IO 後不阻塞當前線程我們就不得不採用異步編程 + event loop。

在這種模式下,異步發起 IO 不會阻塞調用線程,我們可以使用單線程加異步編程的方法來實現多線程效果,但是在這種模式下處理一個 IO 的流程又不得不被拆分成兩部分,這樣的代碼違反程序員直覺,因此難以維護。

那麼很自然的,有沒有一種方法既能有同步編程的簡單理解又會有異步編程的非阻塞呢?

答案是肯定的,這就是協程。關於協程請參考《程序員應如何理解協程》。

Finally!終於到了協程

利用協程我可以以同步的形式來異步編程。

這是什麼意思呢?

我們之所以採用異步編程是爲了發起 IO 後不阻塞當前線程,而是用協程,程序員可以自行決定在什麼時刻掛起當前協程,這樣也不會阻塞當前線程。

而協程最棒的一點就在於掛起後可以暫存執行狀態恢復運行後可以在掛起點繼續運行,這樣我們就不再需要像回調那樣將一個 IO 的處理流程拆分成兩部分了。

因此我們可以在發起異步 IO,這樣不會阻塞當前線程,同時在發起異步 IO 後掛起當前協程,當 IO 完成後恢復該協程的運行,這樣我們就可以實現同步的方式來異步編程了。

接下來我們就用協程來改造一下回調版本的 IO 處理方式:

def start_IO_type_1(io):
  io.start() # IO異步請求
  yield      # 暫停當前協程 
  process_IO_type_1(result) # 處理返回結果

此後我們要把該協程放到 event loop 中監控起來:

def add_to_event_loop(io, event_loop):
  coroutine = start_IO_type_1(io)
  next(coroutine)
  event_loop.add(coroutine)

最後,當 IO 完成後 event loop 檢索出相應的協程並恢復其運行:

while event_loop:
   coroutine = event_loop.wait_one_IO_ready()
   next(coroutine)

現在你應該看出來了吧,上述代碼中沒有回調,也沒有把處理 IO 的流程拆成兩部分,整體的代碼都是以同步的方式來編寫,最棒的是依然能達到異步的效果。

實際上你會看到,採用協程後我們依然需要基於事件編程的 event loop,因爲本質上協程並沒有改變 IO 的異步處理本質,只要 IO 是異步處理的那麼我們就必須依賴 event loop 來監控 IO 何時完成,只不過我們採用協程消除了對回調的依賴,整體編程方式上還是採用程序員最熟悉也最容易理解的同步方式。

總結

看上去簡簡單單的 IO 實際上一點都不簡單吧。

爲了高效進行 IO 操作,我們採用的技術是這樣演進的:

  1. 單線程串行 + 阻塞式 IO(同步)

  2. 多線程並行 + 阻塞式 IO(並行)

  3. 單線程 + 非阻塞式 IO(異步) + event loop

  4. 單線程 + 非阻塞式 IO(異步) + event loop + 回調

  5. Reactor 模式 (更好的單線程 + 非阻塞式 IO+ event loop + 回調)

  6. 單線程 + 非阻塞式 IO(異步) + event loop + 協程

最終我們採用協程技術獲取到了異步編程的高效以及同步編程的簡單理解,這也是當今高性能服務器常用的一種技術組合。

希望這篇文章能對你理解高效 IO 有所幫助。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ufI6Z92jYn5e010Fmr9H4A