前言
通過了解非同步設計的由來,來深入理解非同步事件機制。
什麼是非同步
為了深入理解非同步的概念,就必須先了解非同步設計的由來。
同步
顯然易見的是,同步的概念隨著我們學習第一個輸出Hello World的程式,就已經深入人心。
然而我們也很容易忘記一個事實:一個現代程式語言(如Python)做了非常多的工作,來指導和約束你如何去構建你自己的一個程式。
def f():
print("in f()")
def g():
print("in g()")
f()
g()
你知道in g()
一定輸出在in f()
之後,即函式f完成前函式g不會執行。這即為同步。在現代程式語言的幫助下,這一切顯得非常的自然,從而也讓我們可以將我們的程式分解成
鬆散耦合的函式:一個函式並不需要關心誰呼叫了它,它甚至可以沒有返回值,只是完成一些操作。
當然關於這些是怎麼具體實現的就不探究了,然而隨著一個程式的功能的增加,同步設計的開發理念並不足以實現一些複雜的功能。
併發
寫一個程式每隔3秒列印“Hello World”,同時等待使用者命令列的輸入。使用者每輸入一個自然數n,就計算並列印斐波那契函式的值F(n),之後繼續等待下一個輸入
由於等待使用者輸入是一個阻塞的操作,如果按照同步的設計理念:如果使用者未輸入,則意味著接下來的函式並不會執行,自然沒有辦法做到一邊輸出“Hello World”,
一邊等待使用者輸入。為了讓程式能解決這樣一個問題,就必須引入併發機制,即讓程式能夠同時做很多事,執行緒是其中一種。
執行緒
具體程式碼在example/hello_threads.py
中。
from threading import Thread
from time import sleep
from time import time
from fib import timed_fib
def print_hello():
while True:
print("{} - Hello world!".format(int(time())))
sleep(3)
def read_and_process_input():
while True:
n = int(input())
print(`fib({}) = {}`.format(n, timed_fib(n)))
def main():
# Second thread will print the hello message. Starting as a daemon means
# the thread will not prevent the process from exiting.
t = Thread(target=print_hello)
t.daemon = True
t.start()
# Main thread will read and process input
read_and_process_input()
if __name__ == `__main__`:
main()
對於之前那樣的問題,引入執行緒機制就可以解決這種簡單的併發問題。而對於執行緒我們應該有一個簡單的認知:
- 一個執行緒可以理解為指令的序列和CPU執行的上下文的集合。
- 一個同步的程式即程式,有且只會在一個執行緒中執行,所以當執行緒被阻塞,也就意味著整個程式被阻塞
- 一個程式可以有多個執行緒,同一個程式中的執行緒共享了程式的一些資源,比如說記憶體,地址空間,檔案描述符等。
-
執行緒是由作業系統的排程器來排程的, 排程器統一負責管理排程程式中的執行緒。
- 系統的排程器決定什麼時候會把當前執行緒掛起,並把CPU的控制器交個另一個執行緒。這個過程稱之為稱上下文切換,包括對於當前執行緒上下文的儲存、對目標執行緒上下文的載入。
- 上下文切換會對效能產生影響,因為它本身也需要CPU的週期來執行
I/O多路複用
而隨著現實問題的複雜化,如10K問題。
在Nginx沒有流行起來的時候,常被提到一個詞 10K(併發1W)。在網際網路的早期,網速很慢、使用者群很小需求也只是簡單的頁面瀏覽,
所以最初的伺服器設計者們使用基於程式/執行緒模型,也就是一個TCP連線就是分配一個程式(執行緒)。誰都沒有想到現在Web 2.0時候使用者群裡和複雜的頁面互動問題,
而現在即時通訊和實在實時互動已經很普遍了。那麼你設想如果每一個使用者都和伺服器保持一個(甚至多個)TCP連線才能進行實時的資料互動,別說BAT這種量級的網站,
就是豆瓣這種比較小的網站,同時的併發連線也要過億了。程式是作業系統最昂貴的資源,一臺機器無法建立很多程式。如果要建立10K個程式,那麼作業系統是無法承受的。
就算我們不討論隨著伺服器規模大幅上升帶來複雜度幾何級數上升的問題,採用分散式系統,只是維持1億使用者線上需要10萬臺伺服器,成本巨大,也只有FLAG、BAT這樣公司才有財力購買如此多的伺服器。
而同樣存在一些原因,讓我們避免考慮多執行緒的方式:
- 執行緒在計算和資源消耗的角度來說是比較昂貴的。
- 執行緒併發所帶來的問題,比如因為共享的記憶體空間而帶來的死鎖和競態條件。這些又會導致更加複雜的程式碼,在編寫程式碼的時候需要時不時地注意一些執行緒安全的問題。
為了解決這一問題,出現了「用同一程式/執行緒來同時處理若干連線」的思路,也就是I/O多路複用。
以Linux作業系統為例,Linux作業系統給出了三種監聽檔案描述符的機制,具體實現可參考:
-
select: 每個連線對應一個描述符(socket),迴圈處理各個連線,先查下它的狀態,ready了就進行處理,不ready就不進行處理。但是缺點很多:
- 每次呼叫select,都需要把fd集合從使用者態拷貝到核心態,這個開銷在fd很多時會很大
- 同時每次呼叫select都需要在核心遍歷傳遞進來的所有fd,這個開銷在fd很多時也很大
- select支援的檔案描述符數量太小了,預設是1024
-
poll: 本質上和select沒有區別,但是由於它是基於連結串列來儲存的,沒有最大連線數的限制。缺點是:
- 大量的的陣列被整體複製於使用者態和核心地址空間之間,而不管這樣的複製是不是有意義。
- poll的特點是「水平觸發(只要有資料可以讀,不管怎樣都會通知)」,如果報告後沒有被處理,那麼下次poll時會再次報告它。
-
epoll: 它使用一個檔案描述符管理多個描述符,將使用者關係的檔案描述符的事件存放到核心的一個事件表中,這樣在使用者空間和核心空間的copy只需一次。epoll支援水平觸發和邊緣觸發,最大的特點在於「邊緣觸發」,它只告訴程式哪些剛剛變為就緒態,並且只會通知一次。使用epoll的優點很多:
- 沒有最大併發連線的限制,能開啟的fd的上限遠大於1024(1G的記憶體上能監聽約10萬個埠)
- 效率提升,不是輪詢的方式,不會隨著fd數目的增加效率下降
- 記憶體拷貝,利用mmap()檔案對映記憶體加速與核心空間的訊息傳遞;即epoll使用mmap減少複製開銷
綜上所述,通過epoll的機制,給現代高階語言提供了高併發、高效能解決方案的基礎。而同樣FreeBSD推出了kqueue,Windows推出了IOCP,Solaris推出了/dev/poll。
而在Python3.4中新增了selectors模組,用於封裝各個作業系統所提供的I/O多路複用的介面。
那麼之前同樣的問題,我們可以通過I/O多路複用的機制實現併發。
寫一個程式每隔3秒列印“Hello World”,同時等待使用者命令列的輸入。使用者每輸入一個自然數n,就計算並列印斐波那契函式的值F(n),之後繼續等待下一個輸入
通過最基礎的輪詢機制(poll),輪詢標準輸入(stdin)是否變為可讀的狀態,從而當標準輸入能被讀取時,去執行計算Fibonacci數列。然後判斷時間是否過去三秒鐘,從而是否輸出”Hello World!”.
具體程式碼在example/hello_selectors_poll.py
中。
注意:在Windows中並非一切都是檔案,所以該例項程式碼無法在Windows平臺下執行。
import selectors
import sys
from time import time
from fib import timed_fib
def process_input(stream):
text = stream.readline()
n = int(text.strip())
print(`fib({}) = {}`.format(n, timed_fib(n)))
def print_hello():
print("{} - Hello world!".format(int(time())))
def main():
selector = selectors.DefaultSelector()
# Register the selector to poll for "read" readiness on stdin
selector.register(sys.stdin, selectors.EVENT_READ)
last_hello = 0 # Setting to 0 means the timer will start right away
while True:
# Wait at most 100 milliseconds for input to be available
for event, mask in selector.select(0.1):
process_input(event.fileobj)
if time() - last_hello > 3:
last_hello = time()
print_hello()
if __name__ == `__main__`:
main()
從上面解決問題的設計方案演化過程,從同步到併發,從執行緒到I/O多路複用。可以看出根本思路去需要程式本身高效去阻塞,
讓CPU能夠執行核心任務。意味著將資料包處理,記憶體管理,處理器排程等任務從核心態切換到應用態,作業系統只處理控制層,
資料層完全交給應用程式在應用態中處理。極大程度的減少了程式在應用態和核心態之間切換的開銷,讓高效能、高併發成為了可能。
非同步
通過之前的探究,不難發現一個同步的程式也能通過作業系統的介面實現“併發”,而這種“併發”的行為即可稱之為非同步。
之前通過I/O複用的所提供的解決方案,進一步抽象,即可抽象出最基本的框架事件迴圈(Event Loop),而其中最容易理解的實現,
則是回撥(Callback).
回撥
通過對事件本身的抽象,以及其對應的處理函式(handler),可以實現如下演算法:
維護一個按時間排序的事件列表,最近需要執行的定時器在最前面。這樣的話每次只需要從頭檢查是否有超時的事件並執行它們。
bisect.insort使得維護這個列表更加容易,它會幫你在合適的位置插入新的定時器事件組。
具體程式碼在example/hello_event_loop_callback.py
中。
注意:在Windows中並非一切都是檔案,所以該例項程式碼無法在Windows平臺下執行。
from bisect import insort
from fib import timed_fib
from time import time
import selectors
import sys
class EventLoop(object):
"""
Implements a callback based single-threaded event loop as a simple
demonstration.
"""
def __init__(self, *tasks):
self._running = False
self._stdin_handlers = []
self._timers = []
self._selector = selectors.DefaultSelector()
self._selector.register(sys.stdin, selectors.EVENT_READ)
def run_forever(self):
self._running = True
while self._running:
# First check for available IO input
for key, mask in self._selector.select(0):
line = key.fileobj.readline().strip()
for callback in self._stdin_handlers:
callback(line)
# Handle timer events
while self._timers and self._timers[0][0] < time():
handler = self._timers[0][1]
del self._timers[0]
handler()
def add_stdin_handler(self, callback):
self._stdin_handlers.append(callback)
def add_timer(self, wait_time, callback):
insort(self._timers, (time() + wait_time, callback))
def stop(self):
self._running = False
def main():
loop = EventLoop()
def on_stdin_input(line):
if line == `exit`:
loop.stop()
return
n = int(line)
print("fib({}) = {}".format(n, timed_fib(n)))
def print_hello():
print("{} - Hello world!".format(int(time())))
loop.add_timer(3, print_hello)
def f(x):
def g():
print(x)
return g
loop.add_stdin_handler(on_stdin_input)
loop.add_timer(0, print_hello)
loop.run_forever()
if __name__ == `__main__`:
main()