网络IO的发展
IO多路复用
select多路复用
// readfds:关心读的fd集合;writefds:关心写的fd集合;excepttfds:异常的fd集合
int select (int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
nfds:等待的文件描述符的最大值+1,例如:应用程序想要去等待文件描述符3,5,8的事件,则nfds=max(3,5,8)+1
fd_set类型:
- fd_set类型本质上是一个位图,位图的位置表示与之相对应的文件描述符。其内容表示该文件描述符是否有效,1代表该位置的文件描述符有效,0则表示该位置的文件描述符无效。
- 如果将文件描述符2,3设置在位图当中,则位图表示为1100。
- fd_set的上限是1024个文件描述符。
readfds:
- readfds是等待读事件的文件描述符集合,如果不关心读事件(缓冲区有数据),则可以传NULL值。
- 应用进程和内核都可以设置readfds。
-
- 应用进程设置readfds是为了通知内核去等待readfds中的文件描述符的读事件。
- 内核设置readfds是为了通知应用哪些fd的读事件被触发了。
writefds:等待写事件(缓冲区中是否有空间)的集合,如果不关心写事件,则可以传值NULL
exceptfds:如果内核等待相应的文件描述符发生异常,则将失败的文件描述符设置到exceptfds中,如果不关心错误事件,可以传值NULL
timeout:设置select在内核中阻塞的时间,如果想要设置为非阻塞,则设置为NULL。如果想让select阻塞5秒,则创建一个struct timeval time={5,0};
- struct timeval的结构体类型定义:
struct timeval {
long tv_sec;
long tv_usec;
};
返回值:
- 如果没有文件描述符就绪就返回0;
- 如果调用失败返回-1;
- 如果timeout中readfds中有事件发生,则返回timeout剩下的时间
select函数监视的文件描述符分3类,分别是writefds、readfds和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据可读、可写或者except),或者超时(timeout指定等待时间,如果立即返回设置为NULL即可),函数返回。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。
select目前几乎在所有的平台上支持,其良好的跨平台支持也是它的一个优点。select的缺点在于:
- 单个进程能够监听的文件描述符的数量存在最大限制。在Linux平台上,select可以支持监听1024个文件描述符,当然可以通过修改宏定义甚至重新编译内核的方式来提升这一个限制,但是仍然无法改变轮询造成的效率低的缺点。
- 每次应用进程调用一次select之前,都需要重新设定writefds和readfds,如果进行轮询调用select,就会影响CPU效率。
- 内核每一次等待文件描述符都会重新扫描所有readfds或者writefds中的所有文件描述符,如果有较多的文件描述符,则会影响效率。
select的工作流程
应用进程和内核都需要从readfds和writefds中获取信息。
- 内核需要从readfds和writefds中获取到哪些文件描述符需要等待。
- 应用程序需要从readfds和writefds中获取到哪些文件描述符的事件就绪。
如果我们要不断轮询等待的文件描述符,则应用程序需要不断的重新设置readfds和writefds。因为每一次用户程序调用select,内核都会去修改readfds和writefds。所以应用程序中需要设置一个数组,用来保存程序需要等待的文件描述符。从而保证了每一次用户程序在调用select的时候可以将需要监听的文件描述符可以准确的传递给内核。
select服务器
如果是一个select服务器进程,则服务器进程会不断的接收有新连接,每一个连接对应一个文件描述符,如果想要我们的服务器能够同时等待多个连接的数据到来,我们监听套接字listen_sock读取新连接的时候,我们需要将新连接的文件描述符保存到read_arrays数组中,下一次轮询检测的就会将新连接的文件描述符设置到readfds中。这里需要注意一下,如果有连接关闭,则将相应的文件描述符从read_arrays数组中移除。
poll多路复用
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};
pollfd结构包含了要监听的event和发生的event,不再使用select的”参数-值“的传递方式。同时pollfd并没有最大数量上的限制(但是数量过大后性能也是会下降的)。poll和select函数一样,poll返回后,需要轮询pollfd来获取就绪的文件描述符。
select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上同时连接的大量客户端在某一时刻可能只有很少的处于就绪状态,因此随着监听描述符数量的增长,其效率也会线性下降。
epoll多路复用
epoll的相关系统调用接口
epoll_create
//创建epollFd,底层是在内核态分配一段区域,底层数据结构红黑树+双向链表
int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大
调用epoll_create后,内核会创建一个epoll_create对象,对象中包括跟踪检测事件的红黑树,就绪队列,回调机制。
- 自从Linux2.6.8之后,size参数是被忽略的
- 返回值:创建epoll_create后会返回一个epoll对象的文件描述符,调用者可以通过文件描述符访问到epoll对象
- 当创建好epoll句柄后,它就会占用一个fd的值,在linux下如果查看/proc/进程id/fd,是能够看到这个fd的,所以在使用完epoll后,必须调用close函数关闭epoll创建的文件描述符,否则可能导致系统中的fd被耗尽。
epoll_ctl
//往红黑树中增加、删除、更新管理的socket fd
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_ctl接口时用来维护epoll对象中红黑树的节点,epoll_ctl可以在红黑树中添加、删除、修改节点:
- epfd:epoll对象的文件描述符
- op:选择修改epoll中红黑树的方式
-
- EPOLL_CTL_ADD:往红黑树中插入节点
- EPOLL_CTL_MOD:修改红黑树中的节点信息
- EPOLL_CTL_DEL:删除红黑树中节点
- fd:需要监听的文件描述符
- epoll_event:保存的是事件信息
events本质是一个位图,它是用来表示事件的等待方式和事件的工作方式,相对应的宏定义如下:
-
- EPOLLIN:表示对应的文件描述符可读(包括对端的socket正常关闭)
- EPOLLOUT:表示对应的文件描述符可写
- EPOLLPRI:表示对应的文件描述符有紧急的事件可读(这里应该表示有外带数据到来)
- EPOLLET:表示使用ET的工作方式
- EPOLLERR:表示对应的文件描述符发生错误
- EPOLLHUP:表示对应的文件描述符被挂断
- EPOLLONESHOT:只监听一次的事件,当监听完这次事件之后,如果还需要继续监听这个事件的话,需要再次把这个socket加入到EPOLL队列里
如果想要设置events多个条件,可以将用“|”表示,比如如果既想要读事件又想要是ET的触发事件方 式,则可以用EPOLLIN|EPOLLET表示
epoll_data是一个联合体,他只能记录一个信息,他可以是指针,或者是一个文件描述符。如果是epoll服务器,epoll_data中一般记录的是socket文件描述符或者是一个ptr指针指向一个用户自定义的结构体(ptr指向的结构体中包含了socket的文件描述符以及触发事件的回调函数)。
返回值:调用成功,返回0;调用失败返回-1,并且设置errno错误码
epoll_ctl本质是以fd-event作为key-value映射关系插入到红黑树中,底层会根据红黑树节点的event中events判断是需要检测读事件还是检测写事件。
epoll_wait
//这个api是用来在第一阶段阻塞,等待就绪的fd。
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
epoll_wait能够获取到就绪队列中已经就绪的事件:
- epfd:epoll对象的文件描述符
- events:将从就绪队列中获取到的事件信息保存进events数组中,上层就可以通过events数组中获取到的事件信息判断接下来的事件操作。如果想要从就绪队列中取出多个文件描述符信息,则用户程序需要传递给内核一个epoll_event类型的数组。
- maxevents:期望获取就绪队列中的事件的个数
- timeout:在内核中阻塞的时间为timeout秒,直到就绪队列不为空
返回值:
- 成功:返回获取到的事件的数量
- 0:表明在timeout时间内,就绪队列一直为空(也就是超时返回)
- -1:表示epoll_wait发生错误,并且设置了errno的错误码
epoll_wait本质是内核将就绪队列中已经就绪的节点的event信息复制到用户空间。用户程序可以通过event的信息判断是哪一个文件描述符的事件就绪,events中判断是读事件就绪还是写事件就绪。
epoll的工作方式
水平触发模式(level-triggered,LT)
epoll对文件描述符的操作模式(LT模式):
- LT模式是缺省的工作方式,并且同时支持block和non-block的套接字。在LT模式下,内核会告诉应用程序一个文件描述符是否就绪了,然后用户就可以对这个就绪的fd进行IO操作。
- 如果应用程序不处理该事件,那么在下次调用epoll_wait时,内核会再次响应应用程序并通知应用程序此事件就绪;也就是说,水平触发模式的socket可以不用一次性读取socket缓冲区中的数据。因为只要socket缓冲区中有数据,则会一直触发回调函数,然后将socket的事件加入到就绪队列中,上层调用epoll_wait则就可以一直获取到该就绪的socket文件描述符。
边缘触发模式(edge-triggered,ET)
epoll对文件描述符的操作模式(ET模式):
- 使用边缘触发,当底层的socket文件描述符中的缓冲区出现变化的时候(缓冲区中数据从无到有,从有到多),才会触发回调函数将socket的事件加入到就绪队列中。
- ET模式是高速工作模式,但是需要注意的是该模式只支持non-block的套接字,这样做的目的是为了避免程序阻塞在最后一次write或者read操作,recv阻塞在内核中,就相当于破坏了epoll的作用。
-
- ET模式下每次write或者read需要循环调用write或者read直到返回EAGAIN错误。
- ET模式下只在socket描述符状态发生改变的时候才会触发事件,如果不一次把socket内核缓冲区中的数据读完,那么会导致socket内核缓冲区中即使还有一部分数据,该socket的可读事件也不会被触发
- 在ET模式下,当文件描述符从未就绪状态变为就绪状态时,内核就会通过epoll来通知到应用程序。这个时候如果一直不对这个fd做IO操作(从而导致这个fd一直处于未就绪状态),内核就不会发送更多的通知(only once)。
- 如果socket缓冲区中没有发生变化,则socket一直不会被触发,即使相对应的socket缓冲区中有数据。
- 在ET模式下,如果用户通过epoll_wait检测到事件发生时,执行了fd的读操作,但是数据没有读完(也就是说留了一部分的数据在接收缓冲区中)。那么这个fd对应的读缓冲区中的剩余数据只能在内核下次检测到事件的时候将其读出。
- 如果是ET模式触发的socket,那么每次都需要通过循环调用recv将事件中的socket缓冲区中的数据读取干净。如果没有将数据读取干净,那么下次socket的缓冲区没有数据就绪,就一直不会触发socket的可读事件,那么内核就不会将socket的可读事件加入到就绪链表中,也就是说socket缓冲区中剩余的数据就一直不会被应用程序读取上来。
epoll相较于select/poll多路复用的优势
epoll的功能跟select和poll一样,都是用来检测文件描述符中的数据是否就绪,当有事件就绪,就可以通知给应用层。上层调用read/recv或者write/send等类似接口就不会被阻塞。
select/poll的IO多路复用系统调用接口有如下缺陷:
1、他们需要额外创建数组报文文件描述符,每一次检测的时候,都需要将数组中的文件描述符重新设置进文件描述符集中。
2、调用select/poll检测文件描述符集是否有文件描述符事件就绪的时间复杂度是O(N),因为内核需要依次检测文件描述符集中每个文件描述符的事件是否就绪。
3、select中的文件描述符集能否设置的文件描述符是有限的。
epoll通过两方面就很好解决了select和poll的缺陷:
- epoll在内核中使用红黑树来跟踪进程所有待检测的文件描述符,把需要监控的socket通过epoll_ctl函数加入到内核的红黑树里。
-
- 红黑树是个高效的数据结构,它的增删改查的时间复杂度是O(logN)
- 当需要加入某个文件描述符进行跟踪检测,则需要使用epoll_ctl接口将文件描述符添加到红黑树中
- 添加到红黑树中的文件描述符则会不断的进行检测,如果想要取消epoll跟踪的检测某个文件描述符,则也可以使用epoll_ctl接口将红黑树中相对应的节点给删除掉
- epoll使用事件的驱动机制,内核中会维护着一个就绪队列。
-
- 当某个文件描述符有事件发生时,内核则会通过回调函数将其事件加入到这个就绪队列中。
- 当用户调用epoll_wait接口时,内核通过判断就绪队列是否为空来获取到哪些文件描述符的事件准备就绪;如果不为空,则说明有文件描述符的事件就绪,那么内核就会返回就绪队列中文件描述的个数给到应用程序。
- epoll检测是否有文件描述符就绪的时间复杂度是O(1)
TCP的带外数据
有些传输层协议具有带外数据(Out of Band, OOB)的概念,用于迅速通告对方本端发生的重要事件。
- 带外数据比普通数据(或者说是带内数据)有更高的优先级,它应该总是立即发送,而不管发送缓冲区是否有排队等待发送的普通数据。
- 带外数据的传输可以使用一条独立的传输层连接,也可以映射到传输普通数据的连接中,TCP采用的是后者
- 内核通知应用程序带外数据到达的两种方式
-
- SIGURG信号
- IO复用产生的异常事件
- 检测带外数据在数据流的具体位置(使用系统调用sockatmark())
- UDP没有带外数据
带外数据的发送方式
带外数据的应用
- 心跳包机制使用带外数据,可以及早发现slave机的死亡
- 带外数据的一个常见的用途体现在rlogin程序中
例子
客户端代码
const char* oob_data = "abc";//带外数据
const char* normal_data = "123";
send(sockfd, normal_data, strlen(normal_data), 0);
send(sockfd, oob_data, strlen(oob_data), MSG_OOB);//使用 MSG_OOB标记
send(sockfd, normal_data, strlen(normal_data), 0);
服务器端代码(这里使用sockatmark检测带外数据在数据流的具体位置)
/**
* @brief sockatmark 系统调用检测带外数据
*
* @details
*/
char buffer[BUF_SIZE];
memset(buffer, '\0', BUF_SIZE);
ret = recv(connfd, buffer, BUF_SIZE - 1, 0);
printf("got %d bytes of normal data '%s'\n", ret, buffer);
// 判断下一个被读到的数据是否是带外数据
int oob_flag = 0;
oob_flag = sockatmark(connfd);
if (oob_flag) {
memset(buffer, '\0', BUF_SIZE);
ret = recv(connfd, buffer, BUF_SIZE - 1, MSG_OOB); // 使用MSG_OOB 标志的recv函数接收带外数据
// ret = recv(connfd, buffer, 1, MSG_OOB);//实际只有1个字节的缓存区
printf("got %d bytes of oob data '%s'\n", ret, buffer);
}
memset(buffer, '\0', BUF_SIZE);
ret = recv(connfd, buffer, BUF_SIZE - 1, 0);
printf("got %d bytes of normal data '%s'\n", ret, buffer);
使用select来检测带外数据
- 普通数据我们放在fd_set集合的可读集合中
- 带外数据放在fd_set集合的异常集合中
/**
* @brief select检测带外数据
*
* @details select上接收到普通数据或者带外数据都会使select返回,
* 但是返回以后的fds处于不同的就绪状态,普通数据触发可读状态,带外数据触发异常状态。
*/
char buf[BUF_SIZE];
fd_set read_fds;
fd_set except_fds;
while (1) {
FD_ZERO(&read_fds);
FD_ZERO(&except_fds);
FD_SET(connfd, &read_fds);
FD_SET(connfd, &except_fds);
memset(buf, 0, sizeof(buf));
ret = select(connfd + 1, &read_fds, NULL, &except_fds, NULL);
if (ret < 0) {
printf("selec failure\n");
break;
}
if (FD_ISSET(connfd, &read_fds)) {
ret = recv(connfd, buf, sizeof(buf - 1), 0);
if (ret <= 0) {
break;
}
printf("get %d bytes of normal data %s\n", ret, buf);
} else if (FD_ISSET(connfd, &except_fds)) {
ret = recv(connfd, buf, sizeof(buf - 1), MSG_OOB);//
if (ret <= 0) {
break;
}
printf("get %d bytes of obb data %s\n", ret, buf);
}
}
#include <stdio.h>
#include <stdlib.h>
#include <strings.h>
#include <unistd.h>
#include <errno.h>
#include <sys/select.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#define LISTEN_NUM 10
int main(int argc,char *argv[])
{
char *ip=argv[1];
int port=atoi(argv[2]);
int recvLen;
int connFd,acceptFd;
int selectMaxFd,selectRetValue;
socklen_t cliAddrLen;
char cliAddrBuf[24];
char commBuff[1024];
struct timeval waitTimeValue;
struct sockaddr_in serAddr;
struct sockaddr_in cliAddr;
if((connFd=socket(AF_INET,SOCK_STREAM,0))==-1){
perror("socket");
exit(EXIT_FAILURE);
}
bzero(&serAddr,sizeof(serAddr));
serAddr.sin_family=AF_INET;
serAddr.sin_port=htons(port);
if(inet_pton(AF_INET,ip,&serAddr.sin_addr.s_addr)==-1){
perror("inet_pton");
exit(EXIT_FAILURE);
}
if(bind(connFd,(struct sockaddr*)&serAddr,sizeof(serAddr))==-1){
perror("inet_pton");
exit(EXIT_FAILURE);
}
if(listen(connFd,LISTEN_NUM)==-1){
perror("listen");
exit(EXIT_FAILURE);
}
fd_set readSet;
fd_set errorSet;
bzero(&cliAddr,sizeof(cliAddr));
cliAddrLen=sizeof(cliAddr);
bzero(&waitTimeValue,sizeof(waitTimeValue));
waitTimeValue.tv_sec=0;
waitTimeValue.tv_usec=0;
if((acceptFd=accept(connFd,(struct sockaddr*)&cliAddr,&cliAddrLen))==-1){
if(errno==EINTR){
printf("accept:catch signal...\n");
exit(EXIT_SUCCESS);
}
}else{
bzero(cliAddrBuf,sizeof(cliAddrBuf));
if(inet_ntop(AF_INET,&cliAddr.sin_addr.s_addr,cliAddrBuf,sizeof(cliAddrBuf))==NULL){
printf("Get Connect: an unrecognized client address\n");
}else{
printf("Get Connect: %s:%d\n",cliAddrBuf,ntohl(cliAddr.sin_port));
}
}
while(1)
{
bzero(commBuff,sizeof(commBuff));
FD_ZERO(&readSet);
FD_ZERO(&errorSet);
FD_SET(acceptFd,&readSet);
FD_SET(acceptFd,&errorSet);
selectMaxFd=acceptFd+1;
//printf("selecting...\n");
switch(selectRetValue=select(selectMaxFd,&readSet,NULL,&errorSet,&waitTimeValue))
{
case -1:
if(errno==EINTR)
printf("select:catch signal...\n");
else
perror("select");
continue;
case 0:
printf("select:time out...\n");
continue;
default:
if(FD_ISSET(acceptFd,&readSet)){
if((recvLen=recv(acceptFd,commBuff,sizeof(commBuff),0))==-1){
perror("recv");
continue;
}else if(recvLen==0){
printf("client close..");
close(connFd);
exit(EXIT_SUCCESS);
}else{
printf("Get normal data of client::%s\n",commBuff);
break;
}
}
if(FD_ISSET(acceptFd,&errorSet)){
if((recvLen=recv(acceptFd,commBuff,sizeof(commBuff),MSG_OOB))==-1){
perror("recv");
continue;
}else if(recvLen==0){
printf("client close..");
close(connFd);
exit(EXIT_SUCCESS);
}else{
printf("Get oob data of client:%s\n",commBuff);
break;
}
}
break;
}
}
exit(EXIT_SUCCESS);
}
#include<sys/socket.h>
#include<sys/types.h>
#include<unistd.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<assert.h>
#include<stdio.h>
#include<errno.h>
#include<string.h>
#include<fcntl.h>
#include<stdlib.h>
int main(int argc, char* argv[])
{
if (argc <= 2)
{
return 1;
}
const char* ip = argv[1];
int port = atoi(argv[2]);
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
address.sin_port = htons(port);
inet_pton(AF_INET, ip, &address.sin_addr);
int listenfd = socket(PF_INET, SOCK_STREAM, 0);
assert(listenfd >= 0);
int ret = bind(listenfd, (struct sockaddr*)&address, sizeof(address));
assert(ret != -1);
ret = listen(listenfd, 5);
assert(ret != -1);
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int connfd = accept(listenfd, (struct sockaddr*)&client_address, &client_addrlength);
if (connfd < 0)
{
printf("errno is: %d\n", errno);
close(listenfd);
}
char buf[1024];
fd_set read_fds; /* 一个用于测试 可读状态的 文件描述符集合 */
fd_set exception_fds; /* 一个用于测试 异常状态的 文件描述符集合 */
FD_ZERO(&read_fds);
FD_ZERO(&exception_fds);
while (1)
{
memset(buf, '\0', sizeof(buf));
/* 每次调用 select 前都要重新在 read_fds 和 exception_fds 中设置文件描述符 connfd,因为事件发生之后文件描述符集合将被内核修改 */
FD_SET(connfd, &read_fds);
FD_SET(connfd, &exception_fds);
ret = select(connfd + 1, &read_fds, NULL, &exception_fds, NULL);
if (ret < 0)
{
printf("selection failure\n");
break;
}
/* 对于可读事件,采用普通的 recv 函数读取数据 */
if (FD_ISSET(connfd, &read_fds))
{
ret = recv(connfd, buf, sizeof(buf) - 1, 0);
if (ret <= 0)
{
break;
}
printf("get %d bytes of normal data: %s\n", ret, buf);
}
/* 对于异常事件,采用带 MSG_OOB 标志的 recv 函数读取带外数据 */
else if (FD_ISSET(connfd, &exception_fds))
{
ret = recv(connfd, buf, sizeof(buf) - 1, MSG_OOB);
if (ret <= 0)
{
break;
}
printf("get %d bytes of oob data: %s\n", ret, buf);
}
}
close(connfd);
close(listenfd);
return 0;
}