IO多路复用

168 阅读8分钟

先了解一下socket

socket是对TCP/IP协议的封装,应用层和TCP/IP协议簇之间的抽象层,对用户而言只需要调几个封装好的接口

工作流程:

服务端初始化socket,bind绑定端口,listen对端口监听,并等待客户端连接
客户端初始化socket连接,connect与服务端建立连接,请求数据
服务端返回数据给客户端,结束连接

文件描述符(fd)

在linux系统中一切皆文件,内核利用文件描述符来访问文件
我们可以在linux上执行 nc -l 80 启动一个服务
ps 获取nc进程id,在/proc/{pid}/fd 文件下可以看到有个文件描述符3指向刚刚启动的80端口的服务

我们可以再模拟一个clinet与8080服务进行通信:nc localhost 8080
可以看到fd下有了一个文件描述符4指向与80端口建立socket连接的client

再补充一点知识 ...

Linux内核

我们知道计算机的组成有CPU、内存、硬盘以及网卡声卡等,而在一个操作系统中,谁来管理这些硬件呢?比如我们运行一段代码,我们需要cpu来计算,需要内存来存储我们的变量,以及硬盘存储我们的一些资源文件,这时候计算机是怎么调度这些硬件设备呢?

应用程序运行在操作系统之上,操作系统通过内核去访问硬件资源
内核是操作系统的重要组成部分,对应用程序执行流程管理,负责整个硬件的驱动

用户态、内核态
用户态:应用程序运行的空间
内核态:控制计算机硬件资源

当我们运行一段程序就可能存在用户态和内核态的切换,因为一些硬件的操作会交给内核做。用户不能直接操作系统底层资源,同时也可以保证操作体统的安全提供更稳定的运行环境

触发用户态到内核的切换:
系统调用:应用程序向操作系统发出的服务请求
中断:来自硬件设备的处理请求
异常:非法指令或其它原因导致当前指令执行失败

像我们申请堆内存会调用malloc() 函数,这是在用户态,而malloc函数实际上会调用系统的brk()或者mmap()函数分配一段虚拟空间,此时属于内核态

linux 执行man syscalls可以看到所有内核提供系统调用的函数

I/O模型

linux 有5种I/O模型:

1、阻塞I/O(blocking IO)

2、非阻塞I/O (nonblocking I/O)

3、I/O 复用 (I/O multiplexing)

4、信号驱动I/O (signal driven I/O)

5、异步I/O (asynchronous I/O)

比如我们现在需要设计一个服务,它可以接受多个客户端同时请求,你会怎么设计?
下面我们将顺着阻塞I/O-->非阻塞I/O-->I/O多路复用这条线路展开

阻塞I/O

假设客户端C1通过connect与服务端建立连接,服务端会一直blocking,直到与C1交互完成,别的请求才可以进来

服务器要支持同时处理多个客户端的请求,就需要开多个线程去处理不同客户端的请求来达到并发。但是如果有上万个客户端同时请求,你的服务就可能支撑不住了,因为多线程上下文切换需要大量的计算,带来消耗非常高

什么是上下文切换呢?

线程是CPU最小的执行单位,一个CPU在某一时刻只能执行一个任务,CPU通过时间片轮转算法执行任务,当一个时间片执行完成后会切换到下一个线程,时间片很短,看起来像多个线程并发执行

假设我的代码有20行,由于时间片很短,很有可能执行到第5行就被切换到下一个任务,此时需要保存当前线程的运行状态,等待CPU下一次的调度,恢复状态继续执行即可

上下文切换简单来说就是存储和还原CPU的状态,当线程的数量上来时,上下文切换对CPU的开销是非常大的

那么单个线程可以实现多个客户端的请求吗?

非阻塞I/O

假设客户端C1通过connect与服务端建立连接,并开始发出请求向服务端write数据,同时客户端C2也想与服务端建立连接,服务端此时不阻塞并且接受C2的请求,服务端会把客户端的fd都放在一个数组,然后依次遍历处理每个客户fd到内核空间询问是否有数据到达

那么,服务器同时处理多个客户端的请求,使用单线程就可以支持了。用一个 循环监听各个客户端是否有数据到达

带来问题:比如同时有1w客户端连接时,那么数组里面会有1w个客户端的fd,如果前面9999个客户端都没有数据达到,也需要经过一次系统内核调用,也就是用户态到内核态的切换

I/O多路复用

select

select和非阻塞I/O一样,单线程支持多个客户端同时连接,假设C1、C2同时向服务端发送请求,服务端同样会把客户端的fd放在一个数组里面。不同的是此时的fdset会从用户态拷贝到内核态,存在内核里面,select在内核里面循环遍历客户端fdset是否有数据到达,如果有就返回给服务端做相应的处理

其实两者的区别就在于:非阻塞I/O是将遍历fdset的这件事情放到用户空间来做,而selsect从用户空间拷贝到内核空间,在内核里做

优化的地方是:只需一次全量从用户态拷贝到内核态,之后都只返回有数据到达的fd,服务端只需要拿到有数据返回的fd去内核空间取就可以了,不需要每次都遍历全部的fd去内核态取有数据返回的fd

我们经常会听到select会有限制1024个大小,但其实这个也是可以往上调的。为什么会是1024呢?

内核里面select会遍历fdset来拿到有数据到达的fd,select接收的参数并不是fdset的一个数组,而是一个bipmap,bitmap默认是1024,比如有fdset(fd4,fd5,fd8...) ,那么bitmap会是(00011001...)

带来问题:虽然fdset放到了内核空间去监听,但是假设有1w个客户端,还是需要循环遍历1w次

Poll

poll和select的工作原理一样,select和poll函数的区别在于它们定义的结构体不一样,参数不一样
1、selsect传入的是fd的bitmap会有1024的限制,poll传入的是fd数组,监听的描述符几乎不受限制
2、select监听的fd如果有数据到达会会将bitmap的fd置位做一个标记,这会导致每次重新遍历都需要生成一个新的bimap,而poll的结构体里面直接有个revevt参数标记,有数据到达只需置为1,重新遍历的时候将其置为0

epoll

epoll主要提供3个系统函数:

1、epoll_create(int size):创建eventpoll对象,并返回一个与之关联的文件描述符fd

2、epoll_ctl(int epfd,int op,int fd,struct epoll_event * event):应用程序向epoll中添加、删除和修改感兴趣的事件

epfd: epoll_create()返回的文件描述符
op:操作动作(添加、修改或删除)}
fd:客户端的描述符
epoll_evevt:内核监听的事件对象

3、epoll_wait(int epfd,struct epoll_event * event,int maxevents, int timeout):等待就绪事件,返回就绪事件个数

工作流程:服务器在初始化socket并开始监听时,会调用epoll_create() 方法,创建eventpoll对象并返回一个和对象关联的文件描述符,比如fd4,此时会在内核里面开辟一块fd4的空间。当有客户端C1向服务端建立连接时,会通过epoll_ctl()方法,将C1的文件描述符、操作动作以及事件对象放到fd4下的空间。此时epoll_wait()会等待就绪事件,如果事件就绪会从fd4下的空间拷贝到一个就绪列表,服务端从就绪列表消费

相对于selsect、poll,epoll不用循环遍历客户端的fdset来获取有数据到达的fd,而是把就绪的fd放到一块空间,服务端可以直接获取客户端请求的数据

可以扫码关注微信公众号,一起学习交流