Python異步編程原理篇之IO多路複用模塊selector

image

selector 簡介

selector 是一個實現了IO複用模型的python包,實現了IO多路複用模型的 select、poll 和 epoll 等函數。
它允許程序同時監聽多個文件描述符(例如套接字),並在其中任何一個就緒時進行相應的操作。這樣可以有效地管理併發 I/O 操作,提高程序的性能和資源利用率。

image

本篇主要講解selector編程示例,以socket編程爲主題,首先分析阻塞IO模型的網絡編程,然後對比selector實現的IO多路複用模型的網絡編程。

阻塞IO模型下的 socket 網絡編程

通過socket實現最簡單的客戶端和服務端通信的功能,阻塞IO模型的特點就是在文件IO或網絡IO時獲取數據的函數會一直阻塞,直到數據到來。
服務端:server.py

import socket

# 創建TCP套接字
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# 綁定IP地址和端口號
server_address = ('0.0.0.0', 12346)
server_socket.bind(server_address)

# 監聽連接
server_socket.listen()

# 接受客戶端連接請求
print("服務器已啓動,等待客戶端連接...")
client_socket, client_address = server_socket.accept()
print(f"與客戶端 {client_address} 建立連接")

# 向客戶端發送消息
message = "歡迎連接到服務器!"
client_socket.sendall(message.encode())

while True:

    # 從客戶端接收消息
    data = client_socket.recv(1024).decode()
    print(f"客戶端消息:{data}")
    client_socket.sendall(f"服務器收到消息:{data}".encode())
    if data == "close":
        # 關閉客戶端套接字
        client_socket.close()

客戶端:client.py

import socket

# 創建TCP套接字
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# 服務器地址和端口號
server_address = ('localhost', 12346)

# 連接服務器
client_socket.connect(server_address)

# 接收服務器的消息
data = client_socket.recv(1024).decode()
print(f"已連接到服務器, 服務器消息:{data}")

while True:
    # 向服務器發送消息
    message = input()
    if message == "end":
        break
    client_socket.sendall(message.encode())
    print(client_socket.recv(1024).decode())

# 關閉客戶端套接字
client_socket.close()

啓動服務端,未啓動客戶端

image

啓動服務端,代碼執行到client_socket, client_address = server_socket.accept()暫停,accept 是一個阻塞函數。
函數說明:
client_socket, client_address = server_socket.accept()
阻塞接口,等待客戶端的連接,沒有客戶端連接時阻塞等待,有客戶端連接時返回新的聊天socket,用於後續發送和接收消息

繼續啓動客戶端

image

啓動客戶端之後,客戶端連接服務端,服務端代碼執行到data = client_socket.recv(1024).decode(), recv 是一個阻塞函數。
函數說明:
data = client_socket.recv(1024).decode()
阻塞接口,等待緩衝區有消息到來。沒有消息時阻塞等待,有消息到來返回消息內容

客戶端發送消息

image

客戶端發送消息,data = client_socket.recv(1024).decode()收到消息,從網絡協議棧中獲取消息,並返回。繼續whie True 循環的下一輪循環,阻塞在相同地方。

IO多路複用模型下的socket 網絡編程 selector

selector是實現IO多路複用模型的模塊,首先回憶一下IO多路複用。
IO多路複用是通過selectpollepoll監聽文件句柄,當有文件句柄處於就緒狀態,就通知對應的應用程序處理。

服務端:server.py

import selectors
import socket

# 選擇一個當前平臺最優的IO多路複用模型
sel = selectors.DefaultSelector()


def accept(server_socket):
    conn, addr = server_socket.accept()
    print(f"與客戶端 {addr} 建立連接")

    conn.setblocking(False)  # 設定非阻塞
    # 註冊conn對象到selector中,當conn可讀時,返回conn和回調函數read
    sel.register(conn, selectors.EVENT_READ, read)


def read(conn):
    data = conn.recv(1024).decode('utf-8')
    print(f"客戶端消息:{data}")
    if data == "close":
        sel.unregister(conn)
        conn.close()
    else:
        conn.sendall(f"服務器收到消息:{data}".encode())


if __name__ == "__main__":
    sock = socket.socket()
    sock.bind(("0.0.0.0", 9999))
    sock.listen()
    sock.setblocking(False)  # 設置sock非阻塞
    # 將sock註冊到selector中,當sock可讀時,返回sock和回調函數accept
    sel.register(sock, selectors.EVENT_READ, accept)

    print("創建事件循環")
    while True:
        events = sel.select()  # 阻塞運行,有就緒的事件返回就緒事件列表
        for key, _ in events:
            print(key)
            # key.data: 註冊的回調函數  key.fileobj: 註冊的文件句柄
            callback = key.data  # 註冊的回調函數
            callback(key.fileobj)

主要的函數:
一、自動選擇文件IO模型selectors.DefaultSelector()
選擇一個當前平臺最優的IO模型,一般來說是epoll或kqueue。存在的可選項包括:

  • SelectSelector
  • PollSelector
  • EpollSelector
  • DevpollSelector
  • KqueueSelector

DefaultSelector 是一個指向當前平臺上可用的最高效實現的別名,當選擇epoll時,可以認爲 sel = EpollSelector
返回:一個select對象

二、文件註冊 sel.register(sock, selectors.EVENT_READ, accept)
函數原型:

register(fileobj, events, data=None)

註冊一個用於選擇的文件對象,在其上監視 I/O 事件。
fileobj 是要監視的文件對象。 它可以是整數形式的文件描述符或者具有 fileno() 方法的對象。
events 是要監視的事件的位掩碼。
data 是一個任意對象或變量。
返回:
這將返回一個新的 SelectorKey 實例,實例具體內容見下一個函數的key

三、獲取就緒文件events = sel.select()
函數原型:

select(timeout=None)

可用於遍歷獲取狀態變爲就緒註冊的文件,如果設置超時時間則可能會拋出超時異常。
返回:一個(key, events)的元組,

  • key: 一個SelectorKey類的實例,包括

        fileobj: 已註冊的文件對象。
        fd: 下層的文件描述符
        events: 必須在此文件對象上被等待的事件。
    
  • events:文件句柄可讀還是可寫的標識。爲EVENT_READ或EVENT_WRITE,或者二者的組合

client.py

import socket

client = socket.socket(family=socket. AF_INET, type=socket.SOCK_STREAM)
host = socket.gethostname()
client.connect((host, 9999))

while True:
    data = input("客戶端發送數據:").strip()
    client.send(data.encode())
    if data == "end" or data == "":
        client.close()
        break
    print(client.recv(1024).decode("utf-8"))

服務端啓動,客戶端未啓動
image

if __name__ == "__main__":
    sock = socket.socket()
    sock.bind(("0.0.0.0", 9999))
    sock.listen()
    sock.setblocking(False)  # 設置sock非阻塞
    # 將sock註冊到selector中,當sock可讀時,返回sock和回調函數accept
    sel.register(sock, selectors.EVENT_READ, accept)

    print("創建事件循環")
    while True:
        events = sel.select()  # 阻塞運行,有就緒的事件返回就緒事件列表

服務端啓動,代碼完成的功能包括:

  1. 創建一個socket,並綁定IP,監聽端口
  2. 設置socket爲非阻塞,否則超時會報錯
  3. 將socket註冊到 selector 中,等待socket就緒,綁定就緒之後的回調函數accept
  4. 進入while True循環,訪問select返回的就緒列表。這個阻塞函數,沒有文件讀寫就緒就會阻塞。

繼續啓動客戶端
image

啓動一個客戶端,客戶端連接到服務端,socket文件句柄有連接請求,select返回可讀狀態的socket。返回的events是一個列表,當中只有一個就緒的文件句柄。

key.data拿到註冊的回調函數也就是accept函數,key.fileobj拿到文件句柄的socket對象。調用accept函數,傳入socket對象。

def accept(server_socket):
    conn, addr = server_socket.accept()
    print(f"與客戶端 {addr} 建立連接")

    conn.setblocking(False)  # 設定非阻塞
    # 註冊conn對象到selector中,當conn可讀時,返回conn和回調函數read
    sel.register(conn, selectors.EVENT_READ, read)

accept中先通過accept接收連接,返回通信使用的文件句柄conn,然後設置conn爲非阻塞,最後將conn阻塞到selector中,傳入回調函數read。等conn文件句柄可讀時,就表示有數據發送過來,就可以調用read函數讀取內容了。

客戶端發送消息

image

客戶端發送消息時,selector會返回可讀狀態的conn文件句柄,從返回對象中獲取回調函數,調用回調函數read,傳入文件句柄。

def read(conn):
    data = conn.recv(1024).decode('utf-8')
    print(f"客戶端消息:{data}")
    if data == "close":
        sel.unregister(conn)
        conn.close()
    else:
        conn.sendall(f"服務器收到消息:{data}".encode())

在read函數中,首先獲取了網絡協議棧中的消息內容,然後判斷消息是否爲關閉連接。如果不是則發送一條消息給對方。

整個基於IO多路複用模型的網絡編程流程就是這樣。

selector 原理分析

selector是操作系統的IO多路複用模型的一種實現。通過selectpollepoll監聽文件句柄,在文件句柄可讀的狀態下,會返回就緒的文件句柄。

返回就緒狀態文件句柄

sel = selectors.DefaultSelector()
while True:
    events = sel.select()

循環中訪問sel.select()就是監聽文件句柄狀態的函數,一個阻塞函數。應用程序調用該函數後會等待,直到有數據到來,數據從設備發送到內核空間,在socket編程中就是數據流從網卡到內核空間中。當數據到達內核空間中,該函數返回文件句柄相關的內容。

image

數據拷貝

當文件句柄就緒之後,就可以從文件句柄裏讀取數據了。
image

在selector中相關的函數是

  • conn, addr = server_socket.accept()
  • data = conn.recv(1024).decode('utf-8')

總結

一個完整的IO多路複用模型就是由兩個部分組成,分別是

  • 返回就緒狀態文件句柄
  • 數據拷貝

image

asyncio 和 selector 的關係

selectors 則是 asyncio 的底層實現之一。asyncio實現的協程是由事件循環+ 任務組成的,而selector就是事件循環的重要依賴模塊。
asyncio 使用了 selectors 模塊來實現底層的併發 I/O 操作。通過將 selectors 的功能封裝爲 asyncio 提供的事件循環(Event Loop)和其他協程相關的工具。

回顧一下事件循環的機制

任務列表 = [ 任務1, 任務2, 任務3,... ]

while True:
    可執行的任務列表,已完成的任務列表 = 去任務列表中檢查所有的任務,將'可執行'和'已完成'的任務返回
    
    for 就緒任務 in 已準備就緒的任務列表:
        執行已就緒的任務
        
    for 已完成的任務 in 已完成的任務列表:
        在任務列表中移除 已完成的任務

    如果 任務列表 中的任務都已完成,則終止循環

事件循環就是一個while True的循環,循環中做的事情有三個:

  1. 獲取就緒狀態的任務和已完成的任務
  2. 執行就緒狀態的任務
  3. 移除已完成的任務

那麼selector的功能在事件循環中的功能就非常明顯了,就是負責返回IO相關的就緒任務。

asyncio 庫使用了底層的 selectors 模塊來監聽和管理文件描述符的狀態變化,並在合適的時候將控制權交給其他的協程。這樣可以實現非阻塞的 I/O 操作,並支持高併發和並行執行。
selectors 提供了底層的 I/O 多路複用機制,而 asyncio 在其之上提供了更高級的異步編程框架。

附錄asyncio模塊事件循環核心模塊

def run_forever(self):
    """Run until stop() is called."""
    self._check_closed()
    self._check_running()
    self._set_coroutine_origin_tracking(self._debug)

    old_agen_hooks = sys.get_asyncgen_hooks()
    try:
        self._thread_id = threading.get_ident()
        sys.set_asyncgen_hooks(firstiter=self._asyncgen_firstiter_hook,
                               finalizer=self._asyncgen_finalizer_hook)

        events._set_running_loop(self)
        while True:
            self._run_once()
            if self._stopping:
                break
    finally:
        self._stopping = False
        self._thread_id = None
        events._set_running_loop(None)
        self._set_coroutine_origin_tracking(False)
        sys.set_asyncgen_hooks(*old_agen_hooks)

連載一系列關於python異步編程的文章。包括同異步框架性能對比、異步事情驅動原理等。歡迎關注微信公衆號第一時間接收推送的文章。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章