从解决问题的思路去理解IO复用的由来

写在前面

近日一直在学习理解IO多路复用的相关概念。协程,epoll,阻塞非阻塞等等概念看了很多资料,博客,视频,感觉总是差点意思,直到刚才看了这篇文章才有一点融会贯通的感觉。很多底层概念是相互依赖的,只说其一就会分裂,只说用法不说原因就会莫名其妙。我一向喜欢从提出问题,解决问题的思路来去理解概念。下面讲讲我的一些个人理解

附上大神的文章

一些浅显的知识(或者说常识

  1. 线程是CPU调度的最小执行单位。CPU给每个线程分配时间片。当线程时间片用完,或者由于某些原因阻塞(如IO阻塞)时,操作系统会进行调度,切换线程的上下文。虽然我们说线程切换的代价相对于进程切换来说小了很多,但是仍然是不可忽略的系统开销。
  2. 对于进程与线程之间的关系,并不建议用树的结构去理解,也就是 一个进程里面有包含多个线程。虽然逻辑是这样的,但是我更推荐从物理上去理解:所有的线程有一张表,所有的进程有一张表,二者互不干涉。只不过每一个线程都会标识属于哪一个进程,这样不同线程间就能共享一个进程的地址空间,资源。引自这个视频
    在这里插入图片描述
    这里做一个我理解的图。进程其实相当于一个家,一个存放资源的base,不同的线程通过base去访问资源。这里花篇幅陈述这个概念,是希望大家明确:线程是调度的基本单位,建立起这个清晰的概念。

下面回归正题:

阻塞式IO

listenfd = socket();   // 打开一个网络通信端口
bind(listenfd);        // 绑定
listen(listenfd);      // 监听
while(1) {
  connfd = accept(listenfd);  // 阻塞建立连接
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接,循环等待下一个连接
}

这是服务端处理客户端请求的代码

显然,accept函数,read函数都可能会发生阻塞。
在accept这里发生阻塞很正常,但是read发生阻塞,会有问题:
read被阻塞,我这个线程就阻塞挂起了,切换到其他线程,那就意味着它无法再去accept其他客户端的请求。(因为当前是单线程或者说单进程来处理请求)。

在这里插入图片描述
为了解决:**“它无法再去accept其他客户端的请求”**这个问题,我们这样修改代码

while(1) {
  connfd = accept(listenfd);  // 阻塞建立连接
  pthread_create(doWork);  // 创建一个新的线程
}
void doWork() {
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接,循环等待下一个连接
}

这样每一个连接请求用一个线程来处理:主线程接收到连接请求就建立一个子线程来处理它,然后继续监听请求。这样就避免了read函数阻塞带来的问题。

**但是带来新的问题:**比方说现在有线程A,分配到了CPU的时间片,然后他去执行doWork函数,走到read函数,发现客户端没给发数据,所以只能阻塞,阻塞就要挂起,就要由用户态切换到内核态,就要进行线程上下文切换,就有开销
试想,如果n个线程都没有收到信息,那cpu的就在它们之间反复横跳,啥也没干,净切换线程上下文。

所以read函数阻塞的问题一定要解决

非阻塞式IO

操作系统为我们提供非阻塞的read系统调用,如果当前没有可读的内容,read函数就返回-1,然后线程去干其他事情,而不是导致整个线程阻塞挂起。
线程会采取轮询的方式,检查read是否有返回值。
当read返回值不为1时,说明数据已经从网卡拷贝到了内核的缓冲区,则开始将数据从内核的缓冲区load到用户的数据区。

在这里插入图片描述
到这里似乎已经很完美。 **但是其实还是有问题:**线程轮询使用read系统调用,还是会导致用户态与内核态的频繁切换。

刚才,操作系统是在不停地切换线程,切换用户态与内核态;现在没有频繁切换线程,但是仍然在频繁地切换内核态与用户态。

比方说,现在某个线程分配到了CPU的时间片,美滋滋去doWork,使用一次read系统调用,发现返回为-1,然后就去干其他的,每隔一会儿轮询使用一次read,每次都会从用户态切换到内核态,检查fd,再切回去。好好一个时间片啥也没干,全用在系统调用,切换状态上了。

如果IO等待时间不长,倒问题不大;如果等待时间长,CPU就处于空耗状态。
此外,一个线程对应一个连接,无法适应高并发的问题。毕竟线程需要占用资源,而系统资源是有限的。

IO多路复用

相比于**“一线程,一连接”的方式,我们能不能“一线程,多连接”**呢?(这里可以引入协程的概念)
这正是IO多路复用的核心思想。我们用一个线程监听多个连接的fd(文件描述符),再对每一个fd调用非阻塞的read。这样就避免了线程过多的问题。

你会想问,那非阻塞轮询导致用户态与内核态频繁切换的问题还是没解决啊?
别急,select,poll,epoll最终会给出答案。

这三个方法的核心都是,将轮询导致无意义的check,转交给内核来完成(用户态轮询->内核态轮询),这样只需要一次内核-用户态切换就行了。

源码级讲解看这篇文章

  • select方案

我们用一个线程A,不断地监听客户端的请求,并将生成的fd添加到一个集合当中。

我们用另外一个线程B,调用select函数,将bitmap传入到内核,由内核轮询check各个fd的状态,如果fd可读,则将相应的位图置为1,否则为0. 此时B是阻塞状态的。
当select函数返回后,B重新获得CPU,根据位图,B能知道哪些fd已经可以读入数据,虽然仍需要遍历整个集合,但是不再需要对没有准备好的fd去调用read,避免了无意义的系统调用。
在这里插入图片描述
可以看出,总开销是一次select系统调用+若干次read系统调用

  • poll方案

poll方案的进步在于声明了一个数据结构来表示fd,打破了select只能监听1024个fd的限制(因为这个数据结构的数组,想开多大就开多大)

  • epoll方案

epoll方案将文件描述符的集合变成了内核态与用户态共享。这样省去了select调用时由用户态切换为内核态的开销。

此外,epoll会告诉用户有多少个fd是已经准备好的,并将它们放到列表的最前面(底层用红黑树来组织),这样epoll返回后,就不再需要遍历整个集合,而只需要遍历准备好的n个,并调用read即可。

内核也不再通过轮询的方式找到就绪的文件描述符,而是谁准备好了就通知一声,内核就把它的fd放到列表前面,也就是异步的方式。

epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。
LT模式是默认模式在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。

ET模式是高速工作方式,。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

到这里似乎所有问题都得到了完美的解决,其实不然。
虽然epoll函数是非阻塞的,也就是判断数据是否已经从网卡拷贝到内核缓冲区是非阻塞的,但是,实际读取,将数据从内核拷贝到用户数据区,这个过程依然是阻塞的!

只要阻塞,就会涉及到线程切换。

这里引入协程的概念。协程本质是一些函数,能够在用户态进行切换,所以代价比线程切换要小得多。因此协程也叫做用户态线程。这里我们将read函数做一步封装,封装成一个函数,也就是一个协程。如果当前协程A的read被阻塞了,就会切换到另一个协程B去read,当A read完之后,自动切换回A,恢复协程的上下文。

这样,就解决了阻塞的问题。

感觉看别人的博客,视频终归很难形成自己的理解。还是要多多结合源码

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值