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()呼叫。
外層的 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 版本。