并发与并行、同步与异步、阻塞与非阻塞以及Linux五种IO模型

本文深入探讨了计算机科学中的并发编程概念,详细讲解了并发、并行、同步、异步、阻塞与非阻塞的基本定义及其在多核CPU环境下的运作机制。全面解析了Linux下的五种IO模型,包括阻塞式IO、非阻塞式IO、多路复用、信号驱动式IO和异步非阻塞IO,对比分析了它们的工作流程、优缺点以及应用场景,为读者提供了一个清晰的IO模型理解框架。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1 并发与并行、同步与异步、阻塞与非阻塞

1.1 并发、并行

  并发是指在多核cpu的同一个核上,一个时间段内,有多个线程(或进程)按照一定的机制交替运行。也就是每一个时刻,只有一个线程(或进程)在运行。
  并行是指在多核cpu上,任意时刻点,每个核都会被一个线程(或进程)所占用。也就是任意时刻,都有多个线程(或进程)在同时运行。

1.2 同步、异步

(此部分解释可能有误)
  同步和异步关注的是消息通信机制

  同步是多个任务(多线程或多进程)之间协调彼此运行的步调,保证协同运行的各个任务具有正确的执行顺序,多任务之间是相互依赖的关系。进一步的说明:就是前一个进程的输出作为后一个进程的输入,当第一个进程没有输出时第二个进程必须等待。具有同步关系的一组并发任务(线程或进程)相互发送的信息称为消息或事件。
  即:调用者(主进程/主线程)向被调用者(子进程/子线程)发出一个调用后,该调用在没有执行完成之前,该调用就不返回,但一旦调用返回,调用者就得到返回结果。【换句话说就是由调用者主动等待这个调用的结果。】
  异步就是彼此独立,在等待某事件的过程中继续做自己的事,不需要等待这一事件完成后再工作。异步是让调用方法的主线程不需要同步等待另一线程的完成,从而可以让主线程干其它的事情。
  即:在调用者向被调用者发出一个调用后,该调用不会等待执行完成之后再返回而是直接立即返回一个调用者可接受的对象,之后该调用会继续进行完成剩余工作,最终给被调用者发出通知。【换句话说就是当一个异步过程调用发出后,调用者不会立刻得到结果,而是再调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。】

  在处理IO时,同步需要调用者主动读写数据,在读写数据的过程中还是会阻塞;异步只需要IO操作完成的通知,并不主动读写数据,由操作系统内核完成数据的读写。

1.3 阻塞、非阻塞

(此部分解释可能有误)
  阻塞和非阻塞关注的是调用者(主进程/主线程)在等待调用结果(消息,返回值)时的状态

  阻塞是调用者(主进程/主线程)向被调用者(子进程/子线程)发出一个调用后,调用结果返回之前,调用者会被挂起。
  非阻塞是调用者(主进程/主线程)向被调用者(子进程/子线程)发出一个调用后,调用者并不会被阻塞,而是调用者会隔时轮询该调用操作是否完成,并且调用者会完成一些与该调用无关的操作。

  从根本上说,阻塞就是调用者“被”休息,cpu区处理其它进程去了。非阻塞可以理解成:将大的时间阻塞分成N多的小的阻塞(轮询时每一次询问,从发出询问到获取调用是否完成所需的时间),所以调用者不断地有机会“被”光顾。

2 linux的五种IO模型

2.1阻塞式IO(blocking IO, bio)
2.1.1 场景描述

  我和女友点完餐后,不知道什么时候能做好,只好坐在餐厅里面等,直到做好,然后吃完才离开。女友本想还和我一起逛街的,但是不知道饭能什么时候做好,只好和我一起在餐厅等,而不能去逛街,直到吃完饭才能去逛街,中间等待做饭的时间浪费掉了。这就是典型的阻塞。

2.1.2 网络模型

  在这个IO模型中,用户空间的应用程序执行一个系统调用(recvform),这会导致应用程序阻塞,什么也不干,直到数据准备好,并且将数据从内核复制到用户进程,最后进程再处理数据,在等待数据到处理数据的两个阶段,整个进程都被阻塞。不能处理别的网络IO。调用应用程序处于一种不再消费 CPU 而只是简单等待响应的状态,因此从处理的角度来看,这是非常有效的。在调用recv()/recvfrom()函数时,发生在内核中等待数据和复制数据的过程,大致如下图:
在这里插入图片描述

2.1.3 流程描述

  当用户进程调用了recv()/recvfrom()这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。第二个阶段:当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

  所以,blocking IO的特点就是在IO执行的两个阶段都被block了。
  优点:能够及时返回数据,无延迟;对内核开发者来说这是省事了;
  缺点:对用户来说处于等待就要付出性能的代价了;

2.2非阻塞式IO(nonblocking IO, nio)
2.2.1 场景描述

  我女友不甘心白白在这等,又想去逛商场,又担心饭好了。所以我们逛一会,回来询问服务员饭好了没有,来来回回好多次,饭都还没吃都快累死了啦。这就是非阻塞。需要不断的询问,是否准备好了。

2.2.2 网络模型

  同步非阻塞就是 “每隔一会儿瞄一眼进度条” 的轮询(polling)方式。在这种模型中,设备是以非阻塞的形式打开的。这意味着 IO 操作不会立即完成,read 操作可能会返回一个错误代码,说明这个命令不能立即满足(EAGAIN 或 EWOULDBLOCK)。
  在网络IO时候,非阻塞IO也会进行recvform系统调用,检查数据是否准备好,与阻塞IO不一样,”非阻塞将大的整片时间的阻塞分成N多的小的阻塞, 所以进程不断地有机会 ‘被’ CPU光顾”。
  也就是说非阻塞的recvform系统调用调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error。进程在返回之后,可以干点别的事情,然后再发起recvform系统调用。重复上面的过程,循环往复的进行recvform系统调用。这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。
  在linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程如图所示:
在这里插入图片描述

2.2.3 流程描述

  当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。

  所以,nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有。
  优点:能够在等待任务完成的时间里干其他活了(包括提交其他任务,也就是 “后台” 可以有多个任务在同时执行)。
  缺点:任务完成的响应延迟增大了,因为每过一段时间才去轮询一次read操作,而任务可能在两次轮询之间的任意时间完成,这会导致整体数据吞吐量的降低。并且频繁的轮询会消耗大量的cpu时间。

2.3多路复用( IO multiplexing)
2.3.1 场景描述

  与第二个方案差不多,餐厅安装了电子屏幕用来显示点餐的状态,这样我和女友逛街一会,回来就不用去询问服务员了,直接看电子屏幕就可以了。这样每个人的餐是否好了,都直接看电子屏幕就可以了,这就是典型的IO多路复用。

2.3.2 网络模型

  由于同步非阻塞方式需要不断主动轮询,轮询占据了很大一部分过程,轮询会消耗大量的CPU时间,而 “后台” 可能有多个任务在同时进行,人们就想到了循环查询多个任务的完成状态,只要有任何一个任务完成,就去处理它。如果轮询不是进程的用户态,而是有人帮忙就好了。那么这就是所谓的 “IO 多路复用”。UNIX/Linux 下的 select、poll、epoll 就是干这个的(epoll 比 poll、select 效率高,做的事情是一样的)。
  IO多路复用有两个特别的系统调用select、poll、epoll函数。select调用是内核级别的,select轮询相对非阻塞的轮询的区别在于—前者可以等待多个socket,能实现同时对多个IO端口进行监听,当其中任何一个socket的数据准好了,就能返回进行可读,然后进程再进行recvform系统调用,将数据由内核拷贝到用户进程,当然这个过程是阻塞的。select或poll调用之后,会阻塞进程,与blocking IO阻塞不同在于,此时的select不是等到socket数据全部到达再处理, 而是有了一部分数据就会调用用户进程来处理。如何知道有一部分数据到达了呢?监视的事情交给了内核,内核负责数据到达的处理。也可以理解为"非阻塞"吧。
  I/O复用模型会用到select、poll、epoll函数,这几个函数也会使进程阻塞,但是和阻塞I/O所不同的的,这两个函数可以同时阻塞多个I/O操作。而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写时(注意不是全部数据可读或可写),才真正调用I/O操作函数。
  对于多路复用,也就是轮询多个socket。多路复用既然可以处理多个IO,也就带来了新的问题,多个IO之间的顺序变得不确定了,当然也可以针对不同的编号。具体流程,如下图所示:
在这里插入图片描述

2.3.3 流程描述

  IO multiplexing就是我们说的select,poll,epoll,有些地方也称这种IO方式为event driven IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。
  当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
  多路复用的特点是通过一种机制一个进程能同时等待IO文件描述符,内核监视这些文件描述符(套接字描述符),其中的任意一个进入读就绪状态,select, poll,epoll函数就可以返回。对于监视的方式,又可以分为 select, poll, epoll三种方式。epoll底层使用红黑树算法。在并发相对高,连接活跃度相对低时,epoll比select性能好;在并发性相对低,连接活跃度相对高时,select比epoll性能好。
  上面的图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。
  所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。(select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)
  在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。所以IO多路复用是阻塞在select,epoll这样的系统调用之上,而没有阻塞在真正的I/O系统调用如recvfrom之上。

  了解了前面三种IO模式,在用户进程进行系统调用的时候,他们在等待数据到来的时候,处理的方式不一样,直接等待,轮询,select或poll轮询,两个阶段过程:
  第一个阶段有的阻塞,有的不阻塞。
  第二个阶段都是阻塞的。
  从整个IO过程来看,他们都是顺序执行的,因此可以归为同步模型(asynchronous)。都是用户进程主动等待且向内核检查状态。【此句很重要!!!】
  高并发的程序一般使用同步非阻塞方式而非多线程 + 同步阻塞方式。要理解这一点,首先要扯到并发和并行的区别。比如去某部门办事需要依次去几个窗口,办事大厅里的人数就是并发数,而窗口个数就是并行度。也就是说并发数是指同时进行的任务数(如同时服务的 HTTP 请求),而并行数是可以同时工作的物理资源数量(如 CPU 核数)。通过合理调度任务的不同阶段,并发数可以远远大于并行度,这就是区区几个 CPU 可以支持上万个用户并发请求的奥秘。在这种高并发的情况下,为每个任务(用户请求)创建一个进程或线程的开销非常大。而同步非阻塞方式可以把多个 IO 请求丢到后台去,这就可以在一个进程里服务大量的并发 IO 请求。

2.4 信号驱动式IO(signal-driven IO)

  信号驱动式I/O:首先我们允许Socket进行信号驱动IO,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。过程如下图所示:
在这里插入图片描述

2.5 异步非阻塞 IO(asynchronous IO)
2.5.1 场景描述

  女友不想逛街,又餐厅太吵了,回家好好休息一下。于是我们叫外卖,打个电话点餐,然后我和女友可以在家好好休息一下,饭好了送货员送到家里来。这就是典型的异步,只需要打个电话说一下,然后可以做自己的事情,饭好了就送来了。

2.5.2 网络模型

  相对于同步IO,异步IO不是顺序执行。用户进程进行aio_read系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。等到socket数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。IO两个阶段,进程都是非阻塞的。
  Linux提供了AIO库函数实现异步,但是用的很少。目前有很多开源的异步IO库,例如libevent、libev、libuv。异步过程如下图所示:
在这里插入图片描述

2.5.3 流程描述

  用户进程发起aio_read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal或执行一个基于线程的回调函数来完成这次 IO 处理过程,告诉它read操作完成了。
  在 Linux 中,通知的方式是 “信号”:
  如果这个进程正在用户态忙着做别的事(例如在计算两个矩阵的乘积),那就强行打断之,调用事先注册的信号处理函数,这个函数可以决定何时以及如何处理这个异步任务。由于信号处理函数是突然闯进来的,因此跟中断处理程序一样,有很多事情是不能做的,因此保险起见,一般是把事件 “登记” 一下放进队列,然后返回该进程原来在做的事。
  如果这个进程正在内核态忙着做别的事,例如以同步阻塞方式读写磁盘,那就只好把这个通知挂起来了,等到内核态的事情忙完了,快要回到用户态的时候,再触发信号通知。
  如果这个进程现在被挂起了,例如无事可做 sleep 了,那就把这个进程唤醒,下次有 CPU 空闲的时候,就会调度到这个进程,触发信号通知。

  异步 API 说来轻巧,做来难,这主要是对 API 的实现者而言的。Linux 的异步 IO(AIO)支持是 2.6.22 才引入的,还有很多系统调用不支持异步 IO。Linux 的异步 IO 最初是为数据库设计的,因此通过异步 IO 的读写操作不会被缓存或缓冲,这就无法利用操作系统的缓存与缓冲机制。
  很多人把 Linux 的 O_NONBLOCK 认为是异步方式,但事实上这是前面讲的同步非阻塞方式。需要指出的是,虽然 Linux 上的 IO API 略显粗糙,但每种编程框架都有封装好的异步 IO 实现。操作系统少做事,把更多的自由留给用户,正是 UNIX 的设计哲学,也是 Linux 上编程框架百花齐放的一个原因。
  从前面 IO 模型的分类中,我们可以看出 AIO 的动机:
  同步阻塞模型需要在 IO 操作开始时阻塞应用程序。这意味着不可能同时重叠进行处理和 IO 操作。
  同步非阻塞模型允许处理和 IO 操作重叠进行,但是这需要应用程序根据重现的规则来检查 IO 操作的状态。
  这样就剩下异步非阻塞 IO 了,它允许处理和 IO 操作重叠进行,包括 IO 操作完成的通知

  IO多路复用除了需要阻塞之外,select 函数所提供的功能(异步阻塞 IO)与 AIO 类似。不过,它是对通知事件进行阻塞,而不是对 IO 调用进行阻塞。

2.6 异步阻塞

  异步阻塞没有实际意义,一般不会使用.

2.7 五种IO模型

在这里插入图片描述

3. bio → nio → select、poll → epoll

python代码示例

在这里插入图片描述
(1) bio

每线程,没连接

优势:

  • 可以接收很多的连接

问题:

  • 多线程的创建消耗空间资源
  • 多线程的并发导致多线程的切换消耗时间资源

根源:

  • BLOCKING(阻塞),即accecpt和recv的阻塞

解决方案:

  • 使用NONBLOCKING(非阻塞)

(2) nio

优势:

  • 规避了多线程问题

问题:

  • 循环遍历导致频繁的系统调用,使得cpu频繁的在用户空间和内核空间进行切换,消耗空间和时间资源。

举例说明问题:

  • 假设10000个连接,只有一个发来数据。每循环一次,必须向内核发送10000次的系统调用,其中有9999次可能是无意义的,消耗了时间和空间资源。

根源:

  • 频繁的系统调用。

解决方案:

  • select或poll
    • 使用一次系统调用将轮询交给内核去做。

(3) select或poll

优势:

  • 通过一次系统调用(select或poll),把文件描述符集fd_set传递给内核,内核进行遍历,这种遍历减少了系统调用的次数。

问题:

  • (i) 每次select或poll都需要重新从用户空间想内核空间传递fd_set集合,造成时间资源的浪费。
  • (ii) 每次select或poll都需要重新遍历fd_set中的每一个fd,造成时间资源的浪费。

解决方案:

  • epoll
    • (i) 在内核开辟空间(该空间用一个文件描述符表示)保留fd_set,使每个fd从用户空间到内核只复制一次。用的红黑树算法。
    • (ii) 在内核还有一个就绪链表,用于保存已经准备就绪的fd,当一个fd可读或可写准备就绪时会调用回调函数,将该fd放入就绪链表,则通过epoll_wait可一次将该列表读出。

(4) epoll

优势:

  • fd_set中的每一个fd从用户空间到内核空间只复制一次。
  • 不需要轮询,降低时间复杂度。

(5) IO多路复用解决的问题及select VS poll VS epoll

IO多路复用解决的问题

  • 解决了IO状态问题,并不解决读写数据问题。
  • 使用一次系统调用询问所有IO状态,避免多次使用系统调用recv进行频繁的在用户空间与内核空间进行切换,降低时间与资源开销。

在这里插入图片描述




[参考博客]
https://mp.weixin.qq.com/s__biz=MzAxODI5ODMwOA==&mid=2666538919&idx=1&sn=6013c451b5f14bf809aec77dd5df6cff&scene=21#wechat_redirect
https://blog.youkuaiyun.com/sdr_zd/article/details/78748497
https://www.cnblogs.com/xiawen/p/3328033.html
https://blog.youkuaiyun.com/u011494739/article/details/53423521
IO多路复用的三种机制Select,Poll,Epoll
[学习视频]
https://www.bilibili.com/video/av64192449/?p=74
https://www.bilibili.com/video/BV1Af4y117ZK?from=search

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值