如何評價node-fibers?

如何評價node-fibers?

laverdet/node-fibers,是否可看作fiber.js在node.js上的可替代解決方案?而且語法基本和generator差不多,async/await也可基於此實現。


簡單說,node-fibers 這項目在它出現的時候(2011年)是一種有價值的嘗試,儘管當時就可以斷言其基本不可能進入 JS 標準,原因見 TC39 成員之一 Dave Herman 的文章:Why coroutines won』t work on the web:

Coroutines are not going to happen in JavaScript. They would break one of the best features of JavaScript: the simplicity of the event loop execution model. And the demands they would place on current engines for portability are simply unrealistic.

(粗體是我所加)

JS 標準選擇了:以 Promise 為欽定的非同步原語,加入 generator 而不是 coroutine。這也意味著放棄 fiber 這條路。現在 async/await 已經進入了 ES2017 標準,各引擎也早已實現,就更加沒有必要使用這庫。

laverdet/node-fibers 頁面上列出的基於 fibers 的庫,除了 streamline.js、asyncawait、meteor 之外,絕大多數都已經2、3年沒更新維護了,基本上等於棄坑。還在維護的三個,streamline.js 是一個老牌的非同步編譯器,它只是把 fibers 作為又一個編譯 target 而已,實際有沒有人用,不知道;asyncawait 則幾乎跟標準的 async/await 寫法相差無幾,可以認為是用函數來作為偽關鍵字,遷移起來非常簡單,所以可能還有人在用;meteor 算是最大牌的了,是一個全棧應用框架,用 fibers 給標準 Promise 擴展了 async/await 方法,跟前述 asyncawait 庫基本一致,不過也只能在 server 端使用此擴展,按照全棧追求 isomorphic 來說,我猜也不會有很多人用。

注意,node-fibers 雖然比 fibjs 有融入 node.js/npm 生態的優點,但也存在一些小問題。比如與 node.js 的 domain 機制可能不配合;與其他 native 庫或許會有不兼容的問題;性能上則據稱大概是純 callback 的 70% 左右。

關於 fibjs,參見2014年評論 fibjs 的兩個答案:

@尤雨溪 如何評價fibjs? - 知乎

@Jim Liu 如何評價fibjs? - 知乎

小右指出「await 關鍵字讓同步和非同步調用有明確區分」,吉姆更明確的說「(fibjs)雖然代碼都是同步了,但是開發過程中事實上除非對每個函數實現都了解,否則很難掌握每一行代碼到底會不會掛起fiber」,並表示「不敢苟同」響馬(fibjs作者)所說「我不認為讓開發者關心一個調用是同步還是非同步是一件好事情」。

他們表達的意思其實就是前面 Dave Herman 說的:coroutine 會打破 JS 的 event loop 執行模型的簡單性。而 yield 關鍵字的好處就是「you can always tell when and what you』re suspending」,await 關鍵字也是類似的。

我這裡稍微補充一點點當初討論 fibjs 時我闡述過的類似觀點:

JavaScript 語言有一項重要的語義:run-to-completion。Dave Herman 講的 event loop execution model 也是指這項語義。

這項語義早在 JS 誕生時就存在,但直到 ES6 之前,都沒有在 spec 里明確的寫出來。ES6 因為加入了 Promise,終於加入了這一語義的描述:

Once execution of a Job is initiated, the Job always executes to completion. No other Job may be initiated until the currently running Job completes.

不過描述得仍然不是最完整,尤其是僅僅以 spec 的文本來說,字面上並沒有禁止 host 在某些地方違反這一語義。所以當初 fibjs 的作者 @響馬 大叔認為 fibjs 並不違反 ECMAScript 規範,而我認為是違反的(包括 node-fibers 所做的擴展也一樣違反了 ECMAScript 規範)。或者不說違反,也可以說在某些方面不是 conformant 的(但是以 coroutine 來說,因為其影響不像某些擴展是局部的,因此性質是比較嚴重的)。產生分歧的原因就在於 spec 沒有直接表達清楚這一語義約束。所以這也不怪響馬大叔,畢竟 TC39 的成員雖然都清楚這項語義,但是對於 spec 應該寫到怎樣明確的程度,包括當前 spec 是否正確的表達了這些約束,之前也存在分歧(參見 revised proposal still violates run-to-completion execution semantics · Issue #27 · tc39/proposal-dynamic-import)。

在這個 issue 的討論中,TC39 的成員之一,Allen Wirfs-Brock 明確定義了 run-to-completion:

Here are the requirements that ES spec is trying to express via the jobs abstraction:
1. ES semantics divides computational work in to a set of "jobs"
2. Only one job may be actively undergoing evaluated at any point in time. (jobs never perform concurrent computation)
3. Once evaluation of a job starts active evaluation, it must run to completion before evaluation of any other job starts.
4. Once a job that has evaluated to completion it is never re-evaluated.
5. When the current job completes, another job is selected for evaluation.
6. There are some very limited constraints on the order in which jobs are selected for evaluation.

在這個目前尚未合併的 PR(Layering: fix the jobs infrastructure by domenic · Pull Request #735 · tc39/ecma262)中,討論的另一方 Domenic Denicola 對 spec 中的描述做了改進:

* Only one Job may be actively undergoing evaluation at any point in time.
* Once evaluation of a Job starts, it must run to completion before evaluation of any other Job starts.
* The evaluation of any Jobs enqueued via previous calls to HostEnqueueJob with the same queueName argument must complete before evaluation of the Job starts.

如果從通俗的角度說,run-to-completion 其實就是一段 JS 的程序會一直運行到結束,而不會被中間打斷,比如我們熟知的 setTimeout 的回調總是在當前程序運行完之後才會執行。正因如此,一段 JS 程序運行,其狀態的變化是非常確定性的結果的。我們有時候把這個性質不嚴謹的稱為「JS是 單線程的」,不會遇到多線程編程的困難。

yield 和 async/await 從一定程度上來說改變了 run-to-completion。因為沒有這些之前,一個函數會執行到底,現在則是在函數當中可能 suspend。這個時候,關鍵字就起作用了,yield 和 await 關鍵字明確指示了 suspend 的時機和位置。這樣,雖然確定性比原來有所下降(suspend 期間可能有其他程序修改狀態——特別的,多個 async 函數並行執行的情況可視為類似於多線程,也就是面臨了狀態共享問題),但是我們對於兩個 suspend 點之間的部分運行起來的效果仍然有非常確定性的信心。

而如果是像 fibjs 和 node-fiber 那樣,沒有關鍵字指示,實際上每個函數調用都有可能 suspend,這對於 JS 程序員推斷程序狀態來說是災難性地提高了難度。

之所以響馬大叔不 care 這一點,是因為響馬叔是 C++ 程序員,其招募的團隊的編程背景則多是 Java 的。對於 C++ 和 Java 程序員來說,由於函數/方法默認是線程不安全的,不要說每個函數/方法調用處可能 suspend,任何語句或表達式裡面的任何一個非原子操作都可能被中斷。所以 fibjs 相比較 C++/Java 來說實際上還增加了確定性。

明白了這層背景差異,就不難理解為什麼響馬叔認為的「非同步向上傳染」對於 JS 程序員來說非但不是壞事,反而是好事了。fibjs 並不是不傳染,只是隱式傳染卻沒有標記。async/await 顯式的傳播,雖然看上去繁瑣,但卻必要的幫助 JS 程序員標誌了這個函數/此處調用需要特別注意。

另外,響馬叔表示 fiber 的性能更好。對此前面 Dave Herman 講的第二點其實暗示了相反的可能。或許 V8 的架構,加入 fiber 不一定會降低性能,但是其他引擎就不一定。

當然,對我以及大部分 JS 程序員來說,前面講的編程模型上的問題比這存疑的性能問題要重要得多。

需要補充的是,node-fiber 的作者估計也是明白我說的問題的,因此他明確表示,不要直接使用 node-fibers,而應使用包裝過的抽象,如 Future 或前面提到的那些庫。如果看過 Future 和前面那些庫,就會發現,有限度的使用它們是有可能規避前述的問題的。比如我前面提到的 asyncawait 就是以函數形式模仿關鍵字。

以上。【2017年5月13日答】

【更新】

響馬大叔也來回答了這個問題。我嘗試對他的一些論點做些回應。

第一,響馬大叔表示「從一開始,我就說,顯式非同步是錯的」。

我覺得是這樣,語言的確是可能存在設計錯誤的。但是我認為顯式的非同步(通過關鍵字、運算符等表示非同步運算)並不是設計錯誤,而是不同的設計選擇,並且從現實情況看,絕對是一個不壞的設計選擇。

有許多語言做了這樣的選擇,比如C#(5+)、JS(ES2017)、Python(3.5+)、Hack(PHP方言)、Dart……有些語言雖然沒有直接的語言級支持,但是可以通過宏達成,比如 Scala(scala/async)。有些語言很有可能在未來的版本中加入它,比如Rust。

我提請各位注意一點,async/await的設計是最近才流行的。不像很多早期的語言,其設計僅僅是一個人或者幾個人完成,出現設計錯誤一點也不奇怪。async/await最早出自於C#(源頭可溯及F#),微軟別的不好說,語言設計水準還是相當高的(比方說,垠神批了那麼多語言,但貌似C#/F#他到目前為止還沒有懟過)。其次,async/await加入其他語言都是經過各語言社區好幾年非常廣泛和深入的討論之後才加的。也就是,我們可以坦率的說,上述這些語言社區總體上是不贊同響馬大叔的觀點的。以JS為例,其語言委員會是各個大公司代表組成,這些代表可不是泛泛之輩,都是工程界、學術界的翹楚。比如我前面提到的AWB,他來自微軟,且這個老頭也是Smalltalk語言委員會的成員。尤其,相比其他語言社區,新特性通常只要多數票贊同即可,JS自ES6開始的規則是必須全體一致同意(所謂harmony嘛),所以至少ES2017投票的那些所有人都是贊同async/await的(至少針對JS語言肯定如此)——要知道JS委員會絕對不是一團和氣的,互相懟的例子非常多,我不久前還做過一個presentation,講的就是Apple怒懟其他3家(微軟、谷歌和Mozilla)從而把STC/PTC特性拖入僵局。

儘管,採納這一設計的語言多,贊同這一設計的權威多,並不能代表這設計就是對的,總是存在這樣的可能性:他們都錯了,響馬大叔完爆他們!——但是我覺得至少我們可以更謹慎一點,避免過於主觀的評論(比如「羞答答的遮羞布」)。

順便,一個非常可資比較的語言特性是,在許多函數式編程語言中,有副作用和無副作用(純)的函數通過命名進行區分(有副作用的以「!」結尾),類比來說,可稱之為「顯式副作用」。顯然,副作用和非同步一樣也有傳染問題。固然大多數編程語言並沒有區分,反正我是沒見過有人表示,這樣做的函數式編程式語言就是「錯的」。

第二,關於鎖。

有了suspend之後確實就可能有資源爭用,如果有爭用,就需要鎖。但是請注意兩點:

1. 只是「有可能」,而不是必然。實際上在大多數情況下,async/await只是為了表達非同步操作不需要阻塞,以及在某些點(await之處)存在順序上的依賴關係。真正存在共享狀態的情況並不多。如果沒有共享狀態,就不需要鎖。

2. 即使存在共享狀態,基於async/await和傳統多線程和fiber也有很大的不同。我前面已經指出,傳統多線程中,程序可能在任何一個點上(對於fiber來說是在任何函數調用上)suspend,但是async/await只會在await那個點suspend。可反向理解為從一個await到下一個await之間的所有代碼都是默認加鎖的。因此,一段async代碼里有10個函數調用,其中只有一個是await的,那麼我只要考慮該await處所調用的async函數對共享狀態的存取,而不用管其他9個函數調用。但是fiber你需要考慮所有10個。

比如一段代碼,其中一個底層實現,現在是同步的,於是中間某層某個邏輯沒有加鎖,有一天這個底層實現改成 await 了,一路改上來就不說了,鬼才記得中間哪一層在猴年馬月苦思冥想後決定不加鎖了。

響馬大叔說的這個例子,當你「一路改上來的」時候,每一處新加的await就是你需要重新review共享狀態,考慮是否需要鎖的地方。(實際上不應該「一路上」都需要考慮鎖,因為鎖是針對狀態的,如果狀態也要一路浮上來,說明代碼寫得有問題,抽象沒做好,泄漏了。)我們反過來說,如果是fiber,確實不需要「一路改上來」,但是可能suspend的點其實是自動擴散了,那麼鬼知道是不是有需要加鎖的地方沒加呢?

第三,forEach的例子。

響馬大叔的那個 forEach(async ...) 的例子我覺得沒有什麼好說的。這純粹是理解async/await語義的問題。響馬大叔認為「講道理的輸出」,我表示理解不能。你用基於fiber的asyncawait庫寫,輸出也是一樣的啊(012=012)!你就算直接用fiber寫,(比方說在sleep里suspend)輸出也不是這個效果啊,而應該是001122=的順序啊!所以,響馬大叔這個「講道理的輸出」(012012=)我覺得可能是筆誤了。

回到async/await的語義,我認為實踐已經證明,c#/js/python/dart/hack/scala... 程序員都能輕鬆理解之。一些複雜的case,可能需要程序員理解async/await背後的Task/Promise/Future/Awaitable...機制;但是,反正不會比多線程要難理解吧。

關於forEach,實際上不是不能用,只是forEach沒有返回值,所以你沒法await之。常見的用法是await Promise.all(array.map(async ...))。我認為你要重構代碼(把原來同步的改為非同步),總得建立在理解的基礎上。

當然,我理解這裡響馬大叔無非意思是,如果是fiber,你不需要理解promise,你可以繼續用forEach,啥代碼都不動就自動享受非同步的好處。但是仔細想一想,你就會發現這是不成立的。就算你用fiber,假如你使用的是基於fiber包裝的庫,比如前面提到的asyncawait庫,你一樣需要理解promise/future;假如你說你直接用fiber,那這個forEach雖然不用變,但極可能本來明明可以並行非同步的,結果被強制同步了,也就是並沒有達到使用非同步的效果;而如果你需要並行非同步,你還是不能直接保留forEach不變,還是得依賴其他基於fiber的非同步庫和背後的抽象概念。要不你自己手寫fiber yield原語來看看?

第四,關於「一門語言,如果想要發展的好,必須考慮更多的使用場景。

考慮更多場景是ok的。但是語言應該針對主要use cases設計——這其實是一般的設計原則,也適用於庫和API。

我前面就提到過,共享狀態並沒有想像的那麼多。並且,因為共享狀態、鎖之類的很麻煩,我們希望盡量避免,避免不了也要盡量限制其影響範圍,盡量將其局域化。

那麼在通用編程語言中,以多線程和共享狀態爭用為前提就是為了一個在80%場景里不需要的東西犧牲了整個語言的簡單性。

【未完待續】


被邀的時候看了,想躲開這個問題,但是還是被老賀拖進來了。那就回答一下吧。

從一開始,我就說,顯式非同步是錯的。ECMAScript 這幾年的發展,一步步印證了我的觀點。只剩下一個羞答答的遮羞布 await/async 了。

我來說說 ECMAScript 接下來要解決什麼。

首先,下一步大家會面對鎖。會 suspend,就必須解決資源爭用。說不需要加鎖,不是自欺欺人,就是誤人子弟。不是官方,也會有第三方提供基礎的鎖原語。

既然有鎖,說加了 await 關鍵字,就可以知道什麼時候需要加鎖,什麼時候不需要加鎖的想法是天真的。比如一段代碼,其中一個底層實現,現在是同步的,於是中間某層某個邏輯沒有加鎖,有一天這個底層實現改成 await 了,一路改上來就不說了,鬼才記得中間哪一層在猴年馬月苦思冥想後決定不加鎖了。

如果連鎖都引入了,那還羞羞答答的幹什麼呢?(更何況 ES7 已經引入了一個低劣的 spinlock 了)。

然後,大家會面對更加挑戰人性的代碼。摘抄幾段:

files.forEach(async (file) =&> {
const contents = await fs.readFile(file)
console.log(contents)
})

上面這段代碼並不能運行,你必須使用其它方式,如果不幸你用同步寫了類似的代碼,重構的時候就填坑去吧。可是明明有 forEach,為什麼不讓用?

再看這一段:

for await ( const line of readLine(file)) {
console.log(line);
}

惡不噁心,感不感動?更不要說,他們活生生把 import 搞成 await import 了,你需要仔細辨別引入一個模塊的時候要不要加 await (此處可以吐三回)。

最後,一門語言,如果想要發展的好,必須考慮更多的使用場景。目前而言,JavaScript 社區的前端味道太重,這很不健康。前端業務是扁平的,雖然看起來很複雜,但是上面是 UI,下面是 HTTP,中間層寫出花,也是很薄的一層(並不是鄙視前端,這個不要撕)。

後端不是這樣的,更多的場景不是這樣的。前端根本沒有機會考慮並發。總共一個瀏覽器,一個人在操作,你把滑鼠點壞掉,也不一定能製造出一個並發現場。後端隨便幾百人,動輒上千人,慘一點的可能幾萬人。不認真面對並發,會死的很難看。而且也已經有很多人遇到本來在內存處理的數據,突然存進文件,全部代碼都要重寫的狀況。

顯式非同步這個特性,從 nodejs 開始算,都已經八年了,去看看入門教程和使用技巧,還佔據了一半以上的篇幅,這難道還不說明問題嗎?

結尾多說兩句。95% 的程序員都搞不懂鎖,但是他們都能寫出很好的代碼。但是搞不懂顯式非同步,你連個文件都打不開。

很多程序員還沒遇到需要用鎖的需求就改行了。

================= 補充分割線 =====================

@尤雨溪 說 forEach 那段代碼是可以執行的。那麼我們來看看是怎麼執行的。

下面這段代碼:

async function sleep(timeout) {
return new Promise((resolve, reject) =&> {
setTimeout(function () {
resolve();
}, timeout);
});
}

async function test(n) {
console.log("Do some thing, " + new Date(), n);
await sleep(1000);
console.log("Do other things, " + new Date(), n);
}

(async function () {
[0, 1, 2].forEach(test);
console.log("============================================");
})();

講道理的輸出應該是:(原文筆誤,感謝 @賀師俊 指出)

Do some thing, Sun May 28 2017 22:54:46 GMT+0800 (CST) 0
Do other things, Sun May 28 2017 22:54:47 GMT+0800 (CST) 0
Do some thing, Sun May 28 2017 22:54:47 GMT+0800 (CST) 1
Do other things, Sun May 28 2017 22:54:48 GMT+0800 (CST) 1
Do some thing, Sun May 28 2017 22:54:48 GMT+0800 (CST) 2
Do other things, Sun May 28 2017 22:54:49 GMT+0800 (CST) 2
============================================

而實際輸出是:

Do some thing, Sun May 28 2017 22:54:46 GMT+0800 (CST) 0
Do some thing, Sun May 28 2017 22:54:46 GMT+0800 (CST) 1
Do some thing, Sun May 28 2017 22:54:46 GMT+0800 (CST) 2
============================================
Do other things, Sun May 28 2017 22:54:47 GMT+0800 (CST) 0
Do other things, Sun May 28 2017 22:54:47 GMT+0800 (CST) 1
Do other things, Sun May 28 2017 22:54:47 GMT+0800 (CST) 2

=============== 再次補充分割線 ===================

有人問 c# 是如何解決這個問題的,有沒有非同步鎖。

在 c# 里有完整的並發解決方案,有線程,線程池,鎖,並發容器等等等等,await 只是用於改良非同步的語法糖。你可以永遠不用 await 一樣很好地處理並發。c# 也從來沒有拿 await 全面顛覆經典並發框架的想法。c# 如果想要用鎖,它有現成的。

ECMAScript 不同,ECMAScript 把並發控制的寶全部壓在 await 上面,那麼就要基於 await 解決全部問題。

而並發是運行時問題,而不是語法問題,想要在語法層面解決並發,除非徹底拋棄原有的語法體系,否則就需要在原有整套語法上都增加一個 await 修飾,還要準備一套支持 await 的標準庫。這件事正在發生,而且會越來越多。

這就很尷尬了,相當尷尬。


前一兩年前我還在用這東西,源碼也大概看過,實現很優雅。

從語法層面來說,我個人覺得是碾壓await/async的,畢竟後者是轉成promise了,而fibers是真的把一個一個非同步函數轉同步函數了,這也導致當初我做業務系統的時候所有函數都是沒有回調,全程同步函數在搞。

網上有人說性能有問題,這個我當初沒有遇到過,而且從源碼上看來並沒有什麼問題。即便是真的有性能問題那很可能是因為開發者沒有處理好閉包之類的內存使用導致的問題。

而從功能成面來說,沒有類似promise.all這樣的函數,得自己封裝一個:手動用非同步隊列來發起多個非同步任務,這點確實是比較消耗性能的。但也是小額度的消耗(就是原本一個非同步外面又嵌套了一個變成兩個非同步任務),可以忽略。

但是這個庫我現在不用了,核心原因就一個:**錯誤捕捉**。

當初這個庫對於錯誤捕捉到概念和官方是一致的,就是用domain。這個東西已經被官方在7+給廢除了,廢除的原因我就不提免得跑題。不過當初用fibers的時候錯誤的拋出確實不是很友好,反正代碼寫得自己也很懵逼,明明用domain嵌套了為什麼沒有捕捉到?這類的情況很經常發生。


我覺得,nodejs不適用於任何多js線程模型,因為這樣破壞了nodejs本身的單線程假設,把這個isolate的所有數據結構都至於危險中,所以就必須引入鎖,但是這就和nodejs當初的設想相違背了。

我覺得,對於nodejs來說,最好的解決非同步問題的方法是,continuation。但是要完美實現這種東西,大概只能靠引擎本身了。

我的一個腦洞是這樣:

https://github.com/zszszsz/threadify.js

這個是之前閑得沒事隨便寫的,代碼質量不高。

如果可以在一個普通的function上調用yield的話,那麼nodejs的非同步就不會帶來任何問題了。

為什麼continuation不會破壞單線程的假設,因為在存在多處理和搶佔的真多線程中,一個線程無法預料到自己何時被打斷,以及其他處理器上是否有隊友,但是在continuation的情況下,yield是由function主動控制的,只要在「臨界區」不調用yield,function就永遠不用擔心同步問題,因此也不需要鎖。


作為少數用await和async寫過有複雜IO邏輯的程序(KV資料庫)的人.

我直說了吧, await和async這種顯式非同步才是未來,

不要說JavaScript站在這邊了, C++標準委員會也把這個加進C++17的選項了(雖然目前並沒有任何POSIX系的編譯器實現...),

是不是有點欽定的感覺?

------

說下原因,

確定的事件流意味著不要加鎖, 或者很少需要加鎖. 因為事件流已經很確定了, 還要加鎖解決什麼競爭問題呢? 鎖只是作為一個trigger在開始和結束等其它很少的情況下運用到.

實際場景:

一個NodeJS寫的資料庫正在處理10個query, 後面的操作有可能需要資料庫建立一個快照確保之前query的結果不受影響.

await和async就很簡單了, 前10個query硬碟操作結束返回主線程的時候, 查看一下快照就好了.

真·多線程就要把存快照的對象加上鎖, 哪個心智負擔小一目了然.

鎖沒加好還會死鎖, 然後debug吧.

多線程的地獄難度debug完了之後還要騙自己, 我de了這麼複雜這麼隱藏的bug是不是很厲害?

2017年結束前, Clang++有機會把協程支持補上嗎???

------

感謝@cholerae告知, 重大利好! 協程進Clang SVN trunk了!


對語言/開發工具的改進如果不能搞成語言標準或事實標準,最後都會被淘汰。


node 有 async/await 語法之後,不用再關注這類項目了。


推薦閱讀:

node相比傳統服務端技術棧差在哪裡?
Vue.js中ajax請求代碼應該寫在組件的methods中還是vuex的actions中?
SeaJS 和 Browserify 的模塊化方案有哪些區別?
Web 前端 IDE 用的都是什麼啊?
為什麼中國開源界喜歡「自主研發」輪子?

TAG:前端開發 | JavaScript | Nodejs | 前端工程師 | Web伺服器 |