Python asyncio 與一般 TCP 程式有何不同?
兩者都使用 TCP 傳送資料,差別在程式等待網路回應時,如何安排其他工作。本文用同步 socket 與 asyncio Stream API 建立一組 server/client,逐段說明連線、訊息收發、多用戶端處理,以及各自的優缺點。
一、先釐清:TCP 是通訊協定,asyncio 是程式設計方式
TCP 負責在兩台電腦之間建立可靠的位元組串流。它會處理資料順序、重傳等傳輸工作。無論使用一般的 socket,或使用 asyncio,底層都可以是 TCP。
一般 TCP 程式常使用阻塞式 socket:呼叫 accept()、recv() 或 sendall() 時,程式可能停下來等待作業系統完成工作。asyncio 則用事件迴圈和協程管理等待中的 I/O;當某個連線等待資料時,事件迴圈能轉去處理其他連線。
二、阻塞式與非同步的執行差異
| 項目 | 阻塞式 socket | asyncio |
|---|---|---|
| 等待資料 | recv() 等待時,該執行緒暫停。 | await reader.readline() 等待時,協程讓出控制權;事件迴圈可執行其他工作。 |
| 並行處理連線 | 常見作法是每個 client 建立一個執行緒,或使用 selector 等機制。 | 通常以一個事件迴圈管理許多 I/O 協程,不必替每個連線各開一個執行緒。 |
| 程式閱讀 | 基本 socket 呼叫直觀,適合認識 TCP API。 | 需要理解 async、await、事件迴圈及協程取消。 |
| 適用工作 | 少量連線、簡單工具、短小服務。 | 大量網路等待、聊天服務、I/O 密集型應用。 |
asyncio 的「非同步」不表示 Python 程式會自動同時使用多個 CPU 核心。一般協程在同一個事件迴圈中輪流執行;當協程遇到可等待的 I/O 才讓出控制權。CPU 密集工作若長時間不讓出控制權,仍可能卡住整個事件迴圈。
三、一般 TCP 版本:阻塞式 server/client
以下範例使用換行符號 \n 作為一則訊息的結尾。TCP 傳送的是連續位元組,不會自動替應用程式標示每則訊息的邊界,因此雙方要自行約定訊息格式。
一般 TCP server
import socket
HOST = "127.0.0.1"
PORT = 8888
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((HOST, PORT))
server.listen()
print(f"Server listening on {HOST}:{PORT}")
conn, address = server.accept() # 阻塞:等第一個 client 連線
with conn:
print("Connected:", address)
conn.sendall(b"Connected. Type /quit to leave.\n")
buffer = b""
while True:
chunk = conn.recv(1024) # 阻塞:等資料抵達
if not chunk:
break
buffer += chunk
# TCP 是位元組串流;可能一次收到半行或多行
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
message = line.decode("utf-8", errors="replace").rstrip("\r")
if message == "/quit":
break
print(f"{address}: {message}")
conn.sendall(f"Server received: {message}\n".encode("utf-8"))
else:
continue
break
print("Server stopped.")
逐段理解一般 TCP server
socket.AF_INET指 IPv4;socket.SOCK_STREAM表示串流 socket,也就是常見的 TCP。bind((HOST, PORT))把 server 綁定到 IP 位址與埠號。使用127.0.0.1只接受本機連線。listen()開始等待連線,accept()取出一個已建立的連線。此簡化版一次只處理一個 client。recv(1024)最多讀取 1024 位元組。一次recv()不保證剛好取得一整則訊息,所以程式用buffer暫存,直到找到換行符號。sendall()持續送出資料,直到資料都交給作業系統傳送;這比只呼叫一次send()更適合初學範例。
accept() 接下一位。常見改法是在每次 accept() 後開一個執行緒處理該連線,或改用非阻塞 I/O。一般 TCP client
import socket
HOST = "127.0.0.1"
PORT = 8888
with socket.create_connection((HOST, PORT)) as client:
print(client.recv(1024).decode("utf-8").rstrip())
while True:
message = input("> ")
client.sendall((message + "\n").encode("utf-8"))
if message == "/quit":
break
# 此簡化範例假設每次回覆都會在一次 recv 中取得
response = client.recv(1024)
if not response:
print("Server closed the connection.")
break
print(response.decode("utf-8").rstrip())
client 的 create_connection() 建立 TCP 連線,input() 讀取鍵盤輸入,再將字串編碼成 UTF-8 位元組送出。這個版本採取「送一則、等一則回覆」的流程,因此簡單易懂;若要同時接收 server 主動推送的資料,就要再加入執行緒或其他 I/O 管理方式。
四、asyncio 版本:事件迴圈安排多個連線
asyncio server
import asyncio
HOST = "127.0.0.1"
PORT = 8888
async def handle_client(reader, writer):
peer = writer.get_extra_info("peername")
print(f"Client connected: {peer}")
writer.write(b"Connected. Type /quit to leave.\n")
await writer.drain()
try:
while True:
data = await reader.readline()
if not data: # client 正常關閉連線
break
message = data.decode("utf-8", errors="replace").rstrip("\r\n")
if message == "/quit":
break
print(f"{peer}: {message}")
writer.write(f"Server received: {message}\n".encode("utf-8"))
await writer.drain()
finally:
writer.close()
await writer.wait_closed()
print(f"Client disconnected: {peer}")
async def main():
server = await asyncio.start_server(handle_client, HOST, PORT)
print(f"Server listening on {HOST}:{PORT}")
async with server:
await server.serve_forever()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("Server stopped.")
asyncio server 的重點
async def handle_client(...)定義協程。每當有 client 連入,start_server()會呼叫這個處理函式。reader與writer是 asyncio 提供的串流讀寫介面。reader.readline()讀到換行才回傳一則訊息。await表示這裡可能需要等待。等待資料或等待寫入完成時,事件迴圈可以處理其他 client。writer.write()把資料放入輸出緩衝區;await writer.drain()等待緩衝區適度排空,避免大量資料不受控制地累積。asyncio.start_server()啟動 TCP server;serve_forever()持續接受連線。每個 client 都有自己的handle_client協程,因此連線可交錯處理。asyncio.run(main())建立並執行事件迴圈,程式結束時收尾關閉。
asyncio client:邊輸入、邊接收
import asyncio
HOST = "127.0.0.1"
PORT = 8888
async def receive_messages(reader):
while True:
data = await reader.readline()
if not data:
print("\nServer closed the connection.")
return
print(data.decode("utf-8", errors="replace").rstrip())
async def send_messages(writer):
while True:
# input() 是同步阻塞函式,移到工作執行緒以免卡住事件迴圈
message = await asyncio.to_thread(input, "> ")
writer.write(f"{message}\n".encode("utf-8"))
await writer.drain()
if message == "/quit":
return
async def main():
reader, writer = await asyncio.open_connection(HOST, PORT)
receiver = asyncio.create_task(receive_messages(reader))
try:
await send_messages(writer)
finally:
writer.close()
await writer.wait_closed()
receiver.cancel()
await asyncio.gather(receiver, return_exceptions=True)
if __name__ == "__main__":
asyncio.run(main())
client 把接收與傳送拆成兩個協程。asyncio.create_task() 讓接收工作先在背景執行;主流程等待使用者輸入並傳送訊息。由於內建的 input() 是阻塞函式,範例用 asyncio.to_thread() 把它放到工作執行緒,避免鍵盤輸入期間事件迴圈完全停住。
reader.readline() 需要一個可辨識的結束標記;本範例採用換行。實際系統也常用固定長度標頭、長度前綴或 JSON Lines。五、asyncio 的優點與限制
| 面向 | 優點 | 限制與注意事項 |
|---|---|---|
| 連線數 | 適合管理大量同時等待 I/O 的連線,協程通常比每連線一個執行緒更輕量。 | 仍受作業系統、網路、記憶體、檔案描述符與程式設計品質限制。 |
| 資源使用 | 可減少大量執行緒的建立與切換成本。 | 不是零成本;每個連線仍需要狀態與緩衝記憶體。 |
| 流程控制 | 透過 await 清楚標出可能等待 I/O 的位置。 | 忘記 await、阻塞事件迴圈或處理取消不當,會造成難查的錯誤。 |
| CPU 運算 | 適合網路、資料庫、檔案等 I/O 密集工作。 | 不會自動加速長時間 CPU 計算;需考慮 process pool 或其他架構。 |
| 函式庫相容性 | 許多現代網路函式庫有 async 介面。 | 同步函式庫若直接在協程中執行,可能阻塞整個事件迴圈。 |
六、如何執行兩組程式
- 確認電腦已安裝 Python 3.9 以上版本。這些範例使用 Python 內建模組,不需額外安裝套件。
- 將 server 與 client 存成不同檔案,例如
tcp_server.py、tcp_client.py。 - 開啟第一個終端機,在程式所在資料夾執行 server:
python tcp_server.py。asyncio 版本則執行對應的asyncio_server.py。 - 再開第二個終端機執行 client:
python tcp_client.py,或執行python asyncio_client.py。 - 輸入訊息後按 Enter。輸入
/quit結束 client;在 server 終端機按 Ctrl+C 停止 server。
要測試多個連線,可開啟多個 client 終端機。前述簡化的阻塞式 server 一次處理一個 client;asyncio server 可以在事件迴圈中交錯服務多個連線。
0.0.0.0,client 的 HOST 則改為 server 電腦的區網 IP;也要確認作業系統防火牆允許該埠號。不要把未加密、未驗證的教學 server 直接暴露到公網。七、什麼時候選哪一種?
- 教學初期想認識 IP、埠號、
bind、listen、accept、send、recv:先寫一般 socket 版本。 - 服務需要同時等待許多 client、網路 API 或資料庫回覆:可考慮 asyncio,並搭配支援 async 的函式庫。
- 工作主要是影像處理、模型推論或大型計算:先分析 CPU 負載;asyncio 本身不會讓計算平行化。
- 系統很小、只有少數連線:選團隊較容易維護的寫法,通常比追求某種模式更重要。
結語
一般阻塞式 TCP 程式會在呼叫讀取或接受連線時等待,適合入門與簡單服務。asyncio 把等待中的工作交給事件迴圈管理,讓程式可在等待某個 client 時處理其他 client,適合 I/O 密集且連線較多的服務。理解差異的關鍵,是知道 await 讓出控制權的時機、TCP 位元組串流的特性,以及阻塞函式可能對整個事件迴圈造成的影響。
沒有留言:
張貼留言