JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

撰寫一個 TCP 伺服器與用戶端

既然通訊端不過就是一個你能讀能寫的檔案,現在我們來把一段真正的對話接起來。我們會走過 TCP 伺服器與用戶端各自所下的那一串確切呼叫、看清為何伺服器需要兩種通訊端,並遇上每個初學者第一個跌倒的陷阱:TCP 是位元組串流,而不是訊息服務。

兩種角色,兩套劇本

在上一篇指南裡,你認識了 Berkeley 通訊端 API,以及那個重要的想法:一條網路連線交到你手上時,表現得就像一個檔案——你從中讀出位元組、把位元組寫入其中。那是個溫柔的幻覺。本篇指南會把幻覺化為一支能跑的程式,方法是走過開啟一段 TCP 對話時、每一邊所下的那一串確切呼叫。它的形狀直接來自主從模型:一邊等待,另一邊發起。

這種不對稱很重要。伺服器是先就位的一邊,待在一個已知的位址與一個已知的連接埠上,等著任何人來敲門。用戶端則是知道伺服器住在哪裡、並率先開啟對話的一邊。網頁瀏覽器是用戶端;它撥打的那台網頁伺服器是伺服器。兩者各自照不同的劇本走,而最該掌握的一點是:它們呼叫的並不是同一組函式——伺服器跑的是一場較長的儀式,用戶端跑的是較短的一場。

伺服器的儀式:socket、bind、listen、accept

伺服器的工作,是讓自己變得可被連到,然後耐心地一次接待一位來客。它分四步做到。首先它呼叫 `socket()`,建立一個全新、未使用的端點——一個還沒有位址的裸通訊端。接著它呼叫 bind(),把那個通訊端釘到一個特定的本地(IP、連接埠)上——例如「我所有介面上的 8080 埠」——這就是它宣告「用戶端該撥打的位址」的方式。然後 listen() 把通訊端切換成被動模式,告訴核心:「這是一道門;開始替我把進來的連線請求排進佇列。」

第四步是初學者覺得意外的那一步。當伺服器呼叫 `accept()` 時,它會阻塞(睡著),直到有一個用戶端連線到來——而一旦有連線到來,`accept()` 會回傳一個全新的、專屬於那一個用戶端的通訊端。所以一台繁忙的伺服器同時握有兩種通訊端:一個監聽通訊端,它什麼都不做,只負責接納新來者;以及每段進行中的對話各一個的已連線通訊端。你透過每個用戶端各自的已連線通訊端跟它對話,絕不會透過那個監聽通訊端。正是這種分離,讓伺服器能繞回 `accept()`、迎接下一位來客,同時較舊的那些對話還繼續進行。

  SERVER                              CLIENT
  ------                              ------
  s = socket()                        c = socket()
  bind(s, 0.0.0.0:8080)
  listen(s)
  conn = accept(s)  <----- connect(c, 1.2.3.4:8080)
         |                                |
         |   read(conn) / write(conn)  <-> write(c) / read(c)
         |                                |
  close(conn)                         close(c)
  (loop back to accept for next client)

  s    = listening socket (one, passive)
  conn = connected socket (one PER client)
兩套劇本並排對照。伺服器的監聽通訊端 s 只負責產出已連線通訊端;所有真正的讀寫都發生在 conn(伺服器側)與 c(用戶端側)上。

用戶端較短的路徑:socket、connect

用戶端的事情輕鬆些。它呼叫 `socket()` 造出自己的端點,然後呼叫 connect(),指名它想連到的伺服器(IP、連接埠)。就是這一個呼叫,會在底層引發你先前認識過的三向交握——那段同步序號的「SYN、SYN-ACK、ACK」往返。用戶端不呼叫 `bind()`,而這個省略,正悄悄地做著有用的事。

由於用戶端沒有綁定連接埠,核心會悄悄地從一段高編號的範圍裡,替它挑一個沒在用的——也就是一個臨時連接埠,一個只為這條連線借用、結束時就歸還的暫時編號。這就是為什麼伺服器住在固定、眾所周知的連接埠(網頁伺服器在 80 或 443 埠),用戶端的連接埠卻是 51324 這種隨手就忘的數字。它也解釋了你的筆電怎麼能同時對同一個網站開十個分頁:每條連線都靠它完整的四元組——來源 IP、來源連接埠、目的 IP、目的連接埠——來區分,而那十個不同的臨時連接埠,讓這十段對話彼此分明,即使四個數字裡有三個一模一樣。

  1. 伺服器:socket()——造出一個沒有位址的裸端點。
  2. 伺服器:bind() 到一個固定的(IP、連接埠),接著 listen() 轉為被動,然後 accept() 並阻塞,等著有人來敲門。
  3. 用戶端:socket(),接著 connect() 到伺服器的(IP、連接埠)——這會觸發三向交握。
  4. 交握完成:伺服器的 accept() 醒來,回傳一個專屬於這一個用戶端的全新已連線通訊端。
  5. 兩邊透過各自的已連線通訊端 read() 與 write() 位元組,完事後再 close()。

第一個真正的陷阱:TCP 是位元組串流,不是訊息

這是幾乎每個人都會犯一次的錯。你用一次 `write()` 寫了「HELLO」、用第二次寫了「WORLD」,然後你期待另一邊會把它們 read() 回成兩則整齊的訊息。TCP 從不做這種承諾。它給你的是位元組串流的抽象:一條單一、毫無縫隙的位元組之河,不記錄某一次寫入在哪裡結束、下一次又在哪裡開始。接收端可能一口氣 read() 到「HELLOWORLD」,也可能是「HEL」再「LOWORLD」,或任何別種切法。你寫進去的那些邊界根本沒有被保留——TCP 大可在途中把你的資料合併或切碎。

這不是臭蟲,而是 TCP「傳遞位元組而非訊息」這件事的誠實後果。解方是:應用程式必須自己做訊息分框——它得自己建立一套辦法,去找出串流裡每則訊息在哪裡結束。有兩種經典的食譜:替每則訊息加上長度前綴(「接下來的 5 個位元組是一則訊息」),或替每則訊息結尾加上一個約定好的分隔符(譬如一個換行字元,就像許多文字協定那樣)。無論哪一種,接收端都會持續讀取並緩衝,直到湊齊一整則訊息,再把它切出來。本級的下一篇指南,就專門講分框,以及取捨恰好相反的 UDP。

一次一個?阻塞,以及接下來會碰到的

最簡單的伺服器——socket、bind、listen、accept,然後把一個用戶端完整服務完才繞回去——能跑,但要留意它的天花板。預設情況下,每個通訊端呼叫都是阻塞的:`accept()` 會睡到有用戶端來敲門,`read()` 會睡到真的有位元組抵達。當伺服器正阻塞在「從一個慢吞吞的用戶端讀取」上時,它沒辦法接待任何別人。一台用這種天真寫法搭起來的網頁伺服器,一次只能服務一位訪客,其他所有人都得排隊。

有三條逃生路,而它們替本級接下來的內容鋪好了路。你可以替每個用戶端生出一條執行緒或一個行程(簡單,但在數千個用戶端時很笨重);你可以把通訊端切換成非阻塞模式,讓呼叫立刻回傳而不是睡著;或者——可規模化的那個答案——你可以用 I/O 多工(select、poll,或 Linux 的 epoll),用單單一條執行緒去盯著許多通訊端,只對真正就緒的那些動作。本級的第四篇指南,就正是在搭這個——那是那些能同時握住數萬條連線的伺服器背後的技術。

還有兩個陷阱值得現在先點個名,兩者都留給第五篇指南。當你 `close()` 一條連線之後,先關閉的那一邊會在 TIME-WAIT 狀態裡逗留一陣子——這是個刻意的安全暫停,重啟伺服器時可能會嚇你一跳(「位址已在使用中」)。而 TCP 可能會悄悄地把細小的寫入扣住、好把它們批次送出,這個行為叫做 Nagle 演算法,它對大批量傳輸有幫助,卻可能替一個你來我往、講求互動的協定添上一段惱人的延遲。兩者都是特色,不是臭蟲——但知道它們存在,正是「在你筆電上能跑的程式碼」與「在真實世界裡能跑的程式碼」之間的分界。