1.经典“入门级”问题:IO 多路复用是什么意思?
在单个线程通过记录跟踪每一个Sock(I/O流)的状态来同时管理多个I/O流. 发明它的原因,是尽量多的提高服务器的吞吐能力。
其中,“复用”指的是复用同一个线程/进程。
其实就是操作系统为你提供了一个功能,当你的某个socket可读或者可写的时候,它可以给你一个通知,让你去处理读事件或者写事件。
而这个功能能够通过select/poll/epoll
等来使用。这些函数都可以同时监视多个描述符的读写就绪状况,这样,多个描述符的 I/O 操作都能在一个线程内并发交替地顺序完成 。
Select
使用
int select(int maxfd,
fd_set *readset,
fd_set *writeset,
fd_set *exceptset,
const struct timeval *timeout);
返回:若有就绪描述符则为其数目,若超时则为0,若出错则为-1
- maxfd 表示的是待测试的描述符基数,它的值是待测试的最大描述符加 1。比如现在的 select 待测试的描述符集合是{0,1,4},那么 maxfd 就是 5,为啥是 5,而不是 4 呢?
- 紧接着的是三个描述符集合,分别是读描述符集合 readset、写描述符集合 writeset 和异常描述符集合 exceptset,
这三个分别通知内核,在哪些描述符上检测数据可以读,可以写和有异常发生。
那么如何设置它们呐?Linux 提供了以下这些宏来进行设置:
void FD_ZERO(fd_set *fdset);//全部设置为0
void FD_SET(int fd, fd_set *fdset);//设置为1
void FD_CLR(int fd, fd_set *fdset);//设置为0
int FD_ISSET(int fd, fd_set *fdset);//判断0/1
其中 0 代表不需要处理,1 代表需要处理。
select 有一个文件描述符集合(fd_set
),里面是一个整数数组,每个整数的每一位对应一个文件描述符,类似于位图
!!!!
程序示例:
*使用select函数可以以非阻塞的方式和多个socket通信。程序只是演示select函数的使用,连接数达到最大值后会终止程序。
1. 程序使用了一个数组fd,通信开始后把需要通信的多个socket描述符都放入此数组
2. 首先生成一个叫sock_fd的socket描述符,用于监听端口。
3. 将sock_fd和数组fd中不为0的描述符放入select将检查的集合fdsr。
4. 处理fdsr中可以接收数据的连接。如果是sock_fd,表明有新连接加入,将新加入连接的socket描述符放置到fd。 */
// select_server.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define MYPORT 8100 //连接时使用的端口
#define MAXCLINE 5 //连接队列中的个数
#define BUF_SIZE 200
int fd[MAXCLINE]; //连接的fd
int conn_amount; //当前的连接数
void showclient()
{
int i;
printf("client amount:%d\n",conn_amount);
for(i=0;i<MAXCLINE;i++)
{
printf("[%d]:%d ",i,fd[i]);
}
printf("\n\n");
}
int main(void)
{
int sock_fd,new_fd; //监听套接字 连接套接字
struct sockaddr_in server_addr; // 服务器的地址信息
struct sockaddr_in client_addr; //客户端的地址信息
socklen_t sin_size;
int yes = 1;
char buf[BUF_SIZE];
int ret;
int i;
//建立sock_fd套接字
if((sock_fd = socket(AF_INET,SOCK_STREAM,0))==-1)
{
perror("setsockopt");
exit(1);
}
printf("sockect_fd = %d\n", sock_fd);
//设置套接口的选项 SO_REUSEADDR 允许在同一个端口启动服务器的多个实例
// setsockopt的第二个参数SOL SOCKET 指定系统中,解释选项的级别 普通套接字
if(setsockopt(sock_fd,SOL_SOCKET,SO_REUSEADDR,&yes,sizeof(int))==-1)
{
perror("setsockopt error \n");
exit(1);
}
server_addr.sin_family = AF_INET; //主机字节序
server_addr.sin_port = htons(MYPORT);
server_addr.sin_addr.s_addr = INADDR_ANY;//通配IP
memset(server_addr.sin_zero,'\0',sizeof(server_addr.sin_zero));
if(bind(sock_fd,(struct sockaddr *)&server_addr,sizeof(server_addr)) == -1)
{
perror("bind error!\n");
exit(1);
}
if(listen(sock_fd,MAXCLINE)==-1)
{
perror("listen error!\n");
exit(1);
}
printf("listen port %d\n",MYPORT);
fd_set fdsr; //文件描述符集的定义
int maxsock;
struct timeval tv;
conn_amount =0;
sin_size = sizeof(client_addr);
maxsock = sock_fd;
while(1)
{
//这两部是非常重要的,不可缺少的,缺少了有可能导致状态不会更新
//缺点1:
FD_ZERO(&fdsr);
FD_SET(sock_fd,&fdsr);
//超时的设定,这里也可以不需要设置时间,将这个参数设置为NULL,表明此时select为阻塞模式
tv.tv_sec = 30;
tv.tv_usec =0;
//将所有的连接全部加到这个这个集合中,可以监测客户端是否有数据到来
for(i = 0; i < MAXCLINE; i++)
{
if(fd[i]!=0)
{
FD_SET(fd[i],&fdsr);
}
}
//如果文件描述符中有连接请求 会做相应的处理,实现I/O的复用 多用户的连接通讯
ret = select(maxsock +1,&fdsr,NULL,NULL,&tv);
if(ret <0) //没有找到有效的连接 失败
{
perror("select error!\n");
break;
}
else if(ret ==0)// 指定的时间到,
{
printf("timeout \n");
continue;
}
//下面这个循环是非常必要的,因为你并不知道是哪个连接发过来的数据,所以只有一个一个去找。
//缺点2:
for(i=0;i<conn_amount;i++)
{
if(FD_ISSET(fd[i],&fdsr))
{
ret = recv(fd[i],buf,sizeof(buf),0);
//如果客户端主动断开连接,会进行四次挥手,会出发一个信号,此时相应的套接字会有数据返回,告诉select,我的客户断开了,你返回-1
if(ret <=0) //客户端连接关闭,清除文件描述符集中的相应的位
{
printf("client[%d] close\n",i);
close(fd[i]);
FD_CLR(fd[i],&fdsr);
fd[i]=0;
conn_amount--;
}
//否则有相应的数据发送过来 ,进行相应的处理
else
{
if(ret <BUF_SIZE)
memset(&buf[ret],'\0',1);
printf("client[%d] send:%s\n",i,buf);
}
}
}
if(FD_ISSET(sock_fd,&fdsr))
{
new_fd = accept(sock_fd,(struct sockaddr *)&client_addr,&sin_size);
if(new_fd <=0)
{
perror("accept error\n");
continue;
}
//添加新的fd 到数组中 判断有效的连接数是否小于最大的连接数,如果小于的话,就把新的连接套接字加入集合
if(conn_amount < MAXCLINE)
{
for(i = 0; i < MAXCLINE; i++)
{
if(fd[i]==0)
{
fd[i] = new_fd;
break;
}
}
conn_amount++;
printf("new connection client[%d]%s:%d\n",conn_amount,inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port));
if(new_fd > maxsock)
{
//缺点3:
maxsock = new_fd;
}
}
else
{
printf("max connections arrive ,exit\n");
send(new_fd,"bye",4,0);
close(new_fd);
continue;
}
}
showclient();
}
for(i=0;i<MAXCLINE;i++)
{
if(fd[i]!=0)
{
close(fd[i]);
}
}
exit(0);
}
select 内部实现:
ret = select(maxsock +1,&fdsr,NULL,NULL,&tv);
- select 模式使用数组位图的方式来保存自己所监控的fd信息了。
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大(比如:内核会修改传入的参数数组,这个对于一个需要调用很多次的函数,是非常不友好的)
- 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大(比如:如果任何一个sock(I/O stream)出现了数据,select 仅仅会返回,但是并不会告诉你是那个sock上有数据,于是你只能自己一个一个的找)
select 缺点:
- 1.
select会修改传递的fd_sets
(UNP中把他叫做:值-结果参数),这样就不能重用它们,所以必须在每次调用select前重新设置文件描述符. - 2.
手动循环遍历查找
.要找出引发事件的描述符,您必须手动迭代集合中的所有描述符,并在每个描述符上调用FD_ISSET。如果你有2,000个这样的描述符,并且只有其中一个是活动的 - 而且可能是最后一个 - 你每次等待都会浪费CPU周期。 - 3.
支持的文件描述符数目有限.
以#define __FD_SETSIZE 1024
为限.虽然某些操作系统允许您通过在包含sys / select.h之前重新定义FD_SETSIZE来破解此限制,但这不是可移植的。实际上,Linux会忽略这种黑客攻击并且限制将保持不变。 - 4.
当描述符在select集合中被监听时其他的线程不能修改它
。假设你有一个管理线程检测到sock1等待输入数据的时间太长需要关闭它,以便重新利用sock1来服务其他工作线程。但是它还在select的监听集合中。如果此时这个套接字被关闭会发生什么?select的man手册中有解释:如果select正在监听的套接字被其他线程关闭,结果是未定义的。 - 5.
如果另外一个线程突然决定通过sock1发送数据,在等待select返回之前不能监听这个套接字的写事件
(还得等待select返回,然后重新FD_SET,添加到对应的集合中)。 - 6.当填充描述符集合时,select会给你带来额外的负担,因为你
需要计算描述符中的最大值并把它当作函数参数传递给select
。
什么时候还需要使用select:
当然操作系统开发人员也会意识到这些缺陷,并且在设计poll接口时解决了大部分问题,因此你会问,还有任何理由使用select吗?为什么不直接淘汰它了?其实还有两个理由使用它:
- 1.第一个原因是
可移植性
。select已经存在很长时间了,你可以确定每个支持网络和非阻塞套接字的平台都会支持select,而它可能还不支持poll。另一种选择是你仍然使用poll然后在那些没有poll的平台上使用select来模拟它。 - 2.第二个原因是
select的超时时间理论上可以精确到微秒级别。而poll和epoll的精度只有毫秒级
。这对于桌面或者服务器系统来说没有任何区别,因为它们不会运行在纳秒精度的时钟上,但是在某些与硬件交互的实时嵌入式平台,降低控制棒关闭核反应堆.可能是需要的。(这就可以作为一个更加精确的sleep()来用)
只有在上面提到的原因中你必须使用select没有其他选择。但是如果你编写的程序永远不会处理超过一定数量的连接(例如:200),此时select和poll之间选择不在于性能,而是取决于个人爱好或者其他原因。
Poll
使用
int poll(struct pollfd *fds, unsigned long nfds, int timeout);
返回值:若有就绪描述符则为其数目,若超时则为0,若出错则为-1
struct pollfd {
int fd; /* file descriptor */
short events; /* events to look for */
short revents; /* events returned */
};
struct pollfd
结构- events 可以表示多个不同的事件,具体的实现可以通过使用二进制掩码位操作来完成,例如,POLLIN 和 POLLOUT 可以表示读和写事件。
- revents 表示结果事件。
- 参数 nfds 描述的是数组 fds 的大小,就是向 poll 申请的事件检测的个数。
- timeout 表示时间,单位是毫秒。
示例
// 先宏定义长度
#define MAX_POLLFD_LEN 4096
int main() {
/*
* 在这里进行一些初始化的操作,
* 比如初始化数据和socket等。
*/
int nfds = 0;
pollfd fds[MAX_POLLFD_LEN];
memset(fds, 0, sizeof(fds));
fds[0].fd = listenfd;
fds[0].events = POLLRDNORM;
int max = 0; // 队列的实际长度,是一个随时更新的,也可以自定义其他的
int timeout = 0;
int current_size = max;
while (1) {
// 阻塞获取
// 每次需要把fd从用户态拷贝到内核态
nfds = poll(fds, max+1, timeout);
if (fds[0].revents & POLLRDNORM) {
// 这里处理accept事件
connfd = accept(listenfd);
//将新的描述符添加到读描述符集合中
}
// 每次需要遍历所有fd,判断有无读写事件发生
for (int i = 1; i < max; ++i) {
if (fds[i].revents & POLLRDNORM) {
sockfd = fds[i].fd
if ((n = read(sockfd, buf, MAXLINE)) <= 0) {
// 这里处理read事件
if (n == 0) {
close(sockfd);
fds[i].fd = -1;
}
} else {
// 这里处理write事件
}
if (--nfds <= 0) {
break;
}
}
}
}
Poll内部实现:
nfds = poll(fds, max+1, timeout);
- poll模型里面通过使用链表的形式来保存自己监控的fd信息,正是这样poll模型里面是没有了连接限制,可以支持高并发的请求。
- 用户态与内核态之间的拷贝与遍历。同 select
Poll优点:
- 1.
它监听的描述符数量没有限制,可以超过1024。(因为其是用链表写的)
- 2.它不会修改struct pollfd数据中传递的数据。因此,只要将生成事件的描述符的revents成员设置为零,就可以在poll()调用之间重用它。
Poll缺点:
其实就是将上面select 的缺点中减去刚刚提到的两个优点就行.
- 2.和select一样必须通过遍历描述符列表来查找哪些描述符产生了事件。更糟糕的是在内核空间也需要通过遍历来找到哪些套接字正在被监听,然后再重新遍历整个列表来设置事件。
- 4.和select一样它也不能在描述符被监听的状态下修改或者关闭套接字。会出现未定义行为
什么时候应该选择使用Poll:
- 跨平台
- 同一时刻你的应用程序监听的套接字少于1000(这种情况下使用epoll不会得到任何益处)。
- 您的应用程序需要一次监视超过1000个套接字,但连接非常短暂(这是一个接近的情况,但很可能在这种情况下,您不太可能看到使用epoll的任何好处,因为epoll 的加速将这些新描述符添加到集合中会浪费等待 - 见下文
- 您的应用程序的设计方式不是在另一个线程正在等待它们更改事件(即您没有使用kqueue或IO完成端口移植应用程序)。
select/poll 的共同的缺点
一 返回后需要遍历fd集合找到就绪的 fd ,但 fd 集合就绪 的描述符很少
二 select/poll 均需将 fd 集合在用户态和内核态之间来回拷贝和遍历
Epoll
epoll 是Linux(也是Linux)中最新,最好的轮询方法。
从图中可以明显地看到,epoll 的性能是最好的,即使在多达 10000 个文件描述的情况下,其性能的下降和有 10 个文件描述符的情况相比,差别也不是很大。而随着文件描述符的增大,常规的 select 和 poll 方法性能逐渐变得很差。
使用
epoll_create
int epoll_create(int size);
int epoll_create1(int flags);
返回值: 若成功返回一个大于0的值,表示epoll实例;若返回-1表示出错
参数 size,在一开始的 epoll_create 实现中,是用来告知内核期望监控的文件描述字大小,然后内核使用这部分的信息来初始化内核数据结构,在新的 Linux2.6 版本实现之后,这个参数不再被需要,因为内核可以动态分配需要的内核数据结构。我们只需要注意,每次将 size 设置成一个大于 0 的整数就可以了。
epoll_ctl:负责 epoll item 的添加/删除/修改操作
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
返回值: 若成功返回0;若返回-1表示出错
- epfd:Epoll句柄
- op:表示增加还是删除一个监控事件,它有三个选项可供选择:
EPOLL_CTL_ADD: 向 epoll 实例注册文件描述符对应的事件; EPOLL_CTL_DEL:向 epoll 实例删除文件描述符对应的事件; EPOLL_CTL_MOD: 修改文件描述符对应的事件。
- fd:文件描述符
- event:第四个参数表示的是注册的事件类型,并且可以在这个结构体里设置用户需要的数据,其中最为常见的是使用联合结构里的 fd 字段,表示事件所对应的文件描述符。
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
epoll_wait
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
返回值: 成功返回的是一个大于0的数,表示事件的个数;返回0表示的是超时时间到;若出错返回-1.
- events:第二个参数
返回给用户空间需要处理的 I/O 事件
,这是一个数组,数组的大小由 epoll_wait 的返回值决定,这个数组的每个元素都是一个需要待处理的 I/O 事件
,其中 events 表示具体的事件类型,事件类型取值和 epoll_ctl 可设置的值一样,这个 epoll_event 结构体里的 data 值就是在 epoll_ctl 那里设置的 data,也就是用户空间和内核空间调用时需要的数据。 - maxevents:表示 epoll_wait 可以返回的最大事件值。
示例
int main(int argc, char* argv[])
{
/*
* 在这里进行一些初始化的操作,
* 比如初始化数据和socket等。
*/
// 内核中创建ep对象
epfd=epoll_create(256);
// 需要监听的socket放到ep中
epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);
while(1) {
// 阻塞获取
nfds = epoll_wait(epfd,events,20,0);
for(i=0;i<nfds;++i) {
if(events[i].data.fd==listenfd) {
// 这里处理accept事件
connfd = accept(listenfd);
// 接收新连接写到内核对象中
epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev);
} else if (events[i].events&EPOLLIN) {
// 这里处理read事件
read(sockfd, BUF, MAXLINE);
//读完后准备写
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
} else if(events[i].events&EPOLLOUT) {
// 这里处理write事件
write(sockfd, BUF, n);
//写完后准备读
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
}
}
}
return 0;
}
EPoll的内部实现:红黑树+就绪链表+内核高速cache
- 1.执行 epoll_create 时,创建了红黑树和就绪 list 链表。
- 2.执行epoll_ctl时,如果增加fd(socket),则检查在红黑树中是否存在,存在立即返回,不存在则添加到红黑树上,并且注册fd的回调函数(内核定义的回调函数),内核在检测到某fd就绪时会调用回调函数将fd 添加到就绪链表中。
- 3.执行 epoll_wait 时立刻通过
共享内存
的方式返回准备就绪链表 rdlist 里的数据即可。
Linux 下 Epoll 源码深入解读
EPoll的优点:
- 1.
epoll只会返回有事件发生的描述符
,所以不需要遍历所有监听的描述符来找到哪些描述符产生了事件。 - 2.
你可以将处理对应事件的方法和所需要的数据附加到被监听的描述符上
。在上面的例子中我们附加了一个类的指针,这样就可以直接调用处理对应事件的方法。 - 3.你
可以在任何时间添加或者删除套接字
,即使有其他线程正在epoll_wait函数中。你甚至可以修改正在被监听描述符的事件,不会产生任何影响。这种行为是被官方支持的而且有文档说明。这样就可以使我们在写代码时有更大的灵活性。 - 4.因为内核知道所有被监听的描述符,所以即使没有人调用 epoll_wait(),内核也可以记录发生的事件,这允许实现一些有趣的特性,例如边沿触发,这将在另一篇文章中讲到。
- 5.epoll_wait()函数可以让多个线程等待同一个 epoll 队列而且推荐设置为边沿触发模式,这在其他轮询方式中是不可能实现的
EPoll的缺点:
- 1.
改变监听事件的类型(例如从读事件改为写事件)需要调用epoll_ctl系统调用,而这在poll中只需要在用户空间简单的设置一下对应的掩码
。如果需要改变5000个套接字的监听事件类型就需要5000次系统调用和上下文切换(直到2014年epoll_ctl函数仍然不能批量操作,每个描述符只能单独操作),这在poll中只需要循环一次pollfd结构体。 - 2.
每一个被accept()的套接字都需要添加到集合中,在epoll中必须使用epoll_ctl来添加–这就意味着每一个新的连接都需要两次系统调用
,而在poll中只需要一次。如果你的服务有非常多的短连接它们都接受或者发送少量数据,epoll所花费的时间可能比poll更长。(解释了上文) - 3.
epoll是Linux上独有的
,虽然其他平台上也有类似的机制但是他们的区别非常大,例如边沿触发这种模式是非常独特的(FreeBSD的kqueue对它的支持非常粗糙)。
什么情况下使用Epoll:
- 1.
你的程序通过多个线程来处理大量的网络连接
。如果你的程序只是单线程的那么将会失去epoll的很多优点。并且很有可能不会比poll更好。 - 2.
你需要监听的套接字数量非常大(至少1000)
;如果监听的套接字数量很少则使用epoll不会有任何性能上的优势甚至可能还不如poll。 - 3.
你的网络连接相对来说都是长连接
;就像上面提到的epoll处理短连接的性能还不如poll因为epoll需要额外的系统调用来添加描述符到集合中。 - 4.你的应用程序依赖于Linux上的其他特性
如果上面的条件都不成立,你更应该使用 poll
epoll_wait 获取句柄时,检测 rdlist 中事件有无就行了,ovflist 只是怕在通过共享内存从内核传递就绪fd到用户
的时候,产生新的事件的一个暂存的地方!!!!!
(1)什么是活跃的连接?
正在进行请求与响应的连接
(2)红黑树是如何知道有事件到来的?
网卡发现报文后,内核可以直接从地址定位到事件
(3)事件的操作
当需要操作(添加,删除,修改)某个事件时,操作的是树上的节点,不用遍历所有事件
;插入、删除、查找的最坏时间复杂度都为 O(logn)
。
当某个事件发生时,系统把它加入到链表中。
对 Epoll 的一些感悟:
- 如果对 listenfd 设置 ET 模式,accept 只要没有接受完 全连接队列,这些连接就不会再次被触发了!!!就是从 rdlist 上删除了呗
- LT 的话就是会将没处理完的放在 rdlist 的最前面,然后跟着下次需要返回的一起返回
原文来源:
https://www.ulduzsoft.com/2014/01/select-poll-epoll-practical-difference-for-system-architects/