Linux Socket 編程簡介

在 TCP/IP 協議中,"IP地址 + TCP或UDP端口號" 能夠惟一標識網絡通信中的一個進程,"IP地址+端口號" 就稱爲 socket。本文以一個簡單的 TCP 協議爲例,介紹如何建立基於 TCP 協議的網絡程序。html

TCP 協議通信流程

下圖描述了 TCP 協議的通信流程(此圖來自互聯網):linux

下圖則描述 TCP 創建鏈接的過程(此圖來自互聯網):編程

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

TCP 鏈接創建後數據傳輸的過程:網絡

創建鏈接後,TCP 協議提供全雙工的通訊服務,可是通常的客戶端/服務器程序的流程是由客戶端主動發起請求,服務器被動處理請求,一問一答的方式。所以,服務器從 accept() 返回後馬上調用 read(),讀 socket 就像讀管道同樣,若是沒有數據到達就阻塞等待,這時客戶端調用 write() 發送請求給服務器,服務器收到後從 read() 返回,對客戶端的請求進行處理,在此期間客戶端調用 read() 阻塞等待服務器的應答,服務器調用 write() 將處理結果發回給客戶端,再次調用 read() 阻塞等待下一條請求,客戶端收到後從 read() 返回,發送下一條請求,如此循環下去。socket

下圖描述了關閉 TCP 鏈接的過程:函數

若是客戶端沒有更多的請求了,就調用 close() 關閉鏈接,就像寫端關閉的管道同樣,服務器的 read() 返回 0,這樣服務器就知道客戶端關閉了鏈接,也調用 close() 關閉鏈接。注意,任何一方調用 close() 後,鏈接的兩個傳輸方向都關閉,不能再發送數據了。若是一方調用 shutdown() 則鏈接處於半關閉狀態,仍可接收對方發來的數據。性能

在學習 socket 編程時要注意應用程序和 TCP 協議層是如何交互的: 學習

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

下面經過一個簡單的 TCP 網絡程序來理解相關概念。程序分爲服務器端和客戶端兩部分,它們之間經過 socket 進行通訊。spa

服務器端程序

下面是一個很是簡單的服務器端程序,它從客戶端讀字符,而後將每一個字符轉換爲大寫並回送給客戶端:

#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協議的網絡程序

相關文章
相關標籤/搜索