1. 知识点
目前支持I/O多路复用的系统调用有select,pselect,poll,epoll。与多进程和多线程技术相
比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进
程/线程,从而大大减小了系统的开销。
I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般
是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都
是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是
阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用
户空间
2. select 函数
0 表示标准输入 STDIN_FILENO
1 表示标准输出 STDOUT_FILENO
2 表示标准错误输出 STDERR_FILENO
2.1 select存在三个问题
[1] 每次调用select,都需要把被监控的fds集合从用户态空间拷贝到内核态空间,高并发场景
下这样的拷贝会使得消耗的资源是很大的。
[2] 能监听端口的数量有限,单个进程所能打开的最大连接数由FD_SETSIZE宏定义,监听上
限就等于fds_bits位数组中所有元素的二进制位总数,32位机默认1024个,64位默2048。
[3] 被监控的fds集合中,只要有一个有数据可读,整个socket集合就会被遍历一次,用户线程并不知道哪些 fds 收到数据,只能挨个遍历每个socket来收集可读事件了。
2.2 函数接口
1)用户进程需要监控某些资源 fds,在调用 select 函数后会阻塞,操作系统会将用户线程加入这些资源的等待队列中。
2)直到有描述符就绪(有数据可读、可写或有 except)或超时(timeout 指定等待时间,如果立即返回设为 null 即可),函数返回。
3)select 函数返回后,中断程序唤起用户线程。用户可以遍历 fds,通过 FD_ISSET 判断具体哪个 fd 收到数据,并做出相应处理。
select 函数优点明显,实现起来简单有效,且几乎所有操作系统都有对应的实现。
2.2.1 接口
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
2.2.2 参数:
int nfds:管理的最⼤的⽂件描述符+1
fd_set *readfds:⽂件描述符表(集合),监视管理⽂件描述符的读操作是否就绪
fd_set *writefds:⽂件描述符表(集合),监视管理⽂件描述符的写操作是否就绪,没有就 写NULL
fd_set *exceptfds:⽂件描述符表(集合),监视管理⽂件描述符的异常
struct timeval *timeout:timeout:超时设置。
Null:一直阻塞直到有文件描述符就绪或出错
时间值为0:仅仅检测文件描述符集的状态,然后立即返回
时间值不为0:在指定时间内,如果没有事件发生,则超时返回。
超时设置过后,如果select超时了,那么返回值是0, 并且超时时间的结构体会变成 0s
2.2.3 返回值:
成功:返回监视到就绪的⽂件描述符的个数,会把监视的表修改为只剩下就绪的⽂件描述符
失败:返回-1
2.2.4 操作⽂件描述符表:
void FD_CLR(int fd, fd_set *set);//把⽂件描述符fd从set表删除
int FD_ISSET(int fd, fd_set *set);//判断fd是否在set集合中,返回值:描述符存在集合里返回真1,不存在返回假0
void FD_SET(int fd, fd_set *set);//把fd 加⼊到set表,将 fd_set 结构中对应的位设置为1,表示以便通过 select 函数对文件描述符 fd进行监视。
void FD_ZERO(fd_set *set);//清空表,将其所有位都设置为0。
2.3 多路复用实现通信
服务端select_serve.c
#include <stdio.h>
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <pthread.h>
#include <sys/time.h>
//基于TCP 的IO多路复用通信
//服务端
int main()
{
//创建套接字
int sock_fd = socket(PF_INET,SOCK_STREAM,0);
//初始化本机地址和端口
struct sockaddr_in srvaddr;
srvaddr.sin_family = PF_INET;
srvaddr.sin_port = htons(10000);
srvaddr.sin_addr.s_addr = inet_addr("192.168.124.151");
socklen_t srvaddr_len = sizeof(srvaddr);
//绑定套接字
bind(sock_fd,(struct sockaddr *)&srvaddr,srvaddr_len);
//监听
listen(sock_fd,4);
//等待连接
printf("等待连接中......\n");
int conn_fd = accept(sock_fd,NULL,NULL);
printf("连接成功!!\n");
//定义一个集合
fd_set jihe;
while(1)
{
FD_ZERO(&jihe);//清空集合,将其所有位都设置为0。
FD_SET(conn_fd,&jihe);//把conn_fd套接字(套接字说白了也是文件描述符)添加进集合
//标准输入文件STDIN_FILENO-------0
FD_SET(STDIN_FILENO,&jihe);//把标准输入添加进集合
//多路复用的系统调用,这里监控读操作,
//一旦有操作select就会被select监控到,接着配合后面的FD_ISSET()判断出具体时集合中的哪个文件描述符
int ret = select(conn_fd+1,&jihe,NULL,NULL,NULL);
if(-1 == ret)//监控失败
{
perror("select failed");
continue;
}
if(0 == ret)//超时
{
continue;
}
//判断文件描述符是否在集合中 如果已连接套接字在集合,则进行读操作,读取客户端发来的信息
if(FD_ISSET(conn_fd,&jihe) == 1)
{
char rbuf[128]={0};
read(conn_fd,rbuf,sizeof(rbuf));
printf("from cli:%s\n",rbuf);
//FD_CLR(conn_fd,&jihe);
}
//如果我们有标准输入在集合中,则写入,即通过已连接套接字发送给客户端
if(FD_ISSET(STDIN_FILENO,&jihe) == 1)//判断出是STDIN_FILENO标准输入
{
char wbuf[128]={0};//定义缓冲区
fgets(wbuf,sizeof(wbuf),stdin);//键盘输入
write(conn_fd,wbuf,sizeof(wbuf));
//FD_CLR(STDIN_FILENO,&jihe);
}
}
}
客户端select_cilent.c
#include <stdio.h>
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <pthread.h>
#include <sys/time.h>
//基于TCP 的IO多路复用通信
//客户端
int main()
{
//创建套接字
int sock_fd = socket(PF_INET,SOCK_STREAM,0);
//初始化服务端网络地址
struct sockaddr_in srvaddr;
srvaddr.sin_family = PF_INET;
srvaddr.sin_port = htons(10000);
srvaddr.sin_addr.s_addr = inet_addr("192.168.124.151");
socklen_t srvaddr_len = sizeof(srvaddr);
//这个绑定可有可无
// bind(sock_fd,(struct sockaddr *)&srvaddr,srvaddr_len);
//listen(sock_fd,4);
//请求连接
connect(sock_fd,(struct sockaddr *)&srvaddr,srvaddr_len);
//定义一个集合
fd_set jihe;
while(1)
{
FD_ZERO(&jihe);//清空集合
FD_SET(sock_fd,&jihe);//把sock_fd套接字(套接字说白了也是文件描述符)添加进集合
//标准输入文件STDIN_FILENO-------0
FD_SET(STDIN_FILENO,&jihe);//把标准输入添加进集合
//多路复用的系统调用,这里监控读操作
int ret = select(sock_fd+1,&jihe,NULL,NULL,NULL);
if(-1 == ret)
{
perror("select failed");
continue;
}
if(0 == ret)
{
continue;
}
//判断文件描述符是否在集合中 如果已连接套接字在集合,则进行读操作,读取客户端发来的信息
if(FD_ISSET(sock_fd,&jihe) == 1)
{
char rbuf[128]={0};
read(sock_fd,rbuf,sizeof(rbuf));
printf("from srv:%s\n",rbuf);
//FD_CLR(sock_fd,&jihe);
}
//如果我们有标准输入文件描述符在集合中,则写入,即通过已连接套接字发送给服务端
if(FD_ISSET(STDIN_FILENO,&jihe) == 1)
{
char wbuf[128]={0};
fgets(wbuf,sizeof(wbuf),stdin);
write(sock_fd,wbuf,sizeof(wbuf));
//FD_CLR(STDIN_FILENO,&jihe);
}
}
}
3. poll
3.1 优点
poll 函数与 select 原理相似,都需要来回拷贝全部监听的文件描述符,不同的是:
1)poll 函数采用链表的方式替代原来 select 中 fd_set 结构,因此可监听文件描述符数量不受限。
2)poll 函数返回后,可以通过 pollfd 结构中的内容进行处理就绪文件描述符,相比 select 效率要高。
3)新增水平触发:也就是通知程序 fd 就绪后,这次没有被处理,那么下次 poll 的时候会再次通知同个 fd 已经就绪。
3.2 缺点
和 select 函数一样,poll 返回后,需要轮询 pollfd 来获取就绪的描述符。poll和select同样
存在一个性能缺点就是包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,以及个别描述符就绪触发整体描述符集合的遍历的低效问题。而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
4. epoll
epoll 使用一个文件描述符管理多个描述符,将用户进程监控的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间只需拷贝一次。
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
4.1 优点
1)没有最大并发连接的限制,能打开的 FD 的上限远大于 1024。
2)效率提升,不是轮询的方式,不会随着 FD 数目的增加效率下降。
3)内存拷贝,利用 mmap() 文件映射内存加速与内核空间的消息传递,即 epoll 使用 mmap 减少复制开销。
4)新增 ET 模式。
5. 总结
5.1 select、poll、epoll区别
三种函数在的 Linux 内核里有都能够支持,其中 epoll 是 Linux 所特有,而 select 则应该是 POSIX 所规定,一般操作系统均有实现。
5.2 工作模式
1)LT模式
LT(level triggered)模式:也是默认模式,即当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件,并且下次调用 epoll_wait 时,会再次响应应用程序并通知此事件。
2)ET模式
ET(edge-triggered)模式:当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
ET 是一种高速工作方式,很大程度上减少了 epoll 事件被重复触发的次数。epoll 工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。