Skip to content

Commit

Permalink
编写续集
Browse files Browse the repository at this point in the history
  • Loading branch information
chuigda committed Jul 15, 2023
1 parent 6b99346 commit 0ad2ddf
Show file tree
Hide file tree
Showing 2 changed files with 129 additions and 6 deletions.
118 changes: 118 additions & 0 deletions markdown/asynkronous-and-koroutine-2.md
Original file line number Diff line number Diff line change
@@ -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 是最原始的方式,而协程则是目前为止的“终极解决方案”
- 协程的实现方式有两种:有栈协程和无栈协程。有栈协程的实现原理简单,但需要运行时的支持;无栈协程的不需要运行时,但要进行一系列复杂的变换,还会引入染色问题
- *没有银弹*
17 changes: 11 additions & 6 deletions markdown/asynkronous-and-koroutine.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ processData(data)
```

这段代码里涉及了两种任务:
- 从远程计算机获取数据的 `readData` 是一个 *IO 任务*,它并不消耗任何计算资源,只是在等待远程计算机的响应就绪。
- 对数据进行处理的 `processData` 是一个*计算任务*,它消耗本地计算机的 CPU 资源。
- 从远程计算机获取数据的 `readData` 是一个 **IO 任务**,它并不消耗任何计算资源,只是在等待远程计算机的响应就绪。
- 对数据进行处理的 `processData` 是一个**计算任务**,它消耗本地计算机的 CPU 资源。

在上述代码中,当程序执行到 `readData` 时,如果远程计算机没有响应,那么程序就要停下来等待。对于一个**阻塞式**
API 来说,它会让线程挂起,将控制权交还给操作系统,让操作系统调度另一个可以执行的线程。 直到数据到来时,
Expand Down Expand Up @@ -123,7 +123,7 @@ while (true) {

## 回调式 API

早期 JavaScript 使用回调式的 API,例如:
上面的那个 big while (或称**事件循环**)虽然高效,但毫无疑问它把所有 IO 操作集中在了一起,这就非常地*反封装*。解决这个问题的方式原始之一就是使用回调式 API 对其进行一些封装:

```javascript
readData(connection, function (data) {
Expand Down Expand Up @@ -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,咕咕咕。*

0 comments on commit 0ad2ddf

Please sign in to comment.