Linux網絡編程 - 使用套接字格式建立連接以及數據交互

1. 服務端準備連接的過程

創建套接字:

int socket(int domain, int type, int protocol)

domain 就是指 PF_INET、PF_INET6 以及 PF_LOCAL 等,表示什麼樣的套接字。

type 可用的值是:

SOCK_STREAM: 表示的是字節流,對應 TCP;

SOCK_DGRAM: 表示的是數據報,對應 UDP;

SOCK_RAW: 表示的是原始套接字。

參數 protocol 原本是用來指定通信協議的,但現在基本廢棄。因爲協議已經通過前面兩個參數指定完成。protocol 目前一般寫成 0 即可。

bind:

創建出來的套接字如果需要被別人使用,就需要調用 bind 函數把套接字和套接字地址綁定。

bind(int fd, sockaddr * addr, socklen_t len)

第二個參數是通用地址格式sockaddr * addr,這裏得注意,雖然接收的是通用地址格式,實際上傳入的參數可能是 IPv4、IPv6 或者本地套接字格式。bind 函數會根據 len 字段判斷傳入的參數 addr 該怎麼解析,len 字段表示的就是傳入的地址長度,它是一個可變值。其實可以把 bind 函數理解成這樣:

bind(int fd, void * addr, socklen_t len)

不過 BSD 設計套接字的時候大約是 1982 年,那個時候的 C 語言還沒有void *的支持,爲了解決這個問題,BSD 的設計者們創造性地設計了通用地址格式來作爲支持 bind 和 accept 等這些函數的參數。對於使用者來說,每次需要將 IPv4、IPv6 或者本地套接字格式轉化爲通用套接字格式,就像下面的 IPv4 套接字地址格式的例子一樣:

struct sockaddr_in name;
bind (sock, (struct sockaddr *) &name, sizeof (name))

設置 bind 的時候,對地址和端口可以有多種處理方式。可以把地址設置成本機的 IP 地址,這相當告訴操作系統內核,僅僅對目標 IP 是本機 IP 地址的 IP 包進行處理。但是這樣寫的程序在部署時有一個問題,編寫應用程序時並不清楚自己的應用程序將會被部署到哪臺機器上,可以利用通配地址的能力幫助我們解決這個問題。

對於 IPv4 的地址來說,使用 INADDR_ANY 來完成通配地址的設置;對於 IPv6 的地址來說,使用 IN6ADDR_ANY 來完成通配地址的設置。

struct sockaddr_in name;
name.sin_addr.s_addr = htonl (INADDR_ANY); /* IPV4通配地址 */

除了地址,還有端口。如果把端口設置成 0,就相當於把端口的選擇權交給操作系統內核來處理,操作系統內核會根據一定的算法選擇一個空閒的端口,完成套接字的綁定。這在服務器端不常使用。

listen

bind 函數只是讓我們的套接字和地址關聯,讓服務器真正處於可接聽的狀態,這個過程需要依賴 listen 函數。初始化創建的套接字,可以認爲是一個"主動"套接字,其目的是之後主動發起請求(通過調用 connect 函數,後面會講到)。通過 listen 函數,可以將原來的"主動"套接字轉換爲"被動"套接字,告訴操作系統內核:“我這個套接字是用來等待用戶請求的。”當然,操作系統內核會爲此做好接收用戶請求的一切準備,比如完成連接隊列。

int listen (int socketfd, int backlog)

第一個參數 socketfd 爲套接字描述符,第二個參數 backlog,官方的解釋爲未完成連接隊列的大小,這個參數的大小決定了可以接收的併發數目。但是如果這個參數過大也會佔用過多的系統資源,一些系統,比如 Linux 並不允許對這個參數進行改變。

accept

當客戶端的連接請求到達時,服務器端應答成功,連接建立,這個時候操作系統內核需要把這個事件通知到應用程序,並讓應用程序感知到這個連接。accept 這個函數的作用就是連接建立之後,操作系統內核和應用程序之間的橋樑。它的原型是:

int accept(int listensockfd, struct sockaddr *cliaddr, socklen_t *addrlen)

函數的第一個參數 listensockfd 是套接字,可以叫它爲 listen 套接字,因爲這就是前面通過 bind,listen 一系列操作而得到的套接字。函數的返回值,是一個全新的描述字,代表了與客戶端的連接。這裏一定要注意有兩個套接字描述字,第一個是監聽套接字描述字 listensockfd,它是作爲輸入參數存在的;第二個是返回的已連接套接字描述字

這裏可能有個疑問,爲什麼要把兩個套接字分開呢?網絡程序的一個重要特徵就是併發處理,不可能一個應用程序運行之後只能服務一個客戶,如果是這樣, 雙 11 搶購得需要多少服務器才能滿足全國 “剁手黨 ” 的需求?

所以監聽套接字一直都存在,它是要爲成千上萬的客戶來服務的,直到這個監聽套接字關閉;而一旦一個客戶和服務器連接成功,完成了 TCP 三次握手,操作系統內核就爲這個客戶生成一個已連接套接字,讓應用服務器使用這個已連接套接字和客戶進行通信處理。如果應用服務器完成了對這個客戶的服務,比如一次網購下單,一次付款成功,那麼關閉的就是已連接套接字,這樣就完成了 TCP 連接的釋放。而監聽套接字一直都處於“監聽”狀態,等待新的客戶請求到達並服務。

2. 客戶端發起連接的過程

第一步還是和服務端一樣,要建立一個套接字,方法和前面是一樣的。不一樣的是客戶端需要調用 connect 向服務端發起請求。

connect

客戶端和服務器端的連接建立,是通過 connect 函數完成的。

int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen)

第一個參數 sockfd 是連接套接字,通過前面講述的 socket 函數創建。第二個、第三個參數 servaddr 和 addrlen 分別代表指向套接字地址結構的指針和該結構的大小。套接字地址結構必須含有服務器的 IP 地址和端口號。客戶在調用函數 connect 前不必非得調用 bind 函數,因爲如果需要地址的話,內核會自動確定源 IP 地址,並按照一定的算法選擇一個臨時端口作爲源端口。

如果是 TCP 套接字,那麼調用 connect 函數將激發 TCP 的三次握手過程,而且僅在連接建立成功或出錯時才返回。其中出錯返回可能有以下幾種情況:

  • 三次握手無法建立,客戶端發出的 SYN 包沒有任何響應,於是返回 TIMEOUT 錯誤。這種情況比較常見的原因是對應的服務端 IP 寫錯。
  • 客戶端收到了 RST(復位)回答,這時候客戶端會立即返回 CONNECTION REFUSED 錯誤。這種情況比較常見於客戶端發送連接請求時的請求端口寫錯,因爲 RST 是 TCP 在發生錯誤時發送的一種 TCP 分節。產生 RST 的三個條件是:目的地爲某端口的 SYN 到達,然而該端口上沒有正在監聽的服務器(如前所述);TCP 想取消一個已有連接;TCP 接收到一個根本不存在的連接上的分節。
  • 客戶發出的 SYN 包在網絡上引起了"destination unreachable",即目的不可達的錯誤。這種情況比較常見的原因是客戶端和服務器端路由不通。

3. 著名的 TCP 三次握手

                                           

我們剛剛學習了服務端和客戶端連接的主要函數,下面結合這些函數講解一下 TCP 三次握手的過程。注意,這裏我們使用的網絡編程模型都是阻塞式的。所謂阻塞式,就是調用發起後不會直接返回,由操作系統內核處理之後纔會返回。

服務器端通過 socket,bind 和 listen 完成了被動套接字的準備工作,被動的意思就是等着別人來連接,然後調用 accept,就會阻塞在這裏,等待客戶端的連接來臨;客戶端通過調用 socket 和 connect 函數之後,也會阻塞。接下來的事情是由操作系統內核完成的,更具體一點的說,是操作系統內核網絡協議棧在工作。

  • 客戶端的協議棧向服務器端發送了 SYN 包,並告訴服務器端當前發送序列號 j,客戶端進入 SYNC_SENT 狀態
  • 服務器端的協議棧收到這個包之後,和客戶端進行 ACK 應答,應答的值爲 j+1,表示對 SYN 包 j 的確認,同時服務器也發送一個 SYN 包,告訴客戶端當前我的發送序列號爲 k,服務器端進入 SYNC_RCVD 狀態
  • 客戶端協議棧收到 ACK 之後,使得應用程序從 connect 調用返回,表示客戶端到服務器端的單向連接建立成功,客戶端的狀態爲 ESTABLISHED,同時客戶端協議棧也會對服務器端的 SYN 包進行應答,應答數據爲 k+1;
  • 應答包到達服務器端後,服務器端協議棧使得 accept 阻塞調用返回,這個時候服務器端到客戶端的單向連接也建立成功,服務器端也進入 ESTABLISHED 狀態。

4. 發送函數和發送緩衝區

發送數據時常用的有三個函數,分別是 write、send 和 sendmsg。

ssize_t write (int socketfd, const void *buffer, size_t size)
ssize_t send (int socketfd, const void *buffer, size_t size, int flags)
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags)

第一個函數是常見的文件寫函數,如果把 socketfd 換成文件描述符,就是普通的文件寫入。如果想指定選項,發送帶外數據,就需要使用第二個帶 flag 的函數。所謂帶外數據,是一種基於 TCP 協議的緊急數據,用於客戶端 - 服務器在特定場景下的緊急處理。如果想指定多重緩衝區傳輸數據,就需要使用第三個函數,以結構體 msghdr 的方式發送數據。

你看到這裏可能會問,既然套接字描述符是一種特殊的描述符,那麼在套接字描述符上調用 write 函數,應該和在普通文件描述符是一致的,都是通過描述符句柄寫入指定的數據。但是其實,內在的區別還是很不一樣的,只不過操作系統內核爲讀取和發送數據做了很多我們表面上看不到的工作。

  • 對於普通文件描述符而言,一個文件描述符代表了打開的一個文件句柄,通過調用 write 函數,操作系統內核幫我們不斷地往文件系統中寫入字節流。注意,寫入的字節流大小通常和輸入參數 size 的值是相同的,否則表示出錯。
  • 對於套接字描述符而言,它代表了一個雙向連接,在套接字描述符上調用 write 寫入的字節數有可能比請求的數量少,這在普通文件描述符情況下是不正常的。

接下來拿 write 函數舉例,重點闡述發送緩衝區的概念。

你一定要建立一個概念,當 TCP 三次握手成功,即連接成功建立後,操作系統內核會爲每一個連接創建配套的基礎設施,比如發送緩衝區。發送緩衝區的大小可以通過套接字選項來改變,當我們的應用程序調用 write 函數時,實際是把數據從應用程序中拷貝到操作系統內核的發送緩衝區中,並不一定是把數據通過套接寫出去。這裏有幾種情況:

第一種情況很簡單,操作系統內核的發送緩衝區足夠大,可以直接容納這份數據,我們的程序從 write 調用中退出,返回寫入的字節數就是應用程序的數據大小。

第二種情況是,操作系統內核的發送緩衝區是夠大了,不過還有數據沒有發送完,或者數據發送完了,但是操作系統內核的發送緩衝區不足以容納應用程序數據,在這種情況下,操作系統內核並不會返回,也不會報錯,而是應用程序被阻塞,也就是說應用程序在 write 函數調用處停留,不直接返回。這裏,大部分 UNIX 系統的做法是一直等到可以把應用程序數據完全放到操作系統內核的發送緩衝區中,再從系統調用中返回。

更形象地說,操作系統內核是很聰明的,當 TCP 連接建立之後,它就開始運作起來。可以把發送緩衝區想象成一條包裹流水線,有個聰明且忙碌的工人不斷地從流水線上取出包裹(數據),這個工人會按照 TCP/IP 的語義,將取出的包裹(數據)封裝成 TCP 的 MSS 包,以及 IP 的 MTU 包,最後走數據鏈路層將數據發送出去。這樣我們的發送緩衝區就又空了一部分,於是又可以繼續從應用程序搬一部分數據到發送緩衝區裏,這樣一直進行下去,到某一個時刻,應用程序的數據可以完全放置到發送緩衝區裏。在這個時候,write 阻塞調用返回。注意返回的時刻,應用程序數據並沒有全部被髮送出去,發送緩衝區裏還有部分數據,這部分數據會在稍後由操作系統內核通過網絡發送出去。

                                         

5. 讀取數據

在 UNIX 的世界裏萬物都是文件,這就意味着可以將套接字描述符傳遞給那些原先爲處理本地文件而設計的函數。這些函數包括 read 和 write 交換數據的函數。

先從最簡單的 read 函數開始看起:

ssize_t read (int socketfd, void *buffer, size_t size)

read 函數要求操作系統內核從套接字描述字 socketfd讀取最多多少個字節(size),並將結果存儲到 buffer 中。返回值告訴我們實際讀取的字節數目,也有一些特殊情況,如果返回值爲 0,表示 EOF(end-of-file),這在網絡中表示對端發送了 FIN 包,要處理斷連的情況;如果返回值爲 -1,表示出錯。當然,如果是非阻塞 I/O,情況會略有不同,在後面的非阻塞 I/O中會講到。

注意這裏是最多讀取 size 個字節。如果我們想讓應用程序每次都讀到 size 個字節,就需要編寫下面的函數,不斷地循環讀取。

/* 從socketfd描述字中讀取"size"個字節. */
size_t readn(int fd, void *buffer, size_t size) {
    char *buffer_pointer = buffer;
    int length = size;

    while (length > 0) {
        int result = read(fd, buffer_pointer, length);

        if (result < 0) {
            if (errno == EINTR)
                continue;     /* 考慮非阻塞的情況,這裏需要再次調用read */
            else
                return (-1);
        } else if (result == 0)
            break;                /* EOF(End of File)表示套接字關閉 */

        length -= result;
        buffer_pointer += result;
    }
    return (size - length);        /* 返回的是實際讀取的字節數*/
}

對這個程序稍微解釋下:

6-19 行的循環條件表示的是,在沒讀滿 size 個字節之前,一直都要循環下去。

10-11 行表示的是非阻塞 I/O 的情況下,沒有數據可以讀,需要繼續調用 read。

14-15 行表示讀到對方發出的 FIN 包,表現形式是 EOF,此時需要關閉套接字。

17-18 行,需要讀取的字符數減少,緩存指針往下移動。

20 行是在讀取 EOF 跳出循環後,返回實際讀取的字符數。

6. 驗證緩衝區的實驗

客戶端代碼示例:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define MESSAGE_SIZE 10240000

void send_data(int sockfd) {
    char *query;
    query = malloc(MESSAGE_SIZE + 1);
    for (int i = 0; i < MESSAGE_SIZE; i++) {
        query[i] = 'a';
    }
    query[MESSAGE_SIZE] = '\0';

    const char *cp;
    cp = query;
    size_t remaining = strlen(query);
    while (remaining) {
        int n_written = send(sockfd, cp, remaining, 0);
        fprintf(stdout, "send into buffer %ld \n", n_written);
        if (n_written <= 0) {
            fprintf(stderr, "Send failed !\n");
            return;
        }
        remaining -= n_written;
        cp += n_written;
    }
    return;
}

int main()
{
        int sockfd;
        int connect_rt;
        struct sockaddr_in serv_addr;

        sockfd = socket(PF_INET, SOCK_STREAM, 0);
        serv_addr.sin_family = AF_INET;
        serv_addr.sin_port = htons(7878);
        inet_pton(AF_INET, "192.168.133.131", &serv_addr.sin_addr);

        connect_rt = connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
        if (connect_rt < 0)
        {
                fprintf(stderr, "Connect failed !\n");
        }
        send_data(sockfd);
        return 0;
}

服務端代碼示例:

#include    <sys/types.h>    /* basic system data types */
#include    <sys/socket.h>    /* basic socket definitions */
#include    <netinet/in.h>    /* sockaddr_in{} and other Internet defns */
#include    <arpa/inet.h>    /* inet(3) functions */
#include    <errno.h>
#include    <signal.h>
#include    <stdio.h>
#include    <stdlib.h>
#include    <string.h>
#include    <unistd.h>
#include    <string.h>        

/* 從socketfd描述字中讀取"size"個字節. */
size_t readn(int fd, void *buffer, size_t size) {
    char *buffer_pointer = buffer;
    int length = size;

    while (length > 0) {
        int result = read(fd, buffer_pointer, length);
        if (result < 0) {
            if (result == EINTR)
                continue;     /* 考慮非阻塞的情況,這裏需要再次調用read */
            else
                return (-1);
        } else if (result == 0)
            break;                /* EOF(End of File)表示套接字關閉 */

        length -= result;
        buffer_pointer += result;
    }
    return (size - length);        /* 返回的是實際讀取的字節數*/
}

void read_data(int sockfd) {
    ssize_t n;
    char buf[1024];
    int time = 0;
    for (;;) {
        fprintf(stdout, "block in read\n");
        if ((n = readn(sockfd, buf, 1024)) == 0)
            return;

        time++;
        fprintf(stdout, "1K read for %d \n", time);
        usleep(1000);
    }
}

int main()
{
        int listenfd, connfd;
        socklen_t  cli_addr_len;
        struct sockaddr_in serv_addr, cli_addr;
        listenfd = socket(PF_INET, SOCK_STREAM, 0);

        bzero(&serv_addr, sizeof(serv_addr));
        bzero(&cli_addr, sizeof(cli_addr));
        serv_addr.sin_family = AF_INET;
        serv_addr.sin_port = htons(7878);
        serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
        bind(listenfd, (struct sockaddr*) &serv_addr, sizeof(serv_addr));

        listen(listenfd, SOMAXCONN);
        connfd = accept(listenfd, (struct sockaddr* )&cli_addr, &cli_addr_len);
        read_data(connfd);

        return 0;
}

實驗一: 

服務端屏幕輸出如下:

                                                           

客戶端發送了一個很大的字節流 1萬KB, 服務端不斷地在屏幕上打印出讀取字節流的過程,而客戶端直到最後所有的字節流發送完畢纔打印出下面的一句話,說明在此之前 send 函數一直都是阻塞的

                                                      

實驗二: 服務端處理變慢

將服務端代碼中睡眠時間加長,把客戶端發送字節數減小,從10240000 調整爲 102400,再次運行服務端和客戶端。發現客戶端很快打印出結果,而服務端還在處理,不斷在屏幕輸出結果。這個例子說明,發送成功僅僅表示的是數據被拷貝到了發送緩衝區中,並不意味着連接對端已經收到所有的數據。至於什麼時候發送到對端的接收緩衝區,或者更進一步說,什麼時候被對方應用程序緩衝所接收,對我們而言完全都是透明的。

 

溫故而知新!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章