單執行緒與非同步
Javascript是單執行緒執行、支援非同步機制的語言。進入正題之前,我們有必要先理解這種執行方式。
以「起床上班」的過程為例,假設有以下幾個步驟:
- 起床(10min)
- 洗刷(10min)
- 換衣(5min)
- 叫車(10min)
- 上班(15min)
最簡單粗暴的執行方式就是按順序逐步執行,這樣從起床到上班共需50分鐘,效率較低。如果能在「洗刷」之前先「叫車」,就可以節省10分鐘的等車時間。
這樣一來「叫車」就成了非同步操作。但為何只有「叫車」可以非同步呢?因為車不需要自己開過來,所以自己處於空閒狀態,可以先乾點別的。
把上面的過程寫成程式碼:
function 起床() { console.info('起床'); }
function 洗刷() { console.info('洗刷'); }
function 換衣() { console.info('換衣'); }
function 上班() { console.info('上班'); }
function 叫車(cb) {
console.info('叫車');
setTimeout(function() {
cb('車來了');
}, 1000);
}
起床();
叫車(function() {
上班();
});
洗刷();
換衣();
複製程式碼
因為「上班」要在「叫車」之後才能執行,所以要作為「叫車」的回撥函式。然而,「叫車」需要10分鐘,「洗刷」也需要10分鐘,「洗刷」執行完後剛好車就到了,此時會不會先執行「上班」而不是「換衣」呢?Javascript是單執行緒的語言,它會先把當前的同步程式碼執行完再去執行非同步的回撥。而非同步的回撥則是另一片同步程式碼,在這片程式碼執行完之前,其他的非同步回撥也不會被執行。所以「上班」不會先於「換衣」執行。
接下來考慮一種情況:手機沒電了,想叫車得先充電。很明顯,充電的過程也可以非同步執行。整個過程應該是:
寫成程式碼則是:
function 充電(cb) {
console.info('充電');
setTimeout(function() {
cb(0.1); // 0.1表示充了10%
}, 1000);
}
起床();
充電(function() {
叫車(function() {
上班();
});
});
洗刷();
換衣()
複製程式碼
充電、叫車、上班是非同步序列(按順序執行)的,所以要把後者作為前者的回撥函式。可見,序列的非同步操作越多,回撥函式的巢狀就會越深,最終形成了回撥金字塔(也叫回撥地獄):
充電(function() {
叫車(function() {
其他事情1(function() {
其他事情2(function() {
其他事情3(function() {
上班();
});
});
});
});
});
複製程式碼
這樣的程式碼極難閱讀,也極難維護。此外,還有更復雜的問題:
- 除了非同步序列,還有非同步並行,甚至是序列、並行互相穿插。
- 非同步程式碼的異常無法通過try...catch捕獲,異常處理相當不方便。
可喜的是,隨著非同步程式設計的發展,上面提及的這些問題越來越好解決了,下面就給大家介紹四種解決方案。
Async庫
Async是一個非同步操作的工具庫,包含流程控制的功能。
「async.series」即為執行非同步序列任務的方法。例如:
// 充電 -> 叫車
async.series([
function(next) {
充電(function(battery) {
next(null, battery);
});
},
function(next) {
叫車(function(msg) {
next(null, msg);
});
}
], function(err, results) {
if (err) {
console.error(err);
} else {
console.dir(results); // [0.1, '車來了']
上班();
}
});
複製程式碼
「async.series」的第一個引數是要執行的步驟(陣列),每一個步驟都是一個函式。這個函式有一個引數「next」,非同步操作完成後必須呼叫「next」:
- 如果非同步操作順利完成,則呼叫「next」時的第一個引數為null,第二個引數為執行結果;
- 如果出現異常,則呼叫「next」時的第一個引數為異常資訊。
「async.series」的第二個引數則是這些步驟全部執行完成後的回撥函式。其中:
- 第一個引數是異常資訊,不為null時表示發生異常;
- 第二個引數是由執行結果彙總而成的陣列,順序與步驟的順序相對應。
「async.waterfall」是另一個用得更多的非同步序列方法,它與「async.series」的區別是:把上一步的結果傳給下一步,而不是彙總到最後的回撥函式。例如:
// 充電 -> 叫車
async.waterfall([
function(next) {
充電(function(battery) {
next(null, battery);
});
},
// battery為上一步的next所傳的引數
function(battery, next) {
if (battery >= 0.1) {
叫車(function(msg) {
next(null, msg);
});
} else {
next(new Error('電量不足'));
}
}
], function(err, result) {
if (err) {
console.error(err);
} else {
console.log(result); // '車來了'
上班();
}
});
複製程式碼
而執行非同步並行任務的方法則是「async.parallel」,用法與「async.series」類似,這裡就不再詳細說明了。
那序列、並行相互穿插又是怎樣的呢?
// 從起床到上班的整個過程
async.series([
function(next) {
起床();
next();
},
function(next) {
async.parallel([
function(next) {
async.waterfall([
function(next) {
充電(function(battery) {
next(null, battery);
});
},
function(battery, next) {
if (battery >= 0.1) {
叫車(function(msg) {
next(null, msg);
});
} else {
next(new Error('電量不足'));
}
}
], next);
},
function(next) {
洗刷();
換衣();
next();
}
], next);
}
], function(err, results) {
if (err) {
console.error(err);
} else {
上班();
}
});
複製程式碼
可見,如果序列和並行互相多穿插幾次,還是會出現一定程度的回撥金字塔現象。
Asycn庫的優點是符合Node.js的非同步程式設計模式(回撥函式的第一個引數是異常資訊,Node.js原生的非同步介面都這樣)。然而它的缺點也正是如此,回撥函式中有一個異常資訊引數,還佔據了第一位,實在是太不方便了。
Promise
Promise是ES6標準的一部分,它提供了一種新的非同步程式設計模式。但是ES6定稿比較晚,且舊的瀏覽器無法支援新的標準,因而有一些第三方的實現(比如Bluebird,不僅實現了Promise的標準,還進行了擴充套件)。順帶一提,Node.js 4.0+已經原生支援Promise。
那Promise究竟是什麼玩意呢?Promise代表非同步操作的最終結果,跟Promise互動的主要方式是通過它的「then」或者「catch」方法註冊回撥函式去接收最終結果或者是不能完成的原因(異常)。
使用Promise首先要把非同步操作Promise化:
function 充電Promisify() {
return new Promise(function(resolve) {
充電(function(battery) {
resolve(battery);
});
// 也可以簡寫為 充電(resolve)
});
}
function 叫車Promisify(battery) {
return new Promise(function(resolve, reject) {
if (battery >= 0.1) {
叫車(function(msg) {
resolve(msg);
});
// 也可以簡寫為 叫車(resolve)
} else {
reject(new Error('電量不足'));
}
});
}
複製程式碼
具體來說,就是建立一個Promise物件,建立時需要傳入一個函式,這個函式有兩個引數「resolve」和「reject」。操作成功時呼叫「resolve」,出現異常時呼叫「reject」。而想要獲得非同步操作的結果,正如前面所提到的,需要呼叫Promise物件的「then」方法:
叫車Promisify(0.1).then(function(result) {
console.log(result); // '車來了'
}, function(err) {
console.error(err);
});
叫車Promisify(0).then(function(result) {
console.log(result);
}, function(err) {
console.error(err.message); // '電量不足'
});
複製程式碼
「then」方法有兩個引數:
- 第一個引數是操作成功(resolved)時的回撥;
- 第二個引數是操作拒絕(rejected)時的回撥。
要注意的是,建立Promise物件時傳入的函式只會執行一次,即使多次呼叫了「then」方法,該函式也不會重複執行。這樣一來,一個Promise實際上還快取了非同步操作的結果。
下面看一下基於Promise的非同步序列是怎樣的:
// 充電 -> 叫車
充電Promisify().then(function(battery) {
return 叫車Promisify(battery);
}).then(function(result) {
console.log(result); // '車來了'
上班();
}).catch(function(err) {
console.error(err);
});
複製程式碼
如果「then」的回撥函式返回的是一個Promise物件,那麼下一個「then」的回撥函式就會在這個Promise物件完成之後再執行。所以多個步驟只需要通過「then」鏈式呼叫即可。此外,這段程式碼的「then」只有一個引數,而異常則由「catch」方法統一處理。
接下來看一下非同步並行,需要用到「Promise.all」這個方法:
// 充電、洗刷並行
Promise.all([
充電Promisify(),
new Promise(function(resolve) {
洗刷();
resolve();
})
]).then(function(results) {
console.dir(results); // [0.1, undefined]
}, function(err) {
console.error(err);
});
複製程式碼
最後是序列和並行穿插:
// 從起床到上班的過程
new Promise(function(resolve) {
起床();
resolve();
}).then(function() {
return Promise.all([
充電Promisify().then(function(battery) {
return 叫車Promisify(battery);
}),
new Promise(function(resolve) {
洗刷();
換衣();
resolve();
})
]);
}).then(function(results) {
console.dir(results); // ['車來了', undefined]
上班();
}).catch(function(err) {
console.error(err);
});
複製程式碼
可見,基於Promise的非同步程式碼比Async庫的要簡潔得多,通過「then」的鏈式呼叫可以很好地控制執行順序。但是由於現有的大部分非同步介面都不是基於Promise寫的,所以要進行二次封裝。
順帶一提,其實jQuery的「$.ajax」方法返回的就是一個不完全的Promise(沒有實現Promise的所有介面):
$.ajax('a.txt').then(function(resultA) {
console.log(resultA);
return $.ajax('b.txt');
}).then(function(resultB) {
console.log(resultB);
});
複製程式碼
Generator Function
Generator Function,中文譯名為生成器函式,是ES6中的新特性。這種函式通過「function *」進行宣告,函式內部可以通過「yield」關鍵字暫停函式執行。
這是一個生成器函式的例子:
function* genFn() {
console.log('begin');
var value = yield 'a';
console.log(value); // 'B'
return 'end';
}
var gen = genFn();
console.log(typeof gen); // 'object'
var g1 = gen.next();
g1.value; // 'a'
g1.done; // false
var g2 = gen.next('B');
g2.value; // 'end'
g2.done; // true
複製程式碼
如果是普通的函式,執行「genFn()」後就會返回「end」,但生成器函式並不是這樣。執行「genFn()」後,實際上是建立了一個生成器函式物件,此時函式內的程式碼不會執行。而呼叫這個物件(gen)的「next」方法時,函式開始執行,直到「yield」暫停。「next」方法的返回值是一個物件,它有兩個屬性:
- value:yield關鍵字後面的值(如果為表示式,則為表示式的計算結果);
- done:函式是否執行完畢。
第二次呼叫「gen.next」時,傳入了一個引數值「B」。「next」方法的引數值即為當前暫停函式的「yield」的返回值,所以函式內部value的值為「B」。然後函式繼續執行,返回「end」。所以「g2.value」為的值「end」,此時函式執行完畢,「g2.done」的值為「true」。
那到底這玩意對非同步程式設計有何助益呢?且看這段程式碼:
function* 叫車Gen(battery) {
try {
var result = yield 叫車Promisify(battery);
console.log(result); // '車來了'
} catch (e) {
console.error(e);
}
}
var gen = 叫車Gen(0.1), promise = gen.next().value;
promise.then(function(result) {
gen.next(result);
}, function(err) {
gen.throw(err);
});
複製程式碼
其執行過程大概是:執行非同步操作後就暫停了「叫車Gen」的執行,非同步操作完成後通過「gen.next」把「result」回傳到「叫車Gen」中;如果出現異常,就通過「gen.throw」丟擲以便在「叫車Gen」裡面捕獲。
但是這樣繞來繞去又有什麼好處呢?仔細觀察可以發現,「叫車Gen」內部雖然執行的是非同步操作,但完全就是同步的寫法(沒有回撥函式,異常捕獲也是用常規的「try...catch」)。進一步思考,如果能把後面的細節封裝起來,那就真的可以用同步的方式寫非同步的程式碼了。而後面的細節部分也是有規律可循的,封裝起來並不是難事(只是有點繞):
function asyncByGen(genFn) {
var gen = genFn();
function nextStep(g) {
if (g.done) { return; }
if (g.value instanceof Promise) {
g.value.then(function(result) {
nextStep(gen.next(result));
}, function(err) {
gen.throw(err);
});
} else {
nextStep(gen.next(g.value));
}
}
nextStep(gen.next());
}
複製程式碼
藉助這個函式,非同步程式設計可以前所未有地簡單:
// 非同步序列:充電 -> 叫車
asyncByGen(function *() {
try {
var battery = yield 充電Promisify();
console.log(
yield 叫車Promisify(battery)
); // '車來了'
} catch (e) {
console.error(e);
}
});
複製程式碼
// 非同步並行:充電、洗刷並行
asyncByGen(function *() {
try {
console.dir(
yield Promise.all([
充電Promisify(),
new Promise(function(resolve) {
洗刷();
resolve()
})
])
); // [0.1, undefined]
} catch (e) {
console.error(e);
}
});
複製程式碼
// 序列、並行互相穿插:從起床到上班的過程
asyncByGen(function*() {
try {
起床();
console.dir(
yield Promise.all([
充電Promisify().then(function(battery) {
return 叫車Promisify(battery);
}),
new Promise(function(resolve) {
洗刷();
換衣();
resolve();
})
])
); // [0.1, undefined]
上班();
} catch (e) {
console.error(e);
}
});
複製程式碼
生成器函式是一種比較新的特性,雖然Node.js 4.0+已經原生支援,但在舊版本瀏覽器上肯定無法執行。因此如果要在瀏覽器端使用還得通過編譯器(如Babel)編譯成ES5的程式碼,這也是這種解決方案的最大缺點。
講到這裡,順便介紹一下「co」庫。這個庫的功能類似於「asyncByGen」,但它封裝得更好,功能也更多,是用生成器函式寫非同步程式碼必不可少的利器。
async/await
如果你還是看不懂生成器函式的執行過程,那也沒關係,因為它已經“過時”了!ES7提供了「async」、「await」兩個關鍵字,可以達到跟「asyncByGen」一樣的效果。
首先給大家介紹一個這兩個關鍵字的用法。「async」是用來宣告非同步函式的,這種函式的返回值總是Promise物件(即使函式內部返回的不是Promise物件,也會返回一個結果為undefined的Promise物件)。
async function asyncFnA() {
return Promise.resolve('A');
}
asyncFnA().then(function(result) {
console.log(result); // 'A'
});
async function asyncFnB() {
}
asyncFnB().then(function(result) {
console.log(result); // undefined
});
複製程式碼
「await」只能用在由「async」宣告的非同步函式的內部,它會等待其後的Promise物件確定狀態後再執行後續的語句:
(async function() {
var battery = await 充電Promisify();
console.log(battery); // 0.1
})();
複製程式碼
順帶提一下,「await」後面不一定非要跟著Promise物件,也可以是一個普通的值,這樣相當於是執行同步程式碼。
下面用「async/await」重寫上面的例子:
// 非同步序列:充電 -> 洗刷
(async function() {
try {
var battery = await 充電Promisify();
return await 叫車Promisify(battery);
} catch (e) {
console.error(e);
}
})().then(function(msg) {
console.log(msg); // 車來了
});
複製程式碼
// 非同步並行:充電、洗刷並行
(async function() {
try {
return await Promise.all([
充電Promisify(),
(async function() {
洗刷();
})()
]);
} catch (e) {
console.error(e);
}
})().then((results) => {
console.dir(results); // [0.1, undefined]
});
複製程式碼
// 序列、並行互相穿插:從起床到上班的過程
(async function() {
try {
起床();
console.dir(
await Promise.all([
充電Promisify().then(function(battery) {
return 叫車Promisify(battery);
}),
(async function() {
洗刷();
換衣();
})()
])
); // [0.1, undefined]
上班();
} catch (e) {
console.error(e);
}
})();
複製程式碼
可見,與生成器函式相比,「async/await」又使非同步程式設計變得更為簡單了。Node.js 7.6+以及大部分主流瀏覽器的最新版本都已經支援這兩個關鍵字了,但還是那句話:如果要在瀏覽器端使用,編譯器(如Babel)是少不了的。
後記
本文的第一版寫於2015年年底,現在(2017年中)重讀一遍,覺得有不少可以改進的地方,而且技術也在不斷髮展,於是又修改了一遍。改動包括:
- 把示例程式碼由原來的「AJAX讀取檔案」改成文章開頭所述的「從起床到上班的過程」。雖然用到了中文函式名,但都是可以執行的。
- 新增「async/await」一節。
本文也發表在作者個人部落格:非同步流程控制 | Node.js開發 | Heero's Blog
文章同步釋出在:[貝聊知乎]