爲什麼 JS 是單線程?
衆所周知,Javascript 語言的執行環境是"單線程"(single thread)。
所謂"單線程",就是指一次只能完成一件任務。如果有多個任務,就必須排隊,前面一個任務完成,再執行後面一個任務,以此類推。
而瀏覽器是多線程的,JS 線程就是其中一個:
- 瀏覽器 GUI 渲染線程
- JavaScript 引擎線程
- 瀏覽器定時觸發器線程
- 瀏覽器事件觸發線程
- 瀏覽器 http 異步請求線程
瀏覽器線程知識中重要的一點是:
GUI渲染進程和 JavaScript 引擎進程是互斥的,因爲如果這兩個線程可以同時運行的話, JavaScript 的 DOM 操作將會擾亂渲染線程執行渲染前後的數據一致性。而且如果 DOM 一變化,界面就立刻重新渲染,效率必然很低
所以 JS 主線程執行任務時,瀏覽器渲染線程處於掛起狀態。
同理,如果 JS 採用多線程同步的模型,那麼如何保證同一時間修改了 DOM, 到底是哪個線程先生效呢?從操作系統調度多線程的上下文開銷,到實際編程裏的鎖、線程同步等問題,都讓開發變得比較困難。
所以 JS 最終採用了單線程的事件模型。
我之前的文章《JS專題之事件循環》也有講過這塊內容,歡迎翻閱。
一、同步與異步
單線程模式這種排隊執行的好處是實現起來比較簡單,執行環境相對單純;壞處是只要有一個任務耗時很長,後面的任務都必須排隊等着,會拖延整個程序的執行。常見的瀏覽器無響應(假死),往往就是因爲某一段Javascript代碼長時間運行(比如死循環),導致整個頁面卡在這個地方,其他任務無法執行。
爲了解決這個問題,Javascript語言將任務的執行模式分成兩種:同步(Synchronous)和異步(Asynchronous)。
那同步和異步的區別是什麼?
我們想象一個很常見的場景:我們去麪館吃牛肉麪,櫃檯人很多,前面在排隊下單。
這個時候,同步就是,收銀員收了你的錢,告訴你要在櫃檯站着等面煮好,煮好後,就端面開吃,後面的人也只能等前面的人面煮好了才能付款下單然後等着面煮好端走~
而異步就是,收銀員收了你的錢,然後給了你一張小票,小票上有一個你的編號,收銀員告訴你,可以去座位上,你的面一煮好,會大聲叫你,你就來端面開吃。
我們可以看出,我們是過程的調用者,麪館是被調用者,牛肉麪煮好,是我們想要的結果,同步是調用者需要主動地等待這個結果。異步是被動的等待結果,當被調用者有結果了,就會通過消息機制或者回調機制告訴調用者結果。
同步和異步關注的是消息通信機制,同步就是在發出一個調用時,在沒有得到結果之前,該調用就不返回。但是一旦調用返回,就得到返回值了。而異步則是相反,調用在發出之後,這個調用就直接返回了,所以沒有返回結果, 而是在調用發出後,被調用者通過狀態、通知來通知調用者,或通過回調函數處理這個調用。
以上:
- 下單吃麪是發起調用函數
- 端面開吃的回調函數
- 煮好的面是調用的結果,也是回調函數的參數
將例子抽象成僞代碼:
orderNoodle("牛肉麪", function(noodle) {
// 端面
getNoodle();
// 吃麪
eatNoodle();
});
三、事件循環
關於事件循環如何執行異步代碼可以翻閱前面的文章《JS專題之事件循環》,這裏大概提一下。
如果遇到異步事件,JS 引擎會把事件函數壓入執行調用棧,但瀏覽器識別到它是異步事件後,會將其彈出執行棧,當異步函數有返回結果後,JS 引擎將異步事件的回調函數放入事件隊列中,如果執行調用棧爲空,就將回調函數壓入執行調用棧執行。
四、回調函數
在 JavaScript 中,函數 function 作爲一等公民,使用上非常自由,無論調用它,或者作爲參數,或者作爲返回值都可以。
因爲單線程異步的特點,後來在 JS 中,慢慢將函數的業務重點轉移到了回調函數中。
function step1(cb) {
console.log("step1");
cb()
}
function step2(){
console.log("step2");
}
step1(step2); // step1 step2
代碼會按先後順序執行 step1, step2。
現在假設我們有這樣的需求:請求文件1後,獲取文件1 中的數據後請求文件2,獲取文件 2 中的數據後,又請求文件三。
var fs = require("fs");
fs.readFile("./file1.json", function(err, data1) {
fs.readFile("./file2.json", function (err, data2) {
fs.readFile("./file3.json", function(err, data3) {
})
})
})
五、回調函數的問題
由第四節可以看出,回調函數的寫法存在很多問題。
- 回調地獄(洋蔥模型)
當多個異步事務多級依賴時,回調函數會形成多級的嵌套,被花括號一層層包括,代碼變成
金字塔型結構,也被稱爲回調地獄和洋蔥模型。
在回調地獄的情況下,代碼邏輯的梳理,流程的控制,代碼封裝維護,錯誤處理都變得越來越困難。
- 異常處理
try...catch 是被設計成捕獲當前執行環境的異常,意思是隻能捕獲同步代碼裏面的異常,異步調用裏面的異常無法捕獲。
function readFile(fileName) {
setTimeout(function () {
throw new Error("類型錯誤");
}, 1000);
}
try {
readFile('./file1.json');
} catch (e) {
// 如果異步事件出錯,打印不出來錯誤信息
console.log('err', e);
}
在 nodejs 對回調函數採用 error first 的思想,回調函數的第一個參數保留給一個錯誤error對象,如果有錯誤發生,錯誤將通過第一個參數err返回。
原因是一個有回調函數的函數,執行分兩段,第一段執行完之後,任務所在的上下文環境就已經結束了。在這以後拋出的錯誤,原來的上下文已經無法捕捉,只能當做參數,傳入第二階段。
fs.readFile('/etc/passwd', 'utf8', function (err, data) {
if(err) {
console.log(err)
return;
}
});
總結
回調函數是 JS 異步編程中的基石,但同時也存在很多問題,不太適合人類自然語言的線性思維習慣。
接下來幾篇文章,我將梳理 JS 中異步編程中的歷史演進中 Promise, generator, async&await 相關的內容,歡迎關注。