IO复用

以往服务器等待客户端连接需要阻塞accept(),等待客户端发数据需要阻塞,如果同时有多个客户端发起请求,那么就gg,
当然可以通过多线程,多进程来解决,但是开销太大。于是就有了IO多路转接,

其核心思想时服务器通过内核作为媒介来监听客户端的连接请求,内核阻塞去监听客户端状态,一旦其状态变化,内核就通知
服务器去处理

select()

函数原型

int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);

1
2
3
4
//@parm1 :nfds 表示所监听的文件描述符最大值+1
// @parm2,3,4 所监听文件描述符事件(可读/可写/异常)
//@parm5:设置超时限制
//返回值>0:监听的所有集合,总满足条件的总数;==0 超时;<0 错误

配套操作

1
2
3
4
void FD_CLR(int fd, fd_set *set); //
int FD_ISSET(int fd, fd_set *set);//判断fd是否在集合中
void FD_SET(int fd, fd_set *set);//将集合对应位置为1
void FD_ZERO(fd_set *set);//将集合清0

再看文件描述集

三个文件描述符参数是传入传出参数

举个例子,readfds 监听了{fd1,fd2,fd3,fd4} 对应位图[1111]
select返回2,表明有两个文件描述符准备好了,这个时候监听集合可能就变成了[1001] ,因此我们需要轮循才能知道是哪个文件描述符状态发生了改变。

select 详细过程:

  1. 当用户 process 调用 select 的时候,select 会将需要监控的 readfds 集合拷贝到内核空间(假设监控的仅仅是 socket 可读),

  2. 内核遍历自己监控的 socket sk,挨个调用 sk 的 poll 逻辑以便检查该 sk 是否有可读事件,遍历完所有的 sk 后,如果没有任何一个 sk 可读,那 select 会调用 schedule_timeout 进入 schedule 循环,使得 process 进入睡眠。如果在 timeout 时间内某个 sk 上有数据可读了,或者等待 timeout 了,则调用 select 的 process 会被唤醒,

  3. 接下来 select 就是遍历监控的 sk 集合,挨个收集可读事件并返回给用户。

select()缺点

  1. 监听的文件描述符上限1024
  2. 需要轮循才能知道是哪个文件描述符状态发生了改变,当客户端连接数过多,但是准备就绪的又比较少时,轮循代价比较大
  3. 由于监听集合是传入传出参数,因此在调用select()之前需要先保存之前状态
  4. fds集合需要从用户空间拷贝到内核空间的问题,我们希望不需要拷贝

select处理完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
int main(int argc, char const *argv[])
{
fd_set rfds;
char buf[BUFSIZE] = {0};
int listenfd = createSocket(); //这里封装了,可以简单理解为将socket(),bind(),listen() 都封装了

// 保存所有的文件描述符
set<int> fdset;
fdset.insert(listenfd);

while (1)
{
FD_ZERO(&rfds);
// 每次都要重新设置rfds.因为select返回时,rfds被内核改变,里面只保存了就绪的文件描述符
for (int fd : fdset) FD_SET(fd, &rfds);
int ret = select(*fdset.rbegin()+1, &rfds, NULL, NULL, NULL);
if (ret > 0)
{
for (int fd : fdset) //这里就是轮询监听的文件描述符集合,因为使用了集合,就不需要从0-fds.size()+1遍历呢
{
// 有新的连接
if (fd == listenfd && FD_ISSET(fd, &rfds))
{

int cfd = accept();
// 添加到 fd_set 结构体,并记录到 set
FD_SET(cfd, &rfds);
fdset.insert(cfd);
}
//有请求 读数据
else if (FD_ISSET(fd, &rfds))
{
int lenrecv = -1;
memset(buf, 0, BUFSIZE);

lenrecv = recv(fd, buf, BUFSIZE-1, 0);
if (lenrecv > 0)
{
printf("%s\n", buf);
}
else if (0 == lenrecv)
{
// 客户端退出,删除文件描述符,并关闭
fdset.erase(fd);
FD_CLR(fd, &rfds);
printf("delete connection fd: %d\n", fd);
close(fd);
}
}
}

close(listenfd);

return 0;
}

poll

int poll(struct pollfd *fds, unsigned int nfds, int timeout);

poll机制和select类似,通过轮询管理的文件描述符,根据其状态来进行处理。

  1. 但是poll()没有最大文件描述符数量上限。
  2. fds描述方式和select()不同,使用pollfd()基于链表实现,而fd_set()基于数组
    1
    2
    3
    4
    5
    struct pollfd {
    int fd; /* file descriptor */
    short events; /* requested events */
    short revents; /* returned events */
    };

poll与select对比

功能实现上

  • select 的描述符类型使用数组实现,FD_SETSIZE 大小默认为 1024,因此默认只能监听少于 1024 个描述符。如果要监听更多描述符的话,需要修改 FD_SETSIZE 之后重新编译;而 poll 没有描述符数量的限制;
  • select 会修改描述符,而 poll 不会(请求事件和返回事件分开)
  • poll 提供了更多的事件类型(POLLIN,POLLOUT,POLLERR,POLLHUP)

速度

select 和 poll 速度都比较慢,每次调用都需要将全部描述符从应用进程缓冲区复制到内核缓冲区。

可移植性

几乎所有的系统都支持 select,但是只有比较新的系统支持 poll。

epoll

使用场景:连接文件描述符多,但是监听的少,或者说处于就绪态的文件描述符少,使用epoll

1
2
3
4
5
int epoll_create(int size); //返回指向内核的红黑树根

int epoll_ctl(int epfd, int op, int fd, struct epoll_event*event);

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

epoll 只需要将描述符从进程缓冲区向内核缓冲区拷贝一次(处理红黑树节点时候),并且进程不需要通过轮询来获得事件完成的描述符。

为什么不在需要轮循获得准备就绪的fd?
因为epoll_wait() 返回的
struct epoll_event * events 已经包含了就绪的文件描述符,并且该结构体含有fd,以及事件信息
常见的处理方式

1
2
3
4
5
6
7
8
9
10
11
struct epoll_event activeEvs[100];
int n = epoll_wait(efd, activeEvs, kMaxEvents, waitms);//返回准备就绪的文件描述符个数
for (int i = 0; i < n; i++) {
int fd = activeEvs[i].data.fd; //提取fd
int events = activeEvs[i].events; //提取返回事件
if (events & (EPOLLIN | EPOLLERR)) {
if (fd == lfd) {
handleAccept(efd, fd);
} else {
handleRead(efd, fd);
}

LT&&ET

Level Triggered (LT) 水平触发

socket接收缓冲区不为空 有数据可读 读事件一直触发

Edge Triggered (ET) 边沿触发

socket的接收缓冲区状态变化时触发读事件,即空的接收缓冲区刚接收到数据时触发读事件

其实就是模拟电路中的上升沿以及水平概念,LT只要有数据就会读,ET只有当缓冲区状态发生改变才会触发。

ET很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。只支持 No-Blocking,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

三种IO复用应用场景

select

  1. 实时性要求高;select 的 timeout 参数精度为微秒,而 poll 和 epoll 为毫秒,因此 select 更加适用于实时性要求比较高的场景,比如核反应堆的控制
  2. 可移植性好

poll

poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select。

epoll

适用于:

  • 只运行在linux
  • 大量的连接,但是只有少数同时处于就绪态,并且这些连接为长连接

不适用:

  • 监控的描述符状态变化多,而且都是非常短暂的,因为 epoll 中的所有描述符都存储在内核中,造成每次需要对描述符的状态改变都需要通过 epoll_ctl() 进行系统调用,频繁系统调用降低效率。