高效網絡通信技術揭祕,Socket 原理與實踐

Socket(套接字)是一種在計算機網絡中進行通信的抽象概念。它提供了一種編程接口,使得應用程序能夠通過網絡進行數據交換。Socket 可以在不同的計算機上的進程之間建立連接,實現數據的傳輸和通信。

Socket 是一個端點,由 IP 地址和端口號組成。IP 地址指示計算機的位置,而端口號則指定應用程序在該計算機上運行的特定服務。使用 Socket,應用程序可以通過建立客戶端和服務器之間的連接來實現雙向通信。客戶端通過指定服務器的 IP 地址和端口號來連接服務器,然後可以發送請求並接收服務器的響應。服務器監聽指定端口,並等待客戶端連接請求,在接受請求後進行處理並向客戶端返回響應。

Socket 提供了多種類型和協議的實現,例如 TCP(傳輸控制協議)和 UDP(用戶數據報協議)。TCP 提供可靠性、有序性和基於流的傳輸,適用於需要確保數據完整性和順序的場景;而 UDP 則是一種無連接、不可靠但高效的傳輸方式,適合對實時性要求較高且對數據完整性要求相對較低的場景。

一、網絡中進程之間如何通信

進 程通信的概念最初來源於單機系統。由於每個進程都在自己的地址範圍內運行,爲保證兩個相互通信的進程之間既互不干擾又協調一致工作,操作系統爲進程通信提供了相應設施,如

他們都僅限於用在本機進程之間通信。網間進程通信要解決的是不同主機進程間的相互通信問題(可把同機進程通信看作是其中的特例)。爲此,首先要解決的是網間進程標識問題。同一主機上,不同進程可用進程號(process ID)唯一標識。但在網絡環境下,各主機獨立分配的進程號不能唯一標識該進程。例如,主機 A 賦於某進程號 5,在 B 機中也可以存在 5 號進程,因此,“5 號進程” 這句話就沒有意義了。 其次,操作系統支持的網絡協議衆多,不同協議的工作方式不同,地址格式也不同。因此,網間進程通信還要解決多重協議的識別問題。

其實 TCP/IP 協議族已經幫我們解決了這個問題,網絡層的 “ip 地址” 可以唯一標識網絡中的主機,而傳輸層的 “協議 + 端口” 可以唯一標識主機中的應用程序(進程)。這樣利用三元組(ip 地址,協議,端口)就可以標識網絡的進程了,網絡中的進程通信就可以利用這個標誌與其它進程進行交互。

使用 TCP/IP 協議的應用程序通常採用應用編程接口:UNIX BSD 的套接字(socket)和 UNIX System V 的 TLI(已經被淘汰),來實現網絡進程之間的通信。就目前而言,幾乎所有的應用程序都是採用 socket,而現在又是網絡時代,網絡中進程通信是無處不在,這就是我爲什麼說 “一切皆 socket”。

二、什麼是 TCP/IP、UDP

CP/IP(Transmission Control Protocol/Internet Protocol)即傳輸控制協議 / 網間協議,是一個工業標準的協議集,它是爲廣域網(WANs)設計的。

TCP/IP 協議存在於 OS 中,網絡服務通過 OS 提供,在 OS 中增加支持 TCP/IP 的系統調用——Berkeley 套接字,如 Socket,Connect,Send,Recv 等

UDP(User Data Protocol,用戶數據報協議)是與 TCP 相對應的協議。它是屬於 TCP/IP 協議族中的一種。

TCP/IP 協議族包括運輸層、網絡層、鏈路層,而 socket 所在位置如圖,Socket 是應用層與 TCP/IP 協議族通信的中間軟件抽象層。

三、Socket 是什麼

3.1socket 套接字

socket 起源於 Unix,而 Unix/Linux 基本哲學之一就是 “一切皆文件”,都可以用“打開 open –> 讀寫 write/read –> 關閉 close” 模式來操作。Socket 就是該模式的一個實現,socket 即是一種特殊的文件,一些 socket 函數就是對其進行的操作(讀 / 寫 IO、打開、關閉)。

說白了 Socket 是應用層與 TCP/IP 協議族通信的中間軟件抽象層,它是一組接口。在設計模式中,Socket 其實就是一個門面模式,它把複雜的 TCP/IP 協議族隱藏在 Socket 接口後面,對用戶來說,一組簡單的接口就是全部,讓 Socket 去組織數據,以符合指定的協議。

注意:其實 socket 也沒有層的概念,它只是一個 facade 設計模式的應用,讓編程變的更簡單。是一個軟件抽象層。在網絡編程中,我們大量用的都是通過 socket 實現的。

3.2 套接字描述符

其實就是一個整數,我們最熟悉的句柄是 0、1、2 三個,0 是標準輸入,1 是標準輸出,2 是標準錯誤輸出。0、1、2 是整數表示的,對應的 FILE * 結構的表示就是 stdin、stdout、stderr

套接字 API 最初是作爲 UNIX 操作系統的一部分而開發的,所以套接字 API 與系統的其他 I/O 設備集成在一起。特別是,當應用程序要爲因特網通信而創建一個套接字(socket)時,操作系統就返回一個小整數作爲描述符(descriptor)來標識這個套接字。然後,應用程序以該描述符作爲傳遞參數,通過調用函數來完成某種操作(例如通過網絡傳送數據或接收輸入的數據)。

在許多操作系統中,套接字描述符和其他 I/O 描述符是集成在一起的,所以應用程序可以對文件進行套接字 I/O 或 I/O 讀 / 寫操作。

當應用程序要創建一個套接字時,操作系統就返回一個小整數作爲描述符,應用程序則使用這個描述符來引用該套接字需要 I/O 請求的應用程序請求操作系統打開一個文件。操作系統就創建一個文件描述符提供給應用程序訪問文件。從應用程序的角度看,文件描述符是一個整數,應用程序可以用它來讀寫文件。下圖顯示,操作系統如何把文件描述符實現爲一個指針數組,這些指針指向內部數據結構。

對於每個程序系統都有一張單獨的表。精確地講,系統爲每個運行的進程維護一張單獨的文件描述符表。當進程打開一個文件時,系統把一個指向此文件內部數據結構的指針寫入文件描述符表,並把該表的索引值返回給調用者 。應用程序只需記住這個描述符,並在以後操作該文件時使用它。操作系統把該描述符作爲索引訪問進程描述符表,通過指針找到保存該文件所有的信息的數據結構。

針對套接字的系統數據結構:

1)、套接字 API 裏有個函數 socket,它就是用來創建一個套接字。套接字設計的總體思路是,單個系統調用就可以創建任何套接字,因爲套接字是相當籠統的。一旦套接字創建後,應用程序還需要調用其他函數來指定具體細節。例如調用 socket 將創建一個新的描述符條目:

2)、雖然套接字的內部數據結構包含很多字段,但是系統創建套接字後,大多數字字段沒有填寫。應用程序創建套接字後在該套接字可以使用之前,必須調用其他的過程來填充這些字段。

3.3 文件描述符和文件指針的區別

文件描述符:在 linux 系統中打開文件就會獲得文件描述符,它是個很小的正整數。每個進程在 PCB(Process Control Block)中保存着一份文件描述符表,文件描述符就是這個表的索引,每個表項都有一個指向已打開文件的指針。

文件指針:C 語言中使用文件指針做爲 I/O 的句柄。文件指針指向進程用戶區中的一個被稱爲 FILE 結構的數據結構。FILE 結構包括一個緩衝區和一個文件描述符。而文件描述符是文件描述符表的一個索引,因此從某種意義上說文件指針就是句柄的句柄(在 Windows 系統上,文件描述符被稱作文件句柄)。

基本的 SOCKET 接口函數,在生活中,A 要電話給 B,A 撥號,B 聽到電話鈴聲後提起電話,這時 A 和 B 就建立起了連接,A 和 B 就可以講話了。等交流結束,掛斷電話結束此次交談。 打電話很簡單解釋了這工作原理:“open—write/read—close” 模式。

服務器端先初始化 Socket,然後與端口綁定 (bind),對端口進行監聽 (listen),調用 accept 阻塞,等待客戶端連接。在這時如果有個客戶端初始化一個 Socket,然後連接服務器 (connect),如果連接成功,這時客戶端與服務器端的連接就建立了。客戶端發送數據請求,服務器端接收請求並處理請求,然後把迴應數據發送給客戶端,客戶端讀取數據,最後關閉連接,一次交互結束。

這些接口的實現都是內核來完成。具體如何實現,可以看看 linux 的內核

1)socket() 函數

int  socket(int protofamily, int type, int protocol);//返回sockfd

sockfd 是描述符,socket 函數對應於普通文件的打開操作。普通文件的打開操作返回一個文件描述字,而 socket() 用於創建一個 socket 描述符(socket descriptor),它唯一標識一個 socket。這個 socket 描述字跟文件描述字一樣,後續的操作都有用到它,把它作爲參數,通過它來進行一些讀寫操作。

正如可以給 fopen 的傳入不同參數值,以打開不同的文件。創建 socket 的時候,也可以指定不同的參數創建不同的 socket 描述符,socket 函數的三個參數分別爲:

注意:並不是上面的 type 和 protocol 可以隨意組合的,如 SOCK_STREAM 不可以跟 IPPROTO_UDP 組合。當 protocol 爲 0 時,會自動選擇 type 類型對應的默認協議。

當我們調用 socket 創建一個 socket 時,返回的 socket 描述字它存在於協議族(address family,AF_XXX)空間中,但沒有一個具體的地址。如果想要給它賦值一個地址,就必須調用 bind() 函數,否則就當調用 connect()、listen() 時系統會自動隨機分配一個端口。

2)bind() 函數

正如上面所說 bind() 函數把一個地址族中的特定地址賦給 socket。例如對應 AF_INET、AF_INET6 就是把一個 ipv4 或 ipv6 地址和端口號組合賦給 socket。

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

函數的三個參數分別爲:

struct sockaddr_in {
    sa_family_t    sin_family; /* address family: AF_INET */
    in_port_t      sin_port;   /* port in network byte order */
    struct in_addr sin_addr;   /* internet address */
};

/* Internet address. */
struct in_addr {
    uint32_t       s_addr;     /* address in network byte order */
};
ipv6對應的是: 
struct sockaddr_in6 { 
    sa_family_t     sin6_family;   /* AF_INET6 */ 
    in_port_t       sin6_port;     /* port number */ 
    uint32_t        sin6_flowinfo; /* IPv6 flow information */ 
    struct in6_addr sin6_addr;     /* IPv6 address */ 
    uint32_t        sin6_scope_id; /* Scope ID (new in 2.4) */ 
};

struct in6_addr { 
    unsigned char   s6_addr[16];   /* IPv6 address */ 
};
Unix域對應的是: 
#define UNIX_PATH_MAX    108

struct sockaddr_un { 
    sa_family_t sun_family;               /* AF_UNIX */ 
    char        sun_path[UNIX_PATH_MAX];  /* pathname */ 
};

通常服務器在啓動的時候都會綁定一個衆所周知的地址(如 ip 地址 + 端口號),用於提供服務,客戶就可以通過它來接連服務器;而客戶端就不用指定,有系統自動分配一個端口號和自身的 ip 地址組合。這就是爲什麼通常服務器端在 listen 之前會調用 bind(),而客戶端就不會調用,而是在 connect() 時由系統隨機生成一個。

網絡字節序與主機字節序

主機字節序就是我們平常說的大端和小端模式:不同的 CPU 有不同的字節序類型,這些字節序是指整數在內存中保存的順序,這個叫做主機序。引用標準的 Big-Endian 和 Little-Endian 的定義如下:

  a) Little-Endian 就是低位字節排放在內存的低地址端,高位字節排放在內存的高地址端。

  b) Big-Endian 就是高位字節排放在內存的低地址端,低位字節排放在內存的高地址端。

網絡字節序:4 個字節的 32 bit 值以下面的次序傳輸:首先是 0~7bit,其次 8~15bit,然後 16~23bit,最後是 24~31bit。這種傳輸次序稱作大端字節序。由於 TCP/IP 首部中所有的二進制整數在網絡中傳輸時都要求以這種次序,因此它又稱作網絡字節序。字節序,顧名思義字節的順序,就是大於一個字節類型的數據在內存中的存放順序,一個字節的數據沒有順序的問題了。

所以:在將一個地址綁定到 socket 的時候,請先將主機字節序轉換成爲網絡字節序,而不要假定主機字節序跟網絡字節序一樣使用的是 Big-Endian。由於這個問題曾引發過血案!公司項目代碼中由於存在這個問題,導致了很多莫名其妙的問題,所以請謹記對主機字節序不要做任何假定,務必將其轉化爲網絡字節序再賦給 socket。

3)listen()、connect() 函數

如果作爲一個服務器,在調用 socket()、bind() 之後就會調用 listen() 來監聽這個 socket,如果客戶端這時調用 connect() 發出連接請求,服務器端就會接收到這個請求。

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

listen 函數的第一個參數即爲要監聽的 socket 描述字,第二個參數爲相應 socket 可以排隊的最大連接個數。socket() 函數創建的 socket 默認是一個主動類型的,listen 函數將 socket 變爲被動類型的,等待客戶的連接請求。

connect 函數的第一個參數即爲客戶端的 socket 描述字,第二參數爲服務器的 socket 地址,第三個參數爲 socket 地址的長度。客戶端通過調用 connect 函數來建立與 TCP 服務器的連接。

4)accept() 函數

CP 服務器端依次調用 socket()、bind()、listen() 之後,就會監聽指定的 socket 地址了。TCP 客戶端依次調用 socket()、connect() 之後就向 TCP 服務器發送了一個連接請求。TCP 服務器監聽到這個請求之後,就會調用 accept() 函數取接收請求,這樣連接就建立好了。之後就可以開始網絡 I/O 操作了,即類同於普通文件的讀寫 I/O 操作。

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); //返回連接connect_fd

如果 accept 成功返回,則服務器與客戶已經正確建立連接了,此時服務器通過 accept 返回的套接字來完成與客戶的通信。

注意:accept 默認會阻塞進程,直到有一個客戶連接建立後返回,它返回的是一個新可用的套接字,這個套接字是連接套接字。

此時我們需要區分兩種套接字:

監聽套接字: 監聽套接字正如 accept 的參數 sockfd,它是監聽套接字,在調用 listen 函數之後,是服務器開始調用 socket() 函數生成的,稱爲監聽 socket 描述字 (監聽套接字)

連接套接字:一個套接字會從主動連接的套接字變身爲一個監聽套接字;而 accept 函數返回的是已連接 socket 描述字 (一個連接套接字),它代表着一個網絡已經存在的點點連接。

一個服務器通常通常僅僅只創建一個監聽 socket 描述字,它在該服務器的生命週期內一直存在。內核爲每個由服務器進程接受的客戶連接創建了一個已連接 socket 描述字,當服務器完成了對某個客戶的服務,相應的已連接 socket 描述字就被關閉。

然要問的是:爲什麼要有兩種套接字?原因很簡單,如果使用一個描述字的話,那麼它的功能太多,使得使用很不直觀,同時在內核確實產生了一個這樣的新的描述字。

連接套接字 socketfd_new 並沒有佔用新的端口與客戶端通信,依然使用的是與監聽套接字 socketfd 一樣的端口號

5)read()、write() 等函數

萬事具備只欠東風,至此服務器與客戶已經建立好連接了。可以調用網絡 I/O 進行讀寫操作了,即實現了網咯中不同進程之間的通信!網絡 I/O 操作有下面幾組:

我推薦使用 recvmsg()/sendmsg() 函數,這兩個函數是最通用的 I/O 函數,實際上可以把上面的其它函數都替換成這兩個函數。它們的聲明如下:

 #include <unistd.h>

       ssize_t read(int fd, void *buf, size_t count);
       ssize_t write(int fd, const void *buf, size_t count);

       #include <sys/types.h>
       #include <sys/socket.h>

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

       ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
                      const struct sockaddr *dest_addr, socklen_t addrlen);
       ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                        struct sockaddr *src_addr, socklen_t *addrlen);

       ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
       ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);

read 函數是負責從 fd 中讀取內容. 當讀成功時,read 返回實際所讀的字節數,如果返回的值是 0 表示已經讀到文件的結束了,小於 0 表示出現了錯誤。如果錯誤爲 EINTR 說明讀是由中斷引起的,如果是 ECONNREST 表示網絡連接出了問題。

write 函數將 buf 中的 nbytes 字節內容寫入文件描述符 fd. 成功時返回寫的字節數。失敗時返回 - 1,並設置 errno 變量。 在網絡程序中,當我們向套接字文件描述符寫時有倆種可能。1)write 的返回值大於 0,表示寫了部分或者是全部的數據。2) 返回的值小於 0,此時出現了錯誤。我們要根據錯誤類型來處理。如果錯誤爲 EINTR 表示在寫的時候出現了中斷錯誤。如果爲 EPIPE 表示網絡連接出現了問題 (對方已經關閉了連接)。

其它的我就不一一介紹這幾對 I/O 函數了,具體參見 man 文檔或者 baidu、Google,下面的例子中將使用到 send/recv。

6)close() 函數

在服務器與客戶端建立連接之後,會進行一些讀寫操作,完成了讀寫操作就要關閉相應的 socket 描述字,好比操作完打開的文件要調用 fclose 關閉打開的文件。

#include <unistd.h>
int close(int fd);

close 一個 TCP socket 的缺省行爲時把該 socket 標記爲以關閉,然後立即返回到調用進程。該描述字不能再由調用進程使用,也就是說不能再作爲 read 或 write 的第一個參數。

注意:close 操作只是使相應 socket 描述字的引用計數 - 1,只有當引用計數爲 0 的時候,纔會觸發 TCP 客戶端向服務器發送終止連接請求。

3.4Socket 中 TCP 的建立(三次握手)

TCP 協議通過三個報文段完成連接的建立,這個過程稱爲三次握手 (three-way handshake),過程如下圖所示:

第一次握手:建立連接時,客戶端發送 syn 包 (syn=j) 到服務器,並進入 SYN_SEND 狀態,等待服務器確認;SYN:同步序列編號(Synchronize Sequence Numbers)。

第二次握手:服務器收到 syn 包,必須確認客戶的 SYN(ack=j+1),同時自己也發送一個 SYN 包(syn=k),即 SYN+ACK 包,此時服務器進入 SYN_RECV 狀態;
第三次握手:客戶端收到服務器的 SYN+ACK 包,向服務器發送確認包 ACK(ack=k+1),此包發送完畢,客戶端和服務器進入 ESTABLISHED 狀態,完成三次握手。
一個完整的三次握手也就是: 請求 --- 應答 --- 再次確認。

從圖中可以看出,當客戶端調用 connect 時,觸發了連接請求,向服務器發送了 SYN J 包,這時 connect 進入阻塞狀態;服務器監聽到連接請求,即收到 SYN J 包,調用 accept 函數接收請求向客戶端發送 SYN K ,ACK J+1,這時 accept 進入阻塞狀態;客戶端收到服務器的 SYN K ,ACK J+1 之後,這時 connect 返回,並對 SYN K 進行確認;服務器收到 ACK K+1 時,accept 返回,至此三次握手完畢,連接建立。

我們可以通過網絡抓包的查看具體的流程:

比如我們服務器開啓 9502 的端口。使用 tcpdump 來抓包:

tcpdump -iany tcp port 9502

然後我們使用 telnet 127.0.0.1 9502 開連接:

telnet 127.0.0.1 9502
 
14:12:45.104687 IP localhost.39870 > localhost.9502: Flags [S], seq 2927179378, win 32792, options [mss 16396,sackOK,TS val 255474104 ecr 0,nop,wscale 3], length 0(1)
14:12:45.104701 IP localhost.9502 > localhost.39870: Flags [S.], seq 1721825043, ack 2927179379, win 32768, options [mss 16396,sackOK,TS val 255474104 ecr 255474104,nop,wscale 3], length 0  (2)
14:12:45.104711 IP localhost.39870 > localhost.9502: Flags [.], ack 1, win 4099, options [nop,nop,TS val 255474104 ecr 255474104], length 0  (3)

14:13:01.415407 IP localhost.39870 > localhost.9502: Flags [P.], seq 1:8, ack 1, win 4099, options [nop,nop,TS val 255478182 ecr 255474104], length 7
14:13:01.415432 IP localhost.9502 > localhost.39870: Flags [.], ack 8, win 4096, options [nop,nop,TS val 255478182 ecr 255478182], length 0
14:13:01.415747 IP localhost.9502 > localhost.39870: Flags [P.], seq 1:19, ack 8, win 4096, options [nop,nop,TS val 255478182 ecr 255478182], length 18
14:13:01.415757 IP localhost.39870 > localhost.9502: Flags [.], ack 19, win 4097, options [nop,nop,TS val 255478182 ecr 255478182], length 0
我們看到 (1)(2)(3)三步是建立tcp:
第一次握手:
14:12:45.104687 IP localhost.39870 > localhost.9502: Flags [S], seq 2927179378
客戶端IP localhost.39870 (客戶端的端口一般是自動分配的) 向服務器localhost.9502 發送syn包(syn=j)到服務器》
syn包(syn=j)syn的seq= 2927179378  (j=2927179378)
 
第二次握手:
14:12:45.104701 IP localhost.9502 > localhost.39870: Flags [S.], seq 1721825043, ack 2927179379,
收到請求並確認:服務器收到syn包,並必須確認客戶的SYN(ack=j+1),同時自己也發送一個SYN包(syn=k),即SYN+ACK包:
此時服務器主機自己的SYN:seq:y= syn seq 1721825043。
ACK爲j+1 =(ack=j+1)=ack 2927179379 


第三次握手:
14:12:45.104711 IP localhost.39870 > localhost.9502: Flags [.], ack 1,
客戶端收到服務器的SYN+ACK包,向服務器發送確認包ACK(ack=k+1)
 
客戶端和服務器進入ESTABLISHED狀態後,可以進行通信數據交互。此時和accept接口沒有關係,即使沒有accepte,也進行3次握手完成。
連接出現連接不上的問題,一般是網路出現問題或者網卡超負荷或者是連接數已經滿啦。
 
紫色背景的部分:
IP localhost.39870 > localhost.9502: Flags [P.], seq 1:8, ack 1, win 4099, options [nop,nop,TS val 255478182 ecr 255474104], length 7
客戶端向服務器發送長度爲7個字節的數據,
 
IP localhost.9502 > localhost.39870: Flags [.], ack 8, win 4096, options [nop,nop,TS val 255478182 ecr 255478182], length 0
服務器向客戶確認已經收到數據
 
 IP localhost.9502 > localhost.39870: Flags [P.], seq 1:19, ack 8, win 4096, options [nop,nop,TS val 255478182 ecr 255478182], length 18
然後服務器同時向客戶端寫入數據。
 
 IP localhost.39870 > localhost.9502: Flags [.], ack 19, win 4097, options [nop,nop,TS val 255478182 ecr 255478182], length 0
客戶端向服務器確認已經收到數據
 
這個就是tcp可靠的連接,每次通信都需要對方來確認。

3.5TCP 連接的終止(四次握手釋放)

建立一個連接需要三次握手,而終止一個連接要經過四次握手,這是由 TCP 的半關閉 (half-close) 造成的,如圖:

由於 TCP 連接是全雙工的,因此每個方向都必須單獨進行關閉。這個原則是當一方完成它的數據發送任務後就能發送一個 FIN 來終止這個方向的連接。收到一個 FIN 只意味着這一方向上沒有數據流動,一個 TCP 連接在收到一個 FIN 後仍能發送數據。首先進行關閉的一方將執行主動關閉,而另一方執行被動關閉。

(1)客戶端 A 發送一個 FIN,用來關閉客戶 A 到服務器 B 的數據傳送(報文段 4)。

(2)服務器 B 收到這個 FIN,它發回一個 ACK,確認序號爲收到的序號加 1(報文段 5)。和 SYN 一樣,一個 FIN 將佔用一個序號。

(3)服務器 B 關閉與客戶端 A 的連接,發送一個 FIN 給客戶端 A(報文段 6)。

(4)客戶端 A 發回 ACK 報文確認,並將確認序號設置爲收到序號加 1(報文段 7)。

過程如下:

這樣每個方向上都有一個 FIN 和 ACK。

1.爲什麼建立連接協議是三次握手,而關閉連接卻是四次握手呢?
這是因爲服務端的 LISTEN 狀態下的 SOCKET 當收到 SYN 報文的建連請求後,它可以把 ACK 和 SYN(ACK 起應答作用,而 SYN 起同步作用)放在一個報文裏來發送。但關閉連接時,當收到對方的 FIN 報文通知時,它僅僅表示對方沒有數據發送給你了;但未必你所有的數據都全部發送給對方了,所以你可以未必會馬上會關閉 SOCKET, 也即你可能還需要發送一些數據給對方之後,再發送 FIN 報文給對方來表示你同意現在可以關閉連接了,所以它這裏的 ACK 報文和 FIN 報文多數情況下都是分開發送的。

2.爲什麼 TIME_WAIT 狀態還需要等 2MSL 後才能返回到 CLOSED 狀態?

這是因爲雖然雙方都同意關閉連接了,而且握手的 4 個報文也都協調和發送完畢,按理可以直接回到 CLOSED 狀態(就好比從 SYN_SEND 狀態到 ESTABLISH 狀態那樣);但是因爲我們必須要假想網絡是不可靠的,你無法保證你最後發送的 ACK 報文會一定被對方收到,因此對方處於 LAST_ACK 狀態下的 SOCKET 可能會因爲超時未收到 ACK 報文,而重發 FIN 報文,所以這個 TIME_WAIT 狀態的作用就是用來重發可能丟失的 ACK 報文。

四、Linux 系統

Linux 系統一般有 4 個主要部分:內核、shell、文件系統和應用程序。內核、shell 和文件系統一起形成了基本的操作系統結構,它們使得用戶可以運行程序、管理文件並使用系統。

部分層次結構如圖 1-1 所示:

4.1 linux 內核

Linux 內核是世界上最大的開源項目之一, 內核是與計算機硬件接口的易替換軟件的最低級別。它負責將所有以 “用戶模式” 運行的應用程序連接到物理硬件,並允許稱爲服務器的進程使用進程間通信 (IPC) 彼此獲取信息。

內核是操作系統的核心,具有很多最基本功能,它負責管理系統的進程、內存、設備驅動程序、文件和網絡系統,決定着系統的性能和穩定性。

Linux 內核由如下幾部分組成:內存管理、進程管理、設備驅動程序、文件系統和網絡管理等。如圖:

系統調用接口:SCI 層提供了某些機制執行從用戶空間到內核的函數調用。這個接口依賴於體系結構,甚至在相同的處理器家族內也是如此。SCI 實際上是一個非常有用的函數調用多路複用和多路分解服務。在 ./linux/kernel 中您可以找到 SCI 的實現,並在 ./linux/arch 中找到依賴於體系結構的部分。

1)內存管理

對任何一臺計算機而言,其內存以及其它資源都是有限的。爲了讓有限的物理內存滿足應用程序對內存的大需求量,Linux 採用了稱爲 “虛擬內存” 的內存管理方式。Linux 將內存劃分爲容易處理的“內存頁”(對於大部分體系結構來說都是 4KB)。Linux 包括了管理可用內存的方式,以及物理和虛擬映射所使用的硬件機制。

不過內存管理要管理的可不止 4KB 緩衝區。Linux 提供了對 4KB 緩衝區的抽象,例如 slab 分配器。這種內存管理模式使用 4KB 緩衝區爲基數,然後從中分配結構,並跟蹤內存頁使用情況,比如哪些內存頁是滿的,哪些頁面沒有完全使用,哪些頁面爲空。這樣就允許該模式根據系統需要來動態調整內存使用。

爲了支持多個用戶使用內存,有時會出現可用內存被消耗光的情況。由於這個原因,頁面可以移出內存並放入磁盤中。這個過程稱爲交換,因爲頁面會被從內存交換到硬盤上。內存管理的源代碼可以在 ./linux/mm 中找到。

2)進程管理

進程實際是某特定應用程序的一個運行實體。在 Linux 系統中,能夠同時運行多個進程,Linux 通過在短的時間間隔內輪流運行這些進程而實現 “多任務”。這一短的時間間隔稱爲 “時間片”,讓進程輪流運行的方法稱爲 “進程調度” ,完成調度的程序稱爲調度程序。

進程調度控制進程對 CPU 的訪問。當需要選擇下一個進程運行時,由調度程序選擇最值得運行的進程。可運行進程實際上是僅等待 CPU 資源的進程,如果某個進程在等待其它資源,則該進程是不可運行進程。Linux 使用了比較簡單的基於優先級的進程調度算法選擇新的進程。

通過多任務機制,每個進程可認爲只有自己獨佔計算機,從而簡化程序的編寫。每個進程有自己單獨的地址空間,並且只能由這一進程訪問,這樣,操作系統避免了進程之間的互相干擾以及 “壞” 程序對系統可能造成的危害。 爲了完成某特定任務,有時需要綜合兩個程序的功能,例如一個程序輸出文本,而另一個程序對文本進行排序。爲此,操作系統還提供進程間的通訊機制來幫助完成這樣的任務。Linux 中常見的進程間通訊機制有信號、管道、共享內存、信號量和套接字等。

內核通過 SCI 提供了一個應用程序編程接口(API)來創建一個新進程(fork、exec 或 Portable Operating System Interface [POSⅨ] 函數),停止進程(kill、exit),並在它們之間進行通信和同步(signal 或者 POSⅨ 機制)。

3)文件系統

和 DOS 等操作系統不同,Linux 操作系統中單獨的文件系統並不是由驅動器號或驅動器名稱(如 A: 或 C: 等)來標識的。相反,和 UNIX 操作系統一樣,Linux 操作系統將獨立的文件系統組合成了一個層次化的樹形結構,並且由一個單獨的實體代表這一文件系統。Linux 將新的文件系統通過一個稱爲 “掛裝” 或“掛上”的操作將其掛裝到某個目錄上,從而讓不同的文件系統結合成爲一個整體。Linux 操作系統的一個重要特點是它支持許多不同類型的文件系統。Linux 中最普遍使用的文件系統是 Ext2,它也是 Linux 土生土長的文件系統。但 Linux 也能夠支持 FAT、VFAT、FAT32、MINIX 等不同類型的文件系統,從而可以方便地和其它操作系統交換數據。由於 Linux 支持許多不同的文件系統,並且將它們組織成了一個統一的虛擬文件系統.

虛擬文件系統(VirtualFileSystem,VFS): 隱藏了各種硬件的具體細節,把文件系統操作和不同文件系統的具體實現細節分離了開來,爲所有的設備提供了統一的接口,VFS 提供了多達數十種不同的文件系統。虛擬文件系統可以分爲邏輯文件系統和設備驅動程序。邏輯文件系統指 Linux 所支持的文件系統,如 ext2,fat 等,設備驅動程序指爲每一種硬件控制器所編寫的設備驅動程序模塊。

虛擬文件系統(VFS)是 Linux 內核中非常有用的一個方面,因爲它爲文件系統提供了一個通用的接口抽象。VFS 在 SCI 和內核所支持的文件系統之間提供了一個交換層。即 VFS 在用戶和文件系統之間提供了一個交換層。

VFS 在用戶和文件系統之間提供了一個交換層:

在 VFS 上面,是對諸如 open、close、read 和 write 之類的函數的一個通用 API 抽象。在 VFS 下面是文件系統抽象,它定義了上層函數的實現方式。它們是給定文件系統(超過 50 個)的插件。文件系統的源代碼可以在 ./linux/fs 中找到。

文件系統層之下是緩衝區緩存,它爲文件系統層提供了一個通用函數集(與具體文件系統無關)。這個緩存層通過將數據保留一段時間(或者隨即預先讀取數據以便在需要是就可用)優化了對物理設備的訪問。緩衝區緩存之下是設備驅動程序,它實現了特定物理設備的接口。

因此,用戶和進程不需要知道文件所在的文件系統類型,而只需要象使用 Ext2 文件系統中的文件一樣使用它們。

4)設備驅動程序

設備驅動程序是 Linux 內核的主要部分。和操作系統的其它部分類似,設備驅動程序運行在高特權級的處理器環境中,從而可以直接對硬件進行操作,但正因爲如此,任何一個設備驅動程序的錯誤都可能導致操作系統的崩潰。設備驅動程序實際控制操作系統和硬件設備之間的交互。設備驅動程序提供一組操作系統可理解的抽象接口完成和操作系統之間的交互,而與硬件相關的具體操作細節由設備驅動程序完成。一般而言,設備驅動程序和設備的控制芯片有關,例如,如果計算機硬盤是 SCSI 硬盤,則需要使用 SCSI 驅動程序,而不是 IDE 驅動程序。

5)網絡接口(NET)

提供了對各種網絡標準的存取和各種網絡硬件的支持。網絡接口可分爲網絡協議和網絡驅動程序。網絡協議部分負責實現每一種可能的網絡傳輸協議。衆所周知,TCP/IP 協議是 Internet 的標準協議,同時也是事實上的工業標準。Linux 的網絡實現支持 BSD 套接字,支持全部的 TCP/IP 協議。Linux 內核的網絡部分由 BSD 套接字、網絡協議層和網絡設備驅動程序組成。

網絡設備驅動程序負責與硬件設備通訊,每一種可能的硬件設備都有相應的設備驅動程序。

4.2 linux shell

shell 是系統的用戶界面,提供了用戶與內核進行交互操作的一種接口。它接收用戶輸入的命令並把它送入內核去執行,是一個命令解釋器。另外,shell 編程語言具有普通編程語言的很多特點,用這種編程語言編寫的 shell 程序與其他應用程序具有同樣的效果。

目前主要有下列版本的 shell:

4.3linux 文件系統

各操作系統使用的文件系統並不相同,例如,Windows 98 以前的微軟操作系統使用 FAT(FAT16)文件系統,Windows 2000 以後的版本使用 NTFS 文件系統,而 Linux 的正統文件系統是 Ext2。

在 CentOS 6.3 系統中,默認的文件系統是 Ext4,它是 Ext3(Ext2) 文件系統的升級版,在性能、伸縮性和可靠性方面進行了大量改進,變化可以說是翻天覆地的,比如:

Linux 支持的常見文件系統

Linux 系統能夠支持的文件系統非常多,除 Linux 默認文件系統 Ext2、Ext3 和 Ext4 之外,還能支持 fat16、fat32、NTFS(需要重新編譯內核)等 Windows 文件系統。也就是說,Linux 可以通過掛載的方式使用 Windows 文件系統中的數據。Linux 所能夠支持的文件系統在 "/usr/src/kemels / 當前系統版本 / fs" 目錄中(需要在安裝時選擇),該目錄中的每個子目錄都是一個可以識別的文件系統。我們介紹較爲常見的 Linux 支持的文件系統,如表 1 所示。

4.4 用戶態和內核態

應用程序是無法直接訪問硬件資源的,需要通過通過內核 SCI 層提供的接口來訪問硬件資源。

Linux 系統將自身劃分爲兩部分,一部分爲核心軟件,即是 kernel,也稱作內核空間,另一部分爲普通應用程序,這部分稱爲用戶空間。

區分用戶空間和內核空間的目的是爲確保系統安全。在 CPU 的所有指令中,有一些指令是非常危險的,如果錯用,將導致整個系統崩潰。比如:清內存、設置時鐘等。因爲如果應用程序和內核在同一個保護級別,那麼應用程序就有可能有意或者不小心進入了內核空間,破壞了內核空間的代碼和數據,系統崩潰就不足爲奇。所以 CPU 將指令分爲特權指令和非特權指令,對於那些危險的指令,只允許操作系統及其相關模塊使用,普通的應用程序只能使用那些不會造成災難的指令。Intel 的 CPU 將特權級別分爲 4 個級別:RING0,RING1,RING2,RING3, 內核空間級別爲 “RING0”, 用戶空間級別爲 RING3。

linux 的內核是一個有機的整體。每一個用戶進程運行時都好像有一份內核的拷貝,每當用戶進程使用系統調用時,都自動地將運行模式從用戶級轉爲內核級,此時進程在內核的地址空間中運行。當應用程序進程執行系統調用而陷入內核代碼中執行時,我們就稱進程處於內核運行態(或簡稱爲內核態)。此時處理器處於特權級最高的(RING0 級)內核代碼中執行。當進程處於內核態時,執行的內核代碼會使用當前進程的內核棧。每個進程都有自己的內核棧。當進程在執行用戶自己的代碼時,則稱其處於用戶運行態(用戶態)。即此時處理器在特權級最低的(RING3 級)用戶代碼中運行。當正在執行用戶程序而突然被中斷程序中斷時,此時用戶程序也可以象徵性地稱爲處於進程的內核態。因爲中斷處理程序將使用當前進程的內核棧。這與處於內核態的進程的狀態有些類似。

內核態與用戶態是操作系統的兩種運行級別, 跟 intel cpu 沒有必然的聯繫, 如上所提到的 intel cpu 提供 Ring0-Ring3 四種級別的運行模式,Ring0 級別最高,Ring3 最低。Linux 使用了 Ring3 級別運行用戶態,Ring0 作爲 內核態,沒有使用 Ring1 和 Ring2。

內核空間和用戶空間

x86 CPU 採用了段頁式地址映射模型。進程代碼中的地址爲邏輯地址,經過段頁式地址映射後,才真正訪問物理內存。

通常 32 位 Linux 內核地址空間劃分 0~3G 爲用戶空間,3~4G 爲內核空間。64 位內核地址空間劃分是不同的。

32 位與 64 位具體地址分佈如下圖:

64 位地址時將 0x0000,0000,0000,0000 – 0x0000,7fff,ffff,f000 這 128T 地址用於用戶空間。參見定義:

#define TASK_SIZE_MAX ((1UL << 47) - PAGE_SIZE),注意這裏還減去了一個頁面的大小做爲保護。

而 0xffff,8000,0000,0000 以上爲系統空間地址。注意:該地址前 4 個都是 f,這是因爲目前實際上只用了 64 位地址中的 48 位(高 16 位是沒有用的),而從地址 0x0000,7fff,ffff,ffff 到 0xffff,8000,0000,0000 中間是一個巨大的空洞,是爲以後的擴展預留的。

而真正的系統空間的起始地址,是從 0xffff,8800,0000,0000 開始的,參見:

#define __PAGE_OFFSET     _AC(0xffff,8800,0000,0000, UL)

而 32 位地址時系統空間的起始地址爲 0xC000,0000。

另外 0xffff,8800,0000,0000 – 0xffff,c7ff,ffff,ffff 這 64T 直接和物理內存進行映射,0xffff,c900,0000,0000 – 0xffff,e8ff,ffff,ffff 這 32T 用於 vmalloc/ioremap 的地址空間。

而 32 位地址空間時,當物理內存大於 896M 時(Linux2.4 內核是 896M,3.x 內核是 884M,是個經驗值),由於地址空間的限制,內核只會將 0~896M 的地址進行映射,而 896M 以上的空間用做一些固定映射和 vmalloc/ioremap。而 64 位地址時是將所有物理內存都進行映射。

內核態與用戶態

用戶態 Ring3 狀態不能訪問內核態 Ring0 的地址空間,包括代碼和數據。(例如 32 位 Linux 進程的 4GB 地址空間,3G-4G 部 分大家是共享的,是內核態的地址空間,這裏存放在整個內核的代碼和所有的內核模塊,以及內核所維護的數據)。用戶運行一個程序,該程序所創建的進程開始是運行在用戶態的,如果要執行文件操作,網絡數據發送等操作,必須通過 write,send 等系統調用,這些系統調用會調用內核中的代碼來完成操作,這時,必 須切換到 Ring0,然後進入內核地址空間去執行這些代碼完成操作,完成後,切換回 Ring3,回到用戶態。這樣,用戶態的程序就不能 隨意操作內核地址空間,具有一定的安全保護作用。

處理器總處於以下狀態中的一種:

從用戶空間到內核空間有兩種觸發手段:

  1. 系統調用:用戶空間的應用程序,通過系統調用,進入內核空間。這個時候用戶空間的進程要傳遞很多變量、參數的值給內核,內核態運行的時候也要保存用戶進程的一些寄存器值、變量等。所謂的 “進程上下文”,可以看作是用戶進程傳遞給內核的這些參數以及內核要保存的那一整套的變量和寄存器值和當時的環境等。

  2. 中斷: 硬件通過觸發信號,導致內核調用中斷處理程序,進入內核空間。例如網卡發送一個數據包或硬盤驅動器提供一次 IO 請求等。這個過程中,硬件的一些變量和參數也要傳遞給內核,內核通過這些參數進行中斷處理。所謂的 “中斷上下文”,其實也可以看作就是硬件傳遞過來的這些參數和內核需要保存的一些其他環境(主要是當前被打斷執行的進程環境)。

五、Socket 編程實例

服務器端:一直監聽本機的 8000 號端口,如果收到連接請求,將接收請求並接收客戶端發來的消息,並向客戶端返回消息。

/* File Name: server.c */  
#include<stdio.h>  
#include<stdlib.h>  
#include<string.h>  
#include<errno.h>  
#include<sys/types.h>  
#include<sys/socket.h>  
#include<netinet/in.h>  
#define DEFAULT_PORT 8000  
#define MAXLINE 4096  
int main(int argc, char** argv)  
{  
    int    socket_fd, connect_fd;  
    struct sockaddr_in     servaddr;  
    char    buff[4096];  
    int     n;  
    //初始化Socket  
    if( (socket_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1 ){  
    printf("create socket error: %s(errno: %d)\n",strerror(errno),errno);  
    exit(0);  
    }  
    //初始化  
    memset(&servaddr, 0, sizeof(servaddr));  
    servaddr.sin_family = AF_INET;  
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);//IP地址設置成INADDR_ANY,讓系統自動獲取本機的IP地址。  
    servaddr.sin_port = htons(DEFAULT_PORT);//設置的端口爲DEFAULT_PORT  
  
    //將本地地址綁定到所創建的套接字上  
    if( bind(socket_fd, (struct sockaddr*)&servaddr, sizeof(servaddr)) == -1){  
    printf("bind socket error: %s(errno: %d)\n",strerror(errno),errno);  
    exit(0);  
    }  
    //開始監聽是否有客戶端連接  
    if( listen(socket_fd, 10) == -1){  
    printf("listen socket error: %s(errno: %d)\n",strerror(errno),errno);  
    exit(0);  
    }  
    printf("======waiting for client's request======\n");  
    while(1){  
//阻塞直到有客戶端連接,不然多浪費CPU資源。  
        if( (connect_fd = accept(socket_fd, (struct sockaddr*)NULL, NULL)) == -1){  
        printf("accept socket error: %s(errno: %d)",strerror(errno),errno);  
        continue;  
    }  
//接受客戶端傳過來的數據  
    n = recv(connect_fd, buff, MAXLINE, 0);  
//向客戶端發送迴應數據  
    if(!fork()){ /*紫禁城*/  
        if(send(connect_fd, "Hello,you are connected!\n", 26,0) == -1)  
        perror("send error");  
        close(connect_fd);  
        exit(0);  
    }  
    buff[n] = '\0';  
    printf("recv msg from client: %s\n", buff);  
    close(connect_fd);  
    }  
    close(socket_fd);  
}

客戶端:

/* File Name: client.c */  
  
#include<stdio.h>  
#include<stdlib.h>  
#include<string.h>  
#include<errno.h>  
#include<sys/types.h>  
#include<sys/socket.h>  
#include<netinet/in.h>  
  
#define MAXLINE 4096  
  
  
int main(int argc, char** argv)  
{  
    int    sockfd, n,rec_len;  
    char    recvline[4096], sendline[4096];  
    char    buf[MAXLINE];  
    struct sockaddr_in    servaddr;  
  
  
    if( argc != 2){  
    printf("usage: ./client <ipaddress>\n");  
    exit(0);  
    }  
  
  
    if( (sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0){  
    printf("create socket error: %s(errno: %d)\n", strerror(errno),errno);  
    exit(0);  
    }  
  
  
    memset(&servaddr, 0, sizeof(servaddr));  
    servaddr.sin_family = AF_INET;  
    servaddr.sin_port = htons(8000);  
    if( inet_pton(AF_INET, argv[1], &servaddr.sin_addr) <= 0){  
    printf("inet_pton error for %s\n",argv[1]);  
    exit(0);  
    }  
  
  
    if( connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) < 0){  
    printf("connect error: %s(errno: %d)\n",strerror(errno),errno);  
    exit(0);  
    }  
  
  
    printf("send msg to server: \n");  
    fgets(sendline, 4096, stdin);  
    if( send(sockfd, sendline, strlen(sendline), 0) < 0)  
    {  
    printf("send msg error: %s(errno: %d)\n", strerror(errno), errno);  
    exit(0);  
    }  
    if((rec_len = recv(sockfd, buf, MAXLINE,0)) == -1) {  
       perror("recv error");  
       exit(1);  
    }  
    buf[rec_len]  = '\0';  
    printf("Received : %s ",buf);  
    close(sockfd);  
    exit(0);  
}

inet_pton 是 Linux 下 IP 地址轉換函數,可以在將 IP 地址在 “點分十進制” 和“整數”之間轉換 ,是 inet_addr 的擴展。

int inet_pton(int af, const char *src, void *dst);//轉換字符串到網絡地址:

第一個參數 af 是地址族,轉換後存在 dst 中

af = AF_INET:src 爲指向字符型的地址,即 ASCII 的地址的首地址(ddd.ddd.ddd.ddd 格式的),函數將該地址轉換爲 in_addr 的結構體,並複製在 * dst 中

af =AF_INET6:src 爲指向 IPV6 的地址,函數將該地址轉換爲 in6_addr 的結構體,並複製在 * dst 中

如果函數出錯將返回一個負值,並將 errno 設置爲 EAFNOSUPPORT,如果參數 af 指定的地址族和 src 格式不對,函數將返回 0。

測試:

編譯server.c

gcc -o server server.c

啓動進程:

./server

顯示結果:

======waiting for client's request======

並等待客戶端連接。

編譯 client.c

gcc -o client server.c

客戶端去連接server:

./client 127.0.0.1 

等待輸入消息
發送一條消息,輸入:c++
此時服務器端看到:
客戶端收到消息:
其實可以不用client,可以使用telnet來測試:

telnet 127.0.0.1 8000

注意:

在 ubuntu 編譯源代碼的時候,頭文件 types.h 可能找不到。
使用 dpkg -L libc6-dev | grep types.h 查看。
如果沒有,可以使用 apt-get install libc6-dev 安裝。
如果有了,但不在 / usr/include/sys / 目錄下,手動把這個文件添加到這個目錄下就可以了。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/gZdvrWoJnx7Mg1ZSQ76YZw