Linux Socket 程式設計簡介

sparkdev發表於2018-01-24

在 TCP/IP 協議中,"IP地址 + TCP或UDP埠號" 可以唯一標識網路通訊中的一個程式,"IP地址+埠號" 就稱為 socket。本文以一個簡單的 TCP 協議為例,介紹如何建立基於 TCP 協議的網路程式。

TCP 協議通訊流程

下圖描述了 TCP 協議的通訊流程(此圖來自網際網路):

下圖則描述 TCP 建立連線的過程(此圖來自網際網路):

伺服器呼叫 socket()、bind()、listen() 函式完成初始化後,呼叫 accept() 阻塞等待,處於監聽埠的狀態,客戶端呼叫 socket() 初始化後,呼叫 connect() 發出 SYN 段並阻塞等待伺服器應答,伺服器應答一個SYN-ACK 段,客戶端收到後從 connect() 返回,同時應答一個 ACK 段,伺服器收到後從 accept() 返回。

TCP 連線建立後資料傳輸的過程:

建立連線後,TCP 協議提供全雙工的通訊服務,但是一般的客戶端/伺服器程式的流程是由客戶端主動發起請求,伺服器被動處理請求,一問一答的方式。因此,伺服器從 accept() 返回後立刻呼叫 read(),讀 socket 就像讀管道一樣,如果沒有資料到達就阻塞等待,這時客戶端呼叫 write() 傳送請求給伺服器,伺服器收到後從 read() 返回,對客戶端的請求進行處理,在此期間客戶端呼叫 read() 阻塞等待伺服器的應答,伺服器呼叫 write() 將處理結果發回給客戶端,再次呼叫 read() 阻塞等待下一條請求,客戶端收到後從 read() 返回,傳送下一條請求,如此迴圈下去。

下圖描述了關閉 TCP 連線的過程:

如果客戶端沒有更多的請求了,就呼叫 close() 關閉連線,就像寫端關閉的管道一樣,伺服器的 read() 返回 0,這樣伺服器就知道客戶端關閉了連線,也呼叫 close() 關閉連線。注意,任何一方呼叫 close() 後,連線的兩個傳輸方向都關閉,不能再傳送資料了。如果一方呼叫 shutdown() 則連線處於半關閉狀態,仍可接收對方發來的資料。

在學習 socket 程式設計時要注意應用程式和 TCP 協議層是如何互動的:

  • 應用程式呼叫某個 socket 函式時 TCP 協議層完成什麼動作,比如呼叫 connect() 會發出 SYN 段
  • 應用程式如何知道 TCP 協議層的狀態變化,比如從某個阻塞的 socket 函式返回就表明 TCP 協議收到了某些段,再比如 read() 返回 0 就表明收到了 FIN 段

下面通過一個簡單的 TCP 網路程式來理解相關概念。程式分為伺服器端和客戶端兩部分,它們之間通過 socket 進行通訊。

伺服器端程式

下面是一個非常簡單的伺服器端程式,它從客戶端讀字元,然後將每個字元轉換為大寫並回送給客戶端:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <netinet/in.h>

#define MAXLINE 80
#define SERV_PORT 8000

int main(void)
{
    struct sockaddr_in servaddr, cliaddr;
    socklen_t cliaddr_len;
    int listenfd, connfd;
    char buf[MAXLINE];
    char str[INET_ADDRSTRLEN];
    int i, n;

    // socket() 開啟一個網路通訊埠,如果成功的話,
    // 就像 open() 一樣返回一個檔案描述符,
    // 應用程式可以像讀寫檔案一樣用 read/write 在網路上收發資料。
    listenfd = socket(AF_INET, SOCK_STREAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(SERV_PORT);
    
    // bind() 的作用是將引數 listenfd 和 servaddr 繫結在一起,
    // 使 listenfd 這個用於網路通訊的檔案描述符監聽 servaddr 所描述的地址和埠號。
    bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));

    // listen() 宣告 listenfd 處於監聽狀態,
    // 並且最多允許有 20 個客戶端處於連線待狀態,如果接收到更多的連線請求就忽略。
    listen(listenfd, 20);

    printf("Accepting connections ...\n");
    while (1)
    {
        cliaddr_len = sizeof(cliaddr);
        // 典型的伺服器程式可以同時服務於多個客戶端,
        // 當有客戶端發起連線時,伺服器呼叫的 accept() 返回並接受這個連線,
        // 如果有大量的客戶端發起連線而伺服器來不及處理,尚未 accept 的客戶端就處於連線等待狀態。
        connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len);
      
        n = read(connfd, buf, MAXLINE);
        printf("received from %s at PORT %d\n",
               inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),
               ntohs(cliaddr.sin_port));
    
        for (i = 0; i < n; i++)
        {
            buf[i] = toupper(buf[i]);
        }
            
        write(connfd, buf, n);
        close(connfd);
    }
}

把上面的程式碼儲存到檔案 server.c 檔案中,並執行下面的命令編譯:

$ gcc server.c -o server

然後執行編譯出來的 server 程式:

$ ./server

此時我們可以通過 ss 命令來檢視主機上的埠監聽情況:

如上圖所示,server 程式已經開始監聽主機的 8000 埠了。

下面讓我們介紹一下這段程式中用到的 socket 相關的 API。

int socket(int family, int type, int protocol);

socket() 開啟一個網路通訊埠,如果成功的話,就像 open() 一樣返回一個檔案描述符,應用程式可以像讀寫檔案一樣用 read/write 在網路上收發資料。對於IPv4,family 引數指定為 AF_INET。對於 TCP 協議,type 引數指定為 SOCK_STREAM,表示面向流的傳輸協議。如果是 UDP 協議,則 type 引數指定為 SOCK_DGRAM,表示面向資料包的傳輸協議。protocol 指定為 0 即可。

int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);

伺服器需要呼叫 bind 函式繫結一個固定的網路地址和埠號。bind() 的作用是將引數 sockfd 和 myaddr 繫結在一起,使 sockfd 這個用於網路通訊的檔案描述符監聽 myaddr 所描述的地址和埠號。struct sockaddr *是一個通用指標型別,myaddr 引數實際上可以接受多種協議的 sockaddr 結構體,而它們的長度各不相同,所以需要第三個引數 addrlen 指定結構體的長度。
程式中對 myaddr 引數的初始化為:

bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);

首先將整個結構體清零,然後設定地址型別為 AF_INET,網路地址為 INADDR_ANY,這個巨集表示本地的任意 IP 地址,因為伺服器可能有多個網路卡,每個網路卡也可能繫結多個 IP 地址,這樣設定可以在所有的 IP 地址上監聽,直到與某個客戶端建立了連線時才確定下來到底用哪個 IP 地址,埠號為 SERV_PORT,我們定義為 8000。

int listen(int sockfd, int backlog);

listen() 宣告 sockfd 處於監聽狀態,並且最多允許有 backlog 個客戶端處於連線待狀態,如果接收到更多的連線請求就忽略。

int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);

三方握手完成後,伺服器呼叫 accept() 接受連線,如果伺服器呼叫 accept() 時還沒有客戶端的連線請求,就阻塞等待直到有客戶端連線上來。cliaddr 是一個傳出引數,accept() 返回時傳出客戶端的地址和埠號。addrlen 引數是一個傳入傳出引數(value-result argument),傳入的是呼叫者提供的緩衝區 cliaddr 的長度以避免緩衝區溢位問題,傳出的是客戶端地址結構體的實際長度(有可能沒有佔滿呼叫者提供的緩衝區)。如果給 cliaddr 引數傳 NULL,表示不關心客戶端的地址。
伺服器程式的主要結構如下:

while (1)
{
    cliaddr_len = sizeof(cliaddr);
    connfd = accept(listenfd,
            (struct sockaddr *)&cliaddr, &cliaddr_len);
    n = read(connfd, buf, MAXLINE);
    ......
    close(connfd);
}

整個是一個 while 死迴圈,每次迴圈處理一個客戶端連線。由於 cliaddr_len 是傳入傳出引數,每次呼叫 accept( ) 之前應該重新賦初值。accept() 的引數 listenfd 是先前的監聽檔案描述符,而 accept() 的返回值是另外一個檔案描述符 connfd,之後與客戶端之間就通過這個 connfd 通訊,最後關閉 connfd 斷開連線,而不關閉 listenfd,再次回到迴圈開頭 listenfd 仍然用作 accept 的引數。

客戶端程式

下面是客戶端程式,它從命令列引數中獲得一個字串發給伺服器,然後接收伺服器返回的字串並列印:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define MAXLINE 80
#define SERV_PORT 8000

int main(int argc, char *argv[])
{
    struct sockaddr_in servaddr;
    char buf[MAXLINE];
    int sockfd, n;
    char *str;
    
    if (argc != 2)
    {
        fputs("usage: ./client message\n", stderr);
        exit(1);
    }
    str = argv[1];
    
    sockfd = socket(AF_INET, SOCK_STREAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
    servaddr.sin_port = htons(SERV_PORT);
    
    // 由於客戶端不需要固定的埠號,因此不必呼叫 bind(),客戶端的埠號由核心自動分配。
    // 注意,客戶端不是不允許呼叫 bind(),只是沒有必要呼叫 bind() 固定一個埠號,
    // 伺服器也不是必須呼叫 bind(),但如果伺服器不呼叫 bind(),核心會自動給伺服器分配監聽埠,
    // 每次啟動伺服器時埠號都不一樣,客戶端要連線伺服器就會遇到麻煩。
    connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));

    write(sockfd, str, strlen(str));

    n = read(sockfd, buf, MAXLINE);
    printf("Response from server:\n");
    write(STDOUT_FILENO, buf, n);
    printf("\n");
    close(sockfd);
    return 0;
}

把上面的程式碼儲存到檔案 client.c 檔案中,並執行下面的命令編譯:

$ gcc client.c -o client

然後執行編譯出來的 client 程式:

$ ./client hello

此時伺服器端會收到請求並返回轉換為大寫的字串,並輸出相應的資訊:

而客戶端在傳送請求後會收到轉換過的字串:

在客戶端的程式碼中有兩點需要注意:
    1. 由於客戶端不需要固定的埠號,因此不必呼叫 bind(),客戶端的埠號由核心自動分配。
    2. 客戶端需要呼叫 connect() 連線伺服器,connect 和 bind 的引數形式一致,區別在於 bind 的引數是自己的地址,而 connect 的引數是對方的地址。
至此我們已經使用 socket 技術完成了一個最簡單的客戶端伺服器程式,雖然離實際應用還非常遙遠,但就學習而言已經足夠了。

提升伺服器端的響應能力

雖然我們的伺服器程式可以響應客戶端的請求,但是這樣的效率太低了。一般情況下伺服器程式需要能夠同時處理多個客戶端的請求。可以通過 fork 系統呼叫建立子程式來處理每個請求,下面是大體的實現思路:

listenfd = socket(...);
bind(listenfd, ...);
listen(listenfd, ...);
while (1)
{
    connfd = accept(listenfd, ...);
    n = fork();
    if (n == -1)
    {
        perror("call to fork");
        exit(1);
    }
    else if (n == 0)
    {
        // 在子程式中處理客戶端的請求。
        close(listenfd);
        while (1)
        {
            read(connfd, ...);
            ...
            write(connfd, ...);
        }
        close(connfd);
        exit(0);
    }
    else
    {
        close(connfd);
    }    
}

此時父程式的任務就是不斷的建立子程式,而由子程式去響應客戶端的具體請求。通過這種方式,可以極大的提升伺服器端的響應能力。

總結

本文通過一個簡單的建基於 TCP 協議的網路程式介紹了 linux socket 程式設計中的基本概念。通過它我們可以瞭解到 socket 程式工作的基本原理,以及一些解決效能問題的思路。

參考:
基於TCP協議的網路程式

相關文章