并行与并发
2025-07-31 更新:今天看到FastAPI官方的学习指南,讲解异步、并发和并行很直观,更新了自己的新理解。
基本差异
打开两个文件A和B,分别向其中写入数据后保存,实现的方式有三种模式:
同步顺序执行
先打开文件A,向其中写入内容,关闭A文件,再打开文件B向其中写入内容,关闭B文件
多线程执行(并行)
创建两个线程1和2,线程1中打开文件A,线程2中打开文件B,分别在两个线程中处理
异步IO(并发)
在同一个线程中分派两个任务1和2,分别在1和2中执行打开文件A和文件B的操作,线程中先执行任务1,当1执行到IO操作时,转向执行任务2,任务2执行到IO操作时,线程空闲,等待系统通知,当1的IO执行完成,线程执行1的写文件程序,并再次等待1的IO操作,2也是类似的行为,直到两个任务都执行完成。
Erlang之父Joe Armstrong一个例子解释并行与并发的区别 并发和并行 - Rust语言圣经(Rust Course) :
并发(Concurrent) :多个队列使用同一个咖啡机,每个队列轮换着使用(未必是 1:1 轮换,也可能是其它轮换规则),最终每个人都能接到咖啡。同时存在轮流处理。
并行(Parallel) :每个队列都拥有一个咖啡机,最终也是每个人都能接到咖啡,但是效率更高,因为同时可以有两个人在接咖啡。同时执行。
对于单核上的多线程,其实也是一种并发,因为多个线程之间并没有真正意义上的同时执行,只是轮流执行多个线程。对于多核处理器,多个线程可以在不同的处理器上同时执行,所以是并行。可以把并行看做是一种特殊的并发,因为同时执行的一定同时存在。
并行
并行一般指多个进程或多个线程同时运行在多个处理器上,强调同时执行。
你和朋友去餐厅吃饭,同时有8个前台收银员提供服务,你和朋友分别和一个收银员点餐,点餐后,你和朋友分别在各自的前台等后厨出餐,你必须被迫与厨师同步,等待他把饭做好,在进行后续找位置吃饭。因为如果你不在自己的前台等待,会有别人把你的饭拿走,在等待的过程中,你什么都不能做,只能等后厨做好饭,这是同步操作,但是由于你和朋友同时都在各自的队伍中等待出餐,这就是并行两个任务。
并发
并发并不要求必须同时执行,多个任务都是同时存在的。比并行的概念更宽泛。
你和朋友去餐厅吃饭,你们排队等收银员接单,等队伍排到你们的时候,选了一份双人套餐,收银员通知后厨备餐,你拿到取餐号码。当等餐的时候,你和朋友找了一个位置,一起聊天,玩游戏,过程中,你会时不时的看有没有到你的号。到某一个时刻你看到叫你的号了,你可以等朋友把要说的故事讲完,再到前台取餐,然后一起吃饭,到此整个吃饭任务完成。
并发编程模型
不同语言实现并发编程的模型不尽相同:
- 操作系统线程:线程池方式让多个任务执行在多个线程上,需要处理线程同步,以及线程切换负载也很大。
- 事件驱动编程:通过事件回调机制,性能很高,但是由于回调会导致程序不是顺序执行,多层回调会导致程序很难维护,要找出哪一个回调上出的问题,代码上也会有很多回调函数套回调函数的情况。
- 协程:像线程,但是它对系统底层进行抽象,实现语言自己的类似线程模型,语言的M个线程会以N个操作系统线程执行
- actor模型:把多个并发的计算任务分割为actor,actor之间通过消息传递,类似分布式系统。
并发与并行谁更好?
并发在需要大量等待的场景下效果更好,例如在Web应用中,你的服务器在等待许多不同的客户通过网络发送请求过来,处理完请求后,再等用户的应答,在服务器等的过程中,其实可以做其一些他事情,提高服务器的工作效率,这就是并发。NodeJS和Go语言因此在web开发中很流行原因。
对于在任何情况下,都不需要等待的任务,并发更高效。例如打扫整个房子,你可以先打扫卧室,再打扫客厅,最后打扫餐厅,整个打扫任务过程中,你都不需要等待,你总是在打扫;无论是否轮流并发执行这些打扫任务,使用的总时间都是相同的,因为中间过程都是实际工作打扫房间,你也没有要等待的事情。这时如果来三个人同时打扫,就可以使用原来三分之一的时间完成总任务,这种时候并发更好。每一个人都是一个独立的处理器。
对于大多数执行时间都是实际工作而不是等待的任务,在计算机中一般都是由CPU来完成的,这些任务称为CPU密集型(CPU Bound)任务。CPU密集型的操作主要是复杂的数学计算,例如:
- 音频或图像处理
- 计算机视觉,对图像中的大量的像素点数据计算
- 机器学习中有大量的矩阵和向量乘法
- 深度学习中构建和使用模型
异步
异步执行一个任务时不需要等待它执行完成,可以直接进行别的操作。
同步必须等当前任务执行完成后,才能继续执行后续的操作。
异步和并发没有关系。异步编程更像是一种并发编程模型,它可以让大量的任务并发执行在很小数量的操作系统线程上。
例如编程书中,一般并发的章节中讲的都是多线程的知识,而异步的章节中讲的是Future
和async
编程语言中的异步代码告诉计算机在代码执行的某一个时刻,它需要等待其他地方完成一些事情A,在等待的这段时间里,计算机可以做一些其他事情X。在A完成后,程序等很短时间计算机处理完它刚刚走开去处理的X后,回来继续自己的A后面任务。计算机只要一空闲就会遍历等待自己的任务依次处理。
等待的事情一般都是IO耗时操作,所以又称为“IO密集型(I/O bound)”操作,例如:
- 通过网络发送数据或接收网络数据
- 从磁盘中读取文件内容,或写内容到磁盘文件中
- 调用一个远程API
- 数据库操作,查询等
异步编程比使用多线程更便捷,不需要考虑线程间数据竞争和加锁的问题,代码写起来和同步执行的代码类似。
rust中异步
Why Async? - Asynchronous Programming in Rust (rust-lang.github.io)
什么时候用线程?
当任务的数量比较少时。线程会有CPU切换和内存使用,切换线程非常占用系统资源。多线程可以不用大量修改现有的同步代码,系统编程时可以调整线程的优先级,这在对于时效敏感的程序很重要。使用多线程下载两个文件伪代码
1 | fn get_two_sites() { |
什么时候使用异步?
程序中有大量的IO操作,例如服务器和数据库程序。以及程序的任务数量远大于操作系统的线程数时也适合用异步async,因为异步的runtime使用少量的系统线程,可以处理大量的轻量级任务。由于runtime的引入,使用异步的程序二进制文件也会大一些。实现异步时会生成异步函数的状态机代码,导致程序变大。
异步并不比多线程好,它只是另一种方案。如果没有大量计算场景,不需要使用异步,多线程更简单。
使用异步下载两个文件伪代码示例
1 | async fn get_two_sites_async() { |
rust异步编程模型
Rust Runtime 设计与实现-科普篇 | 下一站 - Ihcblog!
rust中的异步主要用runtime来控制任务的调度执行,语言自身并没有runtime的实现,需要自己实现,tokio就有自己的runtime。
一个runtime有三个部分:
- Executor 负责任务调度,并执行相关操作
- Reactor 与操作系统的实际机制
epoll
交互,当系统通知某个事件发生后,它通过Waker通知Executor对应的任务可以执行了 - 任务队列 可以想象为有两个队列,一个是正在执行的队列,一个是等待唤醒的队列,这两个队列都由Executor来控制调度
python中的异步
python中使用await
关键字告诉CPU程序执行到这里要等待一会儿,CPU可以去做点别的事情,等一会再回来。
await
需要在async def
定义的函数中使用,当调用一个async def
定义的函数时也必须用await
去等它