1. 套接字(SOCKET)相關(guān)概念
網(wǎng)絡(luò)套接字的基本操作:創(chuàng)建(socket)淘太、命名(bind)姻僧、偵聽(listen)、連接(accept)蒲牧、關(guān)閉(shutdown)撇贺、發(fā)送(send)、接受(recv)冰抢,以上這幾種操作均是系統(tǒng)調(diào)用松嘶。
服務(wù)端
服務(wù)端通過socket()
函數(shù)定義一個(gè)socket
文件描述符,并使用bind()
&listen()
函數(shù)監(jiān)聽指定端口挎扰,此時(shí)服務(wù)端狀態(tài)CLOSED->LISTEN
翠订。接下來調(diào)用accept()
函數(shù)監(jiān)聽已經(jīng)完成TCP三次握手客戶端隊(duì)列巢音,這里服務(wù)端的socket
如果設(shè)置成阻塞(默認(rèn)阻塞),當(dāng)調(diào)用accept
函數(shù)時(shí)尽超,會阻塞當(dāng)前進(jìn)程官撼,直到客戶端隊(duì)列中出現(xiàn)已經(jīng)完成三次握手的客戶端連接;同理如果服務(wù)端socket
設(shè)置非阻塞似谁,不管有沒有準(zhǔn)備好傲绣,將會立馬返回結(jié)果。
調(diào)用完accept()
函數(shù)將會生成一個(gè)新socket
會客戶端通信(new-socket
)巩踏,這個(gè)new-socket
經(jīng)歷了三次握手秃诵,狀態(tài):CLOSED->SYNC_RCVD->ESTABLISHED
。
當(dāng)new-socket
調(diào)用read()
函數(shù)時(shí)塞琼,同樣有阻塞和非阻塞兩種模式菠净,阻塞時(shí)當(dāng)前進(jìn)程(線程)一直等待直到網(wǎng)卡返回?cái)?shù)據(jù),非阻塞時(shí)立馬返回結(jié)果彪杉。讀取完數(shù)據(jù)且進(jìn)行完邏輯處理后調(diào)用write()
函數(shù)嗤练,將響應(yīng)返回給客戶端,雖然write
也有NIO
的模式在讶,我們通常認(rèn)為write
時(shí)網(wǎng)卡不會阻塞,會立馬返回霜大。
通信完雙方通過四次揮手构哺,結(jié)束通信,服務(wù)端new-socket
狀態(tài):ESTABLISHED-> CLOSEWAIT -> LAST ACK->CLOSED
战坤。
注意:當(dāng)服務(wù)端的socket
在參與三次握手后曙强,它會創(chuàng)建一個(gè)新socket
參與通信,當(dāng)然雙方結(jié)束通信后只有新創(chuàng)建的socket
會關(guān)閉途茫,負(fù)責(zé)監(jiān)聽的socket
還是listen
狀態(tài)碟嘴。
客戶端
客戶端創(chuàng)建完socket
后,通過調(diào)用connect()
與服務(wù)端進(jìn)行三次握手囊卜,握手完畢娜扇,客戶端的狀態(tài)CLOSED
-> SYN_SEND
-> ESTABLISHED
。
調(diào)用write()
方法發(fā)送請求栅组,同樣write()
幾乎不會阻塞雀瓢;然后再次調(diào)用read()
方法阻塞(非阻塞)讀取服務(wù)端響應(yīng)。
最后通信完畢玉掸,客戶端主動發(fā)起結(jié)束通信刃麸,狀態(tài):ESTABLISHED -> FIN WAIT1 -> FIN WAIT2 -> TIME WAIT
相關(guān)流程
2. C語言中的SOCKET與NIO
c語言最純粹,最接近底層的系統(tǒng)調(diào)用司浪,可以不留余地的欣賞完真實(shí)socket的每個(gè)細(xì)節(jié)泊业。
socket編程的函數(shù)
- 創(chuàng)建socket
/**
* domain 指定發(fā)送通信的域(網(wǎng)絡(luò)層)
AF_UNIX:本地主機(jī)通信把沼,與IPC類似;
AF_INET:Internet地址IPV4協(xié)議簇
AF_IPX: IPX/SPX 協(xié)議簇
AF_APPLETALK: Apple Talk協(xié)議簇
AF_NETBIOS NetBIOS 協(xié)議簇
AF_INET6 Internet地址IPV6協(xié)議簇
AF_IRDA Irda協(xié)議簇
AF_BTH 藍(lán)牙協(xié)議簇
* type 指定通信類型(傳輸層)
SOCK_STREAM:流套接字(eg: TCP)
SOCK_DGRAM:數(shù)據(jù)報(bào)套接字 (eg: UDP)
SOCK_RAW:原始套接字,可以處理ICMP、IGMP等上一層(網(wǎng)絡(luò)層)報(bào)文
SOCK_SEQPACKET:可提供基于數(shù)據(jù)報(bào)的偽流
* protocol 協(xié)議
IPPROTO_ICMP:ICMP協(xié)議吁伺,僅當(dāng) domain為AF_INET或AF_INET6饮睬,且type為SOCK_RAW時(shí)可選。
IPPROTO_IGMP:IGMP協(xié)議箱蝠,僅當(dāng) domain為AF_INET或AF_INET6续捂,且type為SOCK_RAW時(shí)可選。
BTHPROTO_RFCOMM:藍(lán)牙協(xié)議宦搬,僅當(dāng) domain為AF_BTH牙瓢,且type為SOCK_STREAM時(shí)可選。
IPPROTO_TCP:TCP協(xié)議间校,僅當(dāng) domain為AF_INET或AF_INET6矾克,且type為SOCK_STREAM時(shí)可選。
IPPROTO_UDP:UDP協(xié)議憔足,僅當(dāng) domain為AF_INET或AF_INET6胁附,且type為SOCK_STREAM時(shí)可選。
IPPROTO_ICMPv6:ICMPv6協(xié)議滓彰,僅當(dāng) domain為AF_INET或AF_INET6控妻,且type為SOCK_RAW時(shí)可選。
* return: socketfd(正常) / -1 (失斀野蟆)
*/
int socket(int domain, int type, int protocol)
- 命名bind
/**
* sockfd:套接字描述符(socket句柄)
* addr: 指向通用套接字的協(xié)議地址結(jié)構(gòu)弓候,包括協(xié)議、地址和端口等信息
* addrlen: 協(xié)議地址結(jié)構(gòu)的長度
* return: 0 成功; -1 失敗
*/
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
- 監(jiān)聽listen
/**
* sockfd: socket句柄
* backlog:sockfd接收連接的最大數(shù)目
* return: 0 成功; -1 失敗
*/
int listen(int sockfd, int backlog);
- 連接accept
/**
* sockfd: socket句柄
* addr: addr指向通用套接字的協(xié)議地址結(jié)構(gòu)他匪,包括協(xié)議菇存、地址和端口等信息
* addrlen: 協(xié)議地址結(jié)構(gòu)的長度,一般為sizeof(sockaddr_in)
* return: 創(chuàng)造返回一個(gè)新的socket與客戶進(jìn)程通信邦蜜,原sockfd仍用于套接字偵聽
*/
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- 接收recv
/**
* sockfd:與遠(yuǎn)程通信連接的套接字描述符
* buf:接收數(shù)據(jù)的緩沖區(qū)地址
* len:緩沖區(qū)長度
* flags:接收標(biāo)志
*/
int recv(int sockfd, void *buf, size_t len, int flags);
- 讀取read
/**
* fd:套接字文件描述符
* buf:要接收的字符數(shù)組
* nbyte:最大讀取的字節(jié)
*/
int read (int __fd, void *__buf, size_t __nbyte);
- 寫入write
/**
* fildes:套接字文件描述符
* buf:要接收的字符數(shù)組
* nbyte:寫入字節(jié)
*/
int write(int fildes, const void *buf, int nbyte);
BIO模型與例子
我們接下來寫一個(gè)簡單的服務(wù)端接收客戶端請求并相應(yīng)的程序依鸥,包含了server socket
整個(gè)的生命周期:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
void bioServer();
int main() {
bioServer();
return 0;
}
void bioServer() {
int serverFd, newClientFd;
//創(chuàng)建一個(gè)internet ipv4協(xié)議簇的TCP流協(xié)議的文件描述符serverFd
if ((serverFd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)) == 0) {
printf("create socket fail");
return;
}
struct sockaddr_in serverAddress;
memset(&serverAddress, 0, sizeof(serverAddress));
int serverAddressLen = sizeof(serverAddress);
serverAddress.sin_family = AF_INET;
serverAddress.sin_port = htons(8088);
serverAddress.sin_addr.s_addr = inet_addr("127.0.0.1");
//將serverFd綁定到本地8088端口上
if (bind(serverFd, (struct sockaddr *) &serverAddress, serverAddressLen) < 0) {
printf("bind port fail");
return;
}
//開始監(jiān)聽serverFd
if (listen(serverFd, 3) < 0) {
printf("listen fail");
return;
}
//阻塞等待, 接收一個(gè)client請求,并生成新的文件描述符clientFd
if ((newClientFd = accept(serverFd, (struct sockaddr *) &serverAddress, (socklen_t *) &serverAddressLen)) < 0) {
printf("accept fail");
return;
}
char buffer[1024] = {0};
//read數(shù)據(jù)(阻塞讀)
read(newClientFd, buffer, 1024);
printf("%s\n", buffer);
char resp[] = "HTTP/1.1 200\nContent-Type: text/plain\n\nOK";
//send數(shù)據(jù)
write(newClientFd, resp, strlen(resp));
close(newClientFd);
close(serverFd);
return;
}
我們可以通過curl
或者telnet
來測試以上程序是運(yùn)行正常的,如果有多個(gè)客戶端悼沈,那么我們不能僅僅用以上代碼來處理一個(gè)客戶端請求后就cloise
贱迟,所以每當(dāng)收到accept
請求后,新建一個(gè)線程/進(jìn)程去處理這個(gè)socket
井辆。
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include <unistd.h>
void bioServerLoop();
int main() {
bioServerLoop();
return 0;
}
void bioServerLoop() {
//new socket
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd == -1) {
exit(-1);
}
struct sockaddr_in servaddrddd, childAddr;
int len, cfd;
char buff[1024];
memset(&servaddrddd, 0, sizeof(servaddrddd));
servaddrddd.sin_family = AF_INET;
servaddrddd.sin_addr.s_addr = htonl(INADDR_ANY);
servaddrddd.sin_port = htons(6666);
//bind操作
if (bind(fd, (const struct sockaddr *) &servaddrddd, sizeof(servaddrddd)) == -1) {
exit(-1);
}
//listen操作
if (listen(fd, 10) == -1) {
exit(-1);
}
//這里循環(huán)去accept
for (;;) {
printf("wait for connect\n");
len = sizeof(childAddr);
//使用accept(阻塞)去獲取客戶端的新連接
cfd = accept(fd, (struct sockaddr *) &childAddr, &len);
if (cfd == -1) {
break;
}
//fork函數(shù), 復(fù)制本進(jìn)程生成新的子進(jìn)程
if (fork() == 0) {
//close(fd), 子進(jìn)程不需要再有父進(jìn)程的fd引用
close(fd);
//read數(shù)據(jù)(阻塞)
int i = recv(cfd, buff, 1024, MSG_WAITALL);
printf("recv msg from client: %s\n", buff);
write(cfd, buff, i);
//
close(cfd);
}
//新客戶端鏈接已經(jīng)在子進(jìn)程中處理关筒,父進(jìn)程不需要持有子進(jìn)程的cfd引用
close(cfd);
}
}
BIO
模型:
BIO
程序到此結(jié)束,BIO
的阻塞進(jìn)程/線程的特點(diǎn)已經(jīng)在程序標(biāo)出:在accept
和read
的時(shí)候會阻塞杯缺。每當(dāng)有一個(gè)客戶端來連接蒸播,就要有一個(gè)線程/進(jìn)程去阻塞,線程/進(jìn)程對于操作系統(tǒng)來說是十分有限的,所以當(dāng)客戶端并發(fā)上到十萬袍榆、百萬級別的時(shí)候會迅速消耗完系統(tǒng)的資源胀屿。
NIO模型與例子
接下來我們討論NIO
,也就是非阻塞包雀,引入非阻塞的目的就是解決阻塞操作過程中宿崭,避免創(chuàng)建大量線程去等待各自的IO
操作;因?yàn)橐肓朔亲枞判矗耆梢允褂靡粋€(gè)線程去處理多個(gè)阻塞IO葡兑,這樣線程的利用率就大大提升。
對于client socket
(connect赞草、read讹堤、write
)和server socket
(accept、read厨疙、write
)來說洲守,只要將其文件描述符設(shè)置成no_blocked
,那么它的IO
操作函數(shù)就可以不必等待沾凄,直接返回結(jié)果(可能有數(shù)據(jù)梗醇,也可能沒有數(shù)據(jù))。
客戶端一般不涉及到大并發(fā)操作(其實(shí)是和其他io
函數(shù)一樣的)撒蟀,所以我們只討論server socket
的NIO
操作:accept
叙谨、read
、write
保屯,常用場景中write
操作和網(wǎng)卡相關(guān)唉俗,一般不會阻塞,為了簡化邏輯配椭,我們先拿accept
、read
兩個(gè)函數(shù)舉例雹姊。
對于read
操作來說股缸,其實(shí)把網(wǎng)卡的數(shù)據(jù)拷貝到進(jìn)程內(nèi)存上速度是非常之快的,真正時(shí)間瓶頸是花在等待網(wǎng)卡把數(shù)據(jù)準(zhǔn)備好的過程上吱雏,也就是上述說的IO
等待的過程(accept
操作是等待TCP
連接建立敦姻,也是等待IO
的過程)。
c
語言socket
編程中歧杏,我們可以使用fcntl
函數(shù)將某個(gè)文件描述符設(shè)置為非阻塞:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<unistd.h>
#include<fcntl.h>
#include <errno.h>
void nioServer();
void nioServer() {
//new socket
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd == -1) {
exit(-1);
}
struct sockaddr_in servaddrddd, childAddr;
int len, cfd;
char buff[1024];
memset(&servaddrddd, 0, sizeof(servaddrddd));
servaddrddd.sin_family = AF_INET;
servaddrddd.sin_addr.s_addr = htonl(INADDR_ANY);
servaddrddd.sin_port = htons(6666);
//bind操作
if (bind(fd, (const struct sockaddr *) &servaddrddd, sizeof(servaddrddd)) == -1) {
exit(-1);
}
//listen操作
if (listen(fd, 10) == -1) {
exit(-1);
}
//設(shè)置nio
if(fcntl(fd,F_SETFL,fcntl(fd, F_GETFL,0) | O_NONBLOCK) == -1) {
exit(-1);
}
//這里循環(huán)去accept
for (;;) {
printf("wait for connect\n");
len = sizeof(childAddr);
//使用accept(非阻塞)去獲取客戶端的新連接, 生成的新cfd也是非阻塞
cfd = accept(fd, (struct sockaddr *) &childAddr, &len);
if (cfd == -1) {
//-1錯(cuò)誤可能是accept函數(shù)本身出錯(cuò),也可能是nio沒有獲取到客戶端連接
//errno是一個(gè)包含在<errno.h>中預(yù)定義的變量镰惦,可以判斷最近一個(gè)函數(shù)調(diào)用是否產(chǎn)生了錯(cuò)誤,所以這里用errno判斷是否正常
if (errno == EWOULDBLOCK) {
printf("accept no connect, wait for 2s\n");
sleep(2);
continue;
}
break;
}
//fork函數(shù), 復(fù)制本進(jìn)程生成新的子進(jìn)程
if (fork() == 0) {
printf("inter fork\n");
//子進(jìn)程不需要再有父進(jìn)程的fd引用
close(fd);
while(1) {
//read數(shù)據(jù)(非阻塞)
int i = recv(cfd, buff, 1024, MSG_WAITALL);
if (i == -1 && errno == EWOULDBLOCK) {
printf("read cfd:%d no data, wait for 2s\n", i);
sleep(2);
continue;
}
else if (i == -1) {
close(cfd);
return;
}
printf("recv msg from client: %s\n", buff);
//write數(shù)據(jù)(非阻塞)
write(cfd, buff, i);
close(cfd);
break;
}
}
//新客戶端鏈接已經(jīng)在子進(jìn)程中處理,父進(jìn)程不需要持有子進(jìn)程的cfd引用
close(cfd);
}
}
int main() {
nioServer();
return 0;
}
NIO
模型:
通過以上代碼犬绒,我們知道了如果設(shè)置了一個(gè)文件描述符為非阻塞旺入,那么需要手動while()
循環(huán)(輪詢)去判斷各個(gè)IO
操作是否準(zhǔn)備好,相比NIO
來說,這么寫增加了代碼的復(fù)雜度茵瘾、空跑了很多CPU礼华、而且有線程的sleep()
操作,還影響實(shí)時(shí)效率拗秘,但是不要忘記我們的初衷圣絮,我們想要一個(gè)線程去處理多個(gè)IO
事件,只有設(shè)置了非阻塞雕旨,才會有可能實(shí)現(xiàn)線程復(fù)用的優(yōu)勢扮匠。
單線程處理輪詢多個(gè)非阻塞IO
的代碼就不再寫了,有了非阻塞凡涩,相信大家都能寫出來棒搜。
實(shí)際過程中,NIO
線程輪詢的模型幾乎很少用到突照,因?yàn)闉榱四茏尵€程復(fù)用帮非,它犧牲的太多了,更不能忍受的就是每個(gè)應(yīng)用程序都要去設(shè)計(jì)這一套忙輪詢機(jī)制讹蘑,更多細(xì)節(jié)繁瑣難以處理末盔,比如:輪詢多久合適?IO
多了選擇什么數(shù)據(jù)類型去存儲座慰?陨舱。相比之下,身為程序員的我們還是希望能像BIO
那樣簡單阻塞處理版仔,如果有事件來了直接跳過阻塞繼續(xù)執(zhí)行就好游盲。這么一勞永逸的事情,操作系統(tǒng)還是幫我們實(shí)現(xiàn)了蛮粮,那就是多路復(fù)用益缎。
多路復(fù)用IO模型與例子
接下來我們講select
函數(shù)的多路復(fù)用,它的內(nèi)部實(shí)現(xiàn)不僅僅是忙輪詢設(shè)計(jì)這么簡單然想,如果僅僅是忙輪詢莺奔,那么還是會空跑CPU
,它還包括wait()
等待和notify()
通知機(jī)制变泄,可以有效的讓其他程序利用select()
等待的這段時(shí)間令哟。但是對于使用方來看,我們的線程只要等待就可以妨蛹,首先我們先看下位于unistd.h
下的select
函數(shù):
/**
* nfds:sets的文件描述符最大值
* readfds:fd_set類型屏富,包含了需要檢查是否可讀的描述符,輸出時(shí)表示哪些描述符可讀蛙卤。
* writefds:fd_set類型狠半,包含了需要檢查是否可寫的描述符,輸出時(shí)表示哪些描述符可寫。
* errorfds:fd_set類型典予,包含了需要檢查是否出錯(cuò)的描述符甜滨,輸出時(shí)標(biāo)識哪些描述符錯(cuò)誤。
* timeout:最大等待時(shí)間
* return int:返回可以操作的描述符個(gè)數(shù)瘤袖,超時(shí)返回0衣摩,出錯(cuò)返回-1
*/
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* errorfds, struct timeval* timeout);
我們可以發(fā)現(xiàn),select
函數(shù)中最為關(guān)鍵的就是它的文件描述符捂敌,它是set
集合艾扮,存放哪些需要檢查的文件描述符,為了維護(hù)fd_set
占婉,也有四個(gè)宏來操作它:
-
FD_SET()
:將指定的文件描述符存放到set
中泡嘴。 -
FD_CLR()
:將指定的文件描述符從set
中移除。 -
FD_ZERO()
:初始化set
為空逆济。 -
FD_ISSET()
:判斷指定文件描述符是否存在set
中酌予。
以下是用select
來實(shí)現(xiàn)線程多路復(fù)用的邏輯:
#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <netinet/in.h>
#include <sys/time.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <stdlib.h>
#include <memory.h>
void selectIO();
int main() {
selectIO();
return 0;
}
void selectIO() {
int server_sockfd, client_sockfd;
int server_len, client_len;
struct sockaddr_in server_address;
struct sockaddr_in client_address;
int result;
fd_set readfds, testfds;
//new socket
server_sockfd = socket(AF_INET, SOCK_STREAM, 0);
server_address.sin_family = AF_INET;
server_address.sin_addr.s_addr = htonl(INADDR_ANY);
server_address.sin_port = htons(8707);
server_len = sizeof(server_address);
bind(server_sockfd, (struct sockaddr *) &server_address, server_len);
listen(server_sockfd, 5);
//初始化fds, 它是多個(gè)文件描述符列表, 也是維持select可以同時(shí)處理多個(gè)IO的基礎(chǔ)
FD_ZERO(&readfds);
//將服務(wù)端文件描述符加入到set中
FD_SET(server_sockfd, &readfds);
while (1) {
char message[1024];
char respMessage[] = "HTTP/1.1 200\nContent-Type: text/plain\n\nOK";
int fd;
int nread;
testfds = readfds;
printf("server waiting\n");
//select阻塞直到文件描述符set中至少有一個(gè)IO可用的為止, 最后一個(gè)參數(shù)timeout可以設(shè)置阻塞時(shí)長
//我們只關(guān)心可讀set,可寫set一般都不用關(guān)心
result = select(FD_SETSIZE, &testfds, (fd_set *) 0, (fd_set *) 0, (struct timeval *) 0);
if (result < 1) {
perror("server happen error\n");
exit(1);
}
//掃描所有的文件描述符,找到可用的文件描述符
for (fd = 0; fd < FD_SETSIZE; fd++) {
//找到相關(guān)文件描述符
if (FD_ISSET(fd, &testfds)) {
//如果是serverFd那么肯定只有一個(gè)accept()操作
if (fd == server_sockfd) {
client_len = sizeof(client_address);
//accept()獲取一個(gè)可用的連接(一定是已經(jīng)準(zhǔn)備好的, 不會阻塞),生成一個(gè)新的客戶端文件描述符放到set中
client_sockfd = accept(server_sockfd, (struct sockaddr *) &client_address, &client_len);
FD_SET(client_sockfd, &readfds);
printf("adding client on fd %d\n", client_sockfd);
}
//客戶端連接,fork出子進(jìn)程來處理業(yè)務(wù)讀寫
else {
if (fork() == 0) {
//取得數(shù)據(jù)量交給nread
ioctl(fd, FIONREAD, &nread);
if (nread == 0) {
//客戶數(shù)據(jù)請求完畢,關(guān)閉套接字,從集合中清除相應(yīng)描述符
close(fd);
printf("removing by client on fd %d/n", fd);
} else {
//一定可讀,不會阻塞
read(fd, &message, 1024);
printf("recv client on fd %d, message:%s\n", fd, message);
write(fd, respMessage, strlen(respMessage));
close(fd);
}
}
//直接clean, 如果業(yè)務(wù)沒有處理完畢奖慌,可以在子進(jìn)程中重新添加該文件描述符到set中
FD_CLR(fd, &readfds);
close(fd);
}
}
}
}
}
IO復(fù)用模型:
上述程序是一個(gè)簡單的單線程IO多路復(fù)用 + 多線程業(yè)務(wù)處理的IO模型抛虫。由main
線程去執(zhí)行select()
函數(shù)并阻塞,建立連接后的子文件描述符的讀寫事件也仍注冊到main
線程的select()
中简僧。當(dāng)有讀寫事件發(fā)生時(shí)建椰,由線程池負(fù)責(zé)去處理。
流程圖如下:
另外還有一種比較常見的IO復(fù)用模型是:多線程IO多路復(fù)用和業(yè)務(wù)處理岛马。有效的避免了單個(gè)select()
最大文件描述符限制不足的場景: