随想    技术    关于    友链    返回普通版本

异步与协程 - 续集

协程的具体实现总体上和回调式的 API 的实现有异曲同工之妙。简单来说就是,当一个协程需要等待 IO 的时候, 协程会将自己暂时挂起,将控制权交还给协程调度器,这样调度器就能调度另一个没有阻塞、可以运行的协程; 然后把自己挂在 IO 等待队列上,IO 操作完成时,调度器会唤醒正在队列中等待的协程,让它继续执行。

function readData(connection) {
    while (true) {
        const data = readDataNonBlocking(connection)
        if (data === null) {
           // 加入等待队列
           addToWaitingQueue(connection, currentTask())
           // 挂起当前协程,将控制权交还给调度器
           suspend()
        }

        return data
    }
}

显然,在切换协程的过程中,也需要保存/恢复协程代码的上下文。在这种时候,就有两种不同的选择:有栈(stackful)协程无栈(stackless)协程

有栈协程

有栈协程的思路非常简单:每创建一个新的协程,就给它开一个新的栈;而在切换协程的时候,只需要切换到对应的栈,然后设置好程序计数器:

function launchTask(taskFn) {
    taskId = allocateTaskId()
    stack = createStack()

    addToTaskSet({ taskId, taskFn, stack })

    return taskId
}

function suspend() {
    nextTask = scheduleNextTask()
    setCurrentStack(nextTask.stack)
    setProgramCounter(nextTask.programCounter)
}

有栈协程的实现方式非常简单易懂,并且通常情况下,引入有栈协程不需要改变与 IO 不相关的代码。不过,创建和管理栈需要协程运行时的支持,并且也有一定的性能开销。Golang 和 Julia 都使用有栈协程。

无栈协程

与有栈协程相对应地,无栈协程 不需要依靠运行时来开辟栈。无栈协程的实现方法是巧妙地将使用了协程的函数变换为状态机

function CREATE_CONTEXT_readData(connection) {
    return {
        state: 0,
        connection
    }
}

function POLL_readData(context, waker) {
    data = readDataNonBlocking(context.connection)
    if (data === null) {
        // 当 context.connection 准备就绪的时候,它会通过 waker 通知调度器可以再 poll 一次
        addToWaitingQueue(context.connection, waker)
        return { status: PENDING }
    }

    return { status: READY, data }
}

// 这是我们之前传递给 launchTask 的 taskFn
function CREATE_CONTEXT_taskFn(connection) {
    return {
        CONTEXT_readData: CREATE_CONTEXT_readData(connection)
    }
}

function POLL_taskFn(context, waker) {
    status, data = POLL_readData(CONTEXT_readData, waker)
    if (status === PENDING) {
        return { status: PENDING }
    }

    processData(data)
    return { status: READY }
}

while (true) {
    connection = acceptConnection()
    launchTask(CREATE_CONTEXT_taskFn(connection), POLL_taskFn)
}

通过这样的变换,一个异步函数变成了一个 context 和一个负责推着 context 走的 poll 函数。 异步函数的上下文就被存储在了 context 里,而调度器只需要在 waker 被调用的时候再次尝试 poll 这个 context 即可。 这就免除了对运行时的需求。

但,古尔丹,代价是什么呢? 显然,只要一个函数需要调用另一个异步函数,这个函数就必须进行上面的变换, 以保存自己和被调用函数的上下文。反映到代码中,大部分采用无栈协程的语言都要求将异步函数标记为 async, 并在调用异步函数的时候使用 await 关键字 —— 也就是所谓的染色。而最终,我们不得不面临这样一个问题, 那就是只要调用链上有一个异步函数,整个调用链都必须被染色,就像马洛诺斯之血一样,无法洗去。

Rust,JavaScript 和 Python 都使用无栈协程。

总结