多路複用I/O
在簡明網路I/O模型文章可以知道常用的IO
模型。其中同步模型中,使用多路複用I/O
可以提高伺服器的效能。
在多路複用的模型中,比較常用的有select
模型和poll
模型。這兩個都是系統介面,由作業系統提供。當然,Python
的select
模組進行了更高階的封裝。select
與poll
的底層原理都差不多。下面就介紹select
。
select 原理
網路通訊被Unix
系統抽象為檔案的讀寫,通常是一個裝置,由裝置驅動程式提供,驅動可以知道自身的資料是否可用。支援阻塞操作的裝置驅動通常會實現一組自身的等待佇列,如讀/寫等待佇列用於支援上層(使用者層)所需的block
或non-block
操作。裝置的檔案的資源如果可用(可讀或者可寫)則會通知程式,反之則會讓程式睡眠,等到資料到來可用的時候,再喚醒程式。
這些裝置的檔案描述符被放在一個陣列中,然後select
呼叫的時候遍歷這個陣列,如果對於的檔案描述符可讀則會返回改檔案描述符。當遍歷結束之後,如果仍然沒有一個可用裝置檔案描述符,select
讓使用者程式則會睡眠,直到等待資源可用的時候在喚醒,遍歷之前那個監視的陣列。每次遍歷都是線性的。
select 回顯伺服器
select
涉及系統呼叫和作業系統相關的知識,因此單從字面上理解其原理還是比較乏味。用程式碼來演示最好不過了。使用python
的select
模組很容易寫出下面一個回顯伺服器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
import select import socket import sys HOST = 'localhost' PORT = 5000 BUFFER_SIZE = 1024 server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.bind((HOST, PORT)) server.listen(5) inputs = [server, sys.stdin] running = True while True: try: # 呼叫 select 函式,阻塞等待 readable, writeable, exceptional = select.select(inputs, [], []) except select.error, e: break # 資料抵達,迴圈 for sock in readable: # 建立連線 if sock == server: conn, addr = server.accept() # select 監聽的socket inputs.append(conn) elif sock == sys.stdin: junk = sys.stdin.readlines() running = False else: try: # 讀取客戶端連線傳送的資料 data = sock.recv(BUFFER_SIZE) if data: sock.send(data) if data.endswith('\r\n\r\n'): # 移除select監聽的socket inputs.remove(sock) sock.close() else: # 移除select監聽的socket inputs.remove(sock) sock.close() except socket.error, e: inputs.remove(sock) server.close() |
執行上述程式碼,使用curl
訪問http://localhost:5000
,即可看命令列返回請求的HTTP request
資訊。
下面詳細解析上述程式碼的原理。
1 2 3 |
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.bind((HOST, PORT)) server.listen(5) |
上述程式碼使用socket
初始化一個TCP
套接字,並繫結主機地址和埠,然後設定伺服器監聽。
1 |
inputs = [server, sys.stdin] |
這裡定義了一個需要select
監聽的列表,列表裡面是需要監聽的物件(等於系統監聽的檔案描述符)。這裡監聽socket
套接字和使用者的輸入。
然後程式碼進行一個伺服器無線迴圈。
1 2 3 4 5 |
try: # 呼叫 select 函式,阻塞等待 readable, writeable, exceptional = select.select(inputs, [], []) except select.error, e: break |
呼叫了select
函式,開始迴圈遍歷監聽傳入的列表inputs
。如果沒有curl
伺服器,此時沒有建立tcp
客戶端連線,因此改列表內的物件都是資料資源不可用。因此select
阻塞不返回。
客戶端輸入curl http://localhost:5000
之後,一個套接字通訊開始,此時input
中的第一個物件server
由不可用變成可用。因此select
函式呼叫返回,此時的readable
有一個套接字物件(檔案描述符可讀)。
1 2 3 4 5 6 |
for sock in readable: # 建立連線 if sock == server: conn, addr = server.accept() # select 監聽的socket inputs.append(conn) |
select
返回之後,接下來遍歷可讀的檔案物件,此時的可讀中只有一個套接字連線,呼叫套接字的accept()
方法建立TCP
三次握手的連線,然後把該連線物件追加到inputs
監視列表中,表示我們要監視該連線是否有資料IO
操作。
由於此時readable
只有一個可用的物件,因此遍歷結束。再回到主迴圈,再次呼叫select
,此時呼叫的時候,不僅會遍歷監視是否有新的連線需要建立,還是監視剛才追加的連線。如果curl
的資料到了,select
再返回到readable
,此時在進行for
迴圈。如果沒有新的套接字,將會執行下面的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
try: # 讀取客戶端連線傳送的資料 data = sock.recv(BUFFER_SIZE) if data: sock.send(data) if data.endswith('rnrn'): # 移除select監聽的socket inputs.remove(sock) sock.close() else: # 移除select監聽的socket inputs.remove(sock) sock.close() except socket.error, e: inputs.remove(sock) |
通過套接字連線呼叫recv
函式,獲取客戶端傳送的資料,當資料傳輸完畢,再把監視的inputs
列表中除去該連線。然後關閉連線。
整個網路互動過程就是如此,當然這裡如果使用者在命令列中輸入中斷,inputs
列表中監視的sys.stdin
也會讓select
返回,最後也會執行下面的程式碼:
1 2 3 |
elif sock == sys.stdin: junk = sys.stdin.readlines() running = False |
有人可能有疑問,在程式處理sock
連線的是時候,假設又輸入了curl
對伺服器請求,將會怎麼辦?此時毫無疑問,inputs
裡面的server
套接字會變成可用。等現在的for
迴圈處理完畢,此時select
呼叫就會返回server
。如果inputs
裡面還有上一個過程的conn
連線,那麼也會迴圈遍歷inputs
的時候,再一次針對新的套接字accept
到inputs
列表進行監視,然後繼續迴圈處理之前的conn
連線。如此有條不紊的進行,直到for
迴圈結束,進入主迴圈呼叫select
。
任何時候,inputs
監聽的物件有資料,下一次呼叫select
的時候,就會繁返回readable
,只要返回,就會對readable
進行for
迴圈,直到for
迴圈結束在進行下一次select
。
主要注意,套接字建立連線是一次IO
,連線的資料抵達也是一次IO
。
select的不足
儘管select
用起來挺爽,跨平臺的特性。但是select
還是存在一些問題。
select
需要遍歷監視的檔案描述符,並且這個描述符的陣列還有最大的限制。隨著檔案描述符數量的增長,使用者態和核心的地址空間的複製所引發的開銷也會線性增長。即使監視的檔案描述符長時間不活躍了,select
還是會線性掃描。
為了解決這些問題,作業系統又提供了poll
方案,但是poll
的模型和select
大致相當,只是改變了一些限制。目前Linux
最先進的方式是epoll
模型。
許多高效能的軟體如nginx
, nodejs
都是基於epoll
進行的非同步。