我的多路复用I/O学习历程与理解
这篇文章是从系统学习Redis之Redis线程模型引出来的,学习的过程中发现很多东西其实不是很明白。比如它是怎么基于操作系统底层处理事件队列的——操作系统底层的epoll机制,但对于这个机制,当时可以说只知道大概念,细节完全搞不懂
于是开始疯狂查各种资料,查过的资料包含极客时间的《网络编程实战》性能篇,算是比较系统的了解了一点,但还是云里雾里(但是极客时间的课程真的很棒,真的强推,都是一些极富经验的大牛写的)。后来又学习了《美团技术团队》的《Java Nio简析》这篇文章个人感觉有些小的地方思维比较跳跃,不过整体来说还是很棒的,很系统。
后来,又从评论区了解到并深入学习了极客时间的《深入拆解Tomcat & Jetty》的《14|NioEndpoint组件:Tomcat如何实现非阻塞I/O》,个人觉得是对多路复用机制一次很好的实践的文章,但是可能是因为自己学习的顺序有点混乱,还是学的不是很明白。
直到我看到了Katio(从学习极客时间的《Redis核心技术和实战》的评论区知道他)的公众号推荐的一篇文章《彻底搞懂IO多路复用》,彻底搞明白了操作系统底层epoll机制的原理,以及I/O技术的发展历程,文章结合图文形象的描述,让人一下子就能看懂。
下面就基于我这些学习流程,详细讲述下我对I/O多路复用技术的理解:
同步阻塞I/O
步骤一: bind&listen new ServerSocket()
创建两个队列:全连接队列(链表结构)、半连接队列(hash结构)
步骤二: 阻塞等待接收连接(accept)serverSocket.accept()
将socket从半连接队列
添加到全连接队列
中
步骤三: 阻塞进行读取:socket.getInputStream()、inputStream.read()
读取的过程分为两步:从网卡读取到内核态(已就绪)、从内核态读取到用户态(真正读)
改造一:通过新线程来监听套接字实现假非阻塞
获取链接后,通过新的线程来完成读取的操作
改造二:操作系统改造read函数
read()函数在没有数据到达时,立刻返回-1,而不是阻塞的等待
非阻塞指的是从网卡拷贝到内核缓冲区这一部分,从内核缓冲区拷贝到用户态部分还是阻塞的
改造三:多路复用
每次接收到新的连接后,都去创建新的线程,会很快的导致线程资源消耗完成
如果把socket.accept()之后创建好的套接字转换为文件描述符放到一个数组中,然后用一个线程来遍历这个数组,正因为read()是非阻塞的,所以采用一个线程遍历这个文件描述符数组才有必要性,当发现有文件描述符可读(read()返回不是-1时),才需要进行阻塞的读取
但是又带来一个问题,每次遍历调用read()返回-1都是一次浪费,而且调用read()函数相当于系统调用,而且还是while循环进行系统调用吗,性能影响也是比较大的
select
因此就会想能不能让操作系统提供类似的函数,只需要进行一次系统调用,将文件描述符集合拷贝到内核态中,然后由操作系统(内核)去遍历这个文件描述符数组,遍历遇到read()返回值不是-1的(可读)标记为可读,然后将整个数组和可读的文件描述符的个数返回给用户态,供用户态进行读取(read()进行系统调用),只遍历内核态返回的可读的套接字,减少了无效的系统调用
epoll
epoll是为了解决select的个问题点
- 每次拷贝都是基于整个数组的拷贝
- 操作系统遍历文件描述符集合时是阻塞的
- select仅返回可读文件描述符的个数,具体哪个可读还需要用户自行遍历
进行了改进
- 内核保存一份文件描述符集合,用户进行系统调用,将文件描述符传递给内核,内核进行添加、修改和删除
- 内核不再通过轮询来判断文件描述符是否可读,而是通过异步IO事件进行唤醒
- 内核仅会将有IO事件的文件描述符返回给用户,用户无需遍历整个文件描述符
事件驱动:后台有个事件分发线程,当事件发生的时候,分发线程将事件放到事件队列中并绑定回调函数,它的任务就是为每个事件找到对应的回调函数并执行它
我掌握了这篇公众号学到的多路复用技术后,我又回头复习了极客时间的《网络编程实战》、《深入拆解Tomcat & Jetty》的《14|NioEndpoint组件:Tomcat如何实现非阻塞I/O》》的内容。这次感觉非常通透,I/O技术这一块也算是构建了一个完整的框架。
因为这些知识学习的最初是从Redis的线程模型一路延伸出来,接下来我还是继续打算回去学习Redis的下一个模块的。我还计划研究Netty框架,需要通过实践把I/O技术学的更加透彻,也期待到时候和大家分享