以同步方式寫異步代碼
背景
我們知道函數(shù)是一次性執(zhí)行完的,比如下面的例子。
ounter(lineounter(lineounter(lineounter(line
function run() {
run1()
run2()
}
沒辦法執(zhí)行到 run1 時暫停下來,過一會再執(zhí)行 run2,所以碰到異步操作時就會變得下面這種寫法。
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
function run() {
run1()
sleep(() => {
run2()
}, 1000)
}
這種回調(diào)地獄相信很多開發(fā)者都經(jīng)歷過。幸好現(xiàn)代的很多語言都支持以同步的方式寫異步代碼。
ounter(lineounter(lineounter(lineounter(lineounter(line
function run() {
run1()
await sleep(1000)
run2()
}
這種方式對編寫代碼和理解代碼來說都非常有意義。在不同語言中,具體的實現(xiàn)不太一樣,下面看一下 JS/Node.js 和 Go 中的實現(xiàn)。
JS / Node.js
我們知道 Node.js 是一個基于事件驅(qū)動的單線程應(yīng)用,簡單來說,我們寫的代碼最終就是注冊各種事件,比如超時,網(wǎng)絡(luò)讀寫,當(dāng)事件發(fā)生時我們的回調(diào)就會被執(zhí)行,比如下面的例子。
ounter(lineounter(lineounter(line
setTimeout(function() {
console.log(1)
}, 1000);
執(zhí)行上面代碼時,Node.js 首先進(jìn)行自身的初始化,然后執(zhí)行 setTimeout 注冊一個定時器,超時后就會執(zhí)行注冊的回調(diào),這種模式從架構(gòu)上比較簡單易懂,但是基于回調(diào)的方式會讓代碼的邏輯變得混亂和難理解,不利于實際的開發(fā)。現(xiàn)在的 JS 已經(jīng)支持了以同步方式寫異步代碼,保證代碼的編寫和執(zhí)行流程和開發(fā)者的思考邏輯保持一致,下面是一個以同步方式寫異步代碼的例子。
ounter(lineounter(lineounter(line
import timer from "timers/promises"
await timer.setTimeout(1000)
console.log(1)
上面代碼中,demo 函數(shù)會停頓 1s 后繼續(xù)執(zhí)行,符合我們寫代碼的邏輯,上面代碼的執(zhí)行過程如下。
- Node.js 啟動,執(zhí)行 timer.setTimeout 返回一個 Promise,await 該 Promise 時會保存當(dāng)前上下文,然后退出執(zhí)行。
- 繼續(xù)處理其他任務(wù),但是這里沒有其他任務(wù)了,所以阻塞等待定時器超時時間。
- 1s 后定時器超時,resolve timer.setTimeout 對應(yīng)的 promise。
- await 返回,從 await 下面一句代碼繼續(xù)執(zhí)行,輸出 1。
從上面代碼的執(zhí)行流程中可以看到,在 Node.js 中執(zhí)行一塊代碼時,如果碰到 await 會保存上下文然后退出執(zhí)行,等待 await 后面的 promise 決議后再恢復(fù)執(zhí)行,從中可以看到 await 只會阻塞其后面代碼的執(zhí)行,而不會阻塞整個線程的執(zhí)行??匆粋€實際的例子。
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
import timer from "timers/promises"
import http from "http"
http.createServer(async function handler(req, res) {
await timer.setTimeout(1000)
res.end();
}).listen(12345, function() {
http.get("http://localhost:12345");
http.get("http://localhost:12345");
});
上面是 Node.js 處理請求的過程,假設(shè)服務(wù)器啟動后,有兩個請求到來,處理過程如下。
- 第一次執(zhí)行回調(diào) handler,執(zhí)行 await timer.setTimeout(1000) 注冊一個定時器并退出執(zhí)行。
- 事件循環(huán)繼續(xù)處理下一個任務(wù)。
- 第二次執(zhí)行回調(diào) handler,執(zhí)行 await timer.setTimeout(1000) 注冊一個定時器并退出執(zhí)行。
- 事件循環(huán)繼續(xù)處理下一個任務(wù),發(fā)現(xiàn)沒有任務(wù)需要處理了,等待定時器超時。
- 第一個定時器超時,await 恢復(fù)執(zhí)行。
- 第二個定時器超時,await 恢復(fù)執(zhí)行。
通過同步方式寫異步代碼的方式,極大減少了代碼的編寫難度和提高了代碼的可維護(hù)性。但是因為 Node.js 是跑在單個線程的,所以這種以同步方式寫異步代碼的方式的意義也僅僅是代碼層面的。
Go
接下來看看 Go 的做法。
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
package main
import (
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
wg.Add(2)
for i := 0; i < 2; i++ {
go func() {
defer wg.Done()
time.Sleep(time.Second)
}()
}
wg.Wait()
}
上面的代碼中,在主協(xié)程中創(chuàng)建了兩個子協(xié)程,每個子協(xié)程睡眠 1s 后退出。從中可以看到在 Go 中多了一個協(xié)程的概念,并且天然是以同步方式執(zhí)行異步代碼的,在 Go 中,所有 API 都是同步執(zhí)行的,哪怕是一個異步操作。那么 Go 的同步實現(xiàn)異步代碼是怎么實現(xiàn)的呢?和 Node.js 的實現(xiàn)有什么區(qū)別?下面看一下 time.Sleep 的實現(xiàn)。
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
func timeSleep(ns int64) {
gp := getg()
t := gp.timer
if t == nil {
t = new(timer)
gp.timer = t
}
// 超時回調(diào)
t.f = goroutineReady
// 當(dāng)前協(xié)程
t.arg = gp
// 絕對超時時間
t.nextwhen = nanotime() + ns
// 阻塞協(xié)程,并重新調(diào)度其他協(xié)程執(zhí)行
gopark(...)
}
可以看到執(zhí)行 time.Sleep 時會導(dǎo)致整個協(xié)程阻塞并切換到其他就緒協(xié)程執(zhí)行。接著看一下超時后的邏輯。
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
func goroutineReady(arg any, seq uintptr) {
goready(arg.(*g), 0)
}
func goready(gp *g, traceskip int) {
systemstack(func() {
ready(gp, traceskip, true)
})
}
func ready(gp *g) {
// 修改協(xié)程為就緒
casgstatus(gp, _Gwaiting, _Grunnable)
// 放入隊列等待調(diào)度執(zhí)行
runqput(mp.p.ptr(), gp, next)
wakep()
}
超時后 Go 會喚醒協(xié)程把它放入隊列等待執(zhí)行。
總結(jié)
從前面的介紹可以看到同步寫異步代碼都是通過暫停執(zhí)行和恢復(fù)執(zhí)行來實現(xiàn)的,Node.js 是通過 await 控制函數(shù)的暫停和繼續(xù)執(zhí)行,但是它不會影響執(zhí)行實體(線程)的狀態(tài),而 Go 是通過實現(xiàn)新的執(zhí)行實體協(xié)程并阻塞協(xié)程實現(xiàn)的。當(dāng)在單線程環(huán)境中,它們的區(qū)別不是很大,但是 Go 的底層是多線程的,也就是說所有的協(xié)程是分布在多個線程中執(zhí)行的,所以 Go 協(xié)程的意義不僅是實現(xiàn)了同步寫異步代碼,而且還可以利用多核,提高應(yīng)用的性能。另外 Node.js 是在單個線程中實現(xiàn)一個事件循環(huán),在事件循環(huán)中不斷處理就緒的任務(wù),而 Go 是在每個線程中都實現(xiàn)了一個事件循環(huán),這個事件循環(huán)就是調(diào)度器,調(diào)度器會不斷地選擇就緒的協(xié)程執(zhí)行,并判斷哪些定時器超時了,哪些網(wǎng)絡(luò) IO 就緒了,從而把對應(yīng)的協(xié)程加入到調(diào)度隊列中,如此反復(fù)。