Linux 下 socket 編程有什麼需要注意的?

信號中斷?長短連接?有什麼經驗分享的嗎?


Socket是什麼

1、 socket套接字:

socket起源於Unix,而Unix/Linux基本哲學之一就是「一切皆文件」,都可以用「打開open –&> 讀寫write/read –&> 關閉close」模式來操作。Socket就是該模式的一個實現, socket即是一種特殊的文件,一些socket函數就是對其進行的操作(讀/寫IO、打開、關閉).
說白了Socket是應用層與TCP/IP協議族通信的中間軟體抽象層,它是一組介面。在設計模式中,Socket其實就是一個門面模式,它把複雜的TCP/IP協議族隱藏在Socket介面後面,對用戶來說,一組簡單的介面就是全部,讓Socket去組織數據,以符合指定的協議。

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

2、套接字描述符

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

【句柄簡介:句柄,是整個Windows編程的基礎。一個句柄是指使用的一個唯一的整數值,即一個4位元組(64位程序中為8位元組)長的數值,來標識應用程序中的不同對象和同類對象中的不同的實例,諸如,一個窗口,按鈕,圖標,滾動條,輸出設備,控制項或者文件等。應用程序能夠通過句柄訪問相應的對象的信息,但是句柄不是一個指針,程序不能利用句柄來直接閱讀文件中的信息。如果句柄不用在I/O文件中,它是毫無用處的。】

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

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

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

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

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

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

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

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的內核

socket()函數

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

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

  • protofamily:即協議域,又稱為協議族(family)。常用的協議族有,AF_INET(IPV4)、AF_INET6(IPV6)、AF_LOCAL(或稱AF_UNIX,Unix域socket)、AF_ROUTE等等。協議族決定了socket的地址類型,在通信中必須採用對應的地址,如AF_INET決定了要用ipv4地址(32位的)與埠號(16位的)的組合、AF_UNIX決定了要用一個絕對路徑名作為地址。
  • type:指定socket類型。常用的socket類型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等(socket的類型有哪些?)。
  • protocol:故名思意,就是指定協議。常用的協議有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它們分別對應TCP傳輸協議、UDP傳輸協議、STCP傳輸協議、TIPC傳輸協議(這個協議我將會單獨開篇討論!)。

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

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

bind()函數

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

函數的三個參數分別為:

  • sockfd:即socket描述字,它是通過socket()函數創建了,唯一標識一個socket。bind()函數就是將給這個描述字綁定一個名字。
  • addr:一個const struct sockaddr *指針,指向要綁定給sockfd的協議地址。這個地址結構根據地址創建socket時的地址協議族的不同而不同,如ipv4對應的是:

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

  • addrlen:對應的是地址的長度。

通常伺服器在啟動的時候都會綁定一個眾所周知的地址(如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。

listen()、connect()函數

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

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

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

accept()函數

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

參數sockfd參數sockfd就是上面解釋中的監聽套接字,這個套接字用來監聽一個埠,當有一個客戶與伺服器連接時,它使用這個一個埠號,而此時這個埠號正與這個套接字關聯。當然客戶不知道套接字這些細節,它只知道一個地址和一個埠號。參數addr這是一個結果參數,它用來接受一個返回值,這返回值指定客戶端的地址,當然這個地址是通過某個地址結構來描述的,用戶應該知道這一個什麼樣的地址結構。如果對客戶的地址不感興趣,那麼可以把這個值設置為NULL。參數len如同大家所認為的,它也是結果的參數,用來接受上述addr的結構的大小的,它指明addr結構所佔有的位元組個數。同樣的,它也可以被設置為NULL。

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

注意:

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

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

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

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

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

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

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

read()、write()等函數

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

  • read()/write()
  • recv()/send()
  • readv()/writev()
  • recvmsg()/sendmsg()
  • recvfrom()/sendto()

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。

close()函數

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

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

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

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

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

第一次握手:建立連接時,客戶端發送syn包 到伺服器,並進入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

  • 114:12:45.104687 時間帶有精確到微妙
  • localhost.39870 &> localhost.9502 表示通信的流向,39870是客戶端,9502是伺服器端
  • [S] 表示這是一個SYN請求
  • [S.] 表示這是一個SYN+ACK確認包:
  • [.] 表示這是一個ACT確認包, (client)SYN-&>(server)SYN-&>(client)ACT 就是3次握手過程
  • [P] 表示這個是一個數據推送,可以是從伺服器端向客戶端推送,也可以從客戶端向伺服器端推
  • [F] 表示這是一個FIN包,是關閉連接操作,client/server都有可能發起
  • [R] 表示這是一個RST包,與F包作用相同,但RST表示連接關閉時,仍然有數據未被處理。可以理解為是強制切斷連接
  • win 4099 是指滑動窗口大小
  • length 18指數據包的大小

我們看到 (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可靠的連接,每次通信都需要對方來確認。

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

建立一個連接需要三次握手,而終止一個連接要經過四次握手,這是由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)。

對應函數介面如圖:

過程如下:

  • 某個應用進程首先調用close主動關閉連接,這時TCP發送一個FIN M;
  • 另一端接收到FIN M之後,執行被動關閉,對這個FIN進行確認。它的接收也作為文件結束符傳遞給應用進程,因為FIN的接收意味著應用進程在相應的連接上再也接收不到額外數據;
  • 一段時間之後,接收到文件結束符的應用進程調用close關閉它的socket。這導致它的TCP也發送一個FIN N;
  • 接收到這個FIN的源發送端TCP對它進行確認。

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


你想更深入了解學習Linux知識體系,你可以看一下我們花費了一個多月整理了上百小時的幾百個知識點體系內容:

【超全整理】《Linux雲計算從入門到精通》系列實戰筆記全放送


想要寫個玩具,抄書就行,想要寫個高性能的,libevent,boost::asio搞起


首先確定你採用的網路通訊模式:同步/非同步,阻塞/非阻塞。


對於socket來講只有面向連接的TCP和無連接的UDP以及raw socekt三種,長短連接是應用層的行為,不是socket能控制的。至於信號中斷,不知道你要問的是什麼。


1. TIME_WAIT狀態和KEEPALIVE選項;

2. epoll中的非阻塞式讀寫;

3. 定時器

信號中斷實際中捕獲的少


首先你要了解很多網路基礎知識
其次你要了解各種模型
然後你要分析你的系統
最後開始你的socket編程。

socket編程本身只需要知道那幾個函數的用法即可。


推薦使用libuv,nodejs底層的非同步I/O庫,可以完美解決你的問題
https://github.com/libuv/libuv


推薦閱讀:

如何用 Nginx 配置透明 HTTP 和 HTTPS 代理?
Epoll的EPOLLOUT事件的一些疑問?
運維工程師必須掌握的基礎技能有哪些?

TAG:Linux | Socket | Linux開發 |