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

寫一個 TCP 伺服器

客戶端那一篇給了你撥號的那一邊:一個 connect(),你就在對話了。伺服器這一邊是有耐心、也較陌生的一半——它挑一個位址、宣告自己開門營業,然後等候它從未謀面的來電者。這篇把伺服器的生命週期一個系統呼叫一個系統呼叫地走過,建起一個會檢查每一個錯誤的完整回聲伺服器,並對那個逮到每個人的陷阱誠實以告:一個伺服器,許多客戶端。

客戶端與伺服器並不對稱

在前一篇裡,你的客戶端是主動伸手的那一個:它呼叫 socket() 做一個 TCP 通訊端,然後單單一個 connect(),核心就替你跑完了三方交握。整件事讀起來幾乎像在開一個檔案。伺服器是真正不同的工作,先把這點講明白會有幫助。一個客戶端清楚知道它要找誰講話——一個位址、一個埠——然後發起。一個伺服器並不知道誰會打來、何時打來、或有幾個會打來。它的工作是讓自己可被連上:佔住一個固定位址、宣告自己正在傾聽,然後就坐著等連線到來。

正因如此,伺服器這一邊比客戶端那一邊多了幾個步驟,而且它們落在一個固定的順序裡。一個伺服器照這個次序呼叫 socket()、bind()、listen() 與 accept(),才會去搬動哪怕一個位元組。每一個都恰好做一件事:socket() 做出端點,bind() 把它釘到一個已知的位址與埠,listen() 把它翻進一個狀態——在那個狀態裡,核心會替你接受進來的交握,而 accept() 一次交給你一條已完成的連線。跳過或重排其中任何一個,核心都會用一個清楚的 errno 拒絕你——而既然你檢查每一個回傳值,你就真的會看見它。

四個呼叫,一個一個走過

  1. socket(AF_INET, SOCK_STREAM, 0) 建立一個尚未繫結、尚未連線的 TCP 端點,並回傳一個檔案描述符——一個小小的 int,和檔案 I/O 那一章裡的那些一模一樣。它還沒附著到任何位址。
  2. bind() 把這個通訊端附著到你填進一個 struct sockaddr_in 裡的本地位址與埠——例如所有介面上(INADDR_ANY)的埠 8080。這就是你佔住那個埠的地方;如果另一支程式已經握著它,bind() 會以 EADDRINUSE 失敗。
  3. listen(fd, backlog) 告訴核心,這個通訊端現在是被動的:別再試著連出去,開始接受進來的交握。那個 backlog 設定一個佇列的大小——佇列裡裝的是已交握完成、但你還沒 accept() 的連線。
  4. accept(fd, ...) 會擋住,直到一個客戶端連上來,然後為那一條連線回傳一個全新的檔案描述符。你在那個新的 fd 上 read() 與 write()(或 recv() 與 send());原本那個傾聽 fd 維持開著,去接受下一個客戶端。

那串清單裡有兩個細節值得湊近一點看。第一,你交給 bind() 的位址,必須以網路位元組順序填入——那正是這個章節第二篇的主題。埠號要過 htons()、INADDR_ANY 要過 htonl()——跳過它們,你的伺服器在一台小端機器上就會悄悄地繫結到一個被打亂的埠號,而你會花一個小時納悶為什麼沒人連得上。第二,backlog 不是你這輩子能服務幾個客戶端的上限;它只是那條尚未被 accept 的連線短佇列的深度。慣例值 128,幾乎在所有情況下都沒問題。

一個完整、有檢查錯誤的回聲伺服器

這是整個生命週期,濃縮在一支小程式裡:一個回聲伺服器,接受一個客戶端、讀進客戶端送來的任何東西、把同樣的位元組原封不動寫回去,然後迴圈到下一個客戶端。它是回聲客戶端與伺服器那一對裡的伺服器那一半——通訊端程式設計的「hello world」。先讀一遍看它的形狀,然後注意每一個可能失敗的呼叫都被檢查了。帶 SO_REUSEADDR 的 setsockopt() 是個小小的好意,讓你能立刻重啟伺服器,而不必在舊埠上把核心的 TIME_WAIT 期間等過去。

#include <sys/socket.h>
#include <netinet/in.h>   /* sockaddr_in, htons, htonl */
#include <arpa/inet.h>
#include <unistd.h>       /* read, write, close */
#include <string.h>
#include <stdio.h>
#include <stdlib.h>

#define PORT    8080
#define BACKLOG 128

int main(void) {
    /* 1. make the listening socket */
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if (lfd == -1) { perror("socket"); return 1; }

    /* allow immediate restart on the same port */
    int one = 1;
    if (setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR,
                   &one, sizeof one) == -1) {
        perror("setsockopt"); return 1;
    }

    /* 2. fill the address in NETWORK byte order, then bind */
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof addr);
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);  /* every interface */
    addr.sin_port        = htons(PORT);        /* host -> network */
    if (bind(lfd, (struct sockaddr *)&addr, sizeof addr) == -1) {
        perror("bind"); return 1;
    }

    /* 3. switch the socket to passive / listening */
    if (listen(lfd, BACKLOG) == -1) { perror("listen"); return 1; }
    printf("listening on port %d\n", PORT);

    /* 4. serve clients one at a time */
    for (;;) {
        struct sockaddr_in peer;
        socklen_t plen = sizeof peer;
        int cfd = accept(lfd, (struct sockaddr *)&peer, &plen);
        if (cfd == -1) { perror("accept"); continue; }

        char buf[4096];
        ssize_t n;
        /* echo until the client closes (read returns 0 = EOF) */
        while ((n = read(cfd, buf, sizeof buf)) > 0) {
            ssize_t off = 0;
            while (off < n) {          /* handle partial writes */
                ssize_t w = write(cfd, buf + off, n - off);
                if (w == -1) { perror("write"); break; }
                off += w;
            }
        }
        if (n == -1) perror("read");
        close(cfd);                    /* done with THIS client */
    }
    /* not reached; lfd would be closed on a real shutdown path */
}

/* build:  gcc -O2 -Wall server.c  &&  ./a.out */
那兩層巢狀迴圈很要緊:外層的 for(;;) 永遠地接受客戶端,內層的 while 把一個客戶端榨乾,直到 read() 回傳 0——那個 0 是表示對方關閉了的 EOF。那個 off/w 迴圈防的是短寫——write() 搬動的位元組可能比你要求的少,而無聲地忽略它就等於丟資料。

EOF、短寫,與「是位元組不是訊息」這個真相

那個迴圈裡有三個誠實的細節,把一個玩具和一個正確的伺服器區分開來。第一,read() 回傳 0 表示檔案結尾——客戶端關閉了它那一端,那就是你該關掉 cfd、繼續往下走的訊號。回傳 0 不是錯誤、也不是一個該重試的空讀;它是一聲乾淨的再見。把 0 跟 -1(真正的錯誤)搞混,你就會要嘛永遠空轉、要嘛在一條健康的連線上退出。第二,在一個通訊端上的 read() 與 write(),搬動的位元組可能比你要求的少,這正是那個內層 off/w 迴圈存在的理由;一個你給了 100 卻回傳 30 的 write,並沒有失敗,它只是做了部分的工作,而期待你帶著剩下的再呼叫一次。

第三,也最容易被漏掉:TCP 給你的是一條位元組的串流,沒有訊息邊界。如果一個客戶端呼叫了三次 write——「hi」、「there」、「!」——你伺服器的 read() 可能把它們當成一塊「hithere!」交來,或切成「hit」再「here!」,或任何別種切法。核心不保證寄方的一次 write 會變成收方的一次 read。一個回聲伺服器不在乎,因為它只是把到達的位元組原樣彈回去。但一個真正的協定就在乎了,而找出一則訊息在哪裡結束、下一則在哪裡開始的擔子——一個長度前綴、或一個像換行那樣的分隔符——全落在你身上。對那些期待 TCP 會交來一則則整齊訊息的人而言,這是 TCP 最叫人意外的一個事實。

誠實的極限:一次一個客戶端

現在是你必須看清楚的那個缺口,因為它正是真實伺服器更複雜的全部理由。上面那個迴圈服務客戶端是嚴格一次一個的。accept() 回傳客戶端 A,內層迴圈把 A 回聲到 A 關閉,然後輪到外層迴圈再為客戶端 B 呼叫一次 accept()。當 A 連著卻閒著時,B 卡在傾聽 backlog 裡乾等,而第三個客戶端可能被直接拒絕。對一個單機的學習練習,這沒問題。但對任何有兩個同時使用者的東西,它就是一面磚牆——而且這不是你程式裡的臭蟲,它是「在一條會擋住的控制流上做完所有事」這件事的根本後果。

有三條經典的出路,而它們三者的機械裝置你在更早的章節都碰過了。最老的一條是為每一個被接受的客戶端 fork() 一個子行程——父行程直接回去 accept(),而子行程處理那一條連線然後結束;fork() 與回收你在行程那一章已經知道了。第二條是一個客戶端一個執行緒,比一個行程輕、又共用一個位址空間,用你在並行那一章碰過的 pthreads。第三條,也是擴展得最遠的一條,是留在一條執行緒上、但不再擋住:問核心「我這許多通訊端裡,哪一個準備好了?」然後只去服務那些。

那第三條路,正是這個章節最後一篇要去的地方。此刻你伺服器裡的每一個 read() 與 accept() 都是一個會擋住的呼叫:它把整個行程停在那裡,直到有事發生。最後一篇,UDP 與非阻塞通訊端,會展示另一條路——會立刻回傳而不停下來的通訊端,加上那些 I/O 多工工具(select、poll、epoll),讓單一一條執行緒能同時看顧上百條連線。它也跨步走到 UDP,TCP 那位無連線的表親那邊去——那裡沒有 accept()、根本也沒有串流。你現在已經把對話裡有耐心的那一半從頭到尾跑通了;下一篇教它去聽整個房間。