同步和异步的一些事
最近在编程总是会遇到async, Mutex等字眼,今天来盘点以下同步和异步,顺便复习以下操作系统
进程(Process)
正在运行的程序。人们写的程序只定义了入口函数(比如__main_
, main()
, __main__
)。如果执行这个程序,操作系统会为这个程序生成进程。比如在linux中,内核会从0号进程fork()
出一个新的进程,接着把我们的代码装载进这个进程,最后这个进程从入口函数开始一步步运行。进程具有一定的资源,比如虚拟内存空间,正在执行程序文件。进程具有一定的标志,比如权限,调度优先级,进程运行状态。
线程(Thread)
考虑到进程的创建和销毁需要维护大量的数据结构(进程表
),再加上随着科技的发展,多核cpu逐渐称为主流,为了充分利用多核,人们创造了更小的单位:线程。多个线程能够共享同一个进程的资源。貌似linux在某个版本之后的调度算法将cpu的最小调度单元改成了线程,从而增加了对多核的支持。
协程(Coroutine)
比线程更小的单位。协程的调度并不需要系统调用,而是在一个线程内部实现。相当于一个用户态的的操作系统,但是基本的io操作依赖于线程的io系统调用。主要用来解决io密集线程过多的问题。比如1亿个线程的io读写,那么有很多的时间耗费在了线程切换之上(线程也有数据结构,只是比进程简单, 而且线程的切换是系统调用)。因此人们干脆把调度算法写在线程中,在线程调度协程,那么就只有一个线程,1亿个协程。时间虽然都花在了协程的切换之上,但由于协程的数据结构更简单,且不用频繁系统调用,耗时也减少了。
互斥
资源有限而需求无限。A和B都想买车票,一个售票口只能排队。假如A在B前面,B只能进入等待。
同步(sync)
B在A之后才能执行。
- 上面互斥的例子,B只能等待A完成也是一种同步。
- 生产者和消费者和商店。消费者到商店购买产品,没有产品只能等生产者造出产品送到商店才行。同步能让消费者进入等待。
异步(async)
B在A之后执行,不过B可以忙别的事。
- 上面互斥的例子,B可以刷刷抖音,或者视线排队在边上坐着。等待A的通知,或者售票员喇叭的通知。
- 上面同步的第二个例子i,消费者可以刷刷抖音或者在家里躺着。等待生产者的通知,或者商店的短信。
异步的实现
- 创建(
spawn
)一个线程等待资源,主线程干别的事。 - 线程获取资源后通过回调函数执行下一步,或者通过消息队列通知主线程。
简化异步
这个困惑了我很久,如今网上的热词如异步线程
, 阻塞队列
,天生并发
等等等其实都是说的一件事: 简化异步。众所周知,操作系统已经帮我们实现了线程间异步。以linux
为例, 线程天生就有就绪
, 等待
, 运行
三个状态,并且内核也维护了一个阻塞队列。如果从头实现异步,我们需要
- 创建自己的消息队列
- 时不时自己管理线程
- 甚至维护自己的线程池
而这些所谓的高并发语言, 都维护了一个自己的阻塞队列,维护了自己的消息队列,维护了自己的线程池,维护了自己的并发io库,并且拥有自己的异步语法糖。我们只需要创建一个异步函数即可,线程的分配,分离底层库都帮我们实现了。
因此异步线程可以看作特殊的线程: 操作系统的调度器作普通的线程,语言本身的运行时调度器则区别对待。因此用来写服务器非常方便,既可以减少普通线程的开销,又不用自己写搞协程,收到一个请求就async
出一个线程。
简化异步
的编程范式
以nodejs为例分析简化异步写法的历史与发展,其他的大同小异。
-
回调函数时期。为了支持异步,于是将IO进行封装,尾巴带了一个回调函数
// 同步IO函数 function trueIO(): ResultType { ... return res; } // 将IO操作和trueIO装入一个线程,并加入阻塞队列 // 从而不会阻止主线程 function trueIOAsync(callback: (result: ResultType) => void) { setTimeout(function () { const res = trueIO(); callback(trueIO); }, 0) } // 没有实现阻塞队列的语言可能会用spawn function trueIOAsync(callback: (result: ResultType) => void) { spawn(function () { const res = trueIO(); callback(trueIO); }) } // 调用异步IO函数 trueIOAsync(function (res) { console.log(res); })
注:这个
trueIO
和spawn
是我假设的函数 -
Promise时期。后来有人觉得回调函数不太优雅,加上动态语言,函数式思想,
builder
模式,链式调用的流行,就改变了封装方法,Promise应运而生class Promise { callback: ((value: any) => void)[]; new(fn: ((result: ResultType) => void, r(err: ErrType) => void) => void) { fn(this.innerResolve); } then<T>(fn: (value: T) => void) { this.callback.push(fn); return this; } innerResolve(result) { this.callback.forEach(fn => fn(result)); } }
这样就能够统一管理回调函数,于是可以把回调函数封装成链式调用
// 封装异步IO函数,加入阻塞队列 function trueIOPromise() { return new Promise(function (res) { setTimeout(function () { const res = trueIO(); res(res); }, 0); }) // 调用异步IO函数 trueIOPromise().then((res) {console.log(res) }); }
上面是比较底层的Promise,我们也可以画蛇添足通过异步回调函数构造
Promise
function trueIOPromise() { return new Promise(function (res) { trueIOAsync(res(res)); }); }
-
async, await。有人觉得
new Promise(...)
依旧不够优雅,因此出现了async和await。// 借用上面的trueIOPromise() async function asyncFn() { return await trueIOPromise(); } // 经过转译后 function asyncFn() { return trueIOPromise(); } // 第二个例子 async function asyncFn() { res = await trueIOPromise(); res++; return res; } // 经过转译后 function asyncFn() { return new Promise(function (resolve) { let res = undefined; trueIOPromise().then(function (result) { res = result; res++; resolve(res); }) }) }
以上就是三个阶段。至于基于强大过程宏的rust
的future
, invoke
也是大同小异。
异步封装
其实创建一个Promise并不会加入阻塞队列,只有调用栈最底层Promise使用了诸如setTimeout
的异步调用才会。那么如果使用async/await封装同步函数将毫无作用。
async function truIOAsync() {
return trueIO();
}
// 转译
function trueIOAsync() {
return new Promise(function (resolve) {
let result = trueIO();
resolve(result);
})
}
// 使用异步调用才有用
async function trueIOAsync() {
return await new Pormise(function (resolve) {
setTimeout(function () {
let result = trueIO();
resolve(result);
}, 0)
})
}
封装同步函数就是脱下裤子放屁,毫无作用。大可不必追求新的写法而闹笑话。
同步锁
为了实现资源访问的互斥,并且为了减少开关中断系统调用的开销,在代码层面维护一个锁。一次只能一个线程访问。
异步锁
考虑了异步线程和线程的区别。因为异步本质上是交给另一个线程去工作,那么就会有多线程互斥问题。同步锁要求所有线程互斥,包括异步线程。而异步锁则不要求主线程和异步线程两者互斥。
死锁
多个线程需要两个以上的资源,每个资源互斥,则可能死锁。