写在前面
近日一直在学习理解IO多路复用的相关概念。协程,epoll,阻塞非阻塞等等概念看了很多资料,博客,视频,感觉总是差点意思,直到刚才看了这篇文章才有一点融会贯通的感觉。很多底层概念是相互依赖的,只说其一就会分裂,只说用法不说原因就会莫名其妙。我一向喜欢从提出问题,解决问题的思路来去理解概念。下面讲讲我的一些个人理解
一些浅显的知识(或者说常识
- 线程是CPU调度的最小执行单位。CPU给每个线程分配时间片。当线程时间片用完,或者由于某些原因阻塞(如IO阻塞)时,操作系统会进行调度,切换线程的上下文。虽然我们说线程切换的代价相对于进程切换来说小了很多,但是仍然是不可忽略的系统开销。
- 对于进程与线程之间的关系,并不建议用树的结构去理解,也就是 一个进程里面有包含多个线程。虽然逻辑是这样的,但是我更推荐从物理上去理解:所有的线程有一张表,所有的进程有一张表,二者互不干涉。只不过每一个线程都会标识属于哪一个进程,这样不同线程间就能共享一个进程的地址空间,资源。引自这个视频
这里做一个我理解的图。进程其实相当于一个家,一个存放资源的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,恢复协程的上下文。
这样,就解决了阻塞的问题。
感觉看别人的博客,视频终归很难形成自己的理解。还是要多多结合源码