Socket 介面 (The Sockets Interface)

Overview Table

主題 核心重點 書頁
Socket 介面總覽 與 Unix I/O 搭配建構網路應用的函式集,源自 Berkeley(4.2BSD) p.968
Socket address 結構 sockaddr_in(16 bytes,network byte order)+ 泛型 sockaddr 強制轉型 p.969
socket 建立 socket descriptor;回傳的 fd「部分開啟」尚不能讀寫 p.970
connect client 主動建立連線,blocking;成功後由 socket pair 標識連線 p.970-971
bind server 將 socket address 與 descriptor 綁定 p.971
listen 把 active socket 轉為 listening socket;backlog 為佇列提示 p.971
accept 等待連線請求,回傳全新的 connected descriptor p.972-973
getaddrinfo / getnameinfo 主機/服務字串 ↔ socket address 雙向轉換,reentrant、protocol-independent p.973-977
Helper functions open_clientfd / open_listenfd 封裝樣板碼 p.978-981
Echo client/server 迭代式(iterative)伺服器範例;EOF 語意 p.980-984

Socket 介面是一組與 Unix I/O 函式搭配使用的函式,用來建構網路應用程式,已實作於所有 Unix 變種、Windows 與 Macintosh 系統 (p.968)。整體 client-server 互動流程如下(Figure 11.12):

        Client                          Server
     getaddrinfo                     getaddrinfo
          |                               |
        socket                          socket   ┐
          |                               |      │
          |                             bind     │ open_listenfd
  ┐       |                               |      │
  │       |                             listen   ┘
  │       |        Connection             |
  │    connect ----- request -------->  accept
  ┘       |                               |
     rio_writen  ------------------> rio_readlineb
          |                               |            ┌ Await connection
    rio_readlineb <----------------- rio_writen        │ request from
          |                               |            │ next client
        close  -------- EOF ------->  rio_readlineb    │
                                          |            │
                                        close  --------┘
   (open_clientfd = socket + connect)
歷史背景 (p.968 Aside)

Socket 介面由 UC Berkeley 研究者於 1980 年代初開發,故常稱 Berkeley sockets;設計上可搭配任何底層協定,第一個實作是 TCP/IP,隨 Unix 4.2BSD kernel 散布,是 Internet 史上的重要事件。

11.4.1 Socket Address 結構 (p.969)

Linux kernel 的角度,socket 是通訊的端點 (end point);從 Linux 程式的角度,socket 是一個有對應 descriptor開啟檔案。Internet socket address 存放於 16-byte 的 sockaddr_in 結構中,IP address 與 port number 一律以 network byte order(big-endian)儲存

/* IP socket address structure */
struct sockaddr_in {
    uint16_t       sin_family;  /* Protocol family (always AF_INET) */
    uint16_t       sin_port;    /* Port number in network byte order */
    struct in_addr sin_addr;    /* IP address in network byte order */
    unsigned char  sin_zero[8]; /* Pad to sizeof(struct sockaddr) */
};

/* Generic socket address structure (for connect, bind, and accept) */
struct sockaddr {
    uint16_t sa_family;   /* Protocol family */
    char     sa_data[14]; /* Address data */
};

11.4.2 socket 函式 (p.970)

Client 與 server 都用 socket 建立 socket descriptor

#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
/* Returns: nonnegative descriptor if OK, -1 on error */

11.4.3 connect 函式 (p.970-971)

Client 呼叫 connect 與 server 建立連線。

#include <sys/socket.h>
int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);
/* Returns: 0 if OK, -1 on error */

11.4.4 bind 函式 (p.971)

bindlistenacceptserver 端用來與 client 建立連線的函式。

#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
/* Returns: 0 if OK, -1 on error */

11.4.5 listen 函式 (p.971)

Client 是主動 (active) 發起連線請求的實體;server 是被動 (passive) 等待請求的實體。Kernel 預設把 socket 建立的 descriptor 視為 active socket(連線的 client 端),server 必須呼叫 listen 告訴 kernel 這個 descriptor 是給 server 用的。

#include <sys/socket.h>
int listen(int sockfd, int backlog);
/* Returns: 0 if OK, -1 on error */

11.4.6 accept 函式 (p.972-973)

#include <sys/socket.h>
int accept(int listenfd, struct sockaddr *addr, int *addrlen);
/* Returns: nonnegative connected descriptor if OK, -1 on error */

accept 等待連線請求抵達 listenfd,把 client 的 socket address 填入 addr,並回傳一個 connected descriptor,可用 Unix I/O 函式與 client 通訊。

Listening descriptor Connected descriptor
角色 client 連線請求的端點 client-server 之間已建立連線的端點
建立時機 通常只建立一次 每次 server 接受連線請求時建立
存活期間 server 的整個生命週期 只在服務該 client 期間存在

連線建立三步驟(Figure 11.14;假設 listenfd=3,因為 descriptor 0-2 保留給標準檔案):

Step 1: Server 在 accept 中 block,等待 listenfd 上的連線請求
   Client                    Server
   clientfd                  listenfd(3)

Step 2: Client 呼叫 connect(送出連線請求後 block)
   Client  ---- Connection request ---->  Server
   clientfd                  listenfd(3)

Step 3: accept 開啟新的 connfd(4) 並回傳;client 從 connect 返回
   Client  <====== connection ======>  Server
   clientfd                  listenfd(3)
                             connfd(4)
為何要區分 listening 與 connected descriptor?(p.973 Aside)

乍看是多餘的複雜化,實則讓我們能建構並行伺服器 (concurrent server):每當 listening descriptor 收到連線請求,可以 fork 一個新 process 透過 connected descriptor 與該 client 通訊,同時處理多個連線。詳見 12-Concurrent-Programming/01-Process-and-Event-Based-Concurrency

11.4.7 主機與服務轉換 (p.973-977)

Linux 提供 getaddrinfogetnameinfo,在二進位 socket address 結構與字串表示(hostname、host address、service name、port number)之間雙向轉換;與 socket 介面併用時可寫出不依賴特定 IP 版本的網路程式。

getaddrinfo:字串 → socket address

#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
int getaddrinfo(const char *host, const char *service,
                const struct addrinfo *hints, struct addrinfo **result);
/* Returns: 0 if OK, nonzero error code on error */
void freeaddrinfo(struct addrinfo *result);
const char *gai_strerror(int errcode);
result --> [addrinfo] --ai_addr--> [socket addr struct]
              | ai_canonname --> 官方主機名 (需 AI_CANONNAME)
              | ai_next
              v
           [addrinfo] --ai_addr--> [socket addr struct]
              | ai_next
              v
           [addrinfo] --ai_addr--> [socket addr struct]
              | ai_next = NULL
struct addrinfo {
    int              ai_flags;     /* Hints argument flags */
    int              ai_family;    /* First arg to socket function */
    int              ai_socktype;  /* Second arg to socket function */
    int              ai_protocol;  /* Third arg to socket function */
    char            *ai_canonname; /* Canonical hostname */
    size_t           ai_addrlen;   /* Size of ai_addr struct */
    struct sockaddr *ai_addr;      /* Ptr to socket address structure */
    struct addrinfo *ai_next;      /* Ptr to next item in linked list */
};

hints 參數(可選)提供對回傳串列的細部控制:只能設定 ai_familyai_socktypeai_protocolai_flags 四個欄位,其餘必須為 0/NULL(實務上先 memset 清零再設定):

Flag 效果
AI_ADDRCONFIG 建議搭配連線使用:僅在本機有設定 IPv4 時才回傳 IPv4 位址(IPv6 同理)
AI_CANONNAME 令第一個 addrinfo 的 ai_canonname 指向 host 的正式 (canonical) 名稱(預設為 NULL)
AI_NUMERICSERV 強制 service 參數必須是 port number
AI_PASSIVE 回傳可供 server 作 listening socket 用的位址(預設是給 client connect 用的 active socket);此時 host 應為 NULL,位址欄位為 wildcard address,告訴 kernel 此 server 接受發往本主機任何 IP address 的請求
欄位「不透明 (opaque)」的優雅設計 (p.976)

getaddrinfo 填好的欄位可直接傳給 socket 介面函式而不需再處理:ai_family, ai_socktype, ai_protocolsocket();ai_addr, ai_addrlenconnect() / bind()。這使 client/server 程式碼與 IP 版本完全解耦。

getnameinfo:socket address → 字串

#include <sys/socket.h>
#include <netdb.h>
int getnameinfo(const struct sockaddr *sa, socklen_t salen,
                char *host, size_t hostlen,
                char *service, size_t servlen, int flags);
/* Returns: 0 if OK, nonzero error code on error */

hostinfo 範例程式 (p.977):類似 nslookup,hints 設 AF_INET + SOCK_STREAMservice=NULL 呼叫 getaddrinfo,再走訪串列以 getnameinfo(NI_NUMERICHOST) 將每個位址印成 dotted-decimal 字串,最後 freeaddrinfo。執行 ./hostinfo twitter.com 得到 4 個 IP address,與 11.3.2 的 nslookup 結果一致 (p.978)。Practice Problem 11.4 指出 getaddrinfo/getnameinfo 涵蓋了 inet_pton/inet_ntop 的功能且抽象層級更高、與位址格式無關。

11.4.8 Socket 介面 Helper 函式 (p.978-981)

CSAPP 以更高階的 open_clientfdopen_listenfd 封裝 getaddrinfo + socket 介面的樣板碼,兩者皆 reentrant 且 protocol-independent

open_clientfd(client 端)

#include "csapp.h"
int open_clientfd(char *hostname, char *port);
/* Returns: descriptor if OK, -1 on error */

流程(Figure 11.18, code/src/csapp.c):

memset(&hints, 0, ...)
hints.ai_socktype = SOCK_STREAM
hints.ai_flags    = AI_NUMERICSERV | AI_ADDRCONFIG
getaddrinfo(hostname, port, &hints, &listp)
        |
        v
for each p in listp:                 ┌─ socket 失敗 → continue(試下一個)
    socket(p->ai_family,             │
           p->ai_socktype,           │
           p->ai_protocol) ──────────┘
    connect(clientfd, p->ai_addr, p->ai_addrlen)
        ├─ 成功 → break
        └─ 失敗 → Close(clientfd) 再試下一個   ← 必須先關閉!
        |
        v
freeaddrinfo(listp)
p == NULL ? return -1 : return clientfd

open_listenfd(server 端)

#include "csapp.h"
int open_listenfd(char *port);
/* Returns: descriptor if OK, -1 on error */

流程(Figure 11.19):hints 設 SOCK_STREAM + AI_PASSIVE | AI_ADDRCONFIG | AI_NUMERICSERV,以 host=NULL 呼叫 getaddrinfo(因此位址為 wildcard address),走訪串列直到 socketbind 成功,失敗一樣先 Close 再試下一個;最後呼叫 listen(listenfd, LISTENQ) 轉為 listening descriptor,若 listen 失敗須關閉 descriptor 避免洩漏。

open_clientfd open_listenfd
getaddrinfo 參數 (hostname, port, ...) (NULL, port, ...) → wildcard address
hints flags AI_NUMERICSERV | AI_ADDRCONFIG AI_PASSIVE | AI_ADDRCONFIG | AI_NUMERICSERV
走訪串列直到 socket + connect 成功 socket + bind 成功
額外步驟 setsockopt(SO_REUSEADDR)、最後 listen
回傳 可讀寫的 connected descriptor listening descriptor

11.4.9 Echo Client 與 Server 範例 (p.980-984)

Echo client(Figure 11.20):Open_clientfd 建立連線後,以 Rio_readinitb 初始化,進入迴圈——Fgets 從 stdin 讀一行文字、Rio_writen 送給 server、Rio_readlineb 讀回 echo 行、Fputs 印到 stdout。迴圈在 fgets 於 stdin 遇到 EOF 時終止(使用者按 Ctrl+D,或重導向的輸入檔讀完)。之後 client Close(clientfd)——這會使 EOF 通知送往 server(server 的 rio_readlineb 回傳 0 時偵測到)。process 終止時 kernel 會自動關閉所有開啟的 descriptor,故該 close 非必要,但明確關閉自己開啟的 descriptor 是良好的程式習慣

Echo server(Figure 11.21,iterative):Open_listenfd 後進入無窮迴圈,每輪 Accept 等待連線、Getnameinfo 印出連上的 client 的 domain name 與 port、呼叫 echo(connfd) 服務該 client,返回後 Close(connfd)。雙方都關閉各自 descriptor 後,連線即終止。

echo 函式(Figure 11.22):Rio_readinitb 後,while ((n = Rio_readlineb(...)) != 0) 反覆讀一行、印出收到的 byte 數、Rio_writen 原樣寫回,直到 rio_readlineb 遇到 EOF。Rio 套件詳見 10-System-IO/02-Rio-Package-and-File-Metadata

連線上的 EOF 是什麼意思?(p.984 Aside)

沒有所謂的 EOF 字元。EOF 是 kernel 偵測到的一種條件 (condition),應用程式在 read 回傳 0 時得知。對磁碟檔案,EOF 發生在目前檔案位置超過檔案長度時;對 Internet 連線,EOF 發生在某一端 process 關閉它那端的連線時——另一端在試圖讀取串流最後一個 byte 之後的資料時偵測到 EOF。

一般化敘述的例外

  • addrlen = sizeof(sockaddr_in)」僅適用於 IPv4;protocol-independent 程式應使用 getaddrinfo 填好的 ai_addrlen,server 端接收位址則用 sizeof(struct sockaddr_storage)
  • 「client 不需要 bind」是常態,但 kernel 會自動為 client 指派 ephemeral port——並非 client 沒有位址。
  • 「close 非必要(kernel 會代關)」只在 process 即將終止時成立;長壽命 process(如迴圈中的 server)不 close 會造成 descriptor 洩漏

Exam/Test Patterns

情境 / 關鍵字 答案
socket 對 kernel / 對程式分別是什麼? kernel:通訊端點;程式:有 descriptor 的開啟檔案
sin_portsin_addr 的 byte order? 一律 network byte order(big-endian)
_in 字尾的意思? internet(不是 input)
為何 socket 函式收 struct sockaddr *? 當年 C 沒有 void *,以泛型結構 + 應用端強制轉型 (SA) 達成
socket() 回傳的 fd 能馬上讀寫嗎? 不能,只是部分開啟;client 需 connect,server 需 bind+listen+accept
connect 成功後連線由什麼標識? socket pair (x:y, addr.sin_addr:addr.sin_port),y 為 ephemeral port
listen 的作用? 把 kernel 預設的 active socket 轉為 listening socket;backlog 是排隊數提示(常設 1024)
listening vs connected descriptor? 前者建立一次、活整個 server 生命期;後者每次 accept 建立、只活服務期間;區分是為了支援 concurrent server
server 的 listenfd 通常是 3,為什麼? descriptor 0-2 保留給 stdin/stdout/stderr
getaddrinfo 取代了誰?優點? 取代 gethostbyname/getservbyname;reentrant、protocol-independent(getnameinfo 取代 gethostbyaddr/getservbyport)
getaddrinfo 回傳什麼?用完要做什麼? addrinfo 結構的 linked list;必須 freeaddrinfo 否則 memory leak;錯誤用 gai_strerror 轉字串
client / server 各走訪串列到何時停? client:socket+connect 成功;server:socket+bind 成功
AI_PASSIVE + host=NULL 的效果? 回傳 server 用的 listening 位址,位址欄為 wildcard address(接受本機任一 IP 的請求)
每個 unique address 預設回傳幾個 addrinfo? 最多 3 個(connection/datagram/raw);SOCK_STREAM 限制為最多 1 個
SO_REUSEADDR 的目的? 消除 "Address already in use",否則重啟的 server 約 30 秒拒絕連線
為何 echo server 用 sockaddr_storage? 大到能容納任何型別的 socket address,保持 protocol-independent
連線上的 EOF 何時發生?如何偵測? 對端 process 關閉其連線端時;read/rio_readlineb 回傳 0
一次服務一個 client 的 server 叫什麼? iterative server(concurrent server 見 Ch12)
inet_ntop vs getnameinfo? 後者抽象層級更高、與位址格式無關(Practice Problem 11.4)