彻底理解 IO 多路复用实现机制( 二 )


缓存 I/O 的缺点:
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作 , 这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的 。
什么是IO多路复用?

  • IO 多路复用是一种同步IO模型 , 实现一个线程可以监视多个文件句柄;
  • 一旦某个文件句柄就绪 , 就能够通知应用程序进行相应的读写操作;
  • 没有文件句柄就绪就会阻塞应用程序 , 交出CPU 。
多路是指网络连接 , 复用指的是同一个线程
为什么有IO多路复用机制?没有IO多路复用机制时 , 有BIO、NIO两种实现方式 , 但它们都有一些问题
同步阻塞(BIO)
  • 服务端采用单线程 , 当 accept 一个请求后 , 在 recv 或 send 调用阻塞时 , 将无法 accept 其他请求(必须等上一个请求处理 recv 或 send 完 )(无法处理并发)
// 伪代码描述while (true) { // accept阻塞client_fd = accept(listen_fd);fds.append(client_fd);for (fd in fds) {// recv阻塞(会影响上面的accept)if (recv(fd)) {// logic}}}
  • 服务端采用多线程 , 当 accept 一个请求后 , 开启线程进行 recv , 可以完成并发处理 , 但随着请求数增加需要增加系统线程 , 大量的线程占用很大的内存空间 , 并且线程切换会带来很大的开销 , 10000个线程真正发生读写实际的线程数不会超过20% , 每次accept都开一个线程也是一种资源浪费 。
// 伪代码描述while(true) {// accept阻塞client_fd = accept(listen_fd)// 开启线程read数据(fd增多导致线程数增多)new Thread func() {// recv阻塞(多线程不影响上面的accept)if (recv(fd)) {// logic}}}同步非阻塞(NIO)
  • 服务器端当 accept 一个请求后 , 加入 fds 集合 , 每次轮询一遍 fds 集合 recv (非阻塞)数据 , 没有数据则立即返回错误 , 每次轮询所有 fd (包括没有发生读写实际的 fd)会很浪费 CPU 。
// 伪代码描述while(true) {// accept非阻塞(cpu一直忙轮询)client_fd = accept(listen_fd)if (client_fd != null) {// 有人连接fds.append(client_fd)} else {// 无人连接}for (fd in fds) {// recv非阻塞setNonblocking(client_fd)// recv 为非阻塞命令if (len = recv(fd)--tt-darkmode-color: #9940C3;">IO多路复用服务器端采用单线程通过 select/poll/epoll 等系统调用获取 fd 列表 , 遍历有事件的 fd 进行 accept/recv/send, 使其能支持更多的并发连接请求 。
// 伪代码描述while(true) {// 通过内核获取有读写事件发生的fd , 只要有一个则返回 , 无则阻塞// 整个过程只在调用select、poll、epoll这些调用的时候才会阻塞 , accept/recv是不会阻塞for (fd in select(fds)) {if (fd == listen_fd) {client_fd = accept(listen_fd)fds.append(client_fd)} elseif (len = recv(fd)--tt-darkmode-color: #9940C3;">IO多路复用的三种实现
  • select
  • poll
  • epoll
select它仅仅知道了 , 有I/O事件发生了 , 却并不知道是哪那几个流(可能有一个 , 多个 , 甚至全部) , 我们只能无差别轮询所有流 , 找出能读出数据 , 或者写入数据的流 , 对他们进行操作 。 所以select具有O(n)的无差别轮询复杂度 , 同时处理的流越多 , 无差别轮询时间就越长 。
select调用过程
彻底理解 IO 多路复用实现机制文章插图
(1)使用copy_from_user从用户空间拷贝fd_set到内核空间
(2)注册回调函数__pollwait
(3)遍历所有fd , 调用其对应的poll方法(对于socket , 这个poll方法是sock_poll , sock_poll根据情况会调用到tcp_poll,udp_poll或者datagram_poll)


推荐阅读