從觀念到介面
你已經大致知道通訊端是什麼了:應用程式與傳輸層之間的那道門,由一組(IP 位址、連接埠)端點來辨識。你還沒見過的,是那個門把——程式真正轉動來開門、把位元組推過去、再讀回位元組的那一組函式呼叫。這組呼叫就是 Berkeley 通訊端 API,誕生於 1980 年代初的 Unix,耐用到今天你的瀏覽器、你的遊戲,以及你即將寫的伺服器,用的仍是同樣那一小撮函式:socket、bind、listen、accept、connect、send、recv、close。
進入這一階,最該帶在身上的,是 Unix 那句老口號:一切皆檔案。當你開啟一個檔案,作業系統會回給你一個小整數,叫做檔案描述子,從此你就靠傳遞這個號碼來讀寫該檔案。通訊端 API 刻意沿用了一模一樣的形狀。建立一個通訊端,同樣會回給你一個描述子,而你就透過它來讀寫網路,那些呼叫看起來幾乎和檔案的讀寫一樣。對你的程式而言,一條網路連線感覺就像一個檔案,只不過它的另一端剛好是另一塊大陸上的某個行程。
兩段式的位址,以及幾個特別的
要用通訊端,你得說出它住在哪裡,而一個端點永遠是兩塊黏在一起的東西:一個 IP 位址加一個連接埠號。回想你一路帶著的比喻:IP 位址是把你帶到那棟大樓的街道地址,連接埠則是門牌號,指出大樓裡哪個程式該收這封信。寫出來,一個端點看起來像「203.0.113.7 的 TCP 連接埠 443」,或簡寫成 203.0.113.7:443。光有任何一半都不夠;要這一對合在一起,才能獨一無二地指名一個對話對象。
有幾個位址和連接埠值得背下來,因為它們到處都是。0 到 1023 的「知名連接埠」依慣例保留給標準服務,好讓用戶端不必問就知道該敲哪扇門:HTTP 在 TCP 連接埠 80、HTTPS 在 TCP 連接埠 443、DNS 在連接埠 53。當用戶端開啟一條連線時,它不會替自己這一端挑一個有名的號碼;作業系統會從高位範圍(通常是 49152 到 65535)借給它一個臨時的暫時連接埠,只在這場對話的壽命期間使用,之後再收回。這種不對稱——伺服器用固定埠、用戶端用拋棄式埠——正是為什麼數千個用戶端能同時在連接埠 443 上跟同一台網頁伺服器交談。
有一個特別的位址值得單獨一提:127.0.0.1,也就是回送位址,它永遠代表「就是這台機器、就在這裡」。送往它的封包永遠不會碰到任何線材或交換器;核心會直接在主機內部把它們掉頭送回。這就是資料庫與用它的程式能在同一台筆電上交談的方式,也是你測試下一篇寫的那個伺服器時、會把你人生第一個用戶端指向的位址——完全不需要連上網際網路。
伺服器的四步、用戶端的一步
一條 TCP 連線有兩個不對等的側。伺服器是在一個已知地點等待的那一方;用戶端則是跑去敲門的那一方。架起伺服器要四個分明的步驟,把每一步想成一個實體動作會很有幫助。首先 socket 建立描述子,一道還沒連上任何人的門。接著 bind 把那道門釘死在某個特定的(IP、連接埠)上,好讓核心知道哪些抵達的信件屬於它。然後 listen 把通訊端切換成被動模式,開出一間等候室——一個佇列——容納進來的連線請求。最後 accept 會一直阻塞,直到真有用戶端上門,才回傳一個專屬於那一個用戶端的全新通訊端。
最後這一點正是初學者絆倒的地方,所以放慢來看。你用來聆聽的通訊端,不是你用來交談的通訊端。聆聽用的通訊端是櫃台;每次成功的 accept 都遞給你一個全新、獨立、專屬於一場對話的通訊端,而櫃台則立刻回去等下一位訪客。這正是 API 把傳輸層那一階的四元組觀念落實出來:每個被接受的通訊端,都以完整的(來源 IP、來源連接埠、目的地 IP、目的地連接埠)為鍵,這就是單一聆聽埠能扇形展開成數千條各自獨立的活連線的方式。
用戶端的日子簡單多了。它呼叫 socket 取得一個描述子,再呼叫 connect,指名伺服器的(IP、連接埠)。在這個單一的 connect 呼叫背後,核心會悄悄跑完三向交握——你先前追蹤過的 SYN、SYN-ACK、ACK 交換,也就是開啟一場 TCP 對話時那段「你聽得到我嗎、聽得到你呢、聽得到」。當 connect 成功回傳,管子就接通了,兩邊都能讀也能寫。用戶端從不明確地做 bind;核心會自動給它一個暫時連接埠。
SERVER CLIENT
------------------------------ ------------------------------
fd = socket() cfd = socket()
bind(fd, "0.0.0.0", 443)
listen(fd, backlog = 128)
connect(cfd, "203.0.113.7", 443)
<----------- SYN ---------
---------- SYN-ACK ------->
<----------- ACK ---------
conn = accept(fd) -> NEW socket
send(cfd, request_bytes)
recv(conn, buf) <---- data ---- recv(cfd, reply_bytes)
send(conn, reply) ---- data ---->
close(conn) close(cfd)
UDP has no listen/accept/connect dance:
sfd = socket(UDP); bind(sfd, port)
recvfrom(sfd, buf, &who) / sendto(sfd, msg, who)兩種風味的通訊端,以及 TCP 不會給你的東西
通訊端有兩種日常風味,對應到兩個傳輸協定。TCP 通訊端是連線導向的:你做一次交握,然後讀寫一條串流,上面那套 listen/accept/connect 的舞步就適用。UDP 通訊端則是非連線導向的:沒有交握,也沒有每個用戶端各一個的被接受通訊端。你把一個通訊端綁到某個連接埠,然後用 recvfrom 與 sendto,每次呼叫都帶著或回報另一端的位址,因為每個資料報都是獨立成立的。UDP 不是壞掉的或次等的 TCP;它是你刻意做的選擇——當你寧可掉一個封包、也不願等一次重傳時——這是接下來兩篇要探討的。
現在來看整篇最重要的警告,因為它幾乎讓所有人都吃驚。TCP 給你的是一條位元組串流,不是一串訊息。對面那一端可能呼叫了三次 send,分別送出 HELLO、WORLD、BYE,而你的 recv 呼叫拿到的,可能是 HELLOWOR 再 LDBYE,或是一整坨 HELLOWORLDBYE,又或者一次一個位元組。TCP 忠實地依序送達每一個位元組,但它完全不記錄一則應用訊息在哪裡結束、下一則在哪裡開始。那些邊界只存在於送方的腦袋裡。
阻塞,以及邁向多連線的躍進
預設情況下,通訊端的呼叫是阻塞式的:當你呼叫 recv 而還沒有資料抵達時,你的程式就直接停住、停在那裡,直到有位元組出現或連線關閉。同樣地,accept 會停住直到有用戶端敲門,而 send 也可能在核心的外送緩衝區滿了時停住。對一個只跟單一對象交談的小工具來說,阻塞是份禮物:程式碼由上而下讀起來像食譜,你完全不用去想時序。麻煩在你想同時服務不只一個用戶端的那一刻浮現,因為一條卡在用戶端 A 的 recv 裡的執行緒,對用戶端 B 是聾的。
有兩條誠實的出路,後面的篇章都會講到。一是給每條連線各自一條執行緒或行程,這樣一個卡住的讀者不會凍住其他人;簡單,但執行緒耗記憶體,這套做法在數千條連線時會吃緊。二是把通訊端設成非阻塞,這時 recv 會立刻以「會阻塞」回傳而不是等待,然後再用 I/O 多工——一個單一的呼叫(select、poll,或 Linux 上的 epoll)一次看顧一整組通訊端,並告訴你哪些真的可以讀或寫了。如此一條執行緒便能透過只去碰那些有事可做的連線,來同時周旋於數萬條連線之間。
在你開始寫程式前,再做一次現實確認,因為這件事會無聲無息地咬你。即使單一個 send 也可能無法一口氣把你所有位元組推出去:它可能只收下你緩衝區的一部分、回傳一個較小的數字,而 recv 同樣可能回傳比你要求的更少的位元組。這些不完整的讀取與寫入並不是錯誤;它們是承壓中的活位元組串流的正常行為。正確的通訊端程式碼永遠會用迴圈,檢查到底搬動了多少位元組,再為剩下的部分重新呼叫。這個迴圈,連同分框,正是把一個只在 localhost 上跑得動的玩具,與一個能在真實網際網路上存活的程式區分開來的大部分原因。