深入理解IO多路复用 select/poll/epoll 实现原理

0. 结论

本文其他的内容主要是得出了下面几个结论:

服务器要接收客户端的数据,要建立 socket 内核结构,主要包含两个重要的数据结构,(进程)等待队列,和(数据)接收队列,socket 在进程中作为一个文件,可以用文件描述符 fd 来表示,为了方便理解,本文中, socket 内核对象 ≈ fd 文件描述符 ≈ TCP 连接;

阻塞 IO 的主要逻辑是:服务端和客户端建立了连接 socket 后,服务端的用户进程通过 recv 函数接收数据时,如果数据没有到达,则当前的用户进程的进程描述符和回调函数会封装到一个进程等待项中,加入到 socket 的进程等待队列中;如果连接上有数据到达网卡,由网卡将数据通过 DMA 控制器拷贝到内核内存的 RingBuffer 中,并向 CPU 发出硬中断,然后,CPU 向内核中断进程 ksoftirqd 发出软中断信号,内核中断进程 ksoftirqd 将内核内存的 RingBuffer 中的数据根据数据报文的 IP 和端口号,将其拷贝到对应 socket 的数据接收队列中,然后通过 socket 的进程等待队列中的回调函数,唤醒要处理该数据的用户进程;

阻塞 IO 的问题是:一次数据到达会进行两次进程切换,一次数据读取有两处阻塞,单进程对单连接;

非阻塞 IO 模型解决了“两次进程切换,两处阻塞,单进程对单连接”中的“两处阻塞”问题,将“两处阻塞”变成了“一处阻塞”,但依然存在“两次进程切换,一处阻塞,单进程对单连接”的问题;

用一个进程监听多个连接的 IO 多路复用技术解决了“两次进程切换,一处阻塞,单进程对单连接” 中的“两次进程切换,单进程对单连接”,剩下了“一处阻塞”,这是 Linux 中同步 IO 都会有的问题,因为 Linux 没有提供异步 IO 实现;

Linux 的 IO 多路复用用三种实现:select、poll、epoll。select 的问题是:

a)调用 select 时会陷入内核,这时需要将参数中的 fd_set 从用户空间拷贝到内核空间,高并发场景下这样的拷贝会消耗极大资源;(epoll 优化为不拷贝)

b)进程被唤醒后,不知道哪些连接已就绪即收到了数据,需要遍历传递进来的所有 fd_set 的每一位,不管它们是否就绪;(epoll 优化为异步事件通知)

c)select 只返回就绪文件的个数,具体哪个文件可读还需要遍历;(epoll 优化为只返回就绪的文件描述符,无需做无效的遍历)

d)同时能够监听的文件描述符数量太少,是 1024 或 2048;(poll 基于链表结构解决了长度限制)

poll 只是基于链表的结构解决了最大文件描述符限制的问题,其他 select 性能差的问题依然没有解决;终极的解决方案是 epoll,解决了 select 的前三个缺点;

epoll 的实现原理看起来很复杂,其实很简单,注意两个回调函数的使用:数据到达 socket 的等待队列时,通过回调函数 ep_poll_callback 找到 eventpoll 对象中红黑树的 epitem 节点,并将其加入就绪列队 rdllist,然后通过回调函数 default_wake_function 唤醒用户进程 ,并将 rdllist 传递给用户进程,让用户进程准确读取就绪的 socket 的数据。这种回调机制能够定向准确的通知程序要处理的事件,而不需要每次都循环遍历检查数据是否到达以及数据该由哪个进程处理,日常开发中可以学习借鉴下这种思想。

1. Linux 怎样处理网络请求

1.1 阻塞 IO

要讲 IO 多路复用,最好先把传统的同步阻塞的网络 IO 的交互方式剖析清楚。

如果客户端想向 Linux 服务器发送一段数据 ,C 语言的实现方式是:

int main()
{
     int fd = socket();      // 创建一个网络通信的socket结构体
     connect(fd, ...);       // 通过三次握手跟服务器建立TCP连接
     send(fd, ...);          // 写入数据到TCP连接
     close(fd);              // 关闭TCP连接
}

服务端通过如下 C 代码接收客户端的连接和发送的数据:

int main()
{
     fd = socket(...);        // 创建一个网络通信的socket结构体
     bind(fd, ...);           // 绑定通信端口
     listen(fd, 128);         // 监听通信端口,判断TCP连接是否可以建立
     while(1) {
         connfd = accept(fd, ...);              // 阻塞建立连接
         int n = recv(connfd, buf, ...);        // 阻塞读数据
         doSomeThing(buf);                      // 利用读到的数据做些什么
         close(connfd);                         // 关闭连接,循环等待下一个连接
    }
}

把服务端处理请求的细节展开,得到如下图所示的同步阻塞网络 IO 的数据接收流程:

图1.1 同步阻塞网络IO的数据接收流程

主要步骤是:

1)服务端通过 socket() 函数陷入内核态进行 socket 系统调用,该内核函数会创建 socket 内核对象,主要有两个重要的结构体,(进程)等待队列,和(数据)接收队列,为了方便理解,等待队列前可以加上进程二字,其实不加更准确,接收队列同样;进程等待队列,存放了服务端的用户进程 A 的进程描述符和回调函数;socket 的数据接收队列,存放网卡接收到的该 socket 要处理的数据;

2)进程 A 调用 recv() 函数接收数据,会进入到 recvfrom() 系统调用函数,发现 socket 的数据等待队列没有它要接收的数据到达时,进程 A 会让出 CPU,进入阻塞状态,进程 A 的进程描述符和它被唤醒用到的回调函数 callback func 会组成一个结构体叫等待队列项&#

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值