新人菜鸟,尝试讲清楚io这件事情
主要包括 bio,nio,select,epoll
可能也会包含一些 mmap,零拷贝 等一些系统调用
本文中的一些例子主要以
linux
+网络io
为主
闲扯淡
- 为啥要彻底弄懂IO这件事情?
- 面试的时候问:redis网络模型是什么啊?nginx网络模型是什么啊?
- epoll 啊,这就需要牵扯到IO的事情了
- 其实IO这就是就是一个成本的问题,因为用户进程不能直接操作硬件,无法直接从网卡读取数据,需要走 kernel 这一层,必须通过 systemcall(软中断),作用在 cpu 上
- 让kernel帮我们取到硬件上的数据,而这个操作就是成本所在,如何减少这一步的消耗,成为了网络吞吐的一个关键点,有关IO技术的发展,也都是基于这一个出发点的
操作系统的预备知识
- 有关这个部分的知识请看我的另外一篇文章用户态 内核态
- 我在这儿把重点列一下
- 操作系统启动之后的第一个进程是
kernel
(操作系统内核)kernel
会注册 GDT
kernel
运行在Range0
级别,用户进程运行在Range3
级别- 内核 控制着计算机硬件资源(eg:磁盘,网卡)
- 用户进程需要进行 磁盘读写、网络通信等io事件的时候需要通过系统调用来实现,这个时候进程从
Ring3
切换到Ring0
级别,完成相应的操作之后再切回Ring3
- 除去上面说说的 io 事件需要进行
用户态
&内核态
的切换,例如申请内存之类的也是需要的
- 操作系统启动之后的第一个进程是
服务器架构图(无论什么语言)
- 系统调用 socket 得到一个文件描述符fd5
- 系统调用 bind 一个端口
- 系统调用 listen 文件描述符fd5
- 系统调用 accept(阻塞) fd5 接受连接,返回一个新的文件描述符 fd6,代表与客户端的连接
- 系统调用 read/rerecvfrom fd6 读取数据
五种 IO 模型
在神作《UNIX 网络编程》里,总结归纳了 5 种 I/O 模型,包括同步和异步 I/O:
- 阻塞 I/O (Blocking I/O)
- 非阻塞 I/O (Nonblocking I/O)
- I/O 多路复用 (I/O multiplexing)
- 信号驱动 I/O (Signal driven I/O)
- 异步 I/O (Asynchronous I/O)
操作系统上的 I/O 是用户空间和内核空间的数据交互,因此 I/O 操作通常包含以下两个步骤:
- 等待网络数据到达网卡(读就绪)/等待网卡可写(写就绪) –> 读取/写入到内核缓冲区
- 从内核缓冲区复制数据 –> 用户空间(读)/从用户空间复制数据 -> 内核缓冲区(写)
而判定一个 I/O 模型是同步还是异步,主要看第二步:数据在用户和内核空间之间复制的时候是不是会阻塞当前进程,如果会,则是同步 I/O,否则,就是异步 I/O。基于这个原则,这 5 种 I/O 模型中只有一种异步 I/O 模型:Asynchronous I/O,其余都是同步 I/O 模型。
这 5 种 I/O 模型的对比如下:
Bio
block io
同步阻塞I/O模式,数据的读取写入必须阻塞在一个线程内等待其完成。
采用 BIO 通信模型 的服务端,通常由一个独立的 Acceptor 线程负责监听客户端的连接。我们一般通过在while(true) 循环中服务端会调用 accept() 方法等待接收客户端的连接的方式监听请求,请求一旦接收到一个连接请求,就可以建立通信套接字在这个通信套接字上进行读写操作,此时不能再接收其他客户端连接请求,只能等待同当前连接的客户端的操作执行完成, 不过可以通过
多线程
来支持多个客户端的连接,如上图所示。
1 | // 新建socket |
Bio 中的问题?
- 线程数量太多,创建线程也需要走系统调用clone出来,走软中断,系统调用多
- 线程消耗计算机资源:线程栈是独立的,堆是共享的
- CPU在某个时间点只可以有一个线程执行,线程多的话会造成频繁切换
Bio 如何优化?
- 总的来说,bio模型中就是因为 socket 是阻塞的模式,导致需要多线程去解决
- 系统调用 socket 也可以设置成 NONBLOCK(每次系统调用不阻塞,有数据返回数据,没数据直接返回)
- 这就有了后面的nio,一个线程就可以解决n多客户端的连接问题
Nio
- 有人称它为
new io
, 也有人称为non-block io
- 一种同步非阻塞的I/O模型
- 不用开设很多线程,是对 bio 的优化
nio 的问题是什么?
C10K/C100K 问题
:很多客户端连接时候的问题,如果有1w个客户端,每次循环会依次去查询这1w个socket是否有数据- 每次循环会有 O(n) 级别的系统调用(SC),但是可能只有 10 个连接中有数据,大量的系统调用,而且大部分都是无数据的
nio 如何优化?
- 比如 n 个连接中有 m 个 socket 有数据,将原本 O(n) 级别的系统调用降低到 O(m) 级别?
- 系统内核增加了一个系统调用
select
Select
多路复用器(多条路/多个连接 复用了同一个系统调用)
同步模型 : 多路复用器只是返回一个fd状态,具体的读/写还是得进程自己去操作,所以说是同步的
该系统调用允许程序监控一个/多个文件描述符,直到一个/多个文件描述符变成可用状态
1
select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, const struct timespec *timeout, const sigset_t *sigmask);
在这种模型之下,服务器的模式就变成这样:
- 有1w个连接(1w个文件描述符),通过 select 系统调用返回可用的文件描述符 -> 时间复杂度为O(1)
- 对于可用的m个文件描述符,进行读操作 -> 时间复杂度为O(m)
select 的问题是什么?
- 假如有1w个连接(1w个文件描述符),每次循环一次调用一次selelct,每次select我需要将这一万个文件描述符传进去,内核对这1w个fd进行遍历,时间复杂度为 O(n),但这与nio中的 O(n) 不同,后者是 O(n) 次系统调用。
- 每次 select 都需要大量传值(1w个fd)
- 每次 select 内核都需要 O(n) 的遍历
select 如何优化?
- 如果内核中可以开辟一个空间,服务器每次 accept 到一个连接(产生一个文件描述符fd),服务器将fd传入内核,让内核保存,这样就可以减少传递的过程
- 内核事件驱动模式,网卡收到数据,内核将这些 fd 进行标记,后续服务器通过系统调用得到这些有数据的fd即可
- 上面说的这种事件驱动的实现是通过中断来实现
- kernel 会为硬件驱动在内存中开辟 DMA 空间
- 网卡收到数据,会将数据放到 DMA 空间中
- 网卡通过硬中断,中断CPU,让CPU去 DMA 空间中读取数据,得到哪个文件描述符(哪个socket)有数据
- 这样内核就可以直到哪些 fd 中有数据
- 总的来说,上述的两点分别解决了select的 问题2 & 问题3,为此,系统内核增加了系统调用
epoll
epoll
- event poll
- 同步模型 : 多路复用器只是返回一个fd状态,具体的读/写还是得进程自己去操作,所以说是同步的
- 多路复用器(多条路/多个连接 复用了同一个系统调用)
1
2
3
4
int epoll_create(int size); // int epoll_create1(int flags);
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 使用的流程
- 通过
epoll_create
系统调用在内核创建一块区域,返回一个 fd - 当有 连接建立/连接断开,通过
epoll_ctl
系统调用来对内核的这块空间进行修改 - 每次循环通过
epoll_wait
来获取网络数据/事件
epoll 的边缘触发(ET)和水平触发(LT)
epoll的默认模式是水平触发。
先大概了解一下这两种触发模式有什么不同:
- 水平触发(Level Trigger,也称条件触发):只要满足条件,就触发一个事件(只要有数据还未读完,就会一直触发)
- 边缘触发(Edge Trigger):每当状态发生变化时就触发一个事件。
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。
可能概念不容易理解,这里举一个例子大概就能明白两者的区别了:比如某个人让你去买几袋酱油,你只买了一袋回去,水平触发的做法就是他让你继续去把剩下的几袋酱油买回来,如果没有完成任务,就一直通知你;边缘触发的做法就是不管完没完成任务,反正他让你买了,买没买完就是你自己的事了,下次买酱油这件事他就不管了,会让你去做其它的事。
当我们使用边缘触发时,将对应的文件描述符设置为非阻塞即可。
- 因为 read 的时候可能一次性读不完,需要多次读数据,也就是一直读,直到读到EGAIN(EGAIN说明缓冲区已经空了)为止,如果是阻塞的话,会导致整个线程阻塞
设置方法
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。
第一个参数是epoll_create()的返回值,
第二个参数表示动作,用三个宏来表示:
- EPOLL_CTL_ADD:注册新的fd到epfd中;
- EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
- EPOLL_CTL_DEL:从epfd中删除一个fd;
第三个参数是需要监听的fd,第四个参数是告诉内核需要监听什么事,struct epoll_event结构如下:
1
2
3
4struct epoll_event {
__uint32_t events; /* Epoll events /
epoll_data_t data; / User data variable */
};events可以是以下几个宏的集合:
- EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
- EPOLLOUT:表示对应的文件描述符可以写;
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
- EPOLLERR:表示对应的文件描述符发生错误;
- EPOLLHUP:表示对应的文件描述符被挂断;
- EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。
小结
- 综合一下上面所说的几种网络模型,有这样的一个问题:不同网络模型中,CPU在执行什么?
- select : 部分时间是在调用select之后遍历fds,这部分时间就无法执行业务代码
- epoll :cpu无需遍历fds,只有在中断的时候(必定有数据到达)才去读数据 -> 尽量不浪费cpu
几个IO相关的应用
redis
- redis是nonblick的(轮询的),而 nginx 是阻塞的
- redis 只有一个线程,这个线程除去要干io的事情需要干很多其他的事情(eg:客户端请求、LRU,LFU 的淘汰过滤、RDB/AOF)
- nginx 就是等待客户端请求,在进行后续操作,没有其他的工作
- redis 是单线程,串行化
- redis 使用了 epoll 解决io消息事件的问题,即无论多少连接,epoll会告诉我去读/写哪些fds
- 在redis 6.0 之前是纯单线程版本,一个线程不仅仅完成io数据的读写,还需要完成计算,并且返回客户端
- redis 6.x 之后引入了多线程(IO threads)
- 通过epoll拿到多个可读写的fds
- redis允许多线程去读io(可以充分发挥CPU多核性能)
- 但是读取到数据之后的计算操作依旧是有同一个线程去完成的(保证了操作的原子性+串行化)
- 完成计算之后,需要返回客户端,这部操作可以由其他的线程完成
kafka
- 零拷贝(前提:数据不需要加工,数据就是我磁盘中的某个文件)
- 消息队列,支持持久化(存磁盘)
- 由于需要存磁盘,绕不开 kernel
- kafka 是分 segment 的,每一个 segment 的大小可以配置,每一个 segment 就是对于一个磁盘文件
生产者发送数据
- 通过 epoll + recvfrom/read系统调用 获取数据,给数据加头部
- 写数据的时候,如果直接通过 kernel,需要走系统调用,消耗很大
- 通过 mmap 系统调用完成
- 其实就是使用 kernel 的缓存页
消费者取数据
- 读取数据,kafka发现数据在磁盘中,一般流程是这样的:
- kafka进程通过系统调用去读磁盘,获得数据
- kafka进程再通过 write系统调用 将数据写到socket中
- 有两次系统调用
- kafka其实是通过另外一个系统调用
sendfile
1
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
- 通过上述系统调用,直接将两个 fd 传入(磁盘文件,socket),直接将数据发出
- nginx 也使用了
sendfile