9.13. TCP socket

TCP socket 有兩種看似不同、但底層型別相同的型態:連線到遠端伺服器的 用戶端 socket,會呼叫 connect();以及處理傳入連線的 伺服器 socket,會呼叫 bind()listen()accept()。兩種角色都使用 Socket 物件 中介紹的同一個 socket 類別,差別只在於對它們呼叫的方法不同。

9.13.1. TCP 用戶端

最簡單的用戶端會開啟一個連線、送出請求、讀取回應,然後關閉連線:

import socket

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("192.168.1.20", 9000))

s.send(b"hello\n")
reply = s.recv(1024)
print("reply:", reply)

s.close()

connect() 會執行 TCP -- 可靠的位元組串流 所介紹的三向交握,並在連線開啟後返回。send() 將位元組寫入連線;recv() 則從連線中讀取至多指定數量的位元組。應用程式完成後,close() 會關閉連線。

以下是同一段指令碼,改用 Socket 物件 中的 with 陳述式慣用寫法包裝,這樣即使某處拋出例外,socket 仍會被關閉:

import socket

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect(("192.168.1.20", 9000))
    s.send(b"hello\n")
    print(s.recv(1024))

9.13.1.1. 讀取直到完成

單次 recv() 回傳的是 至多 所要求數量的位元組,它可能回傳較少的位元組,因為 TCP 是一個串流,而非一連串訊息。應用程式必須持續讀取,直到取得完整的回應為止:

chunks = []
while True:
    chunk = s.recv(1024)
    if not chunk:                  # empty bytes -> other side closed
        break
    chunks.append(chunk)
reply = b"".join(chunks)

recv() 回傳空的 bytes 時,迴圈便結束。這代表對方已乾淨地關閉了它那一半的連線;在這種風格的協定中,應用程式會將「串流結束」視為與「訊息結束」相同。

9.13.1.2. 傳送直到完成

相對應的注意事項也適用於 send():它可能傳送 少於 所要求數量的位元組,並回傳實際寫入的位元組數。對於大型酬載,需重試尚未送出的剩餘部分:

payload = some_big_bytes
while payload:
    n = s.send(payload)
    payload = payload[n:]

sendall() 在內部完成這個迴圈,因此大多數程式碼只要呼叫它即可,省去手動重試:

s.sendall(some_big_bytes)

9.13.2. TCP 伺服器

伺服器端分為四個步驟:佔用一個連接埠、將 socket 切換為監聽模式、逐一接受連線、在每個被接受的 socket 上進行通訊。以下是一個最精簡的回應(echo)伺服器:

import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("0.0.0.0", 9000))
server.listen(1)
print("listening on port 9000")

while True:
    conn, addr = server.accept()
    print("connection from", addr)

    while True:
        data = conn.recv(1024)
        if not data:
            break
        conn.send(data)            # echo back

    conn.close()

逐步說明:

  • bind() 在相機上佔用一個主機與連接埠。"0.0.0.0" 表示接受任何介面上的連線;將它換成特定 IP 則會把監聽器限制在該介面上。

  • listen() 將 socket 從一般 socket 切換為 監聽 socket。其引數為 待處理佇列長度(backlog),也就是當應用程式忙碌時,MicroPython 會排入佇列的待處理連線數。請選一個小數字;1 在大多數情況下已足夠。

  • accept() 會封鎖,直到有用戶端連入,然後回傳 (conn, addr):一個代表這一條連線的 socket,以及用戶端的位址。監聽 socket 本身仍保持開啟,以接受更多連線。

  • 這次通訊的所有位元組都流經新的 socket conn。讀取與寫入使用的是與用戶端相同的 recv() / send() 呼叫。

  • 當用戶端關閉時,recv() 會回傳 b"";內層迴圈結束,伺服器以 close() 關閉它這一端。

外層的 while True 會跳回 accept() 以等待下一個用戶端。在這種寫法中,伺服器一次只處理一個用戶端;要並行處理多個用戶端,需要使用執行緒或 asyncio。後者正是下一頁的主題。

9.13.3. 常見陷阱

  • 把 recv() 當成以訊息為單位來看待。 它並非如此。兩次 send(b"hi") 呼叫可能會以單次 recv(4) 收到 b"hihi",也可能以兩次 recv(2) 收到。如果訊息邊界很重要,應用程式就必須自行加上分幀(framing)機制,例如換行符號、長度前綴,或任何其他方式。

  • 忘了在短量傳送時重試。 對於超過數百個位元組的任何資料,請使用 sendall()

  • 忘了關閉被接受的 socket。 每個 conn 都是獨立的 socket;關閉監聽 socket 並不會關閉被接受的那些 socket。在兩者上都使用 with 區塊,可讓這件事很難出錯:

    while True:
        with server.accept()[0] as conn:
            # ... talk on conn ...
    
  • 重新繫結到仍處於 TIME_WAIT 狀態的連接埠。 當伺服器在關閉後的幾秒內重新啟動時,bind() 可能會因「address in use」而失敗,因為 MicroPython 仍為前一條連線保留著該連接埠。在 bind() 之前加上 server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 可清除這個狀況。

9.13.4. 後續內容

accept() 上封鎖代表伺服器一次只能服務一個用戶端。在 recv() 上封鎖則代表單一個緩慢的用戶端就會卡住整個迴圈。在相機上的標準解法是 asyncio,將每條連線當成各自的工作(task)來執行,讓事件迴圈在它們之間進行調度。下一頁將介紹本頁所有內容的 asyncio 版本。