异步编程的演进

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 微秒