Learning Objectives
- 用 bind、recvfrom、sendto 寫一個 UDP 伺服器,說明一個 UDP socket 怎麼服務很多個寄件人
- 寫一個依序服務多個用戶端的 TCP 伺服器,說明還沒輪到的連線在哪裡等
- 設計一個一行一個指令、以 OK/ERROR 回應的簡單文字協定
- 說明 TCP 沒有訊息邊界,用緩衝區加換行(或長度欄位)切出一則一則訊息
- 用逾時與重送讓 UDP 程式在封包遺失時還能完成工作,並說明重送可能造成重複
從連得上到講得通
上一課用 TCP 寫了用戶端與伺服器:建立連線、送資料、收資料、關閉。這一課處理接下來一定會遇到的三個問題:
- UDP 怎麼寫伺服器? UDP 沒有連線,伺服器不需要
listen、accept,一個 socket 就能收所有人的資料(第 15 課講過 UDP 的特性)。 - 雙方怎麼知道一則訊息到哪裡結束? 兩支程式要溝通,除了連得上,還要約定訊息的格式,這份約定就是協定(protocol)。 HTTP、SMTP、Redis 都是這樣設計出來的,你也可以設計自己的。
- 封包遺失怎麼辦? TCP 會自動重傳;UDP 不會,要由你的程式決定等多久、要不要重送。
| 問題 | 這一課的做法 |
|---|---|
| UDP 伺服器 | bind 之後用 recvfrom 收、用 sendto 回覆給寄件人 |
| 多個用戶端 | UDP:同一個 socket 依到達順序處理;TCP:accept 一個、服務完再 accept 下一個 |
| 訊息邊界 | 一行一則訊息,用緩衝區湊滿一行才處理 |
| 遺失 | settimeout 設等待上限,逾時就重送,最多幾次 |
這一課的程式跑在模擬網路上:右側的時序圖畫出每個資料報與 TCP 區段,遺失的資料報畫成 ×。 模擬網路的延遲是示意值;真的網路上,延遲與遺失的情況會隨時間變化。
UDP 伺服器:bind、recvfrom、sendto
TCP 伺服器的流程是 bind → listen → accept 得到一條連線 → 在那條連線上 recv、send。
UDP 沒有連線,所以少了中間兩步:
| TCP 伺服器 | UDP 伺服器 | |
|---|---|---|
| 建立 | socket(AF_INET, SOCK_STREAM) |
socket(AF_INET, SOCK_DGRAM) |
| 綁定位址與連接埠 | bind |
bind |
| 等待 | listen、accept(每個用戶端一個新的 socket) |
不需要 |
| 收 | conn.recv(n):拿到一段位元組 |
recvfrom(n):拿到一整個資料報和寄件人位址 |
| 回覆 | conn.sendall(資料) |
sendto(資料, 寄件人位址) |
UDP 伺服器只有一個 socket,所有用戶端的資料報都排在同一個佇列,recvfrom 依到達的順序一個一個拿。
每次都要記下 recvfrom 回傳的位址,回覆時用 sendto 寄回去,因為 socket 本身不記得「對方是誰」。
recvfrom(1024) 的 1024 是這次最多收幾個位元組。UDP 保留訊息邊界:對方 sendto 一次,你 recvfrom 一次就拿到那一整個資料報,
不會黏在一起也不會被切開(資料報比 1024 長時,多出來的部分在多數系統上會被丟掉,所以上限要設得夠大)。
逐步看:一個 UDP socket 收兩個感測器的資料
兩個感測器 S1、S2 每 0.6 秒送一筆讀值到 PC1 的 9000 埠,兩個錯開 0.3 秒。看第 11 行:程式停在 recvfrom,
時間直接跳到下一個資料報到達的時刻;addr 每次都不同,伺服器靠它知道要回覆給誰。
TCP 伺服器依序服務多個用戶端
TCP 伺服器每次 accept 得到一條連線,在那條連線上收送,直到對方關閉(recv 回傳空的 b""),再回去 accept 下一個。
這是最簡單的寫法,叫做依序(iterative)伺服器:
srv.listen()
while True:
conn, addr = srv.accept() # 等下一個用戶端
serve(conn) # 服務這個用戶端,直到對方關閉
conn.close()
服務第一個用戶端時,第二個用戶端如果也來連線,作業系統會先替它完成三向交握,把連線放在 listen 的等待佇列(backlog)裡,
等程式下一次 accept 才拿出來。所以第二個用戶端不會被拒絕,只是要等。
這種寫法的缺點也很明顯:一個用戶端很慢(或一直不關閉),後面的人全部都要等。實務上會用執行緒(threading)、
selectors(同時等待很多個 socket)或 asyncio 同時服務多個連線;這一課的模擬網路只支援依序的寫法,觀念上先把一個連線處理好。
設計一個文字協定,以及 TCP 的訊息邊界
設計協定時要回答幾個問題:一則訊息長什麼樣子、怎麼知道它結束了、成功和失敗各回什麼。 最容易除錯的是文字協定:一行一個指令,一行一個回應,用肉眼就看得懂(SMTP、Redis 的指令都長這樣)。
這一課的範例服務(模擬網路裡的 SRV,6000 埠)用這份約定:
| 用戶端送出 | 伺服器回應 | 說明 |
|---|---|---|
SET 鍵 值 |
OK |
存一筆資料 |
GET 鍵 |
VALUE 值 或 NOT_FOUND |
讀一筆資料 |
QUIT |
BYE,然後關閉連線 |
結束 |
| 其他 | ERROR unknown command |
不認得的指令 |
每一則訊息都以換行 \n 結束,這就是訊息邊界。為什麼一定要約定邊界?因為 TCP 是位元組流(byte stream):
- 對方
sendall三次,你可能一次recv就全部拿到(黏在一起) - 對方
sendall一次,你也可能要recv好幾次才拿完(被切開)
TCP 只保證位元組的順序正確、不遺失,不保證「一次送」對應「一次收」。所以接收端要準備一個緩衝區:
收到的位元組先接在後面,裡面出現完整的一行才拿出來處理,剩下的半行留著等下一次 recv。
切訊息常見的兩種方法:
| 方法 | 做法 | 適合 |
|---|---|---|
| 分隔字元 | 每則訊息以 \n 結束 |
文字協定;內容本身不能含換行 |
| 長度欄位 | 先送固定 4 個位元組表示長度,再送內容 | 二進位資料、內容可能含任何位元組 |
UDP 沒有這個問題:一個資料報就是一則訊息,但一則訊息不能超過一個資料報的大小。
逐步看:三個指令的回應黏在一起
用戶端一次送出三行指令。看第 7 行的 recv(1024):伺服器分三次回應,用戶端卻一次就收到三行;程式要自己用換行切開。
逐步看:用緩衝區處理被切開的訊息
自己寫伺服器,實作一個只有 ADD 的小協定。C1 先連進來,第二個指令 ADD 10 20 被切成兩段送到;
C1 關閉之後,C2 才被 accept,它的兩行指令卻在同一段裡。看 buf 的內容怎麼變化。
小測驗:UDP 伺服器與訊息邊界
UDP 伺服器回覆用戶端時,為什麼要用 sendto(資料, addr),而不是像 TCP 那樣直接 send(資料)?
下面模擬接收端緩衝區的程式會印出什麼?
buf = b""
for chunk in [b"SET a 1\nGE", b"T a\n"]:
buf += chunk
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
print(line.decode())
print(len(buf))
依序(iterative)的 TCP 伺服器正在服務 C1 時,C2 也來連線。C2 會怎樣?
UDP 的逾時與重送
UDP 送出就不管了:資料報在路上遺失,送的一方不會收到任何通知;recvfrom 沒有設逾時,就會一直等下去。
UDP 的規格 RFC 768 寫明它不保證送達、也不防止重複。所以用 UDP 做「一問一答」(例如 DNS 查詢)時,程式要自己負責三件事:
- 設等待上限:
sock.settimeout(0.5),recvfrom超過 0.5 秒沒收到就丟出TimeoutError(舊名稱socket.timeout)。 - 重送:接住
TimeoutError,再送一次。 - 設重送次數上限:網路真的斷了,不能無限重送;試了幾次都失敗就回報錯誤。
sock.settimeout(0.5)
for attempt in range(3):
sock.sendto(request, server)
try:
data, addr = sock.recvfrom(1024)
break # 收到了
except TimeoutError:
pass # 逾時,下一圈重送
else:
print("試了 3 次都失敗") # for 迴圈沒有 break 才會執行 else
逾時要設多長?太短會在回覆還在路上時就重送,太長則遺失時要等很久。實務上常依量到的來回時間調整, 並且每次失敗把等待時間拉長(例如 0.5、1、2 秒),避免網路壅塞時大家一起重送讓情況更糟。
逐步看:第一個資料報遺失,逾時後重送
模擬網路設定讓 PC1 送出的第 1 個 UDP 資料報在路上遺失。看時序圖上的 ×,以及第 12 行等了 0.5 秒之後跳到 except。
逐步看:遺失的是回覆,重送讓伺服器做了兩次
這次請求順利到達,遺失的是伺服器的第 1 個回覆。用戶端分不出是哪一個遺失,只能照樣重送; 伺服器收到兩次「點數加 100」,就加了兩次。看時序圖上兩個往右的 INCR 與那一個 ×。
小測驗:逾時、重送與協定設計
下面模擬重送的程式會印出什麼?(lost 表示第幾次送出會遺失)
lost = {1, 2}
for attempt in range(1, 4):
if attempt in lost:
print("timeout")
continue
print("ok", attempt)
break
else:
print("give up")
協定要傳送一張圖片(任何位元組都可能出現),用哪一種方式標出訊息邊界比較適合?
用戶端用 UDP 查詢,settimeout(0.5),最多送 3 次。伺服器已經關機,這個查詢最久大約多久會放棄?
重點整理
- UDP 伺服器:
bind之後recvfrom拿到「資料+寄件人位址」,用sendto回覆;一個 socket 依到達順序服務所有人。 - 依序的 TCP 伺服器:
accept一個、服務到對方關閉、再accept下一個;還沒輪到的連線在 listen 的等待佇列裡。 - 協定要約定訊息格式、邊界與回應;文字協定一行一則,成功回
OK、失敗回ERROR,容易除錯。 - TCP 沒有訊息邊界:用緩衝區累積,湊滿一行(或收滿長度欄位指定的位元組)才處理。UDP 一個資料報就是一則。
- UDP 的一問一答要自己做
settimeout、重送與次數上限;重送可能讓伺服器收到重複的請求(例如回覆遺失時),協定要能處理:讓請求做幾次結果都一樣,或帶編號讓伺服器認出重複。
這一課的伺服器沒有任何存取控制,只要連得到就能用。下一課講網路安全:防火牆與 ACL 怎麼只讓需要的連線通過。