C溫故補缺(十八):網路程式設計

2023-06-03 06:00:32

計算機網路

參考:TCP三次握手詳解.

OSI模型

簡單分層:

其中,鏈路層還可以分出物理層和資料鏈路層。應用層可以分出對談層,表示層和應用層。

七層模型:

  • 鏈路層:只是物理的位元流和簡單封裝的資料框

  • 網路層:主要任務是,通過路由選擇演演算法,為報文通過通訊子網選擇最適當的路徑。也就是通過ip地址來定址,對應的協定是IP協定。

    而ICMP,是基於IP協定的一種協定,但是按功能劃分屬於網路層,而不是自下而上分到傳輸層。該協定主要用於確認IP包是否成功到達目標地址,以及返回在傳送過程中IP地址被丟棄的原因。

    ARP協定,也是網路層的,就是用來將ip地址解析成物理mac地址的,並將ip和mac關聯存在ARP快取表的協定,以便之後再存取,就不用再解析了。

  • 傳輸層:拎出來詳細研究,見下

  • 對談層:就是用於建立對談的,主要步驟:

  1. 為對談實體間建立連線:為給兩個對等對談服務使用者建立一個對談連線,應該做如下幾項工作。

    1. 將對談地址對映為運輸地址。

    2. 選擇需要的運輸服務質量引數(QoS)。

    3. 對對談引數進行協商。

    4. 識別各個對談連線。

    5. 傳送有限的透明使用者資料。

  2. 資料傳輸階段:這個階段是在兩個對談使用者之間實現有組織的,同步的資料傳輸。使用者資料單元為SSDU,而協定資料單元為SPDU.對談使用者之間的資料傳送過程是將SSDU轉變成SPDU進行的。

  3. 連線釋放:連線釋放是通過"有序釋放","廢棄","有限量透明使用者資料傳送"等功能單元來釋放對談連線的。

from知乎.

  • 表示層:主要負責資料格式的轉換、資料加密解密、資料壓縮、圖片處理等工作,對接應用層。
  • 應用層:就是各種網路服務,http,https,smtp等等

TCP

藉助chatgpt。

TCP(Transmission Control Protocol)協定是一種面向連線的、可靠的、基於流的傳輸協定,是網際網路中最常用的傳輸協定之一。TCP協定主要用於在網路上進行可靠的資料傳輸,其特點是建立連線、傳輸資料、維護連線和釋放連線。

TCP協定的主要特點如下:

  1. 面向連線:TCP協定在傳輸資料之前,需要先建立連線,以確保通訊雙方能夠相互識別和配合。

  2. 可靠性:TCP協定能夠保證資料能夠被正確地傳輸和接收,通過檢驗和和確認機制,可以檢測和糾正傳輸過程中出現的錯誤和丟包。

  3. 按順序傳輸:TCP協定能夠保證資料按照傳送順序進行傳輸和接收,避免資料的亂序和丟失。

  4. 流控制:TCP協定通過滑動視窗機制,控制傳送方的資料流量,避免網路擁塞和封包的丟失。

  5. 擁塞控制:TCP協定通過擁塞視窗控制機制,動態調整傳送方的資料傳輸速率,避免網路擁塞和封包的丟失。

  6. 面向位元組流:TCP協定將資料看作一個位元組流進行傳輸,不考慮資料的邊界和長度,能夠傳輸任意型別的資料。

面向連線的實現

TCP協定實現面向連線的方式主要是通過三次握手建立連線和四次揮手釋放連線。

建立連線的過程如下:

  1. 使用者端向伺服器傳送SYN(同步)請求,表示使用者端要建立連線,並帶有一個亂數A。

  2. 伺服器收到請求後,返回SYN+ACK(同步+確認)響應,表示伺服器收到了連線請求,並帶有一個亂數B和確認數A+1。

  3. 使用者端收到響應後,傳送ACK(確認)響應,表示使用者端確認收到了伺服器的響應,並帶有確認數B+1。

完成以上三步,TCP連線就建立成功了。

釋放連線的過程如下:

  1. 使用者端傳送FIN(結束)請求,表示使用者端不再傳送資料。

  2. 伺服器收到請求後,傳送ACK響應,表示已經收到了FIN請求。

  3. 伺服器再傳送FIN請求,表示伺服器不再傳送資料。

  4. 使用者端收到FIN請求後,傳送ACK響應,表示已經收到了伺服器的請求,連線正式關閉。

完成以上四步,TCP連線就被正常關閉了

可靠性實現

TCP協定實現可靠性的方式主要有以下幾個方面:

  1. 序列號和確認號:TCP協定在傳輸資料時,使用序列號和確認號來保證資料的可靠傳輸。傳送方在傳送資料時,為每個封包設定一個序列號,接收方在接收到封包之後,會向傳送方傳送一個確認號,表示接收到了序列號對應的資料。如果傳送方沒有收到確認號,則會重新傳送封包。

  2. 檢驗和:TCP協定使用檢驗和來保證資料在傳輸過程中不被篡改。傳送方在傳送資料時,會計算封包的檢驗和,接收方在接收到封包後,會重新計算檢驗和,如果接收到的封包的檢驗和與傳送方傳送的不一致,則會丟棄該封包。

  3. 超時重傳:TCP協定在傳送資料時,會設定一個超時時間(RTT),如果在超時時間內沒有接收到接收方的確認號,則會重新傳送封包,以保證資料的可靠傳輸。

  4. 滑動視窗:TCP協定使用滑動視窗機制來控制資料的傳輸速率和流量。傳送方和接收方會維護一個視窗大小,傳送方根據視窗大小和接收方的確認號來控制傳送資料的速度和流量,接收方則根據視窗大小來控制接收資料的流量。

順序傳輸的實現方式

TCP協定實現順序傳輸的方式主要是通過序列號和確認號來保證資料的順序傳輸。

在TCP協定中,傳送方為每個封包設定一個序列號,接收方在接收封包時,會根據序列號來確定封包的順序,如果接收到的封包的序列號不是按照順序遞增的,則會快取該封包,等待後面的封包到達後再進行排序和組合。

在傳送資料時,TCP協定會按照順序將資料分成多個封包進行傳輸,每個封包都帶有一個序列號,接收方會根據序列號來確定封包的順序,並向傳送方傳送確認號,表示已經接收到了序列號對應的封包。如果傳送方沒有收到確認號,則會重新傳送封包,保證封包的順序傳輸。

流控制機制

TCP流控制是通過滑動視窗機制實現的。具體實現機制如下:

  1. 傳送方和接收方都會維護一個滑動視窗,用於控制資料的流動。

  2. 傳送方會根據接收方的視窗大小來動態調整自己的傳送速率。如果接收方視窗變小了,傳送方就會減慢傳送速率,以避免資料的擁塞。

  3. 接收方會在收到一定量的資料後,向傳送方傳送一個確認訊息(ACK),告訴傳送方接收到了這些資料。同時,接收方會把視窗向前滑動一定的距離,讓傳送方繼續傳送資料。

  4. 如果傳送方傳送的資料過多,超過了接收方的視窗大小,接收方就會傳送一個視窗更新訊息,告訴傳送方可以繼續傳送的資料量。

通過這樣的機制,TCP流控制可以保證資料的流動速率適應網路的情況,避免資料的擁塞和丟失。同時,這種機制還可以適應不同的網路環境和資料傳輸需求,具有很高的靈活性和可靠性。

擁塞控制機制
  1. 傳送方和接收方都會維護一個擁塞視窗(cwnd),用於控制資料的傳送速率。初始時,cwnd的大小為一個最大段大小(MSS)。

  2. 傳送方會根據接收方的視窗大小和擁塞視窗的大小來動態調整自己的傳送速率。傳送方每收到一個ACK就會把cwnd增加一個MSS的大小,以逐步增加傳送速率,但是在擁塞發生時,cwnd會被減小以減少傳送速率。

  3. 接收方在收到資料後,會向傳送方傳送一個視窗更新訊息,告訴傳送方可以接收的資料量。如果接收方的視窗變小了,傳送方就會減慢傳送速率,以避免資料的擁塞。如果傳送方沒有收到ACK,就會認為網路出現了擁塞,就會把cwnd減小以降低傳送速率。

  4. 傳送方還會根據網路的擁塞情況來調整擁塞視窗的大小。如果傳送方收到了重複的ACK,就表示網路出現了擁塞,就會把cwnd減小一定的量,以避免繼續傳送造成更嚴重的擁塞。如果傳送方發現沒有收到ACK,就會認為網路出現了擁塞,就會把cwnd減小以降低傳送速率。

通過這樣的機制,TCP擁塞控制可以保證在網路出現擁塞時,傳送方能夠自動降低傳送速率,避免資料的丟失和網路擁堵。同時,這種機制還可以適應不同的網路環境和資料傳輸需求,具有很高的靈活性和可靠性。

位元組流的解釋

TCP(傳輸控制協定)是一種面向位元組流的協定,這意味著TCP將資料視為一個連續的位元組流,而不是一系列獨立的封包或訊息。傳輸的資料沒有固定的邊界或大小,TCP只是把資料看作是一個位元組序列,並在傳輸時按照這個位元組序列進行處理。

在TCP中,傳送方把需要傳輸的資料按照位元組流的形式分割成小的資料塊,稱為TCP段。然後,傳送方把每個TCP段封裝成一個TCP報文段,並在報文頭中新增一些控制資訊,如源埠、目的埠、序號、確認號、視窗大小等。傳送方把TCP報文段傳送給接收方。

接收方在收到TCP報文段後,按照報文頭中的序號和確認號資訊,將TCP段重新組裝成原始的資料。如果接收方收到了亂序的TCP段,它會先快取這些TCP段,等待缺失的TCP段到來後再進行組裝。如果接收方收到了重複的TCP段,它會忽略這些TCP段,只傳送一次ACK確認報文段。

TCP的通訊流程

UDP

UDP(使用者資料包協定)是一種簡單的、無連線的、面向資料包的協定,它可以在IP網路中進行快速傳輸。與TCP協定不同,UDP協定不提供可靠性和流量控制等服務,但是它的優點是速度快,具有較低的延遲和較小的網路開銷。

UDP協定的特點如下:

  1. 無連線性:UDP協定是無連線的,傳送資料前不需要建立連線。這意味著應用程式可以快速地傳送資料,並且不需要等待建立連線這一步驟。

  2. 面向資料包:UDP協定是面向資料包的,每個封包都是獨立的,UDP協定不會像TCP協定那樣把資料流分割成小的資料塊,也不會在傳送和接收的資料之間維護狀態資訊。

  3. 不可靠性:UDP協定不提供可靠性和流量控制等服務,因此在傳輸過程中可能會出現封包丟失、重複、亂序等問題。但是,這也使得UDP協定的傳輸速度更快,適用於那些對可靠性要求不高的應用程式。

  4. 簡單性:UDP協定非常簡單,它只包含了必要的功能,沒有複雜的控制機制和狀態資訊。這使得UDP協定的實現非常容易,並且可以在資源有限的裝置上使用。

UDP協定適用於一些對可靠性要求不高的應用程式,如視訊流、音訊流、DNS服務等。這些應用程式需要快速傳輸資料,而且可以容忍一定的資料丟失和重複。

無連線性

與TCP不同,UDP不會在傳輸之前建立連線,並且不會在傳輸後關閉連線。這種無連線的特性使得UDP具有更高的傳輸速率和更低的延遲,但也意味著資料傳輸的可靠性較低,因為UDP無法保證資料的完整性和正確性。

在UDP協定中,封包只包含源地址、目標地址、資料和一些控制資訊,如校驗和等。這些資訊足以保證封包能夠被正確地傳輸,但是它們不能確保封包能夠被正確地接收。如果封包在傳輸過程中丟失或損壞,UDP不會自動重傳封包,而是將它們丟棄。因此,在使用UDP進行資料傳輸時,需要對資料的完整性和正確性進行額外的檢驗和控制。

面向資料包

UDP的底層使用的是IP協定,就是網路層的IP協定。在網路層中,IP協定傳輸的訊息型別是IP資料包,它是無連線的,且不可靠的。所以UDP資料包也是無連線、不可靠的。但是因為直接使用IP協定,速度快,佔用小。在UDP的基礎上加上源地址、目的地址、控制資訊就組成了IP資料包,直接在網路層傳輸。

UDP通訊流程

網路程式設計

參考:csdn-網路通訊.

基本原理

  • 伺服器端:建立socket,繫結scoket和地址資訊,開啟監聽,收到請求後傳送資料

  • 使用者端:建立socket,連線到伺服器端,接收並列印伺服器傳送的資料

流程圖

核心函數

  • socket:建立一個通訊端

  • bind:用於繫結IP地址和埠號到socket;

  • listen:設定能處理的最大連線要求,listen並未開始接收連線,只是設定socket為listen模式

  • accept:用來接收socket連線

  • connect:用於繫結之後的client端與伺服器建立連線

一些小問題

sockaddr_in結構體

sockaddr_in是用於表示IPv4地址和埠號的結構體。其定義如下:

struct sockaddr_in {
    sa_family_t sin_family; // 地址族,一般為AF_INET
    in_port_t sin_port; // 埠號,網路位元組序
    struct in_addr sin_addr; // IPv4地址
    char sin_zero[8]; // 填充,一般為0
};

其中,sa_family_t型別表示地址族,一般情況下為AF_INET表示IPv4地址;in_port_t型別表示埠號,為網路位元組序;struct in_addr型別表示IPv4地址,其定義如下:

struct in_addr {
    in_addr_t s_addr; // IPv4地址,網路位元組序
};

in_addr_t型別表示IPv4地址,為32位元無符號整數,也是網路位元組序。

使用sockaddr_in結構體可以方便地表示IPv4地址和埠號。

errno變數

errno是C/C++語言中的一個全域性變數,用於記錄最近一次系統呼叫發生錯誤的錯誤碼。系統呼叫包括檔案操作、網路操作、程序操作等等。

errno變數通常定義在標頭檔案中,其型別是int。在發生錯誤時,系統會將相應的錯誤碼儲存到errno變數中,以便程式設計師可以根據錯誤碼進行相應的處理。

對於網路程式設計中的Socket庫,send、recv等函數在發生錯誤時會設定errno變數,因此程式設計師可以通過檢查errno變數來判斷函數是否執行成功。例如,send函數在傳送資料失敗時會返回-1,並設定errno變數指示失敗的原因。

常見的errno錯誤碼包括:

  • EACCES:許可權不夠
  • EAGAIN:資源暫時不可用
  • EINTR:系統呼叫被訊號中斷
  • EINVAL:無效的引數
  • ENOMEM:記憶體不足
  • ECONNRESET:連線被重置
  • ETIMEDOUT:連線超時
  • EHOSTUNREACH:主機不可達

timeval

struct timeval是linux系統中定義的結構體:

struct timeval{
__time_t tv_sec;        /* Seconds. */
__suseconds_t tv_usec;  /* Microseconds. */
};

tv_sec是秒,tv_usec是微秒

__time_t和__suseconds_t都是long int的擴充套件名

htons

htons是一個用於將主機位元組序轉換為網路位元組序的函數,其函數原型如下:

#include <arpa/inet.h>
uint16_t htons(uint16_t hostshort);

htons函數的引數hostshort是一個16位元整數,表示要進行轉換的主機位元組序資料。該函數將主機位元組序資料轉換為網路位元組序資料,然後返回轉換後的結果。網路位元組序採用大端位元組序,即高位位元組儲存在低地址,低位位元組儲存在高地址。

htons函數將主機位元組序轉換為網路位元組序的過程如下:

  1. 判斷本地主機的位元組序是大端位元組序還是小端位元組序。如果本地主機是大端位元組序,則不需要進行轉換,直接返回原始資料即可。

  2. 如果本地主機是小端位元組序,則需要將主機位元組序資料轉換為網路位元組序資料。具體操作是將低位位元組儲存在高地址,高位位元組儲存在低地址。

例如,如果要將一個16位元整數0x1234(主機位元組序)轉換為網路位元組序,htons函數將執行以下操作:

  1. 檢測本地主機的位元組序,如果本地主機是小端位元組序,則需要進行轉換。

  2. 將低位位元組0x34儲存在高地址,高位位元組0x12儲存在低地址,得到0x3412(網路位元組序)。

  3. 返回轉換後的結果0x3412。

需要注意的是,htons函數只能用於16位元整數的轉換,如果要轉換32位元整數,需要使用htonl函數。另外,在網路程式設計中,所有傳輸到網路上的資料都必須使用網路位元組序,否則可能會導致資料傳輸錯誤。因此,在編寫網路程式時,應該使用htons等函數將主機位元組序資料轉換為網路位元組序資料。

詳解SOCKET

socket函數

socket原意「插座」,在計算機通訊領域,被翻譯為「通訊端」,是計算機之間進行通訊的一種約定或一種方式,通過socket這種約定,計算機之間可以相互傳送接收資料。socket的本質就是一個檔案,通訊的本質就是在計算之間傳遞這個檔案。

基本語法:SOCKET socket(int af,int type,int protocol);

  • af:地址族,address family,就是IP地址的型別,值包括AF_INET(IPv4)、AF_INET6(IPv6)。

  • type:資料傳輸方式/通訊端型別,值包括SOCK_STREAM(流格式通訊端/面向連線的通訊端)和SCOK_DGRAM(datagram資料包通訊端/無連線的通訊端)

  • protocol:協定,值包括 IPPROTO_TCP(TCP協定),IPPROTO_UDP(UDP 傳輸協定)

  • 返回值SOCKET是int型:

    1. 返回值為 -1:通常表示函數呼叫失敗,可能是由於引數錯誤、許可權不足、系統資源不足等原因引起的。

    2. 返回值為 0:通常表示一個連線已經關閉,此時應該關閉通訊端並釋放資源。

    3. 返回值為正整數:通常表示已經成功地進行了某種操作,具體含義要根據函數的不同而定。例如:

      • socket 函數成功地建立了一個新通訊端,返回的是新通訊端的描述符。

      • bind 函數成功地將一個通訊端與一個本地地址繫結,返回的是 0。

      • listen 函數成功地將一個通訊端設定為監聽狀態,返回的是 0。

      • accept 函數成功地接受了一個連線請求,返回的是新建立連線的通訊端描述符。

    4. EAGAIN/EWOULDBLOCK:表示當前情況下資源已經不可用,需要等待一段時間或者採取其他措施再嘗試操作。

    5. EINTR:表示當前操作被中斷,可能是由於訊號的到來或者其他原因引起的,需要重新嘗試操作。

運用socket,首先需要相關的標頭檔案:

  • <sys/socket.h>:定義了 socket 相關的資料型別、結構體和函數。

  • <netinet/in.h>:定義了網路地址結構體、地址族、埠號等相關的資料型別和宏定義。

  • <arpa/inet.h>:定義了一些 IP 地址轉換的函數。

  • <netdb.h>:定義了一些網路資料庫相關的函數,如獲取主機資訊、服務資訊等。

例子:用socket通訊端存取百度伺服器

#include <iostream>
#include <cstring>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

using namespace std;

int main() {
    // 建立 socket 通訊端
    int client_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (client_sock == -1) {
        cerr << "Failed to create socket." << endl;
        return -1;
    }

    // 建立連線
    sockaddr_in server_addr;//這個結構初始是空的,所以需要申請位元組空間
    memset(&server_addr, 0, sizeof(server_addr));//可以用memset
    server_addr.sin_family = AF_INET;//設定IPv4
    server_addr.sin_addr.s_addr =inet_addr("112.80.248.75");//設定IP主機號,不能是網址,必須先解析成IP
    server_addr.sin_port = htons(80);//設定埠

    if (connect(client_sock, (sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
        cerr << "Failed to connect to server." << endl;
        return -1;
    }

    // 傳送請求
    const char* request = "GET / HTTP/1.1\r\nHost: www.baidu.com\r\n\r\n";
    write(client_sock, request, strlen(request));//向百度的伺服器主機傳送訊息

    // 接收響應
    char buffer[10240];
    int len = read(client_sock, buffer, sizeof(buffer) - 1);
    if (len == -1) {
        cerr << "Failed to receive response." << endl;
        return -1;
    }

    buffer[len] = '\0';//字元型的陣列的長度可能設定的很大,用這個來擷取有效部分,就可以直接cou
    cout << buffer << endl;

    // 關閉連線
    close(client_sock);
    return 0;
}

執行結果:

bind

在網路程式設計中,bind()函數用於將一個通訊端(socket)與一個原生的IP地址和埠號繫結起來。在使用者端程式中不常使用,但是在伺服器端程式中,一般需要先建立一個通訊端,然後將其繫結到一個固定的本地IP地址和埠號上,以便使用者端可以通過這個地址和埠號與伺服器進行通訊。

bind()函數的函數原型如下:

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

其中,sockfd是已經建立好的通訊端描述符,addr是一個指向本地IP地址和埠號的sockaddr型別的指標,addrlen是sockaddr型別的指標的長度。

bind()函數的返回值為0表示繫結成功,否則表示繫結失敗。在呼叫bind()函數之前,需要先通過socket()函數建立一個通訊端,並且需要在sockaddr結構體中指定本地IP地址和埠號,例如:

struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // 繫結到本地任意IP地址
server_addr.sin_port = htons(PORT);  // 繫結到指定埠號

接下來就可以呼叫bind()函數將通訊端與本地IP地址和埠號繫結起來了,例如:

int ret = bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
if (ret == -1) {
    perror("bind error");
    exit(1);
}

詳細例子:

#include<iostream>
#include<sys/socket.h>
#include<arpa/inet.h>//sockaddr_in
#include<cstring>//memset
using namespace std;

int main(){
    int sockfd=socket(AF_INET,SOCK_STREAM,0);
    if(sockfd==-1){
        cerr<<"failed to create socket"<<endl;
        exit(-1);
    }
    int PORT=2337;//設定埠2337,用沒有被佔用的就行
    struct sockaddr_in server_addr;
    memset(&server_addr,0,sizeof(server_addr));
    server_addr.sin_family=AF_INET;
    server_addr.sin_addr.s_addr=htonl(INADDR_ANY);//繫結到任意IP
    server_addr.sin_port=htons(PORT);//繫結到指定埠
    int ret=bind(sockfd,(struct sockaddr*)&server_addr,sizeof(server_addr));
    if(ret==0){
        cout<<"succeed to bind PORT:"<<PORT<<endl;
    }else{
        cerr<<("bind error")<<endl;
        exit(1);
    }
}

listen

在網路程式設計中,listen()函數用於將一個通訊端(socket)轉換成一個監聽通訊端,以便於接受使用者端的連線請求。在伺服器端程式中,一般需要先建立一個通訊端,然後將其繫結到一個固定的本地IP地址和埠號上,最後呼叫listen()函數將其轉換成一個監聽通訊端,以便於接受使用者端的連線請求。

listen()函數的函數原型如下:

int listen(int sockfd, int backlog);

其中,sockfd是已經建立好的通訊端描述符,backlog是指定等待連線佇列的最大長度。

listen()函數的返回值為0表示成功,否則表示失敗。在呼叫listen()函數之前,需要先通過socket()函數建立一個通訊端,並且需要通過bind()函數將其繫結到一個固定的本地IP地址和埠號上,接下來就可以呼叫listen()函數將通訊端轉換成一個監聽通訊端了,例如:

int backlog = 10;  // 等待連線佇列的最大長度
int ret = listen(sockfd, backlog);  // 將通訊端轉換成監聽通訊端
if (ret == -1) {
    perror("listen error");
    exit(1);
}

呼叫listen()函數之後,通訊端就會進入監聽狀態,等待使用者端的連線請求。可以通過accept()函數來接受使用者端的連線請求,並建立一個新的通訊端用於與使用者端進行通訊。

給之前的程式新增listen:

#include<iostream>
#include<sys/socket.h>
#include<arpa/inet.h>//sockaddr_in
#include<cstring>//memset
using namespace std;

int main(){
    int sockfd=socket(AF_INET,SOCK_STREAM,0);
    if(sockfd==-1){
        cerr<<"failed to create socket"<<endl;
        exit(-1);
    }
    int PORT=2337;//設定埠2337,用沒有被佔用的就行
    struct sockaddr_in server_addr;
    memset(&server_addr,0,sizeof(server_addr));
    server_addr.sin_family=AF_INET;
    server_addr.sin_addr.s_addr=htonl(INADDR_ANY);//繫結到任意IP
    server_addr.sin_port=htons(PORT);//繫結到指定埠
//bind函數
    int bindret=bind(sockfd,(struct sockaddr*)&server_addr,sizeof(server_addr));
    if(bindret==0){
        cout<<"succeed to bind PORT:"<<PORT<<endl;
    }else{
        cerr<<("bind error")<<endl;
        exit(-2);
    }
//listen函數
    int backlog=10;//最大連線佇列長度
    int listenret=listen(sockfd,backlog);
    if(listenret==0){
        cout<<"turn to listening"<<endl;
    }else{
        cerr<<"failed to listen PORT"<<PORT<<endl;
        exit(-3);
    }
}

accept

socket的accept函數是用於等待並接受使用者端連線請求的函數。當伺服器端的socket處於listen狀態時,可以呼叫accept函數來接受使用者端的連線請求,並返回一個新的socket描述符,用於與使用者端進行通訊。

accept函數的語法如下:

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

其中,sockfd為伺服器端的socket描述符,addr為指向用於儲存使用者端地址資訊的結構體指標,addrlen為指向儲存使用者端地址資訊長度的變數指標。其中socklen_t,這樣的關鍵字一般都是由基本型別擴充套件過來的,在vs中go to definition,可以追溯到其實質就是usigned int型別。

因為返回的也是一個socket描述符,失敗返回-1,成功返回描述符id

當accept函數被呼叫時,會阻塞等待使用者端連線請求的到來。一旦有使用者端連線請求到達,accept函數會返回一個新的socket描述符,用於與該使用者端進行通訊。同時,addr和addrlen引數也會被填充上使用者端的地址資訊。

需要注意的是,accept函數只有在伺服器端socket處於listen狀態時才能呼叫。而且,accept函數是一個阻塞函數,會一直等待直到有使用者端連線請求到達。如果不希望accept函數一直阻塞,可以通過設定socket為非阻塞模式或設定超時時間等方式來避免阻塞

避免阻塞的方式
使用setsockopt函數
struct timeval timeout; 
timeout.tv_sec = 5;
timeout.tv_usec = 0;
setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, (char *)&timeout, sizeof(timeout));

如果在5秒內沒有收到任何資料,accept函數將返回一個錯誤碼,並設定errno為EAGAIN或EWOULDBLOCK。可以根據這個錯誤碼來判斷是否超時。

if(apct==-1){
    cerr<<"connect configure error";
}else if(acpt==EAGAIN){
    cerr<<"timeout"<<endl;
}else{
    cout<<"connected"<<endl;
}

setsockopt函數是用來給通訊端設定的函數,其定義如下:

int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);

其中,引數說明如下:

  • sockfd:指定需要設定選項的通訊端描述符。
  • level:指定選項的協定層。常用的協定層有SOL_SOCKETIPPROTO_TCPSOL_SOCKET表示通用通訊端選項,而IPPROTO_TCP表示TCP協定選項。
  • optname:指定需要設定的選項名稱。
  • optval:指向儲存選項值的緩衝區。
  • optlen:指定選項值的長度。

setsockopt函數的作用是用於設定通訊端選項,常用的選項包括:

  • SO_REUSEADDR:表示允許地址重用,常用於伺服器開啟多次繫結同一埠的情況。
  • SO_KEEPALIVE:表示開啟TCP的KeepAlive機制。
  • SO_SNDBUFSO_RCVBUF:分別表示傳送緩衝區和接收緩衝區的大小。
  • TCP_NODELAY:表示禁用Nagle演演算法,即允許小封包的傳送。

需要注意的是,setsockopt函數必須在通訊端建立後才能呼叫,且需要在進行任何IO操作之前設定

for more,refer to setsockopt | Microsoft Learn.

select函數
int sockfd = socket(AF_INET, SOCK_STREAM, 0);

while (1) {
    fd_set read_fds;
    FD_ZERO(&read_fds);
    FD_SET(sock, &read_fds);

    struct timeval tv;
    tv.tv_sec = 1;
    tv.tv_usec = 0;

    int ret = select(sockfd + 1, &read_fds, NULL, NULL, &tv);
    if (ret == -1) {
        // select出錯
        continue;
    } else if (ret == 0) {
        // 沒有新連線
        continue;
    }

    int new_sock = accept(sockfd, (struct sockaddr *)&caddr, &len);
    if (new_sock == -1) {
        // accept出錯
        continue;
    }

    // 處理新連線
}

select函數是Unix/Linux系統中的一個系統呼叫,在網路程式設計中常用於實現多路複用IO。它可以監聽多個檔案描述符,當其中任意一個檔案描述符準備就緒時,就會通知程式進行相應的處理。

select函數的原型如下:

#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

引數說明:

  • nfds:需要檢測的檔案描述符數量,即檔案描述符集合中所有檔案描述符的最大值加1(因為檔案描述符是從0開始編號的)。
  • readfds:可讀檔案描述符集合。
  • writefds:可寫檔案描述符集合。
  • exceptfds:異常檔案描述符集合。
  • timeout:select函數的超時時間。如果設定為NULL,則表示等待直到有檔案描述符準備就緒;如果設定為0,則立即返回;如果設定為一個非零值,則表示等待指定時間內有檔案描述符準備就緒。

select函數的返回值為就緒檔案描述符的數量,如果返回0,則表示超時未發生任何事件;如果返回-1,則表示select函數呼叫出錯。

使用select函數,可以實現以下功能:

  • 監聽多個檔案描述符,實現多路複用IO。
  • 設定超時時間,避免程式一直阻塞在select函數呼叫處。
  • 監聽不同型別的事件(可讀、可寫、異常),實現更加靈活的IO操作。
  • 在多執行緒程式設計中,可以使用select函數來實現執行緒間的通訊。
使用fcntl
int sock = socket(AF_INET, SOCK_STREAM, 0);
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sock, F_SETFL, flags | O_NONBLOCK);

根據下面的解釋,flags為sockfd描述符當前的檔案狀態標誌,然後呼叫SETFL,設定描述符的檔案狀態標誌,值設定為flags|O_NONBLOCK。應該是邏輯或操作。

但是這樣設定後,呼叫accept會立即返回,沒有等待時間,可以把accept放在迴圈中,等待client連線。

fcntl函數是一個Unix/Linux系統下的系統呼叫函數,用於對檔案描述符進行操作。其原型如下:

#include <fcntl.h>
int fcntl(int fd, int cmd, ...);

fcntl函數的第一個引數fd是需要進行操作的檔案描述符,第二個引數cmd是需要進行的操作指令,第三個可選引數為操作的附加引數。

fcntl函數的常用操作指令包括:

  • F_DUPFD:複製檔案描述符,生成一個新的檔案描述符;
  • F_GETFL:獲取檔案描述符當前的檔案狀態標誌;
  • F_SETFL:設定檔案描述符的檔案狀態標誌;
  • F_GETLK:獲取檔案鎖;
  • F_SETLK:設定檔案鎖;
  • F_SETLKW:設定檔案鎖,並等待檔案鎖被釋放。

fcntl函數的使用場景包括:

  • 設定檔案描述符的非阻塞模式;
  • 獲取或設定檔案描述符的檔案狀態標誌;
  • 對檔案進行加鎖或解鎖操作等。
使用epoll

epoll是Linux核心中的一種I/O事件通知機制,是高並行網路程式設計中常用的技術之一。epoll通過在核心中註冊感興趣的檔案描述符集合,然後通過系統呼叫等待I/O事件的發生並通知應用程式。

與傳統的select和poll相比,epoll具有更高的效率和可延伸性。這是由於epoll採用了基於事件驅動的方式,只有當檔案描述符上有事件發生時才會通知應用程式,而不必遍歷所有的檔案描述符。此外,epoll支援ET(邊緣觸發)和LT(水平觸發)兩種工作模式,同時還支援一次性註冊多個檔案描述符,從而減少了系統呼叫的次數。

epoll的主要優點包括:

  1. 高效:能夠處理大量並行連線,而不會因為輪詢而導致CPU佔用率過高。

  2. 可延伸:能夠處理數以萬計的並行連線,而且當連線數增加時,效能下降得非常緩慢。

  3. 能夠處理任何型別的檔案描述符:不僅可以處理網路通訊端,還可以處理檔案和管道等。

  4. 支援邊緣觸發和水平觸發兩種工作模式:邊緣觸發模式只在狀態發生變化時才通知應用程式,而水平觸發模式則在檔案描述符上有資料可讀時就通知應用程式,直到資料全部讀取完畢。

邊緣觸發(edge trigger)和水平觸發(level trigger)本來指脈衝訊號的觸發機制。水平指當脈衝訊號持續水平時(高電平低電平都可以),就一直觸發。邊緣觸發,也有說邊沿觸發,指只有出現上升沿或下降沿,也就是高電平轉低電平這樣的變化時,就觸發一次。

邊緣觸發也泛指只在狀態變化的瞬間觸發一次事件,水平觸發則泛指系統在事件狀態保持的時候持續觸發事件。

epoll socket程式設計:

使用epoll編寫socket通常分為以下幾個步驟:

  1. 建立socket:使用socket()函數建立一個socket描述符。

  2. 繫結socket:使用bind()函數將socket與IP地址和埠號繫結。

  3. 監聽socket:使用listen()函數將socket設定為監聽狀態。

  4. 建立epoll範例:使用epoll_create()函數建立一個epoll範例。

  5. 將socket加入epoll監聽佇列:使用epoll_ctl()函數將socket新增到epoll監聽佇列中。

  6. 迴圈監聽epoll事件:使用epoll_wait()函數迴圈監聽epoll事件。

  7. 處理epoll事件:根據不同的事件型別,使用recv()函數接收使用者端傳送的資料,使用send()函數向用戶端傳送資料,或者使用accept()函數接收使用者端的連線請求,並將新連線的socket加入epoll監聽佇列中。

  8. 關閉socket:使用close()函數關閉socket描述符。

//todo 就用epoll socket,兩種模式,c2c,room

connect

connect 函數是用於建立與遠端主機的連線的函數,通常在使用者端程式中使用。下面是 connect 函數的詳細介紹:

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

引數說明:

  • sockfd:已經建立好的通訊端檔案描述符。
  • addr:指向目標地址結構體的指標,該結構體包含目標IP地址和埠號等資訊。
  • addrlenaddr 結構體的長度。
  • 返回值也是表示成功或失敗的狀態,不是新的通訊端。

使用者端連線伺服器端例子:

#include<iostream>
#include<sys/socket.h>
#include<arpa/inet.h>
using namespace std;
int main(){
    int servsock=socket(AF_INET,SOCK_STREAM,0);
    sockaddr_in servaddr;
    servaddr.sin_family=AF_INET;
    servaddr.sin_addr.s_addr=inet_addr("127.0.0.1");
    servaddr.sin_port=htons(2337);
    int con=connect(servsock,(sockaddr*)&servaddr,sizeof(servaddr));
    if(con==0){
        cout<<"connected to server"<<endl;
    }else{
        cerr<<"failed to connect"<<endl;
        exit(-2);
    }
}

send和recv函數

send

C++中的Socket庫是基於BSD通訊端介面的,因此其send函數與BSD通訊端庫中的send函數非常相似。send函數用於將資料傳送到與Socket連線的遠端主機,其語法如下:

ssize_t send(int sockfd, const void *buf, size_t len, int flags);

其中,sockfd引數是Socket描述符,buf引數是要傳送的資料緩衝區指標,len引數是要傳送的資料長度,flags引數是可選的,用於指定傳送資料的選項,例如傳送資料時是否使用帶外資料等。

send函數的返回值是已經成功傳送的資料的位元組數。如果傳送失敗,則會返回-1,並設定errno變數指示失敗的原因。在傳送資料之前,應該先建立好Socket連線,否則send函數會失敗。

send函數的工作原理是將資料快取在核心中,直到緩衝區滿或者超時時間到達才會將資料傳送出去。如果資料太大,超過了緩衝區的大小,則會被分成多個封包進行傳送。

需要注意的是,send函數不保證所有資料都會立即傳送成功,因此需要在傳送資料之後進行檢查確認。如果需要保證資料的可靠傳輸,則可以使用TCP協定,它會自動處理資料的可靠性。

recv

Socket庫中的recv函數是用於接收資料的函數,其函數原型如下:

#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

recv函數的四個引數含義如下:

  • sockfd:指定要接收資料的Socket描述符。
  • buf:指定接收資料的緩衝區地址。
  • len:指定接收資料的最大長度。
  • flags:指定接收資料的標誌位,常用的標誌位有MSGDONTWAIT、MSGOOB等。

recv函數的返回值為接收到的資料長度,如果返回值為0,則表示對端已經關閉連線,如果返回值為-1,則表示發生錯誤。在發生錯誤時,errno變數會被設定為相應的錯誤碼,程式設計師可以通過檢查errno變數來判斷錯誤的原因。

下面是recv函數的工作流程:

  1. 應用程式呼叫recv函數,指定要接收資料的Socket描述符、接收資料的緩衝區地址、接收資料的最大長度和接收資料的標誌位。

  2. 作業系統核心接收到應用程式的請求後,開始等待資料到達。如果資料已經到達,則將資料讀取到核心中的接收緩衝區。

  3. 如果接收緩衝區中沒有資料,則recv函數會阻塞等待,直到有資料到達為止。如果設定了MSG_DONTWAIT標誌,則recv函數會立即返回,不會阻塞等待。

  4. 一旦有資料到達,作業系統核心會將資料從接收緩衝區複製到應用程式指定的接收緩衝區中,並返回實際接收到的資料長度。

  5. 應用程式可以繼續呼叫recv函數接收剩餘的資料,直到接收完所有資料為止。

需要注意的是,在使用recv函數接收資料時,需要根據實際情況判斷接收到的資料是否完整,如果資料不完整需要繼續接收,直到接收到完整的資料為止。另外,為了避免發生死鎖,應該在呼叫recv函數之前先呼叫select或poll等函數進行檢查,以確保接收緩衝區中有資料可讀。

epoll程式設計

參考高並行網路程式設計之epoll詳解.tcp並行伺服器(epoll實現).輔以ChatGPT

在Linux實現epoll之前,IO多路複用一般使用select或者poll,實現的即使就是遍歷輪詢。但效率低,開銷大。

select的缺點:

  1. 單個程序能夠監視的檔案描述符的數量存在最大限制,通常是1024,當然可以更改數量,但由於select採用輪詢的方式掃描檔案描述符,檔案描述符數量越多,效能越差;(在linux核心標頭檔案中,有這樣的定義:#define __FD_SETSIZE    1024)
  2. 核心 / 使用者空間記憶體拷貝問題,select需要複製大量的控制程式碼資料結構,產生巨大的開銷;
  3. select返回的是含有整個控制程式碼的陣列,應用程式需要遍歷整個陣列才能發現哪些控制程式碼發生了事件;
  4. select的觸發方式是水平觸發,應用程式如果沒有完成對一個已經就緒的檔案描述符進行IO操作,那麼之後每次select呼叫還是會將這些檔案描述符通知程序。

poll使用連結串列儲存檔案描述符,雖然沒有了監視檔案數量的限制,但select的其他三個缺陷依然存在。

而epoll實現了不同的機制,不再是輪詢,而是觸發。只有當監聽的檔案描述符發生變化時,才會處理,否則就一直阻塞。這就是epoll的邊緣觸發模式(edge trigger)。

在epoll中,有三個主要的函數:epollcreate、epollctl和epoll_wait。

  1. epoll_create

epoll_create函數用於建立一個epoll範例,返回一個檔案描述符。它的原型如下:

int epoll_create(int size);

引數size指定了需要管理的檔案描述符的個數,但是這個引數在Linux 2.6.8及以後版本被忽略了,因此通常設為0即可。

  1. epoll_ctl

epoll_ctl函數用於向epoll範例中新增或刪除檔案描述符,並設定相應的事件型別。它的原型如下:

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

引數epfd是epoll範例的檔案描述符,引數op指定了要進行的操作,包括:

  • EPOLLCTLADD:向epoll範例中新增檔案描述符,並設定相應的事件型別;
  • EPOLLCTLMOD:修改epoll範例中已有的檔案描述符的事件型別;
  • EPOLLCTLDEL:從epoll範例中刪除檔案描述符。

引數fd是需要新增、修改或刪除的檔案描述符,引數event是一個epoll_event結構體,用於設定事件型別和資料。

如:將一個socket新增到epoll範例。

#include<sys/epoll.h>//epoll
#include<sys/socket.h>
int main(){
    int sockfd=socket(AF_INET,SOCK_STREAM,0);
    struct epoll_event event;//事件結構體
    event.events=EPOLLIN;
    event.data.fd=listen_fd;
    int epollfd=epoll_create(0);
    epoll_ctl(epollfd,EPOLL_CTL_ADD,sockfd,&event);
}
  1. epoll_wait

epoll_wait函數用於等待檔案描述符上的事件,它會一直阻塞,直到有事件發生或超時。它的原型如下:

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

引數epfd是epoll範例的檔案描述符,引數events是一個epoll_event結構體陣列,用於儲存事件,引數maxevents指定了最多可以返回的事件個數,引數timeout指定了超時時間,如果為-1,則表示一直阻塞,直到有事件發生。

在epoll_wait函數返回時,會將事件儲存在events陣列中,並返回事件的個數。每個事件包含了檔案描述符和相應的事件型別。

詳例
#include "server.h"

int main(){
    int server_socket=socket(AF_INET, SOCK_STREAM, 0);
    sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(sockaddr_in));
    server_addr.sin_family=AF_INET;
    server_addr.sin_port=htons(SERVER_PORT);
    server_addr.sin_addr.s_addr=INADDR_ANY;
    if(bind(server_socket,(sockaddr *)&server_addr,sizeof(server_addr))<0){
        cerr<<"chat_server: main: server bind error"<<endl;
        exit(-1);
    }
    if(listen(server_socket,10)<0){
        cerr<<"chat_server: main: server listen error"<<endl;
        exit(-1);        
    }
    int epoll_fd=epoll_create(1);
    epoll_event socket_event,listen_event[MAX_LISTEN];
    socket_event.events=EPOLLIN; //TODO  LT/ET?  //高版本沒有EPOLLLT,預設水平觸發,一旦發現使用者端的連線請求就持續建立連線
    socket_event.data.fd=server_socket;
    epoll_ctl(epoll_fd,EPOLL_CTL_ADD,server_socket,&socket_event);
    while(1){
        int event_num=epoll_wait(epoll_fd,listen_event,MAX_LISTEN,-1); 
        if(event_num<-1){                               
            break;  //無連線則繼續迴圈等待                                    
        }
        for(int i=0;i<event_num;i++){   //遍歷返回事件
            if(listen_event[i].data.fd==server_socket){ //如果是server socket,說明有使用者端發起連線請求,就建立新的連線
                sockaddr_in client_addr;
                socklen_t clinet_size=sizeof(sockaddr_in);
                int client_socket=accept(server_socket,(sockaddr *)&client_addr,&clinet_size);
                if(client_socket<0){    //連線建立失敗,則跳過重連
                    continue;
                }else{
                    cout<<client_addr.sin_addr.s_addr<<":"<<client_addr.sin_port<<" connected"<<endl;
                }
                socket_event.events=EPOLLIN | EPOLLET;    //EPOLLET設定為ET模式
                socket_event.data.fd=client_socket;
                epoll_ctl(epoll_fd,EPOLL_CTL_ADD,client_socket,&socket_event);  //將獲取到的新連線加入到epoll範例中
            }else{//如果不是fd,說明是使用者端傳送了資料
                int session_socket=listen_event[i].data.fd;     //獲取連線,建立通訊
                char *buff;
                int ret = recv(session_socket,buff,2048,0); //非阻塞如果沒有資料那麼就返回-1
                cout<<buff<<endl;
                }
            }
        }
}

這就是一個簡單的epoll tcp伺服器,它能夠以觸發的機制來存取活動事件的描述符,雖然使用起來相比select複雜,但是它的效率更高。

SOCKET的本質

fd

linux上socket的本質是一個fd(file descriptor)檔案,它是由linux核心動態建立、銷燬的。所以,socket檔案並不是普通的磁碟檔案,無法通過傳統路徑存取,實際上,它是一個指向程序已開啟的檔案、裝置或 Socket 的參照。每個程序啟動時,都會分配三個標準的 fd 檔案,這些檔案對應於 stdinstdout 和 stderr。除此之外,每個程序還可以建立任意數量的自定義 fd 檔案,這些檔案可以對應於開啟的磁碟檔案、管道、Socket 等。

我們可以使用readlink來檢視fd參照的原檔案,比如:

edge瀏覽器的一個crashpad程序,PID為74103

可以使用readlink檢視具體的參照檔案

readlink /proc/74103/fd/fdnumber

如圖,3描述符的參照是一個socket,5描述符是一個dat檔案,6描述符是一個bin檔案。

使用者端socket

而我們的tcp伺服器,在有使用者端連線時,也會動態建立fd描述符

如圖我們的server_main的PID為72539,檢視其fd

ls /proc/72539/fd 

伺服器端已經佔用了0-4描述符,當用戶端連線的時候,服務的程序建立fd5,並讀取快取,最後銷燬fd5。所以直接readlink 5是空的,因為事件已經結束了,我們可以通過迴圈讀取來檢視:

#! /bin/bash

while :
do
        readlink /proc/72539/fd/5 >> ./socket.log
done

執行shell指令碼,並用使用者端連線伺服器,檢視socket.log:

這個就是使用者端連線伺服器時建立的socket檔案。