异步编程的演进
1. 单片机裸机程序(无操作系统、无多线程、调度)
只能有一个工作流。所以全部都是非阻塞函数,通过轮询的方式执行。这种最简单的嵌入式设备的程序,工作内容明确,业务逻辑简单,只需设计好执行流程,都可以解决。
while (1) {
func1();
task1();
func2();
task2();
...
}
简单、高效、易于调试,唯一的缺点就是业务逻辑的复杂度上限取决于函数和执行流的设计。
2. 有了不确定的I/O、UI交互
增加了串口接收、解析、应答报文;按键响应、屏幕刷新。这时最简单粗暴的方式是使用中断,前后台的程序框架,而且串口的任务放在串口中断里,屏幕刷新放在定时器中断里,按钮任务放在引脚的外部中断。
这种方式除了简单,其他都是缺点。如代码逻辑分散(不是高内聚低耦合);中断内停留时间过长;大量的全局变量、频繁的数据竞争等等。
稍好一些,也是主流的做法是在中断内置标志位,前台程序中轮询标志位,根据标志位执行任务。此时其实已经有异步编程的意思了。
3. 有了操作系统(多任务、多线程)
支持多个工作流。这时的设计空间就很大了,有一种方式就是回调函数:既然当前无法获取资源、也不知道何时可以获取到,那就指定一个有资源时该干的事,然后执行开一个工作流的单独等待、执行。如各类 UI 框架的事件回调函数,最明显的就是按钮点击函数,优点是代码书写自然,符合设计直觉。但有一类情况,如:
JS的xhr请求处理:
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState == 4 && xhr.status == 200) {
document.getElementById("myDiv").innerHTML = xhr.responseText;
}
}
Qt 的 QModbus 客户端请求:
bool MyModbus::ReadCoils()
{
QModbusDataUnit read_unit(QModbusDataUnit::Coils, start_add, numbers);
if (auto *reply = my_client->sendReadRequest(read_unit, server_id)) {
if (!reply->isFinished()) {
connect(reply, QModbusReply::finished, this, &MyModbus::ReadReadyCoils);
return true;
} else {
delete reply;
return false;
}
}
return false;
}
void MyModbus::ReadReadyCoils()
{
// ...
}
这类 I/O 事件除了有本次的确定性,还有多个事件执行顺序的确定性。如果有多个请求,且后面的请求依赖前一次的请求结果,那就需要将后面的代码写在前一次的回调函数内,就此,形成著名的回调地狱(Callback Hell)问题。
4. async 编程
async 是 Rust 选择的异步编程模型。这种模型在异步执行的本质下,有同步代码的书写逻辑。
先思考一个问题,在没有 async 之前,如果要解决回调地狱,你会怎么做?
一种做法是编写一个函数,定时轮询前次的结果,如:
void sync(void *result) {
while (!is_done(result)) {
mdelay(10);
}
}
将这样的函数嵌在异步任务之间,或者将回调函数定位轮询的结束条件,如 Qt 中:
if (!reply->isFinished()) {
QEventLoop loop;
connect(reply, &QModbusReply::finished, &loop, &QEventLoop::quit);
loop.exec();
}
另一种做法是编写一个内部创建互斥量的函数,将互斥量传递给异步任务,由异步任务完成后释放互斥量,在函数在返回前等待互斥量,形如:
typedef void(*sync_func_t)(mutex_t*);
void sync(sync_func_t func) {
mutex_t *mutex = mutex_new();
func(mutex);
mutex_take(mutex);
}
这是 C 语言的形式,如果用 C++ 或其他带对面的语言,可以使用链式调用的形式写出更优雅的代码,如将互斥量返回:
typedef void(*async_func_t)(mutex_t*);
mutex_t* async(async_func_t func) {
mutex_t *mutex = mutex_new();
func(mutex);
mutex_take(mutex);
return mutex;
}
void foo(void) {
async(func1).take_delete();
}
这个就是 async 编程的基本模型,对我来讲是一种很自然的演进方式。编译器专家在从编译器层面支持了 async、await 语法。async 的底层不会这么简单,是有着一套复杂的设计,而且每种语言都有自己的独特实现,JS 和 Rust 就是完全不同的两种实现方式。
以上方法的本质其实是:当前线程主动让出 CPU。以阻塞任务或耗时任务,单独运行在一个线程中,以阻塞当前线程的方式,让其它任务执行,一次实现并发。但线程是很重的,线程间切换、上下文切换是一个繁重的开销,数量少时没感觉,但如 web 服务器这样的场景,少则成百上千,多则百万上亿,哪怕线程池也难以处理。所以又多了一个协程的概念,一个线程可以有多个协程,Rust 的 async .await 底层就是协程支持的。
async 和多线程的性能对比
操作 | async | 线程 |
---|---|---|
创建 | 0.3 微秒 | 17 微秒 |
切换 | 0.2 微秒 | 1.7 微秒 |