I/O复用使得程序能同时监听多个文件描述符。
Select系统调用
(阻塞监听文件描述符,每次到会将所有的文件描述符返回,返回后必须循环探测具体哪些是就绪的文件描述符,效率O(N)。用户程序必须自己保存所有文件描述符与内核修改后相比较):
select函数的API
#include <sys/select.h>
/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
nfds: 监控的文件描述符集里最大文件描述符加1,因为此参数会告诉内核检测前多少个文件描述符的状态
readfds: 监控有读数据到达文件描述符集合,传入传出参数
writefds: 监控写数据到达文件描述符集合,传入传出参数
exceptfds: 监控异常发生达文件描述符集合,如带外数据到达异常,传入传出参数
timeout: 定时阻塞监控时间,3种情况
1.NULL,永远等下去
2.设置timeval,等待固定时间
3.设置timeval里时间均为0,检查描述字后立即返回,轮询
struct timeval {
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};
void FD_CLR(int fd, fd_set *set); //把文件描述符集合里fd位清0
int FD_ISSET(int fd, fd_set *set); //测试文件描述符集合里fd是否置1
void FD_SET(int fd, fd_set *set); //把文件描述符集合里fd位置1
void FD_ZERO(fd_set *set); //把文件描述符集合里所有位清0
流程:
- TCP服务器设置 socket bind listen
- 将sockfd添加到fds中
- 启动while循环
- 将fds中的文件描述符设置到readfds上
- 启动select
- 循环探测文件描述符就绪
- Sockfd 客户端完成了三次握手 accept insert_fd
- 连接fd 客户端有数据到达
- recv<=0:close(fd),Delete_fd
- / >0 处理数据
select监听0到最大套接字区间,共1024位(一位表示一个套接字,最大为1023)。当该套接字有事件发生,则修改该位。用户通过数组找到感兴趣的套接字,再根据FD_ISSET(fd,fd_set*)是否修改,来判断事件是否发生。
select的优缺点:
优点: 跨平台
缺点:
select能监听的文件描述符个数受限于FD_SETSIZE,一般为1024,单纯改变进程打开的文件描述符个数并不能改变select监听文件个数; 解决1024以下客户端时使用select是很合适的,但如果链接客户端过多,select采用的是轮询模型,会大大降低服务器响应效率,不应在select上投入更多精力。
select只是返回变化的文件描述符的个数, 具体哪个那个变化需要遍历;每次都需要将需要监听的文件描述集合由应用层符拷贝到内核;大量并发,少了活跃,select效率
程序用例:
#include <stdio.h>
#include <sys/select.h>
#include <sys/types.h>
#include <unistd.h>
#include "wrap.h"
#include <sys/time.h>
#define PORT 8888
int main(int argc, char *argv[])
{
//创建套接字,绑定
int lfd = tcp4bind(PORT,NULL);
//监听
Listen(lfd,128);
int maxfd = lfd;//最大的文件描述符
fd_set oldset,rset;
FD_ZERO(&oldset);
FD_ZERO(&rset);
//将lfd添加到oldset集合中
FD_SET(lfd,&oldset);
while(1)
{
rset = oldset;//将oldset赋值给需要监听的集合rset
int n = select(maxfd+1,&rset,NULL,NULL,NULL);
if(n < 0)
{
perror("");
break;
}
else if(n == 0)
{
continue;//如果没有变化,重新监听
}
else//监听到了文件描述符的变化
{
//lfd变化 代表有新的连接到来
if( FD_ISSET(lfd,&rset))
{
struct sockaddr_in cliaddr;
socklen_t len =sizeof(cliaddr);
char ip[16]="";
//提取新的连接
int cfd = Accept(lfd,(struct sockaddr*)&cliaddr,&len);
printf("new client ip=%s port=%d\n",inet_ntop(AF_INET,&cliaddr.sin_addr.s_addr,ip,16),
ntohs(cliaddr.sin_port));
//将cfd添加至oldset集合中,以下次监听
FD_SET(cfd,&oldset);
//更新maxfd
if(cfd > maxfd)
maxfd = cfd;
//如果只有lfd变化,continue
if(--n == 0)
continue;
}
//cfd 遍历lfd之后的文件描述符是否在rset集合中,如果在则cfd变化
for(int i = lfd+1;i<=maxfd;i++)
{
//如果i文件描述符在rset集合中
if(FD_ISSET(i,&rset))
{
char buf[1500]="";
int ret = Read(i,buf,sizeof(buf));
if(ret < 0)//出错,将cfd关闭,从oldset中删除cfd
{
perror("");
close(i);
FD_CLR(i,&oldset);
continue;
}
else if(ret == 0)
{
printf("client close\n");
close(i);
FD_CLR(i,&oldset);
}
else
{
printf("%s\n",buf);
Write(i,buf,ret);
}
}
}
}
}
return 0;
}
Poll系统调用:
poll 系统调用和 seect 类似,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者。
poll 的API:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 监控的事件 */
short revents; /* 监控事件中满足条件返回的事件 */
};
POLLIN 普通或带外优先数据可读,即POLLRDNORM | POLLRDBAND
POLLRDNORM 数据可读
POLLRDBAND 优先级带数据可读
POLLPRI 高优先级可读数据
POLLOUT 普通或带外数据可写
POLLWRNORM 数据可写
POLLWRBAND 优先级带数据可写
POLLERR 发生错误
POLLHUP 发生挂起
POLLNVAL 描述字不是一个打开的文件
nfds 监控数组中有多少文件描述符需要被监控
timeout 毫秒级等待
-1:阻塞等,#define INFTIM -1 Linux中没有定义此宏
0:立即返回,不阻塞进程
>0:等待指定毫秒数,如当前系统时间精度不够毫秒,向上取值
如果不再监控某个文件描述符时,可以把pollfd中,fd设置为-1,poll不再监控此pollfd,下次返回时,把revents设置为0。
相较于select而言,poll的优势:
1、传入、传出事件分离。无需每次调用时,重新设定监听事件。
2、文件描述符上限,可突破1024限制。能监控的最大上限数可使用配置文件调整。
poll相对与sellect的优缺点
优点:
没有文件描述符1024的限制;请求和返回是分离的
缺点和select一样:
1、每次都需要将需要监听的文件描述符从应用层拷贝到内核
2、每次都需要将数组中的元素遍历一遍才知道那个变化了
3、大量并发,少量活跃效率低
-
程序用例:
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <assert.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <poll.h>
#define FDMAXNUM 100
void InitFds(struct pollfd *fds)
{
int i = 0;
for(; i < FDMAXNUM; ++i)
{
fds[i].fd = -1;
}
}
void DeleteFd(int fd, struct pollfd *fds)
{
int i = 0;
for(; i < FDMAXNUM; ++i)
{
if(fds[i].fd == fd)
{
fds[i].fd = -1;
break;
}
}
}
void AddFd(int fd, struct pollfd *fds)
{
int i = 0;
for(; i < FDMAXNUM; ++i)
{
if(fds[i].fd == -1)
{
fds[i].fd = fd;
fds[i].events = POLLIN | POLLRDHUP;
break;
}
}
}
void GetClientLink(int fd, struct pollfd *fds)
{
struct sockaddr_in cli;
int len = sizeof(cli);
int c = accept(fd, (struct sockaddr*)&cli, &len);
if(c == -1)
{
return;
}
AddFd(c, fds);
}
void DealClientData(int fd, struct pollfd *fds, int rdhup)
{
if(rdhup)
{
close(fd);
DeleteFd(fd, fds);
return;
}
char buff[128] = {0};
int n = recv(fd, buff, 127, 0);
if(n <= 0)
{
DeleteFd(fd, fds);
return;
}
printf("%s\n", buff);
send(fd, "OK", 2, 0);
}
int main()
{
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
assert(listenfd != -1);
struct sockaddr_in ser;
memset(&ser, 0, sizeof(ser));
ser.sin_family = AF_INET;
ser.sin_port = htons(6000);
ser.sin_addr.s_addr = inet_addr("127.0.0.1");
int res = bind(listenfd, (struct sockaddr*)&ser, sizeof(ser));
assert(res != -1);
listen(listenfd, 5);
struct pollfd fds[FDMAXNUM];
InitFds(fds);
fds[0].fd = listenfd;
fds[0].events = POLLIN;
while(1)
{
int n = poll(fds, FDMAXNUM, -1);
if(n <= 0)
{
exit(0);
}
int i = 0;
for(; i < FDMAXNUM; ++i)
{
if(fds[i].fd == -1)
{
continue;
}
if(fds[i].fd == listenfd && fds[i].revents & POLLIN)
{
GetClientLink(fds[i].fd, fds);
}
else if(fds[i].revents & POLLIN)
{
if(fds[i].revents & POLLRDHUP)
DealClientData(fds[i].fd, fds, 1);
else
DealClientData(fds[i].fd, fds, 0);
}
}
}
}
Epoll系列系统调用:
epoll是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率,因为它会复用文件描述符集合来传递结果而不用迫使开发者每次等待事件之前都必须重新准备要被侦听的文件描述符集合,另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。
目前epoll是linux大规模并发网络程序中的热门首选模型。
epoll_wait是系统调用,频繁触发耗费系统资源,效率低。
epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)(缓冲区有数据触发)外,还提供了边沿触发(Edge Triggered)(接受到数据触发),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。
epoll基础API:
1、创建一个epoll句柄,参数size用来告诉内核监听的文件描述符的个数,跟内存大小有关。
#include <sys/epoll.h>
int epoll_create(int size)
size:监听数目(内核参考值)
返回值:成功:非负文件描述符;失败:-1,设置相应的errno
将用户关注的文件描述符上的事件直接由内核记录,创建内核事件表(红黑树),返回值用作其他所有epoll系统调用的第一个参数,Size只是给内核一个提示,事件表需要多大由内核决定。
2、控制某个epoll监控的文件描述符上的事件:注册、修改、删除。
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
epfd: 为epoll_creat的句柄
op: 表示动作,用3个宏来表示:
EPOLL_CTL_ADD (注册新的fd到epfd),
EPOLL_CTL_MOD (修改已经注册的fd的监听事件),
EPOLL_CTL_DEL (从epfd删除一个fd);
fd: 监听的文件描述符(同event结构体中fd, 这里是供红黑树查找,是否已经存在)
event: 告诉内核需要监听的事件,内核会拷贝
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
EPOLLIN : 表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
EPOLLOUT: 表示对应的文件描述符可以写
EPOLLPRI: 表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
EPOLLERR: 表示对应的文件描述符发生错误
EPOLLHUP: 表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)而言的
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
返回值:成功:0;失败:-1,设置相应的errno
举例:将cfd上树
int epfd = epoll_create(1);
struct epoll_event ev;
ev. data.fd = cfd;
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD,cfd, &ev);
3、等待所监控文件描述符上有事件的产生,类似于select()调用。
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
events: 用来存内核得到事件的集合,可简单看作数组。
maxevents: 告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,
timeout: 是超时时间
-1: 阻塞
0: 立即返回,非阻塞
>0: 指定毫秒
返回值: 成功返回有多少文件描述符就绪,时间到时返回0,出错返回-1
epoll优势:
- 文件描述符的范围大
- 文件描述符的个数多
- 事件类型更多
- 用户关注的事件由内核维护,每次调用epoll_wait时,不需要将用户空间的数据拷贝到内核空间
- 每次epoll只会返回就绪的文件描述符,
- 用户程序检测就绪文件描述符的效率O(1)
- Epoll内核实现比select和poll高效
- Select poll 轮询方式
- Epoll 回调方式
- Epoll支持高效的ET模式
epoll进阶
事件模型 EPOLL事件有两种模型:
Edge Triggered (ET) 边缘触发只有数据到来才触发,不管缓存区中是否还有数据。
Level Triggered (LT) 水平触发只要有数据都会触发。
思考如下步骤:
- 假定我们已经把一个用来从管道中读取数据的文件描述符(rfd)添加到epoll描述符。
- 管道的另一端写入了2KB的数据
- 调用epoll_wait,并且它会返回rfd,说明它已经准备好读取操作
- 读取1KB的数据
- 调用epoll_wait……
在这个过程中,有两种工作模式:
ET模式(ET模式即Edge Triggered工作模式)
边沿触发 + 非阻塞 = 高速模式
如果我们在第1步将rfd添加到epoll描述符的时候使用了EPOLLET标志,那么在第5步调用epoll_wait之后将有可能会挂起,因为剩余的数据还存在于文件的输入缓冲区内,而且数据发出端还在等待一个针对已经发出数据的反馈信息。只有在监视的文件句柄上发生了某个事件的时候 ET 工作模式才会汇报事件。因此在第5步的时候,调用者可能会放弃等待仍在存在于文件输入缓冲区内的剩余数据。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。最好以下面的方式调用ET模式的epoll接口,在后面会介绍避免可能的缺陷。
1、基于非阻塞文件句柄
//设置cfd为非阻塞
int flags = fcntl(cfd,F_GETFL);//获取的cfd的标志位
flags |= O_NONBLOCK;
fcntl(cfd,F_SETFL,flags);
2、只有当read或者write返回EAGAIN(非阻塞读,暂时无数据)时才需要挂起、等待。但这并不是说每次read时都需要循环读,直到读到产生一个EAGAIN才认为此次事件处理完成,当read返回的读到的数据长度小于请求的数据长度时,就可以确定此时缓冲中已没有数据了,也就可以认为此事读事件已处理完成。
//如果读一个缓冲区,缓冲区没有数据,如果是带阻塞,就阻塞等待,如果
//是非阻塞,返回值等于-1,并且会将errno 值设置为EAGAIN
while(1)
{
char buf[4]="";
//如果读一个缓冲区,缓冲区没有数据,如果是带阻塞,就阻塞等待,如果
//是非阻塞,返回值等于-1,并且会将errno 值设置为EAGAIN
int n = read(evs[i].data.fd,buf,sizeof(buf));
if(n < 0)//出错,cfd下树
{
//如果缓冲区读干净了,这个时候应该跳出while(1)循环,继续监听
if(errno == EAGAIN)
{
break;
}
//普通错误
perror("");
close(evs[i].data.fd);//将cfd关闭
epoll_ctl(epfd,EPOLL_CTL_DEL,evs[i].data.fd,&evs[i]);
break;
}
程序用例:
//epoll边沿触发 + 非阻塞
#include <stdio.h>
#include <fcntl.h>
#include "wrap.h"
#include <sys/epoll.h>
int main(int argc, char *argv[])
{
//创建套接字 绑定
int lfd = tcp4bind(8000,NULL);
//监听
Listen(lfd,128);
//创建树
int epfd = epoll_create(1);
//将lfd上树
struct epoll_event ev,evs[1024];
ev.data.fd = lfd;
ev.events = EPOLLIN;
epoll_ctl(epfd,EPOLL_CTL_ADD,lfd,&ev);
//while监听
while(1)
{
int nready = epoll_wait(epfd,evs,1024,-1);//监听
printf("epoll wait _________________\n");
if(nready <0)
{
perror("");
break;
}
else if( nready == 0)
{
continue;
}
else//有文件描述符变化
{
for(int i=0;i<nready;i++)
{
//判断lfd变化,并且是读事件变化
if(evs[i].data.fd == lfd && evs[i].events & EPOLLIN)
{
struct sockaddr_in cliaddr;
char ip[16]="";
socklen_t len = sizeof(cliaddr);
int cfd = Accept(lfd,(struct sockaddr *)&cliaddr,&len);//提取新的连接
printf("new client ip=%s port =%d\n",inet_ntop(AF_INET,&cliaddr.sin_addr.s_addr,ip,16)
,ntohs(cliaddr.sin_port));
//设置cfd为非阻塞
int flags = fcntl(cfd,F_GETFL);//获取的cfd的标志位
flags |= O_NONBLOCK;
fcntl(cfd,F_SETFL,flags);
//将cfd上树
ev.data.fd =cfd;
ev.events =EPOLLIN | EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_ADD,cfd,&ev);
}
else if( evs[i].events & EPOLLIN)//cfd变化 ,而且是读事件变化
{
while(1)
{
char buf[4]="";
//如果读一个缓冲区,缓冲区没有数据,如果是带阻塞,就阻塞等待,如果
//是非阻塞,返回值等于-1,并且会将errno 值设置为EAGAIN
int n = read(evs[i].data.fd,buf,sizeof(buf));
if(n < 0)//出错,cfd下树
{
//如果缓冲区读干净了,这个时候应该跳出while(1)循环,继续监听
if(errno == EAGAIN)
{
break;
}
//普通错误
perror("");
close(evs[i].data.fd);//将cfd关闭
epoll_ctl(epfd,EPOLL_CTL_DEL,evs[i].data.fd,&evs[i]);
break;
}
else if(n == 0)//客户端关闭 ,
{
printf("client close\n");
close(evs[i].data.fd);//将cfd关闭
epoll_ctl(epfd,EPOLL_CTL_DEL,evs[i].data.fd,&evs[i]);//下树
break;
}
else
{
// printf("%s\n",buf);
write(STDOUT_FILENO,buf,4);
write(evs[i].data.fd,buf,n);
}
}
}
}
}
}
return 0;
}
LT模式
LT模式即Level Triggered工作模式。
与ET模式不同的是,以LT方式调用epoll接口的时候,它就相当于一个速度比较快的poll,无论后面的数据是否被使用。
比较
LT(level triggered):LT是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表。
ET(edge-triggered):ET是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知。请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once).