天天看點

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 三次握手

Linux網絡程式設計 - 使用套接字格式建立連接配接以及資料互動

我們剛剛學習了服務端和用戶端連接配接的主要函數,下面結合這些函數講解一下 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 阻塞調用傳回。注意傳回的時刻,應用程式資料并沒有全部被發送出去,發送緩沖區裡還有部分資料,這部分資料會在稍後由作業系統核心通過網絡發送出去。

Linux網絡程式設計 - 使用套接字格式建立連接配接以及資料互動

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;
}
           

實驗一: 

服務端螢幕輸出如下:

Linux網絡程式設計 - 使用套接字格式建立連接配接以及資料互動

用戶端發送了一個很大的位元組流 1萬KB, 服務端不斷地在螢幕上列印出讀取位元組流的過程,而用戶端直到最後所有的位元組流發送完畢才列印出下面的一句話,說明在此之前 send 函數一直都是阻塞的。

Linux網絡程式設計 - 使用套接字格式建立連接配接以及資料互動

實驗二: 服務端處理變慢

将服務端代碼中睡眠時間加長,把用戶端發送位元組數減小,從10240000 調整為 102400,再次運作服務端和用戶端。發現用戶端很快列印出結果,而服務端還在處理,不斷在螢幕輸出結果。這個例子說明,發送成功僅僅表示的是資料被拷貝到了發送緩沖區中,并不意味着連接配接對端已經收到所有的資料。至于什麼時候發送到對端的接收緩沖區,或者更進一步說,什麼時候被對方應用程式緩沖所接收,對我們而言完全都是透明的。

溫故而知新!

繼續閱讀