2026年10月25日 星期日

Python asyncio 與一般 TCP 程式有何不同?

Python 網路程式設計|入門到實作

Python asyncio 與一般 TCP 程式有何不同?

兩者都使用 TCP 傳送資料,差別在程式等待網路回應時,如何安排其他工作。本文用同步 socket 與 asyncio Stream API 建立一組 server/client,逐段說明連線、訊息收發、多用戶端處理,以及各自的優缺點。

一、先釐清:TCP 是通訊協定,asyncio 是程式設計方式

TCP 負責在兩台電腦之間建立可靠的位元組串流。它會處理資料順序、重傳等傳輸工作。無論使用一般的 socket,或使用 asyncio,底層都可以是 TCP。

一般 TCP 程式常使用阻塞式 socket:呼叫 accept()、recv() 或 sendall() 時,程式可能停下來等待作業系統完成工作。asyncio 則用事件迴圈和協程管理等待中的 I/O;當某個連線等待資料時,事件迴圈能轉去處理其他連線。

容易混淆的地方:「一般 TCP 程式」不一定只支援一個用戶端。它可以透過執行緒或多進程服務多個連線。本文先以單一連線的阻塞式版本作為基準,再說明如何擴充;asyncio 範例則直接示範多連線協程處理。
client 傳送位元組→TCP 傳輸→server 收到位元組→server 回覆

二、阻塞式與非同步的執行差異

項目阻塞式 socketasyncio
等待資料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

  1. socket.AF_INET 指 IPv4;socket.SOCK_STREAM 表示串流 socket,也就是常見的 TCP。
  2. bind((HOST, PORT)) 把 server 綁定到 IP 位址與埠號。使用 127.0.0.1 只接受本機連線。
  3. listen() 開始等待連線,accept() 取出一個已建立的連線。此簡化版一次只處理一個 client。
  4. recv(1024) 最多讀取 1024 位元組。一次 recv() 不保證剛好取得一整則訊息,所以程式用 buffer 暫存,直到找到換行符號。
  5. sendall() 持續送出資料,直到資料都交給作業系統傳送;這比只呼叫一次 send() 更適合初學範例。
多 client 的限制:這個 server 在處理目前 client 時,不會回到 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 的重點

  1. async def handle_client(...) 定義協程。每當有 client 連入,start_server() 會呼叫這個處理函式。
  2. reader 與 writer 是 asyncio 提供的串流讀寫介面。reader.readline() 讀到換行才回傳一則訊息。
  3. await 表示這裡可能需要等待。等待資料或等待寫入完成時,事件迴圈可以處理其他 client。
  4. writer.write() 把資料放入輸出緩衝區;await writer.drain() 等待緩衝區適度排空,避免大量資料不受控制地累積。
  5. asyncio.start_server() 啟動 TCP server;serve_forever() 持續接受連線。每個 client 都有自己的 handle_client 協程,因此連線可交錯處理。
  6. 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() 把它放到工作執行緒,避免鍵盤輸入期間事件迴圈完全停住。

為什麼要用換行分隔?TCP 不保留應用程式的「訊息包」邊界。reader.readline() 需要一個可辨識的結束標記;本範例採用換行。實際系統也常用固定長度標頭、長度前綴或 JSON Lines。

五、asyncio 的優點與限制

面向優點限制與注意事項
連線數適合管理大量同時等待 I/O 的連線,協程通常比每連線一個執行緒更輕量。仍受作業系統、網路、記憶體、檔案描述符與程式設計品質限制。
資源使用可減少大量執行緒的建立與切換成本。不是零成本;每個連線仍需要狀態與緩衝記憶體。
流程控制透過 await 清楚標出可能等待 I/O 的位置。忘記 await、阻塞事件迴圈或處理取消不當,會造成難查的錯誤。
CPU 運算適合網路、資料庫、檔案等 I/O 密集工作。不會自動加速長時間 CPU 計算;需考慮 process pool 或其他架構。
函式庫相容性許多現代網路函式庫有 async 介面。同步函式庫若直接在協程中執行,可能阻塞整個事件迴圈。

六、如何執行兩組程式

  1. 確認電腦已安裝 Python 3.9 以上版本。這些範例使用 Python 內建模組,不需額外安裝套件。
  2. 將 server 與 client 存成不同檔案,例如 tcp_server.py、tcp_client.py。
  3. 開啟第一個終端機,在程式所在資料夾執行 server:python tcp_server.py。asyncio 版本則執行對應的 asyncio_server.py。
  4. 再開第二個終端機執行 client:python tcp_client.py,或執行 python asyncio_client.py。
  5. 輸入訊息後按 Enter。輸入 /quit 結束 client;在 server 終端機按 Ctrl+C 停止 server。

要測試多個連線,可開啟多個 client 終端機。前述簡化的阻塞式 server 一次處理一個 client;asyncio server 可以在事件迴圈中交錯服務多個連線。

區網連線:若要讓同一區域網路的其他電腦連入,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 位元組串流的特性,以及阻塞函式可能對整個事件迴圈造成的影響。

教學提醒:範例為了說明核心流程而刻意保持精簡。正式服務還需設計逾時、錯誤回覆、身分驗證、加密、訊息大小限制、記錄與資源管理。

沒有留言:

張貼留言