天天看點

【網絡篇】第五篇——網絡套接字程式設計(一)(socket詳解)socket程式設計LINUX下socket程式的示範

socket程式設計

套接字概念

資料傳輸方式

ip位址轉換函數

socket常見API

sockaddr資料結構

socket緩沖區以及阻塞模式

LINUX下socket程式的示範

socket程式設計

套接字概念

Socket本身有“插座”的意思,在Linux環境下,用于表示程序間網絡通信的特殊檔案類型。本質為核心借助緩沖區形成的僞檔案。既然是檔案,那麼理所當然的,我們可以使用檔案描述符引用套接字。與管道類似的,Linux系統将其封裝成檔案的目的是為了統一接口,使得讀寫套接字和讀寫檔案的操作一緻。差別是管道主要應用于本地程序間通信,而套接字多應用于網絡程序間資料的傳遞。

在TCP/IP協定中,“IP位址+TCP或UDP端口号”唯一辨別網絡通訊中的一個程序。“IP位址+端口号”就對應一個socket。欲建立連接配接的兩個程序各自有一個socket來辨別,那麼這兩個socket組成的socket pair就唯一辨別一個連接配接。是以可以用Socket來描述網絡連接配接的一對一關系。

套接字通信原理如下圖所示:

【網絡篇】第五篇——網絡套接字程式設計(一)(socket詳解)socket程式設計LINUX下socket程式的示範
簡單例子

socket 的典型應用就是 Web 伺服器和浏覽器:浏覽器擷取使用者輸入的URL,向伺服器發起請求,伺服器分析接收到的URL,将對應的網頁内容傳回給浏覽器,浏覽器再經過解析和渲染,就将文字、圖檔、視訊等元素呈現給使用者。

socket通信屬于網絡協定哪一層?

Socket是應用層與TCP/IP協定族通信的中間軟體抽象層,它是一組接口。在設計模式中,Socket其實就是一個門面模式,它把複雜的TCP/IP協定族隐藏在Socket接口後面,對使用者來說,一組簡單的接口就是全部,讓Socket去組織資料,以符合指定的協定。而我們所說的socket程式設計指的是利用soket接口來實作自己的業務和協定。

綜上所述:Socke接口屬于軟體抽象層,而socket程式設計卻是标準的應用層開發。

資料傳輸方式

計算機之間有很多資料傳輸方式,各有優缺點,常用的有兩種:SOCK_STREAM 和 SOCK_DGRAM。

1)SOCK_STREAM 表示面向連接配接的資料傳輸方式。資料可以準确無誤地到達另一台計算機,如果損壞或丢失,可以重新發送,但效率相對較慢。常見的 http 協定就使用 SOCK_STREAM 傳輸資料,因為要確定資料的正确性,否則網頁不能正常解析。

2)SOCK_DGRAM 表示無連接配接的資料傳輸方式。計算機隻管傳輸資料,不作資料校驗,如果資料在傳輸中損壞,或者沒有到達另一台計算機,是沒有辦法補救的。也就是說,資料錯了就錯了,無法重傳。因為 SOCK_DGRAM 所做的校驗工作少,是以效率比 SOCK_STREAM 高。

簡單例子

QQ 視訊聊天和語音聊天就使用 SOCK_DGRAM 傳輸資料,因為首先要保證通信的效率,盡量減小延遲,而資料的正确性是次要的,即使丢失很小的一部分資料,視訊和音頻也可以正常解析,最多出現噪點或雜音,不會對通信品質有實質的影響。

ip位址轉換函數

隻能處理IPv4的ip位址

不可重入函數

注意參數是struct in_addr

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_addr(const char *cp);
char *inet_ntoa(struct in_addr in);

           

 支援IPv4和IPv6

可重入函數

其中inet_pton和inet_ntop不僅可以轉換IPv4的in_addr,還可以轉換IPv6的in6_addr。

#include <arpa/inet.h>
int inet_pton(int af, const char *src, void *dst);
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
           

socket常見API

socket模型流程圖
【網絡篇】第五篇——網絡套接字程式設計(一)(socket詳解)socket程式設計LINUX下socket程式的示範
 在Linux下建立socket
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
domain:
	AF_INET 這是大多數用來産生socket的協定,使用TCP或UDP來傳輸,用IPv4的位址
	AF_INET6 與上面類似,不過是來用IPv6的位址
	AF_UNIX 本地協定,使用在Unix和Linux系統上,一般都是當用戶端和伺服器在同一台及其上的時候使用
type:
	SOCK_STREAM 這個協定是按照順序的、可靠的、資料完整的基于位元組流的連接配接。這是一個使用最多的socket類型,這個socket是使用TCP來進行傳輸。
	SOCK_DGRAM 這個協定是無連接配接的、固定長度的傳輸調用。該協定是不可靠的,使用UDP來進行它的連接配接。
	SOCK_SEQPACKET該協定是雙線路的、可靠的連接配接,發送固定長度的資料包進行傳輸。必須把這個包完整的接受才能進行讀取。
	SOCK_RAW socket類型提供單一的網絡通路,這個socket類型使用ICMP公共協定。(ping、traceroute使用該協定)
	SOCK_RDM 這個類型是很少使用的,在大部分的作業系統上沒有實作,它是提供給資料鍊路層使用,不保證資料包的順序
protocol:
	傳0 表示使用預設協定。
傳回值:
	成功:傳回指向新建立的socket的檔案描述符,失敗:傳回-1,設定errno

           
 綁定端口号

 socket() 函數用來建立套接字,确定套接字的各種屬性,然後伺服器端要用 bind() 函數将套接字與特定的IP位址和端口綁定起來,隻有這樣,流經該IP位址和端口的資料才能交給套接字處理;而用戶端要用 connect() 函數建立連接配接。

bind()函數

#include <sys/types.h> 
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd:
	socket檔案描述符
addr:
	構造出IP位址加端口号
addrlen:
	sizeof(addr)長度
傳回值:
	成功傳回0,失敗傳回-1, 設定errno
	伺服器程式所監聽的網絡位址和端口号通常是固定不變的,用戶端程式得知伺服器程式的位址和端口号後就可以向伺服器發起連接配接,是以伺服器需要調用bind綁定一個固定的網絡位址和端口号。
bind()的作用是将參數sockfd和addr綁定在一起,使sockfd這個用于網絡通訊的檔案描述符監聽addr所描述的位址和端口号。前面講過,struct sockaddr *是一個通用指針類型,addr參數實際上可以接受多種協定的sockaddr結構體,而它們的長度各不相同,是以需要第三個參數addrlen指定結構體的長度。如:
struct sockaddr_in servaddr;
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(6666);
首先将整個結構體清零,然後設定位址類型為AF_INET,網絡位址為INADDR_ANY,這個宏表示本地的任意IP位址,因為伺服器可能有多個網卡,每個網卡也可能綁定多個IP位址,這樣設定可以在所有的IP位址上監聽,直到與某個用戶端建立了連接配接時才确定下來到底用哪個IP位址,端口号為6666。

           

connect()函數 

#include <sys/types.h> 					/* See NOTES */
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockdf:
	socket檔案描述符
addr:
	傳入參數,指定伺服器端位址資訊,含IP位址和端口号
addrlen:
	傳入參數,傳入sizeof(addr)大小
傳回值:
	成功傳回0,失敗傳回-1,設定errno

           
 使用listen()和accpet()函數

 對于伺服器端程式,使用 bind() 綁定套接字後,還需要使用 listen() 函數讓套接字進入被動監聽狀态,再調用 accept() 函數,就可以随時響應用戶端的請求了。

listen()函數

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int listen(int sockfd, int backlog);
sockfd:
	socket檔案描述符
backlog:
	排隊建立3次握手隊列和剛剛建立3次握手隊列的連結數和
檢視系統預設backlog
cat /proc/sys/net/ipv4/tcp_max_syn_backlog
典型的伺服器程式可以同時服務于多個用戶端,當有用戶端發起連接配接時,伺服器調用的accept()傳回并接受這個連接配接,如果有大量的用戶端發起連接配接而伺服器來不及處理,尚未accept的用戶端就處于連接配接等待狀态,listen()聲明sockfd處于監聽狀态,并且最多允許有backlog個用戶端處于連接配接待狀态,如果接收到更多的連接配接請求就忽略。listen()成功傳回0,失敗傳回-1。

           

所謂被動監聽,是指當沒有用戶端請求時,套接字處于“睡眠”狀态,隻有當接收到用戶端請求時,套接字才會被“喚醒”來響應請求。

請求隊列

當套接字正在處理用戶端請求時,如果有新的請求進來,套接字是沒法處理的,隻能把它放進緩沖區,待目前請求處理完畢後,再從緩沖區中讀取出來處理。如果不斷有新的請求進來,它們就按照先後順序在緩沖區中排隊,直到緩沖區滿。這個緩沖區,就稱為請求隊列(Request Queue)。

緩沖區的長度(能存放多少個用戶端請求)可以通過 listen() 函數的 backlog 參數指定,但究竟為多少并沒有什麼标準,可以根據你的需求來定,并發量小的話可以是10或者20。

如果将 backlog 的值設定為 SOMAXCONN,就由系統來決定請求隊列長度,這個值一般比較大,可能是幾百,或者更多。

當請求隊列滿時,就不再接收新的請求,對于 Linux,用戶端會收到 ECONNREFUSED 錯誤。

注意:listen() 隻是讓套接字處于監聽狀态,并沒有接收請求。接收請求需要使用 accept() 函數。

accept() 函數

當套接字處于監聽狀态時,可以通過 accept() 函數來接收用戶端請求。

#include <sys/types.h> 		
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
sockdf:
	socket檔案描述符
addr:
	傳出參數,傳回連結用戶端位址資訊,含IP位址和端口号
addrlen:
	傳入傳出參數(值-結果),傳入sizeof(addr)大小,函數傳回時傳回真正接收到位址結構體的大小
傳回值:
	成功傳回一個新的socket檔案描述符,用于和用戶端通信,失敗傳回-1,設定errno

           

它的參數與 listen() 和 connect() 是相同的:sock 為伺服器端套接字,addr 為 sockaddr_in 結構體變量,addrlen 為參數 addr 的長度,可由 sizeof() 求得。

 accept() 傳回一個新的套接字來和用戶端通信,addr 儲存了用戶端的IP位址和端口号,而 sock 是伺服器端的套接字,大家注意區分。後面和用戶端通信時,要使用這個新生成的套接字,而不是原來伺服器端的套接字。

最後需要說明的是:listen() 隻是讓套接字進入監聽狀态,并沒有真正接收用戶端請求,listen() 後面的代碼會繼續執行,直到遇到 accept()。accept() 會阻塞程式執行(後面代碼不能被執行),直到有新的請求到來。

socket資料的接收和發送

Linux下資料的接收和發送

Linux 不區分套接字檔案和普通檔案,使用 write() 可以向套接字中寫入資料,使用 read() 可以從套接字中讀取資料。

前面我們說過,兩台計算機之間的通信相當于兩個套接字之間的通信,在伺服器端用 write() 向套接字寫入資料,用戶端就能收到,然後再使用 read() 從套接字中讀取出來,就完成了一次通信。

write() 的原型為:

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

fd 為要寫入的檔案的描述符,buf 為要寫入的資料的緩沖區位址,nbytes 為要寫入的資料的位元組數。

write() 函數會将緩沖區 buf 中的 nbytes 個位元組寫入檔案 fd,成功則傳回寫入的位元組數,失敗則傳回 -1。

read() 的原型為:

ssize_t read(int fd, void *buf, size_t nbytes);
           

fd 為要讀取的檔案的描述符,buf 為要接收資料的緩沖區位址,nbytes 為要讀取的資料的位元組數。

read() 函數會從 fd 檔案中讀取 nbytes 個位元組并儲存到緩沖區 buf,成功則傳回讀取到的位元組數(但遇到檔案結尾則傳回0),失敗則傳回 -1。

sockaddr資料結構

sockaddr結構的出現

套接字不僅支援跨網絡的程序間通信,還支援本地的程序間通信(域間套接字)。在進行跨網絡通信時我們需要傳遞的端口号和IP位址,而本地通信則不需要,是以套接字提供了sockaddr_in結構體和sockaddr_un結構體,其中sockaddr_in結構體是用于跨網絡通信的,而sockaddr_un結構體是用于本地通信的。

為了讓套接字的網絡通信和本地通信能夠使用同一套函數接口,于是就出現了

sockeaddr

結構體,該結構體與

sockaddr_in

sockaddr_un

的結構都不相同,但這三個結構體頭部的16個比特位都是一樣的,這個字段叫做協定家族。

【網絡篇】第五篇——網絡套接字程式設計(一)(socket詳解)socket程式設計LINUX下socket程式的示範

此時當我們在傳遞在傳參時,就不用傳入sockeaddr_in或sockeaddr_un這樣的結構體,而統一傳入sockeaddr這樣的結構體。在設定參數時就可以通過設定協定家族這個字段,來表明我們是要進行網絡通信還是本地通信,在這些API内部就可以提取sockeaddr結構頭部的16位進行識别,進而得出我們是要進行網絡通信還是本地通信,然後執行對應的操作。此時我們就通過通用sockaddr結構,将套接字網絡通信和本地通信的參數類型進行了統一。

注意: 實際我們在進行網絡通信時,定義的還是sockaddr_in這樣的結構體,隻不過在傳參時需要将該結構體的位址類型進行強轉為sockaddr*罷了。

為什麼會有這麼多本地程序間通信的方式? 

本地程序間通信的方式已經有管道、消息隊列、共享記憶體、信号量等方式了,現在在套接字這裡又出現了可以用于本地程序間通信的域間套接字,為什麼會有這麼多通信方式,并且這些通信方式好像并不相關?

實際是因為早期有很多不同的實驗室都在研究通信的方式,由于是不同的實驗室,是以就出現了很多不同的通信方式,比如常見的有System V标準的通信方式和POSIX标準的通信方式。

  • IPv4和IPv6的位址格式定義在netinet/in.h中,IPv4位址用sockaddr_in結構體表示,包括16位位址類型,16位端口号和32位IP位址。
  • IPv4、IPv6位址類型分别定義為常數AF_INET、AF_INET6。這樣,隻要取得某種sockaddr結構體的首位址,不需要知道具體是哪種類型的sockaddr結構體,就可以根據位址類型字段确定結構體中的内容。
  • socket API可以都用struct sockaddr* 類型表示,在使用的時候需要強制轉化成sockaddr_in;這樣的好處是程式的通用性,可以接收IPv4、IPv6,以及UNIX Domain Socket各種類型的sockaddr結構體指針做為參數。

socket緩沖區以及阻塞模式

socket緩沖區

每個 socket 被建立後,都會配置設定兩個緩沖區,輸入緩沖區和輸出緩沖區。

write()/send() 并不立即向網絡中傳輸資料,而是先将資料寫入緩沖區中,再由TCP協定将資料從緩沖區發送到目标機器。一旦将資料寫入到緩沖區,函數就可以成功傳回,不管它們有沒有到達目标機器,也不管它們何時被發送到網絡,這些都是TCP協定負責的事情。

TCP協定獨立于 write()/send() 函數,資料有可能剛被寫入緩沖區就發送到網絡,也可能在緩沖區中不斷積壓,多次寫入的資料被一次性發送到網絡,這取決于當時的網絡情況、目前線程是否空閑等諸多因素,不由程式員控制。

這些I/O緩沖區特性可整理如下:

(1)I/O緩沖區在每個TCP套接字中單獨存在;

(2)I/O緩沖區在建立套接字時自動生成;

(3)即使關閉套接字也會繼續傳送輸出緩沖區中遺留的資料;

(4)關閉套接字将丢失輸入緩沖區中的資料。

阻塞模式

對于TCP套接字(預設情況下),當使用 write()/send() 發送資料時:

1) 首先會檢查緩沖區,如果緩沖區的可用空間長度小于要發送的資料,那麼 write()/send() 會被阻塞(暫停執行),直到緩沖區中的資料被發送到目标機器,騰出足夠的空間,才喚醒 write()/send() 函數繼續寫入資料。

2) 如果TCP協定正在向網絡發送資料,那麼輸出緩沖區會被鎖定,不允許寫入,write()/send() 也會被阻塞,直到資料發送完畢緩沖區解鎖,write()/send() 才會被喚醒。

3) 如果要寫入的資料大于緩沖區的最大長度,那麼将分批寫入。

4) 直到所有資料被寫入緩沖區 write()/send() 才能傳回。

 當使用 read()/recv() 讀取資料時:

1) 首先會檢查緩沖區,如果緩沖區中有資料,那麼就讀取,否則函數會被阻塞,直到網絡上有資料到來。

2) 如果要讀取的資料長度小于緩沖區中的資料長度,那麼就不能一次性将緩沖區中的所有資料讀出,剩餘資料将不斷積壓,直到有 read()/recv() 函數再次讀取。

3) 直到讀取到資料後 read()/recv() 函數才會傳回,否則就一直被阻塞。

這就是TCP套接字的阻塞模式。所謂阻塞,就是上一步動作沒有完成,下一步動作将暫停,直到上一步動作完成後才能繼續,以保持同步性。

 TCP套接字預設情況下是阻塞模式

LINUX下socket程式的示範

和C語言教程一樣,我們從一個簡單的“Hello World!”程式切入 socket 程式設計。

示範了 Linux 下的代碼,server.cpp 是伺服器端代碼,client.cpp 是用戶端代碼,要實作的功能是:用戶端從伺服器讀取一個字元串并列印出來。

伺服器端代碼 server.c:

【網絡篇】第五篇——網絡套接字程式設計(一)(socket詳解)socket程式設計LINUX下socket程式的示範

 用戶端代碼 client.c:

【網絡篇】第五篇——網絡套接字程式設計(一)(socket詳解)socket程式設計LINUX下socket程式的示範

 先編譯 server.c 并運作:

【網絡篇】第五篇——網絡套接字程式設計(一)(socket詳解)socket程式設計LINUX下socket程式的示範

正常情況下,程式運作到 accept() 函數就會被阻塞,等待用戶端發起請求。

接下來編譯 client.c 并運作: 

【網絡篇】第五篇——網絡套接字程式設計(一)(socket詳解)socket程式設計LINUX下socket程式的示範

client 運作後,通過 connect() 函數向 server 發起請求,處于監聽狀态的 server 被激活,執行 accept() 函數,接受用戶端的請求,然後執行 write() 函數向 client 傳回資料。client 接收到傳回的資料後,connect() 就運作結束了,然後使用 read() 将資料讀取出來。

需要注意的是:

server 隻接受一次 client 請求,當 server 向 client 傳回資料後,程式就運作結束了。如果想再次接收到伺服器的資料,必須再次運作 server,是以這是一個非常簡陋的 socket 程式,不能夠一直接受用戶端的請求。

繼續閱讀