UDP:明信片,而非通話
你目前寫過的每一個通訊端都是 TCP 通訊端——一條可靠、有序、以連線為基礎的串流。在任何資料移動之前,三向交握先建立起一條連線,從那之後 TCP 保證你 write() 的任何東西都會以相同的順序從另一邊出來,不遺失、不重複、不錯亂。它感覺就像兩支程式之間一條私有的電話線。UDP 是 TCP 與 UDP 抉擇的另一半,而它刻意地把那一切幾乎全都丟掉。
一個 UDP 通訊端送出的是資料包(datagram):自成一體的小封包,每一個都寫好地址、像投進信箱的明信片一樣丟進網路。沒有交握、沒有連線、沒有串流的概念。每個資料包要嘛完整抵達、要嘛根本不到——網路可能弄丟它、送來兩份副本,或把三個資料包亂序送達,而 UDP 不會告訴你、也不會替你修。作為交換,你得到 TCP 給不了的東西:不必建立連線、沒有逐位元組的排序機制、而且延遲更低,因為一個資料包在你交出它的那一刻就能離開。明信片不可靠,但它快,而且開始寄送不費分文。
在沒有連線的情況下收送
因為沒有連線,UDP 的 API 把那些用來建立連線的呼叫都拿掉了。你仍然呼叫 socket(),但用的是 SOCK_DGRAM 而不是 SOCK_STREAM。沒有 connect()、沒有 listen()、沒有 accept()——一個 UDP 伺服器只要 bind() 到一個埠就開始接收。而因為每個資料包各自寫好地址,讀寫呼叫的樣子也跟著變:你不再對一個已連線的對象用 send() 與 recv(),而是用 sendto()——它每次呼叫都帶上目的地址——以及 recvfrom()——它在交給你資料的同時,也把寄件者的地址一併交給你。地址隨每個封包一起旅行,而不是在連線時一次定死。
/* UDP server core: bind, then loop on recvfrom */
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd == -1) { perror("socket"); return 1; }
struct sockaddr_in me = {0};
me.sin_family = AF_INET;
me.sin_port = htons(9000); /* host -> network byte order */
me.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(fd, (struct sockaddr *)&me, sizeof me) == -1) {
perror("bind"); return 1;
}
char buf[1500]; /* one datagram at a time */
struct sockaddr_in from;
socklen_t fromlen = sizeof from;
ssize_t n = recvfrom(fd, buf, sizeof buf, 0,
(struct sockaddr *)&from, &fromlen);
if (n == -1) { perror("recvfrom"); return 1; }
/* reply straight to whoever sent it: */
sendto(fd, buf, n, 0, (struct sockaddr *)&from, fromlen);注意那個緩衝區大小設成 1500 位元組,並用單一一次 recvfrom() 讀進來。這是刻意的,也是它與 TCP 最深的實務差異:UDP 有訊息邊界,而 TCP 沒有。當寄件者做一次 100 位元組的 sendto() 時,接收者就恰好得到一次 100 位元組的 recvfrom()——一個資料包進、一個資料包出,絕不與下一個合併、也絕不被切開。你學過的位元組順序規則依然適用:埠與位址照舊穿過 htons() 與 htonl() 進入網路位元組順序,因為那部分的世界沒有改變。
訊息邊界:那個會咬人的差異
這個邊界差異值得單獨給它一刻,因為它也往另一個方向切,並且絆倒每一個在兩者之間往返的人。TCP 是一條純粹的位元組串流:它保留你位元組的順序與數量,卻不保留你的 write() 邊界。如果一個 TCP 寄件者先寫 "HELLO" 再寫 "WORLD",接收者可能在一次呼叫裡 read() 到 "HELLOWORLD",或先 "HEL" 再 "LOWORLD",或任何其他的切法——串流記得十個位元組的順序,卻徹底忘了你是當成兩則訊息送出的。
這正是為什麼一個真正的 TCP 伺服器,就是你在上一篇建的那種,不能單純假設一次 read() 等於一則訊息。它必須在串流之上加上自己的訊框——一個長度前綴、一個像換行符的分隔符、或一個固定的記錄大小——並反覆迴圈直到一整則訊息抵達,過程中容忍短讀。UDP 根本沒有這個問題:資料包就是那個訊框。反過來的代價是我們已經見過的那個——那個資料包可能永遠不到,而且沒有串流來抹平那個缺口。你拿 TCP 的訊框雜務去換 UDP 的可靠性雜務;這兩件雜務之中,總有一件是你的。
阻塞:每個通訊端呼叫裡那聲安靜的暫停
現在來到第二個假設。你寫過的每一個通訊端呼叫都是阻塞的,而這是預設。當你呼叫 recv() 而還沒有資料抵達時,你的程式不會回傳——它去睡了。核心把整個執行緒泊靠起來、把 CPU 交給別人,只在終於有一個位元組冒出來時才喚醒你。對 accept() 在沒有待處理連線時、對 connect() 在交握期間、對 send() 在核心的傳送緩衝區滿了時,都是同樣的情形。從你程式碼的角度看,那個呼叫不過是花了很長時間;在底下,阻塞與非阻塞的抉擇談的是誰在等、以及怎麼等。
對一支只和一個對象說話的程式而言,阻塞是一種福氣:你的程式碼從上到下讀起來像一則故事,而核心安靜地處理等待、不燒掉任何 CPU。麻煩出現在你必須同時服務許多對象的那一刻。如果你那唯一的執行緒正睡在 recv() 裡頭、等著客戶端 A,它就對客戶端 B 充耳不聞——而 B 此刻正想送你些東西。一個阻塞呼叫只盯著一個通訊端;當它睡著時,每一條其他連線都得等輪到自己。這正是上一篇 TCP 伺服器撞上的那堵牆,而穿過它有三種經典辦法。
- 每條連線配一個行程或執行緒:簡單,而且讓每條連線各自安心地阻塞。但執行緒與行程要耗記憶體與情境切換,所以這在數千條左右的某處就停止擴展了。
- 非阻塞通訊端加上一個輪詢迴圈:讓那些呼叫立刻回傳,並問核心哪些通訊端就緒了,於是一個執行緒就能照看許多條。這正是本篇接下來要朝向建立的東西。
- 非同步或以完成為基礎的 API:把一個請求與一個放答案的地方交給核心,稍後再被告知它何時完成。一個不同的模型,下面會以「就緒對完成」點到。
非阻塞通訊端,與詢問「誰就緒了?」
一個非阻塞通訊端,是你把它從預設行為切換出來的那種,通常用 fcntl(fd, F_SETFL, O_NONBLOCK) 來做。在那之後,規則變了:一個本來會睡著的呼叫,改為立刻回傳。如果 recv() 有資料,你就拿到;如果沒有,它回傳 -1 並把 errno 設成 EAGAIN(也寫作 EWOULDBLOCK),這是核心客氣的說法,意思是「現在沒有,別等,等會再問。」關鍵在於,EAGAIN 不是一個該記錄下來並中止的錯誤——它是正常的流量控制,而一支非阻塞程式把它當成「下一輪再試這個通訊端」。
但單有一個非阻塞通訊端,會引誘你陷入一個忙碌迴圈——對每個通訊端不停地呼叫 recv()、拿到 EAGAIN、再問一遍,把一顆 CPU 核心釘在 100% 上只為了等待。缺的那一塊,是一個能睡到某件事、許多通訊端之中任何一件變就緒為止的辦法。這正是 I/O 多工提供的:select()、poll() 與 epoll() 這些呼叫,讓你把一整份檔案描述符清單交給核心、說「讓我睡著,並在這些之中任何一個變成可讀或可寫時喚醒我。」一個執行緒、一次阻塞的等待,同時盯著數百個通訊端——既有阻塞的效率,又有非阻塞的廣度。
退一步,把整個章節收進一張圖裡。你從把通訊端當作端點的觀念開始,學了位址、埠與位元組順序,接著建起一個交換可靠串流的 TCP 客戶端與伺服器。這一篇把鏡頭拉廣了兩次:UDP 顯示出可靠與有序是一個選擇、不是定律,代價是你自己寫的程式碼;非阻塞通訊端加上多工,顯示出等待也是一個選擇,是「一個處理一位客戶端的伺服器」與「一個處理一整座城客戶端的伺服器」之間的那道樞紐。通訊端不過是先前章節裡那個檔案描述符的觀念,被拉伸跨越了一個網路——還是你一路信任的那同一套 read()、write() 與 close(),如今搆到了另一台機器。