您当前的位置:首页 > 电脑百科 > 程序开发 > 编程百科

彻底搞懂 IO 底层原理

时间:2020-12-03 12:30:23  来源:  作者:

武侠小说里有很多的“心法”和“招式”。计算机技术里的“心法”和“招式”呢,我们可以简称为“道”和“术”;

“道” 最基础的计算机理论,隐藏于表象之下,非常抽象、晦涩难懂,需要用具象化的事物加以理解;

“术” 具体的技艺,它有可能是一门语言,比如:Python 出手见效快;

我们今天要给大家讲的底层的IO就属于“道”的范畴,看上去简单,实则抽象。并且在它之上衍生出了语言层面用于实战的技术,比如我们熟悉的JAVA语言中的NIO或者像Netty这样的框架

一、混乱的 IO 概念

IO是Input和Output的缩写,即输入和输出。广义上的围绕计算机的输入输出有很多:鼠标、键盘、扫描仪等等。而我们今天要探讨的是在计算机里面,主要是作用在内存、网卡、硬盘等硬件设备上的输入输出操作。

谈起IO的模型,大多数人脑子里肯定是一坨混乱的概念,“阻塞”、“非阻塞”,“同步”、“异步”有什么区别?很多同学傻傻分不清,有尝试去搜索相关资料去探究真相,结果又被淹没在茫茫的概念之中。

这里尝试简单地去解释下为啥会出现这种现象,其中一个很重要的原因就是大家看到的资料对概念的解释都站在了不同的角度,有的站在了底层内核的视角,有的直接在java层面或者Netty框架层面给大家介绍API,所以给大家造成了一定程度的困扰。

所以在开篇之前,还是要说下本文所站的视角,我们将会从底层内核的层面给大家讲解下IO。因为万变不离其宗,只有了解了底层原理,不管语言层面如何花里胡哨,我们都能以不变应万变。

二、用户空间和内核空间

为了便于大家理解复杂的IO以及零拷贝相关的技术,我们还是得花点时间在回顾下操作系统相关的知识。这一节我们重点看下用户空间和内核空间,基于此后面我们才能更好地聊聊多路复用和零拷贝。

彻底搞懂 IO 底层原理

 

硬 件 层(Hardware)

包括和我们熟知的和IO相关的CPU、内存、磁盘和网卡几个硬件;

内核空间(Kernel Space)

计算机开机后首先会运行内核程序,内核程序占用的一块私有的空间就是内核空间,并且可支持访问CPU所有的指令集(ring0 - ring3)以及所有的内存空间、IO及硬件设备;

用户空间(User Space)

每个普通的用户进程都有一个单独的用户空间,用户空间只能访问受限的资源(CPU的“保护模式”)也就是说用户空间是无法直接操作像内存、网卡和磁盘等硬件的;

如上所述,那我们可能会有疑问,用户空间的进程想要去访问或操作磁盘和网卡该怎么办呢?

为此,操作系统在内核中开辟了一块唯一且合法的调用入口“System Call Interface”,也就是我们常说的系统调用,系统调用为上层用户提供了一组能够操作底层硬件的API。这样一来,用户进程就可以通过系统调用访问到操作系统内核,进而就能够间接地完成对底层硬件的操作。这个访问的过程也即用户态到内核态的切换。常见的系统调用有很多,比如:内存映射mmap()、文件操作类的open()、IO读写read()、write()等等。

三、IO模型

1、 BIO(Blocking IO)

我们先看一下大家都熟悉的BIO模型的 Java 伪代码:

ServerSocket serverSocket = new ServerSocket(8080);        // step1: 创建一个ServerSocket,并监听8080端口
while(true) {                                              // step2: 主线程进入死循环
    Socket socket = serverSocket.accept();                 // step3: 线程阻塞,开启监听
     
    BufferedReader reader = new BufferedReader(nwe InputStreamReader(socket.getInputStream()));
    System.out.println("read data: " + reader.readLine()); // step4: 数据读取
 
 
    PrintWriter print = new PrintWriter(socket.getOutputStream(), true);
    print.println("write data");                           // step5: socket数据写入
}

这段代码可以简单理解成一下几个步骤:

  • 创建ServerSocket,并监听8080端口;
  • 主线程进入死循环,用来阻塞监听客户端的连接,socket.accept();
  • 数据读取,socket.read();
  • 写入数据,socket.write();

问题

以上三个步骤:accept(...)、read(...)、write(...)都会造成线程阻塞。上述这个代码使用了单线程,会导致主线程会直接夯死在阻塞的地方。

优化

我们要知道一点“进程的阻塞是不会消耗CPU资源的”,所以在多核的环境下,我们可以创建多线程,把接收到的请求抛给多线程去处理,这样就有效地利用了计算机的多核资源。甚至为了避免创建大量的线程处理请求,我们还可以进一步做优化,创建一个线程池,利用池化技术,对暂时处理不了的请求做一个缓冲。

2、“C10K”问题

“C10K”即“client 10k”用来指代数量庞大的客户端;

BIO看上去非常的简单,事实上采用“BIO+线程池”来处理少量的并发请求还是比较合适的,也是最优的。但是面临数量庞大的客户端和请求,这时候使用多线程的弊端就逐渐凸显出来了:

  • 严重依赖线程,线程还是比较耗系统资源的(一个线程大约占用1M的空间);
  • 频繁地创建和销毁代价很大,因为涉及到复杂的系统调用;
  • 线程间上下文切换的成本很高,因为发生线程切换前,需要保留上一个任务的状态,以便切回来的时候,可以再次加载这个任务的状态。如果线程数量庞大,会造成线程做上下文切换的时间甚至大于线程执行的时间,CPU负载变高。

3、NIO非阻塞模型

下面开始真正走向Java NIO或者Netty框架所描述的“非阻塞”,NIO叫Non-Blocking IO或者New IO,由于BIO可能会引入的大量线程,所以可以简单地理解NIO处理问题的方式是通过单线程或者少量线程达到处理大量客户端请求的目的。为了达成这个目的,首先要做的就是把阻塞的过程非阻塞化。要想做到非阻塞,那必须得要有内核的支持,同时需要对用户空间的进程暴露系统调用函数。所以,这里的“非阻塞”可以理解成系统调用API级别的,而真正底层的IO操作都是阻塞的,我们后面会慢慢介绍。

事实上,内核已经对“非阻塞”做好了支持,举个我们刚刚说的的accept()方法阻塞的例子(Tips:java中的accept方法对应的系统调用函数也叫accept),看下官方文档对其非阻塞部分的描述。

彻底搞懂 IO 底层原理

 

官方文档对accetp()系统调用的描述是通过把"flags"参数设成"SOCK_NONBLOCK"就可以达到非阻塞的目的,非阻塞之后线程会一直处理轮询调用,这时候可以通过每次返回特殊的异常码“EAGAIN”或"EWOULDBLOCK"告诉主程序还没有连接到达可以继续轮询。

我们可以很容易想象程序非阻塞之后的一个大致过程。所以,非阻塞模式有个最大的特点就是:用户进程需要不断去主动询问内核数据准备好了没有!

下面我们通过一段伪代码,看下这个调用过程:

// 循环遍历
while(1) {
    // 遍历fd集合
    for (fdx in range(fd1, fdn)) {
        // 如果fdx有数据
        if (null != fdx.data) {
            // 进行读取和处理
            read(fdx)&handle(fdx);
        }
    }
}

这种调用方式也暴露出非阻塞模式的最大的弊端,就是需要让用户进程不断切换到内核态,对连接状态或读写数据做轮询。有没有一种方式来简化用户空间for循环轮询的过程呢?那就是我们下面要重点介绍的IO多路复用模型

4、IO多路复用模型

非阻塞模型会让用户进程一直轮询调用系统函数,频繁地做内核态切换。想要做优化其实也比较简单,我们假想个业务场景,A业务系统会调用B的基础服务查询单个用户的信息。随着业务的发展,A的逻辑变复杂了,需要查100个用户的信息。很明显,A希望B提供一个批量查询的接口,用集合作为入参,一次性把数据传递过去就省去了频繁的系统间调用。

多路复用实际也差不多就是这个实现思路,只不过入参这个“集合”需要你注册/填写感兴趣的事件,读fd、写fd或者连接状态的fd等,然后交给内核帮你进行处理。

那我们就具体来看看多路复用里面大家都可能听过的几个系统调用 - select()、poll()、epoll()。

4.1 select()

select() 构造函数信息如下所示:

/**
 * select()系统调用
 *
 * 参数列表:
 *     nfds       - 值为最大的文件描述符+1
 *    *readfds    - 用户检查可读性
 *    *writefds   - 用户检查可写性
 *    *exceptfds  - 用于检查外带数据
 *    *timeout    - 超时时间的结构体指针
 */
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

官方文档对**select()**的描述:

DESCRIPTION

select() and pselect() allow a program to monitor multiple file descriptors, waiting until one or more of the file descriptors become "ready" for some class of I/O operation (e.g.,input possible). A file descriptor is considered ready if it is possible to perform the corresponding I/O operation (e.g., read(2)) without blocking.

select()允许程序监控多个fd,阻塞等待直到一个或多个fd到达"就绪"状态。

内核使用select()为用户进程提供了类似批量的接口,函数本身也会一直阻塞直到有fd为就绪状态返回。下面我们来具体看下select()函数实现,以便我们更好地分析它有哪些优缺点。在select()函数的构造器里,我们很容易看到"fd_set"这个入参类型。它是用位图算法bitmap实现的,使用了一个大小固定的数组(fd_set设置了FD_SETSIZE固定长度为1024),数组中的每个元素都是0和1这样的二进制byte,0,1映射fd对应位置上是否有读写事件,举例:如果fd == 5,那么fd_set = 000001000。

同时 fd_set 定义了四个宏来处理bitmap:

FD_ZERO(&set); // 初始化,清空的作用,使集合中不含任何fd

FD_SET(fd, &set); // 将fd加入set集合,给某个位置赋值的操作

FD_CLR(fd, &set); // 将fd从set集合中清除,去掉某个位置的值

FD_ISSET(fd, &set); // 校验某位置的fd是否在集合中

使用bitmap算法的好处非常明显,运算效率高,占用内存少(使用了一个byte,8bit)。我们用伪代码和图片来描述下用户进程调用select()的过程:

彻底搞懂 IO 底层原理

 

假设fds为{1, 2, 3, 5, 7}对应的bitmap为"01110101",抛给内核空间轮询,当有读写事件时重新标记同时停止阻塞,然后整体返回用户空间。由此我们可以看到select()系统调用的弊端也是比较明显的:

  • 复杂度O(n),轮询的任务交给了内核来做,复杂度并没有变化,数据取出后也需要轮询哪个fd上发生了变动;
  • 用户态还是需要不断切换到内核态,直到所有的fds数据读取结束,整体开销依然很大;
  • fd_set有大小的限制,目前被硬编码成了1024
  • fd_set不可重用,每次操作完都必须重置;

4.2 poll()

poll() 构造函数信息如下所示:

/**
 * poll()系统调用
 *
 * 参数列表:
 *    *fds         - pollfd结构体
 *     nfds        - 要监视的描述符的数量
 *     timeout     - 等待时间
 */
int poll(struct pollfd *fds, nfds_t nfds, int *timeout);
 
 
### pollfd的结构体
struct pollfd{
 int fd;// 文件描述符
 short event;// 请求的事件
 short revent;// 返回的事件
}

官方文档对**poll()**的描述:

DESCRIPTION

poll() performs a similar task to select(2): it waits for one of a set of file descriptors to become ready to perform I/O.

poll() 非常像select(),它也是阻塞等待直到一个或多个fd到达"就绪"状态。

看官方文档描述可以知道,poll()和select()是非常相似的,唯一的区别在于poll()摒弃掉了位图算法,使用自定义的结构体pollfd,在pollfd内部封装了fd,并通过event变量注册感兴趣的可读可写事件(POLLIN、POLLOUT),最后把 pollfd 交给内核。当有读写事件触发的时候,我们可以通过轮询 pollfd,判断revent确定该fd是否发生了可读可写事件。

老样子我们用伪代码来描述下用户进程调用 poll() 的过程:

彻底搞懂 IO 底层原理

 

poll() 相对于select(),主要的优势是使用了pollfd的结构体:

  • 没有了bitmap大小1024的限制;
  • 通过结构体中的revents置位;

但是用户态到内核态切换及O(n)复杂度的问题依旧存在。

4.3 epoll()

epoll()应该是目前最主流,使用范围最广的一组多路复用的函数调用,像我们熟知的Nginxredis都广泛地使用了此种模式。接下来我们重点分析下,epoll()的实现采用了“三步走”策略,它们分别是epoll_create()、epoll_ctl()、epoll_wait()。

4.3.1 epoll_create()

/**
 * 返回专用的文件描述符
 */
int epoll_create(int size);
复制代码
彻底搞懂 IO 底层原理

 


彻底搞懂 IO 底层原理

 

用户进程通过 epoll_create() 函数在内核空间里面创建了一块空间(为了便于理解,可以想象成创建了一块白板),并返回了描述此空间的fd。

4.3.2 epoll_ctl()

/**
 * epoll_ctl()系统调用
 *
 * 参数列表:
 *     epfd       - 由epoll_create()返回的epoll专用的文件描述符
 *     op         - 要进行的操作例如注册事件,可能的取值:注册-EPOLL_CTL_ADD、修改-EPOLL_CTL_MOD、删除-EPOLL_CTL_DEL
 *     fd         - 关联的文件描述符
 *     event      - 指向epoll_event的指针
 */
int epoll_ctl(int epfd, int op, int fd , struce epoll_event *event );

刚刚我们说通过epoll_create()可以创建一块具体的空间“白板”,那么通过epoll_ctl() 我们可以通过自定义的epoll_event结构体在这块"白板上"注册感兴趣的事件了。

  • 注册 - EPOLL_CTL_ADD
  • 修改 - EPOLL_CTL_MOD
  • 删除 - EPOLL_CTL_DEL

4.3.3 epoll_wait()

/**
 * epoll_wait()返回n个可读可写的fds
 *
 * 参数列表:
 *     epfd           - 由epoll_create()返回的epoll专用的文件描述符
 *     epoll_event    - 要进行的操作例如注册事件,可能的取值:注册-EPOLL_CTL_ADD、修改-EPOLL_CTL_MOD、删除-EPOLL_CTL_DEL
 *     maxevents      - 每次能处理的事件数
 *     timeout        - 等待I/O事件发生的超时值;-1相当于阻塞,0相当于非阻塞。一般用-1即可
 */
int epoll_wait(int epfd, struce epoll_event *event , int maxevents, int timeout);

epoll_wait() 会一直阻塞等待,直到硬盘、网卡等硬件设备数据准备完成后发起硬中断,中断CPU,CPU会立即执行数据拷贝工作,数据从磁盘缓冲传输到内核缓冲,同时将准备完成的fd放到就绪队列中供用户态进行读取。用户态阻塞停止,接收到具体数量的可读写的fds,返回用户态进行数据处理。

整体过程可以通过下面的伪代码和图示进一步了解:

彻底搞懂 IO 底层原理

 

epoll() 基本上完美地解决了 poll() 函数遗留的两个问题:

  • 没有了频繁的用户态到内核态的切换;
  • O(1)复杂度,返回的"nfds"是一个确定的可读写的数量,相比于之前循环n次来确认,复杂度降低了不少;

四、同步、异步

细心的朋友可能会发现,本篇文章一直在解释“阻塞”和“非阻塞”,“同步”、“异步”的概念没有涉及,其实在很多场景下同步&异步和阻塞&非阻塞基本上是一个同义词。阻塞和非阻塞适合从系统调用API层面来看,就像我们本文介绍的select()、poll()这样的系统调用,同步和异步更适合站在应用程序的角度来看。应用程序在同步执行代码片段的时候结果不会立即返回,这时候底层IO操作不一定是阻塞的,也完全有可能是非阻塞。所以说:

  • **阻塞和非阻塞:**读写没有就绪或者读写没有完成,函数是否要一直等待还是采用轮询;
  • **同步和异步:**同步是读写由应用程序完成。异步是读写由操作系统来完成,并通过回调的机制通知应用程序。

这边顺便提两种大家可能会经常听到的模式:Reactor和Preactor。

  • Reactor 模式:主动模式。
  • Preactor 模式:被动模式。

五、总结

本篇文章从底层讲解了下从BIO到NIO的一个过程,着重介绍了IO多路复用的几个系统调用select()、poll()、epoll(),分析了下各自的优劣,技术都是持续发展演进的,目前也有很多的痛点。后续会继续给大家介绍下与此相关的“零拷贝”技术,以及Java NIO和Netty框架。



Tags: IO   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,如有任何标注错误或版权侵犯请与我们联系(Email:2595517585@qq.com),我们将及时更正、删除,谢谢。
▌相关推荐
近日苹果发布 iOS 15.2/iPadOS 15.2 正式版的更新,整合了此前测试版中所有新功能,因此更新内容不少,也有一些值得了解的实用功能。有没有必要更新 iOS 15.2 正式版?看完如下功能...【详细内容】
2021-12-17  Tags: IO  点击:(22)  评论:(0)  加入收藏
苹果昨日向 iPhone 和 iPad 用户推送了 iOS 15.2 / iPadOS 15.2 正式版更新,带来了多项新功能。据 9To5Mac 报道,iOS 15.2 / iPadOS 15.2 还有一项新功能,允许用户不借助 Mac...【详细内容】
2021-12-16  Tags: IO  点击:(13)  评论:(0)  加入收藏
前言最近对 WebRTC iOS 端源码进行了下载和编译,网上针对 WebRTC iOS 端的编译文章基本都是几年前的,有些地方已经不适用于最新版的 WebRTC 的编译,简单记录下载&编译的过程,以...【详细内容】
2021-11-10  Tags: IO  点击:(38)  评论:(0)  加入收藏
IT之家 8 月 12 日消 苹果今日发布了一款新的开发者工具,旨在强制那些运行 iOS 15 和 iPadOS 15 的设备在使用不安全的 WiFi 网络或 WiFi 速度较慢时优先使用 5G 连接而不是...【详细内容】
2021-08-12  Tags: IO  点击:(151)  评论:(0)  加入收藏
一、介绍    1、介绍        最近无聊,也没什么事做,没事做总是要给自己找点事情做吧,毕竟人的生活在与折腾。于是,决定自己手动写一个 IOC 的框架。我们知道在 Net...【详细内容】
2021-07-20  Tags: IO  点击:(80)  评论:(0)  加入收藏
苹果在上周的 WWDC 带来了 iOS 15 和其他的软件更新,可升级至 iOS 15 的手机包含最新的 iPhone 12 全系列,就连推出快六年的 iPhone 6s 也还是能升级继续战,国外 YouTube 频道...【详细内容】
2021-06-23  Tags: IO  点击:(91)  评论:(0)  加入收藏
环境:Spring5.3.3Spring容器启动时,创建 DefaultListableBeanFactory 工厂实例化 AnnotationConfigApplicationContext对象public AnnotationConfigApplicationContext(String...【详细内容】
2021-06-10  Tags: IO  点击:(152)  评论:(0)  加入收藏
IT之家4月7日消息 去年底,苹果公司要求应用商店所有第三方软件都必须增加 “隐私标签”以进一步保护消费者隐私安全。之后,谷歌旗下大量 iOS 软件长时间停止更新。而到了今年...【详细内容】
2021-04-07  Tags: IO  点击:(309)  评论:(0)  加入收藏
随着AI技术、数据分析等领域兴起,数据变得越来越重要了,数据处理往往需要用到大量的内存,数据量爆发式增长让各种内存密集型应用层出不穷,如Redis数据库、SAP HANA企业核心系统。在CSDN 2019、2020年度的开发者调查数据中...【详细内容】
2021-03-04  Tags: IO  点击:(155)  评论:(0)  加入收藏
IT之家1月11日消息 今日,支付宝 iOS 版 10.2.12 发布。更新日志显示,支付宝迎来了 “集五福,过福年”活动。集五福,过福年。升级至最新版,2021 年新春可享更多玩法。敬请期待!目前...【详细内容】
2021-01-12  Tags: IO  点击:(197)  评论:(0)  加入收藏
▌简易百科推荐
摘 要 (OF作品展示)OF之前介绍了用python实现数据可视化、数据分析及一些小项目,但基本都是后端的知识。想要做一个好看的可视化大屏,我们还要学一些前端的知识(vue),网上有很多比...【详细内容】
2021-12-27  项目与数据管理    Tags:Vue   点击:(1)  评论:(0)  加入收藏
程序是如何被执行的  程序是如何被执行的?许多开发者可能也没法回答这个问题,大多数人更注重的是如何编写程序,却不会太注意编写好的程序是如何被运行,这并不是一个好...【详细内容】
2021-12-23  IT学习日记    Tags:程序   点击:(9)  评论:(0)  加入收藏
阅读收获✔️1. 了解单点登录实现原理✔️2. 掌握快速使用xxl-sso接入单点登录功能一、早期的多系统登录解决方案 单系统登录解决方案的核心是cookie,cookie携带会话id在浏览器...【详细内容】
2021-12-23  程序yuan    Tags:单点登录(   点击:(8)  评论:(0)  加入收藏
下载Eclipse RCP IDE如果你电脑上还没有安装Eclipse,那么请到这里下载对应版本的软件进行安装。具体的安装步骤就不在这赘述了。创建第一个标准Eclipse RCP应用(总共分为六步)1...【详细内容】
2021-12-22  阿福ChrisYuan    Tags:RCP应用   点击:(7)  评论:(0)  加入收藏
今天想简单聊一聊 Token 的 Value Capture,就是币的价值问题。首先说明啊,这个话题包含的内容非常之光,Token 的经济学设计也可以包含诸多问题,所以几乎不可能把这个问题说的清...【详细内容】
2021-12-21  唐少华TSH    Tags:Token   点击:(9)  评论:(0)  加入收藏
实现效果:假如有10条数据,分组展示,默认在当前页面展示4个,点击换一批,从第5个开始继续展示,到最后一组,再重新返回到第一组 data() { return { qList: [], //处理后...【详细内容】
2021-12-17  Mason程    Tags:VUE   点击:(14)  评论:(0)  加入收藏
什么是性能调优?(what) 为什么需要性能调优?(why) 什么时候需要性能调优?(when) 什么地方需要性能调优?(where) 什么时候来进行性能调优?(who) 怎么样进行性能调优?(How) 硬件配...【详细内容】
2021-12-16  软件测试小p    Tags:性能调优   点击:(19)  评论:(0)  加入收藏
Tasker 是一款适用于 Android 设备的高级自动化应用,它可以通过脚本让重复性的操作自动运行,提高效率。 不知道从哪里听说的抖音 app 会导致 OLED 屏幕烧屏。于是就现学现卖,自...【详细内容】
2021-12-15  ITBang    Tags:抖音防烧屏   点击:(23)  评论:(0)  加入收藏
11 月 23 日,Rust Moderation Team(审核团队)在 GitHub 上发布了辞职公告,即刻生效。根据公告,审核团队集体辞职是为了抗议 Rust 核心团队(Core team)在执行社区行为准则和标准上...【详细内容】
2021-12-15  InfoQ    Tags:Rust   点击:(24)  评论:(0)  加入收藏
一个项目的大部分API,测试用例在参数和参数值等信息会有很多相似的地方。我们可以复制API,复制用例来快速生成,然后做细微调整既可以满足我们的测试需求1.复制API:在菜单发布单...【详细内容】
2021-12-14  AutoMeter    Tags:AutoMeter   点击:(20)  评论:(0)  加入收藏
最新更新
栏目热门
栏目头条