【架构】IO多路复用

服务器端编程经常需要构造高性能的IO模型,常见的IO模型有:

  1. 同步阻塞IO(Blocking IO)
    传统的IO模型
  2. 同步非阻塞IO(Non-blocking IO)
    默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK
  3. IO多路复用(IO Multiplexing)
    经典的Reactor设计模式,有时也称异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型
  4. 异步IO(Asynchronous IO)
    经典的Proactor设计模式,也称为异步非阻塞IO

一、同步阻塞IO

服务端为了处理客户端的连接和请求的数据,写了如下代码

listenfd = socket();   // 打开一个网络通信端口
bind(listenfd);        // 绑定
listen(listenfd);      // 监听
while(1) {
  connfd = accept(listenfd);  // 阻塞建立连接
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接,循环等待下一个连接
}

这段代码服务端的线程阻塞在了两个地方,一个是accept函数,一个是read函数

如果再把read函数的细节展开,发现其阻塞在了两个阶段
在这里插入图片描述
在这里插入图片描述

  1. 网卡将客户端发过来的数据拷贝到内核缓冲区
  2. 内核缓冲区设置关联的文件描述符为可读,将内核缓冲区中的数据拷贝到用户缓冲区

整体流程如下:
在这里插入图片描述
所以,如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在 read 函数上不返回,也无法接受其他客户端连接

二、非阻塞IO

为了解决上面的问题,其关键在于改造这个read函数

有一种办法是,每次都创建一个新的进程或线程,去调用read函数,并做业务处理

while(1) {
  connfd = accept(listenfd);  // 阻塞建立连接
  pthread_create(doWork);  // 创建一个新的线程
}
void doWork() {
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接,循环等待下一个连接
}

这样,当给一个客户端建立好连接后,就可以立刻等待新的客户端连接,而不用阻塞在原客户端的read请求上
不过,这不叫非阻塞IO,只不过用了多线程的手段使得主线程没有卡在read函数上不往下处理,操作系统提供的read函数仍然是阻塞

所以真正的非阻塞 IO,不能是仅通过用户层处理,而是要操作系统提供一个非阻塞的read函数
这个read函数的效果是,如果没有数据到达时(到达网卡并拷贝到了内核缓冲区),立刻返回一个错误值(-1),而不是阻塞地等待

操作系统提供了这样的功能,只需要在调用read前,将文件描述符设置为非阻塞即可

fcntl(connfd, F_SETFL, O_NONBLOCK);
int n = read(connfd, buffer) != SUCCESS);

这样,就需要用户线程循环调用read,直到返回值不为-1,再开始处理业务

这里注意到两个问题:

  1. 非阻塞的read,指的是在数据到达前,即数据还未到达网卡,或者到达网卡但还没有拷贝到内核缓冲区之前,这个阶段是非阻塞的
    当数据已到达内核缓冲区,此时调用read函数仍然是阻塞的,需要等待数据从内核缓冲区拷贝到用户缓冲区,才能返回
  2. 为每个客户端创建一个线程,服务器端的线程资源很容易被耗光

整体流程如下:
在这里插入图片描述

三、IO多路复用

当然还有个办法,可以每accept一个客户端连接后,将这个文件描述符(connfd)放到一个数组里,然后弄一个新的线程去不断遍历这个数组,调用每一个元素的非阻塞read方法

fdlist.add(connfd);

while(1) {
  for(fd <-- fdlist) {
    if(read(fd) != -1) {
      doSomeThing();
    }
  }
}

但这和用多线程去将阻塞IO改造成非阻塞IO一样,这种遍历方式只是用户自己想出的小把戏,每次遍历遇到read返回-1时仍然是一次浪费资源的系统调用

在while循环里做系统调用,好比做分布式项目时在while里做rpc请求一样不划算

所以,还是得需要操作系统提供一个有这样效果的函数,将一批文件描述符通过一次系统调用传给内核,由内核层去遍历,才能真正解决这个问题

  • select

select是操作系统提供的系统调用函数,通过它可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后去处理:
在这里插入图片描述
select系统调用的函数定义如下:

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,检查描述字后立即返回,轮询

首先一个线程不断接受客户端连接,并把socket文件描述符放到一个list里

while(1) {
  connfd = accept(listenfd);
  fcntl(connfd, F_SETFL, O_NONBLOCK);
  fdlist.add(connfd);
}

然后,另一个线程不再自己遍历,而是调用select,将这批文件描述符list交给操作系统去遍历

while(1) {
  // 把一堆文件描述符 list 传给 select 函数
  // 有已就绪的文件描述符就返回,nready 表示有多少个就绪的
  nready = select(list);
  ...
}

不过,当select函数返回后,用户依然需要遍历刚刚提交给操作系统的list
只不过,操作系统会将准备就绪的文件描述符做上标识,用户层将不会再有无意义的系统调用开销

while(1) {
  nready = select(list);
  // 用户层依然要遍历,只不过少了很多无效的系统调用
  for(fd <-- fdlist) {
    if(fd != -1) {
      // 只读已就绪的文件描述符
      read(fd, buf);
      // 总共只有 nready 个已就绪描述符,不用过多遍历
      if(--nready == 0) break;
    }
  }
}

可以看出几个细节:

  1. select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的(可优化为不复制)
  2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销(内核层可优化为异步事件通知)
  3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)

整个select的流程图如下:
在这里插入图片描述

可以看到,这种方式既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select 的系统调用 + n 次就绪状态的文件描述符的 read 系统调用)

  • poll

poll也是操作系统提供的系统调用函数

int poll(struct pollfd *fds, nfds_tnfds, int timeout);

struct pollfd {
  intfd; /*文件描述符*/
  shortevents; /*监控的事件*/
  shortrevents; /*监控事件中满足条件返回的事件*/
};

它和select的主要区别就是,去掉了select只能监听1024个文件描述符的限制

  • epoll

还记得上面select的三个细节么?

  1. select调用需要传入fd数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的(可优化为不复制)

  2. select在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销(内核层可优化为异步事件通知)

  3. select仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
    所以 epoll 主要就是针对这三点进行了改进。

  4. 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可

  5. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒

  6. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合

具体,操作系统提供了这三个函数

第一步,创建一个 epoll 句柄

int epoll_create(int size);

第二步,向内核添加、修改或删除要监控的文件描述符

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

第三步,类似发起了select()调用

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

具体流程如下:
在这里插入图片描述

### IO多路复用的概念 IO多路复用是一种同步I/O模型,允许一个线程同时监控多个文件描述符(如套接字)。这种技术使得程序可以在等待多个输入源中的任何一个变为可用时不会被阻塞。当任意一个文件描述符准备好了读取或写入操作,则会立即通知应用程序去处理相应的工作[^1]。 ### 实现原理 在Linux系统下,可以通过`select`, `poll` 或者更高效的`epoll` 来实现这一功能。这些函数可以让进程一次性监视多个文件描述符,并告知哪些已经准备好进行通信活动。具体来说: - **Select**: 可以监听一定数量的文件描述符集合,在指定时间内检查它们是否有待处理的数据。 - **Poll**: 类似于`select`但是没有最大文件数目的限制,并且性能更好一些因为不需要每次调用都重新构建文件列表。 - **Epoll**: 是一种更为先进的接口,特别适合大量并发连接的情况。它可以注册感兴趣的事件并只报告那些确实发生了变化的对象,从而减少了不必要的上下文切换和资源消耗[^4]。 对于每一个可能发生变化的状态——比如可读、可写或是异常情况发生——都可以设置回调函数以便及时响应。这种方式不仅提高了效率而且简化了编程逻辑。 ### 应用场景 在网络服务端开发领域广泛应用着IO多路复用的技术,尤其是在高负载情况下需要保持大量的活跃TCP连接时表现尤为突出。例如Web服务器(Nginx), 缓存数据库(Redis) 都采用了类似的架构来优化其性能[^5]。 #### Nginx 和 Redis 的例子 这两个软件均采用Reactor模式下的IO多路复用来支持大规模并发请求: - 对于Nginx而言, 主要负责接收新到来的HTTP请求(`accept`)而具体的业务逻辑则由工作进程中完成. - 而像Redis这样的键值存储系统则是完全依赖单个工作循环就能高效地应对成千上万的同时在线客户端. ```python import select import socket server_socket = socket.socket() server_socket.bind(('localhost', 8080)) server_socket.listen(5) inputs = [server_socket] while True: readable, writable, exceptional = select.select(inputs,[],[]) for s in readable: if s is server_socket: client_socket, addr = s.accept() inputs.append(client_socket) else: data = s.recv(1024) if not data: inputs.remove(s) s.close() else: print(f"Received {data.decode()}") ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

sysu_lluozh

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值