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

TCP/IP 與通訊端的概念

上一階在同一台機器的兩個行程之間搬運位元組。如今我們要把它們送過整個房間、甚至送過整顆星球,交給一個你永遠不會與之共用記憶體的行程。本篇要建立讓這件事成真的兩個概念——分層的 TCP/IP 模型,以及通訊端(socket),那個長得像檔案描述符、讓你的程式握住一條連線的端點——並說明為何你早就會的 read()/write(),能一路伸到世界的另一端。

從隔壁那台機器,到世界另一頭那台機器

你帶著一份安靜的自信結束了上一階:同一台機器上的兩個行程能夠交談。管線、信號、共享記憶體,以及 Unix 網域通訊端,全都把位元組從一支程式搬到另一支,而核心坐在中間,當那個受信任的居間者。但這每一招都倚靠著一個藏起來的假設——兩個行程都活在同一個核心底下、在同一台機器上。管線是某一個核心裡的緩衝區;共享記憶體是某一台機器的 RAM 被映射了兩次。它們沒有一個能搆到跑在另一台電腦上的行程,因為外頭並沒有一個共享的核心,去握著那塊緩衝區。

那正是本階為了跨越而搭建的那道牆。為 IPC 那一階收尾的那篇——本機 IPC 與網路 IPC——已經直直指向了它:當另一個行程在另一台主機上時,沒有共享記憶體、沒有繼承下來的檔案描述符、沒有共同的父行程——只有一條線(或一段無線電鏈路),在兩台各自獨立的電腦之間,載著電訊號或光訊號。要讓兩台機器上的兩個陌生人協作,我們需要兩樣新東西:一套講好的規則,規定線上的位元組意味著什麼;以及一個你的程式碼能握住、用來代表「我這一端與那台遠方機器的對話」的程式物件。那套規則就是協定;那個物件就是通訊端

協定一點也不奇特——它就是一份關於訊息「格式」與「先後順序」的約定,好讓兩個素未謀面的一方仍能彼此理解。人類的對話也有協定:你說「你好」、你等一下、對方開口、你們輪流講。在網路上,這份約定必須精確得多,精確到「哪一個位元組代表什麼」,因為彼此之間沒有共享的脈絡,也沒有機會回頭問一句「抱歉,你剛剛是什麼意思?」。協定,就是用雙方都以一模一樣方式實作的、白紙黑字的規則,去取代那塊缺失的共同基礎。

為何我們把規則疊成層:TCP/IP 模型

把一個位元組從你的筆電送到一台伺服器,是一疊難題,而網路最了不起的想法,是不要一次解決它們全部。我們反而把這份工作切成許多層,每一層解決一個問題,並信任它底下那一層已經把再下一層解決掉了。這正是你很久以前見過的抽象堆疊,套用到網路上:同一套紀律,讓一個 C 函式得以信任 malloc() 而不必知道堆積的內部,也讓一個網頁瀏覽器得以信任位元組會抵達,而對纜線或路由器一無所知。整個網際網路所運行其上的那疊講好的堆疊,就是 TCP/IP 模型

從底往上讀這疊堆疊,每一層恰好回答一個問題。鏈路層問的是「我要怎麼把位元推上這一條通往下一台機器的線?」——乙太網路、Wi-Fi。在它之上,網際層(TCP/IP 裡的那個 IP)問的是「面對這整張糾纏的機器網,下一跳該交給誰,才能讓這個封包離目的地更近?」——它給每一台主機一個 IP 位址、把封包朝它路由過去,卻不保證封包會抵達、也不保證按順序抵達。傳輸層坐在上面,把 IP 那種不可靠、盡力而為的封包,轉成一個程式真正用得上的東西——而那正是 TCP 與 UDP 棲身之處。最高的那一層,應用層,就是你的協定:HTTP、SMTP,或你自己的程式發明出來的任何規則。

  application  |  HTTP, SMTP, your own protocol   (what the bytes MEAN)
  transport    |  TCP (reliable stream) / UDP (datagrams)
  internet     |  IP  (addressing + routing of packets, best-effort)
  link         |  Ethernet / Wi-Fi  (bits onto one physical hop)

  send  ->  data goes DOWN the stack, each layer wraps it in a header
  recv  ->  data comes UP the stack, each layer strips its own header
TCP/IP 的四層,由上而下。傳送時把你的資料往堆疊下方推,每一層加上一個標頭;接收時把它往上帶回來,每一層剝掉自己那個標頭。通訊端,就是你的程式握在「應用層(你)與傳輸層(TCP 或 UDP)之間那條邊界」上的把手。

在繼續之前,兩句誠實的提醒。第一,這幅四層的圖是一個有用的簡化,不是聖經——較早的 OSI 模型畫了七層,而真實系統會把邊界揉糊;把這些層當成一種思考的方式,而不是輪廓分明的實體物件。第二,分層買來了清晰,卻付了一點小小的代價:每一層都把你的資料包進它自己的標頭裡,所以你送出的一個位元組的訊息,在線上其實是幾十個位元組。那份額外開銷幾乎總是值得,但它是真實的,也正是為什麼又小又頻繁的訊息,遠不如它看起來那麼有效率——這一點在我們把 TCP 拿來與 UDP 權衡時會很要緊。

通訊端:一條對話的檔案描述符

現在來到核心的那個把戲,而它很美,因為你已經擁有它的大部分了。當你的程式想透過網路交談時,它呼叫 socket() 向核心要一個通訊端。回傳的東西不是什麼陌生的新物件——它是一個尋常的檔案描述符,一個小小的 int,和 open() 為檔案、pipe() 為管線交給你的那些一模一樣。核心把那個描述符的另一端接的,不是磁碟上的某個檔案,而是網路堆疊的傳輸層。這正是一切皆檔案達到它最壯麗的回報之處:一條網路連線,穿著和其他一切相同的描述符介面。

一旦通訊端連上,後果美妙至極:你用的是和你對一個本機檔案完全相同的 read() 與 write() 呼叫,去和東京的一台機器交談。write(sockfd, buf, n) 把 n 個位元組交給 TCP,TCP 把它們切成封包、交給 IP,IP 把它們路由過整顆星球;在遠方那台機器上,read() 把那同一批位元組沿著堆疊提回到另一支程式的緩衝區。(網路程式碼往往偏好專門的 send() 與 recv() 呼叫,它們就是 read()/write() 再加上一個 flags 參數,承載一些只屬於網路的選項——但對一條已連線的 TCP 通訊端,樸素的 read() 與 write() 也完全管用。)原來,你花在學描述符介面的那些年,同時也是網路課。

TCP 與 UDP:通訊端能說的兩種傳輸

當你呼叫 socket() 時,你選擇那個描述符要說的傳輸層協定,而日常的這兩個選擇,性格大不相同。TCP 給你一條可靠、有序的位元組串流。它跑一場三向交握來建立連線、為每一個位元組編號、把任何遺失的東西重傳、把亂序抵達的封包重新排序——於是在 IP 那塊不可靠的拼布之上,TCP 把一條乾淨、無損的管線的錯覺交給你的程式,位元組就以你送出時那精確的順序,從這管線裡流過。一條 TCP 通訊端,是你在「正確性比最後那一毫秒更要緊」時伸手去拿的東西:網頁、檔案、電子郵件,任何「掉一個位元組就是一個臭蟲」的場合。

UDP 是相反的那筆交易:沒有交握、沒有連線、沒有重傳、沒有排序。你交給它一塊叫做資料報(datagram)的位元組,它就把那一個封包朝目的地一拋、然後就把它忘了。它可能抵達、可能不抵達、可能在一個比它晚送的之後才抵達——UDP 只承諾:如果一個資料報抵達了,它是完整地抵達的。作為交換,你得到較低的延遲,以及訊息邊界(每一個資料報是一則獨立的訊息,而這正是 TCP 的串流刻意給你的)。UDP 是「反正過期的資料一文不值」時的對的工具——即時語音、一場遊戲的位置更新、DNS 查詢——在這些場合,重送一個一秒前的封包毫無意義。整個 TCP 與 UDP的權衡,就是「可靠」對上「即時」,而你按每個應用各自去挑。

客戶端與伺服器:兩種角色,以及接下來的那些呼叫

即使兩端都握著一個尋常的通訊端,這條連線的建立卻是不對稱的,而為這兩種角色命名,能讓本階其餘部分一下子各就各位。一邊是伺服器:它挑一個眾所周知的連接埠、安頓下來、等候來客——像一家公佈了地址、開了門的店。另一邊是客戶端:它知道伺服器的位址,並主動伸手去發起對話——像一位走進門的顧客。這份不對稱只存在於建立階段;連線一旦存在,兩個通訊端就是對等的,位元組往兩個方向同等地流。

這兩種角色驅動兩支不同的、由系統呼叫組成的小舞,而本階其餘部分會把每一支都慢慢走過一遍。客戶端的序列很短:socket() 造出端點,接著 connect() 撥打伺服器的位址——成功之後,你就有了一條備妥可供 read()/write() 的已連線通訊端。伺服器的序列稍長一點:socket(),接著 bind() 認領一個特定的連接埠,接著 listen() 宣告它開門營業,接著在一個迴圈裡 accept(),把每一個進來的來客接起來。一個現在先輕輕記住的微妙之處:伺服器最後會有兩種通訊端——一個只負責產出新連線的傾聽通訊端,以及每一個被接受的客戶端各一個、真正承載資料的已連線通訊端。

  1. 挑傳輸。呼叫 socket(),選 TCP(可靠串流)或 UDP(資料報)。你拿回一個檔案描述符——一個小小的 int——而此刻還沒有任何東西跨過網路。
  2. 伺服器這邊:bind() 把通訊端綁到一個連接埠以認領一個位址、listen() 把它標記為接受連線,接著 accept() 阻塞著直到一個客戶端到來,它回傳一條為那一個客戶端而生的、嶄新的已連線通訊端。
  3. 客戶端這邊:connect() 把通訊端連到伺服器的位址。在底下,這會跑那場三向交握;成功之後,通訊端就連上、就緒了。
  4. 現在兩邊都把那條已連線的通訊端,當成一個檔案或一條管線那樣去 read()/write()(或 recv()/send()),對話結束時再 close() 它。

那就是本階的整張地圖。下一篇把位址、連接埠與位元組順序釘牢,好讓你真的能填進一個目的地;接著一篇從頭到尾、每個呼叫都檢查地建起一個能動的 TCP 客戶端,另一篇建一個 TCP 伺服器;而最後一篇迎戰 UDP 與阻塞與非阻塞通訊端——一個伺服器要如何不再因為一個慢吞吞的客戶端而凍住,並學會同時應付許多客戶端。一個不必擁有兩台電腦就能練習這一切的友善方法,是回送介面,也就是 127.0.0.1 這個位址,它把網路堆疊回送到你自己的機器:客戶端與伺服器在同一台筆電上並肩跑,透過從不碰到一條實體線的、真正的 TCP 交談。把「通訊端即描述符」這個概念牢牢握在手裡——接下來的一切,不過是在把上面那四個步驟填滿。