From 0ad2ddf787e953bbc7a01c04778a11e2d9d6d8c0 Mon Sep 17 00:00:00 2001 From: ICEYSELF Date: Sat, 15 Jul 2023 21:54:32 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BC=96=E5=86=99=E7=BB=AD=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- markdown/asynkronous-and-koroutine-2.md | 118 ++++++++++++++++++++++++ markdown/asynkronous-and-koroutine.md | 17 ++-- 2 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 markdown/asynkronous-and-koroutine-2.md diff --git a/markdown/asynkronous-and-koroutine-2.md b/markdown/asynkronous-and-koroutine-2.md new file mode 100644 index 0000000..3934826 --- /dev/null +++ b/markdown/asynkronous-and-koroutine-2.md @@ -0,0 +1,118 @@ +!!meta-define:ident:asynkronous-and-koroutine.md-2 +!!meta-define:title:异步与协程 - 续集 +!!meta-define:author:Chuigda WhiteGive +!!meta-define:time:2023-07-13T20:55:43+08:00 +!!meta-define:tags:异步,协程,程序设计语言理论 +!!meta-define:brief:本文在上集的基础上,进一步讨论了有栈和无栈协程的实现,并比较了它们的异同与优劣 + +协程的具体实现总体上和回调式的 API 的实现有异曲同工之妙。简单来说就是,当一个协程需要等待 IO 的时候, +协程会将自己暂时挂起,将控制权交还给协程调度器,这样调度器就能调度另一个没有阻塞、可以运行的协程; +然后把自己挂在 IO 等待队列上,IO 操作完成时,调度器会唤醒正在队列中等待的协程,让它继续执行。 + +```javascript +function readData(connection) { + while (true) { + const data = readDataNonBlocking(connection) + if (data === null) { + // 加入等待队列 + addToWaitingQueue(connection, currentTask()) + // 挂起当前协程,将控制权交还给调度器 + suspend() + } + + return data + } +} +``` + +显然,在切换协程的过程中,也需要保存/恢复协程代码的上下文。在这种时候,就有两种不同的选择:**有栈(stackful)协程**和**无栈(stackless)协程**。 + +## 有栈协程 + +有栈协程的思路非常简单:每创建一个新的协程,就给它开一个新的栈;而在切换协程的时候,只需要切换到对应的栈,然后设置好程序计数器: + +```javascript +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 都使用有栈协程。 + +## 无栈协程 + +与有栈协程相对应地,**无栈协程** 不需要依靠运行时来开辟栈。无栈协程的实现方法是巧妙地将使用了协程的函数变换为**状态机**: + +```javascript +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 都使用无栈协程。 + +## 总结 + +- 对于系列教程讨论的问题而言,程序中的任务总体上可以分为两类:**计算任务** 和 **IO 任务** +- 计算任务带来的时间开销是无法避免的,而 IO 任务的时间开销更多地来自于等待。程序通常希望在 IO 任务不能立即完成时尽可能有活可做,执行其他任务 +- 多线程是对此问题最原始的解决方案,但创建多个线程有开销,并且操作系统的调度器对于这种场合并不理想,线程间通信和共享数据也是一个问题 +- 为了更高效地解决这个问题,我们引入了非阻塞式的 API +- 非阻塞 API 本身非常难以使用,因此需要封装。回调式 API 是最原始的方式,而协程则是目前为止的“终极解决方案” +- 协程的实现方式有两种:有栈协程和无栈协程。有栈协程的实现原理简单,但需要运行时的支持;无栈协程的不需要运行时,但要进行一系列复杂的变换,还会引入染色问题 +- *没有银弹* diff --git a/markdown/asynkronous-and-koroutine.md b/markdown/asynkronous-and-koroutine.md index f2f1787..2e3f061 100644 --- a/markdown/asynkronous-and-koroutine.md +++ b/markdown/asynkronous-and-koroutine.md @@ -22,8 +22,8 @@ processData(data) ``` 这段代码里涉及了两种任务: -- 从远程计算机获取数据的 `readData` 是一个 *IO 任务*,它并不消耗任何计算资源,只是在等待远程计算机的响应就绪。 -- 对数据进行处理的 `processData` 是一个*计算任务*,它消耗本地计算机的 CPU 资源。 +- 从远程计算机获取数据的 `readData` 是一个 **IO 任务**,它并不消耗任何计算资源,只是在等待远程计算机的响应就绪。 +- 对数据进行处理的 `processData` 是一个**计算任务**,它消耗本地计算机的 CPU 资源。 在上述代码中,当程序执行到 `readData` 时,如果远程计算机没有响应,那么程序就要停下来等待。对于一个**阻塞式**的 API 来说,它会让线程挂起,将控制权交还给操作系统,让操作系统调度另一个可以执行的线程。 直到数据到来时, @@ -123,7 +123,7 @@ while (true) { ## 回调式 API -早期 JavaScript 使用回调式的 API,例如: +上面的那个 big while (或称**事件循环**)虽然高效,但毫无疑问它把所有 IO 操作集中在了一起,这就非常地*反封装*。解决这个问题的方式原始之一就是使用回调式 API 对其进行一些封装: ```javascript readData(connection, function (data) { @@ -177,11 +177,16 @@ readData1(connection, function (data) { 简单来说,协程就是另一种对 “big while” 的封装方式。有了协程之后,就可以像原先使用同步 API 一样,写出简洁的代码: ```javascript -data = await readDataAsync(connection) -processData(data) +while (true) { + connection = acceptConnection() + launchTask(async function () { + data = await readData(connection) + processData(data) + }) +} ``` 与原先阻塞式的 `readData` 相比,这里的 `readDataAsync` 并不会阻塞线程,而只是将当前的协程暂停, 控制权会被移交给程序内部的协程调度器,而无须劳烦操作系统。 -协程具体的实现总体上和回调式的 API 的实现有异曲同工之妙。*今天先写到这吧,累死我了, 下次再讲 stackful 和 stackless,multithread runtime 和 work stealing,咕咕咕。* +协程的具体实现总体上和回调式的 API 的实现有异曲同工之妙。*今天先写到这吧,累死我了, 下次再讲 stackful 和 stackless,multithread runtime 和 work stealing,咕咕咕。*