同步与异步、阻塞与非阻塞傻傻分不清楚?你得从linux中的5种IO模型看起

2022-07-11 16:57:36 705 技术小虫有点萌

IO

  • IO 即对input和output的处理,简称为IO读写 我们知道,虽然电脑上跑着我们的应用程序,有对应的内存空间,但是真正的磁盘的读写,内存的读写其实是操作系统来完成的。也就是说你的程序可以运行在我的操作系统上,但是作为用户的程序,是没有权限去读写我的磁盘和内存的。你想读写,必须由我操作系统给你提供API,用户的程序通过API来完成读写操作。那么其实我说的用户程序、操作系统,就是核心态(Kernel model)和用户态(User model)。CPU会在两个model之间切换。

核心态

核心态代码拥有完全的底层资源控制权限,可以执行任何CPU指令,访问任何内存地址,其占有的处理机是不允许被抢占的。内核态的指令包括:启动I/O,内存清零,修改程序状态字,设置时钟,允许/终止中断和停机。内核态的程序崩溃会导致PC停机。

用户态

用户态是用户程序能够使用的指令,不能直接访问底层硬件和内存地址。用户态运行的程序必须委托系统调用来访问硬件和内存。用户态的指令包括:控制转移,算数运算,取数指令,访管指令(使用户程序从用户态陷入内核态)。

怎么完成一次IO

既然我们已经区分了核心态和用户态,而且说了用户态不能完成实质的IO,那么一次完整的IO应该是什么样子的呢?

  • 比如说由一个文件读取的程序发起了读的操作,应该是这样一个过程 1.应用程序调用API,发起对系统内核的调用 2.操作系统调用IO设备,完成数据的IO 3.将数据从核心态缓存区拷贝到用户态缓冲区 4.用户态完成数据的加工处理

CPU在两个model之间的切换

我们前面说了,CPU会在两个model之间切换,那么切换机制是什么?其实是有三种机制

  • 系统调用,比如刚才的例子,要读取文件,程序需要调用操作系统。
  • 程序异常,程序发生异常,需要操作系统处理这种异常。
  • 外围设备的中断 当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。

进程与内核

当有多个程序的时候,那么CPU就会以时间片划分,轮流执行程序,当CPU执行到某个程序的时候,应该当前程序的相关信息,比如上下文,计数器,寄存器,这些都需要内核的支持。

5种IO模型

在这里插入图片描述
  • 5种,即 blocking-IO (阻塞IO),non-blocking-IO (非阻塞IO),IO-multiplexing(IO多路复用),signal driven IO(信号驱动),asynchronous IO(异步IO)
  • 那么在了解这些模型之前,我们应该先知道什么是同步、异步,什么是阻塞、非阻塞

同步与异步

同步、异步描述的是多个线程之间的协作关系,线程之间要么是同步要么是异步 举个例子,前端人以前都会用到的jquery发起ajax请求

        $(document).ready(function(){
            var a=1
            var b=2
            var add=function(){
                $.ajax({url:"5.txt",async:false,success:function(result){
                        a=parseInt(result);
                    }});
            };
            add();
            var c=a + b
            console.log(c);
        });

如果async 为FALSE,那么代表是同步,不执行完毕add方法就不会继续往下执行,那么此时c的结果就是5+2=7(5.txt返回值是5),而如果async 为true代表异步,那么我不再关注add方法有没有完成,而是继续执行代码,那么此时,c的值可能就是1+2=3,但是add方法执行完毕我怎么能知道呢?就是调用success里面的方法,其实就是执行完毕后的回调函数。

也就是说,同步,就是一段调用一旦开始,就必须要返回结果才能继续往下运行。异步就是我不需要等调用的结果而可以继续往下进行,你什么时候执行完毕,通过回调函数通知我。

再通俗点讲就是,这个结果如果是你主动去获取的(不管是一直等还是轮询),那么就是同步,如果是被动通知得到的(回调),那么就是异步。

阻塞与非阻塞

阻塞与非阻塞强调的是一个线程的内部执行状态,阻塞是指调用结果返回前,发起调用的进程会被挂起,不能干其他的活。非阻塞就是调用结果没有返回,但是调用的进程可以继续干其他的活。 比如八九十年代去银行取钱,你得人在那排队,排队期间,你啥都干不了,这就是阻塞,非阻塞就是后来银行有了取号机,你取号之后可以去外面抽根烟,刷个手机啥的,等着服务台喊你就行了。

同步、异步 和阻塞 非阻塞的排列组合

比如这样一个场景,小明说,妈妈,我吃完饭要去打篮球,那么小明的妈妈就去做饭了,小明此时有以下几种方案, 1.小明哪也不去,就在那等妈妈做饭,然后吃完饭去打篮球,这个过程就是同步阻塞 2.小明去感觉等的无聊,就去看电视了,但是每五分钟过来问一下妈妈做好饭了吗,这个就叫 同步非阻塞 3.一会一群小朋友喊小明去打篮球,但是妈妈饭没做好,小明等不及了,跟妈妈说,妈,一会做好了喊我,我去打篮球了。这个就叫做 异步非阻塞 4.小明已经让妈妈做好饭喊他了,但是他还是在等妈妈,哪也不去,这就是异步阻塞,这种场景一般不会出现

同步、异步与阻塞、非阻塞说完了,那么接下来,我们就可以敞开聊聊IO模型了

阻塞IO模型(同步)

当进程发起一次IO调用后,程序就一直等待操作系统准备数据,将数据从内核态拷贝到用户态,然后IO函数返回成功指示。

非阻塞IO模型(同步)

应用程序定时去询问内核的IO函数,询问数据是否准备好,如果准备好了,就进行拷贝,如果没有准备好,内核直接返回未就绪,程序就过一会再来询问。

异步IO

程序发起IO调用后立刻返回结果,表示我已经调用成功,程序继续执行,等数据准备就绪而且已经从内核态拷贝到用户态的时候,发送信号给调用程序

信号驱动

当数据报准备好的时候,内核会向应用程序发送一个信号,进程对信号进行捕捉,并且调用信号处理函数来获取数据报。 在UDP上,SIGIO信号会在下面两个事件的时候产生:

1 数据报到达套接字

2 套接字上发生错误

因此我们很容易判断SIGIO出现的时候,如果不是发生错误,那么就是有数据报到达了。

而在TCP上,由于TCP是双工的,它的信号产生过于频繁,并且信号的出现几乎没有告诉我们发生了什么事情。因此对于TCP套接字,SIGIO信号是没有什么使用的。

IO复用模型

在这里插入图片描述
  • blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。虽然non-blocking IO是非阻塞的,但是在recvfrom阶段他们都是阻塞的

我们前面说了非阻塞IO,进程不会一直等内核的结果,但是进程会隔一段时间就去轮询内核,是否准备好了数据。这样明显有点浪费资源。前面我们还说了异步调用,就是结果返回的时候通过回调函数通知客户端。那么非阻塞IO能不能也这样呢?不要进程一次次的轮询了,内核什么时候准备好,什么时候通知我。

或者我们可以思考一下,当我们浏览网页的时候,有时候是敲键盘,有时候是点击鼠标,电脑是怎么捕获我的动作的呢?难道一直跑一个死循环不停的监听我的动作吗?那我敲键盘,鼠标点击,鼠标悬浮 这些动作 是不是需要分好几个线程阻塞在那等着我们?从实现方式上来说,这种方式固然可以实现。但是会有几个问题,如果你操作太多太快,可能会导致延迟。甚至会由于一个动作的阻塞导致后面的动作全部失效。

那么怎么解决这种问题?UI编程的事件驱动模型是这样做的,你的每个动作对于我来说都是事件,你可能敲键盘了,可能鼠标点击了,我都给你记录到事件队列里面。然后有另外一个循环去消费队列里面的事件,根据事件类型调用不同的函数。

那么我们的操作系统里面,是不是也有这样的一套事件处理机制呢?

文件描述符(fd, File Descriptor)

fd 是 File descriptor 的缩写,中文名叫做:文件描述符。文件描述符是一个非负整数,本质上是一个索引值(这句话非常重要),指向内核为每一个进程所维护的该进程打开文件的记录表。文件描述符在unix系统中几乎无处不在。 当打开一个文件时,内核向进程返回一个文件描述符( open 系统调用得到 ),后续 read、write 这个文件时,则只需要用这个文件描述符来标识该文件,将其作为参数传入 read、write 。

在 POSIX 语义中,0,1,2 这三个 fd 值已经被赋予特殊含义,分别是标准输入( STDIN_FILENO ),标准输出( STDOUT_FILENO ),标准错误( STDERR_FILENO )。

文件描述符是有一个范围的:0 ~ OPEN_MAX-1 ,最早期的 UNIX 系统中范围很小,现在的主流系统单就这个值来说,变化范围是几乎不受限制的,只受到系统硬件配置和系统管理员配置的约束。

IO复用模型核心思路:系统给我们提供一类函数(比如select、poll、epoll函数),它们可以同时监控多个fd的操作,任何一个返回内核数据就绪,应用进程再发起recvfrom系统调用。

IO复用之select、poll、epoll

select

int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,通过遍历fdset,来找到就绪的描述符,将数据从kernel拷贝到用户进程。

  • select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。
  • 单个进程打开的文件描述是有一定限制的,它由FD_SETSIZE设置,默认值是1024,采用数组存储,虽然可以通过编译内核改变,但相对麻烦。
  • 在检查数组中是否有文件描述需要读写时,采用的是线性扫描的方法,即不管这些socket是不是活跃的,我都轮询一遍,所以效率比较低。

poll

int poll (struct pollfd *fds, unsigned int nfds, int timeout);

不同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的指针实现。

struct pollfd {
    int fd; /* file descriptor */
    short events; /* requested events to watch */
    short revents; /* returned events witnessed */
};
  • 采样链表的形式存储,它监听的描述符数量没有限制,可以超过select默认限制的1024大小
  • 类似select,另外在检查链表中是否有文件描述需要读写时,采用的是线性扫描的方法,即不管这些socket是不是活跃的,我都轮询一遍,所以效率比较低

epoll

epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

  • 监视的描述符数量不受限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左 右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。select的最大缺点就是进程打开的fd是有数量限制的。这对 于连接数量比较大的服务器来说根本不能满足。虽然也可以选择多进程的解决方案( Apache就是这样实现的),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。

  • IO的效率不会随着监视fd的数量的增长而下降。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数。

select 和epoll的区别

select 和epoll最大的区别在于对fd的遍历方式不同。

select 模式,就像在医院里,有一个病人喊护士拔针,护士不知道谁喊的,所以护士就一个一个的挨个问一遍。可以说select的调用复杂度是线性的,即O(n)。

后来医院改进了,哪个病人需要帮助,请按电铃,电铃响了,护士办公室根据电铃的编号可以迅速定位到病人在几号病床,这就是epoll的改进。此时,如果护士听到铃声马上去处理了,这种方式就是同步阻塞的,交做ET。如果护士在忙其他的事情,没有马上处理,那么过了一会铃声自动再响,提醒护士做这件事情,这种模式叫做lt。

相关参考

相关参考 相关参考 相关参考 相关参考