前言
Linux系统中断
打个简单的比方,假设你正在家看电影,等待送上门的外卖,这时外卖员敲响了你家的门。此时你正常的流程应当如何?当然是单击暂停键,去门口取外卖,拿回来,边看电影边吃。一个中断的流程也是如此:CPU运行某些进程,当一个外部设备信号传过来时(如键盘写入),对应的信息被硬件上的DMA设备转移到对应的缓冲区,此时硬件会向CPU发起一条中断信号,CPU取得信号后保存进程上下文(在进程描述符:task_struct中)切换到内核态(特权级R0),查找对应的中断处理程序进行处理,处理后再进行上下文切换,CPU并发执行多道程序。
(硬中断:由外部设备随机产生的一种异步中断,不同于软中断,是一种来自CPU的内部事件或程序执行中的事件所引起的过程,系统调用也是一种软中断)
系统调用
所谓系统调用,指的是某些应用程序需要申请系统级资源时向操作系统申请调用的接口。接下来我们简单介绍下系统调用的流程:
1 | //用户空间 |
1 | //IRQ 中断程序入口映射表/中断向量表(内核启动阶段即加载) |
1 | //内核空间 |
Socket基础
1.工作流程
- 服务端要做的事如下:
- 1:创建ServerSocket对象,绑定(bind)监听(listen)端口
- 2:调用accept()方法生成Socket监听客户端请求
- 3:建立连接后读取/写入数据
- 4:关闭资源
- 客户端要做的事如下:
- 1:创建Socket对象连接指定ip及端口
- 2:建立连接后读取/写入数据
- 3:关闭资源
2.socket读写缓冲区工作机制
每个TCP Socket在内核中都有一个发送缓冲区和一个接收缓冲区, TCP的全双工工作模式以及TCP的滑动窗口就是依赖这两个独立的buffer以及buffer的填充状态。
对端发送过来数据,内核把数据缓存入接收缓冲区,应用程序一直没有调用read()读取的话,此数据会一直缓存在相应的socket的接收缓冲区。 若应用程序调用read(),会把接收缓冲区的数据读取用应用程序层的buffer。应用程序调用write()或send()时,仅仅是把buffer中的数据copy到socket的发送缓冲区中。write()或send()返回时,data并不一定已经发送到对端了。
注意,当socket close关闭的时候,输入缓冲区无论是否读完,都会随着socket的消亡而直接释放,而TCP/IP会保证将输出缓冲区内消息全部发送后才释放。
IO多路复用
所谓 I/O 多路复用指的就是 select/epoll 这一系列的多路选择器:支持单一线程同时监听多个文件描述符(I/O 事件),阻塞等待,并在其中某个文件描述符可读写时收到通知。
首先我们先说一下什么是文件描述符(File descriptor),根据它的英文首字母也简称FD,它是一个用于表述指向文件的引用的抽象化概念。它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。
select
1 | int select(int nfds, |
writefds、readfds、和exceptfds是三个文件描述符集合。select会遍历每个集合的前nfds个描述符,分别找到可以读取、可以写入、发生错误的描述符,统称为就绪的描述符。这个所谓的描述符集合的数据结构其实就是一个bitmap数组,内核用一个位来表示一个文件描述符,从内核定义来看,一共有1024个位。0/1分别表示不同状态。
timeout参数表示调用select时的阻塞时长。如果所有文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞超过设置的 timeout 后,返回。如果timeout参数设为 NULL,会无限阻塞直到某个描述符就绪;如果timeout参数设为 0,会立即返回,不阻塞。
select执行时,操作系统把执行select的进程放到响应socket的等待队列中并阻塞,当任何一个 Socket 收到数据后,中断程序将唤起进程。所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面。当进程 A 被唤醒后,它知道至少有一个 Socket 接收了数据,此时程序只需遍历一遍fdset,就能找到就绪的描述符。
这样的问题在于:
每次select都需要将进程加入到监视socket的等待队列,每次唤醒都要将进程从socket等待队列移除。这里涉及两次遍历操作,而且每次都要将fdset数组传递给内核,有一定的开销。
进程被唤醒后,只能知道有socket接收到了数据,无法知道具体是哪一个socket接收到了数据,所以需要用户进程进行遍历,才能知道具体是哪个socket接收到了数据。当数据量足够大时,会明显降低性能。
select和poll其实本质上没有明显区别,只是poll不再采用最大上限1024位的bitmap传递fds,而是采用链表,增加了监视socket的数量。
epoll
epoll很好的解决了如上select带来的问题。
1 | //example |
如上epoll的使用实例,先用epoll_create创建一个epoll对象实例eventpoll,同时返回一个引用该实例的文件描述符,返回的文件描述符仅仅指向对应的epoll实例,并不表示真实的磁盘文件节点。
创建 Epoll 对象后,可以用 epoll_ctl 添加或删除所要监听的Socket fd。同时为fd设置一个回调函数,并监听事件event,并添加到监听列表中。当有事件发生时,会调用回调函数,并将fd添加到epoll实例的就绪列表上。
最后调用epoll_wait阻塞监听 epoll 实例上所有的fd的 I/O 事件。当就绪列表中已有数据,那么epoll_wait直接返回,解决了select每次都需要轮询一遍的问题。
epoll实例内部存储:
- 监听列表:所有要监听的文件描述符,使用红黑树;
- 就绪列表:所有就绪的文件描述符,使用链表;
综上,epoll引入了eventpoll这个中间结构,它通过红黑树来组织所有待监控的socket对象,实现高效的查找,删除和添加。当收到网络数据时,会触发对应的fd的回调函数,这时不是去遍历各个fd的等待队列进行唤醒进程的操作了,而是把收到数据的socket加入到就绪列表(底层是一个双向链表)。eventpoll有个单独的等待队列来维护待唤醒的进程,避免了像select那样每次需要遍历fdset来查找各个fd的等待队列的进程。
推荐阅读
本文仅是抛砖引玉,更多细节要看这些:
select细节剖析:Linux select内核源码剖析_JT同学的博客-CSDN博客_linux select源码
epoll细节剖析:epoll使用详解:epoll_create、epoll_ctl、epoll_wait、close - 雾穹 - 博客园 (cnblogs.com)
理解清晰生动形象:一文搞懂,网络IO模型 - 知乎 (zhihu.com)
在golang中的引申:[详解Go语言I/O多路复用netpoller模型 - luozhiyun`s Blog](https://www.luozhiyun.com/archives/439)
更深的解析(极力推荐):Strike Freedom