參考書籍:《Effective JavaScript》程序員
在JavaScript中,編寫響應多個併發事件的程序的方法很是人性化,並且強大,由於它使用了一個簡單的執行模型(有時稱爲事件隊列或事件循環併發)和被稱爲異步的API。算法
在一些語言中,咱們會習慣性地編寫代碼來等待某個特定的輸入。編程
var text = downloadSync('http://example.com/file.txt'); console.log(text);
形如downloadSync這樣的函數被稱爲同步函數(或阻塞函數)。程序會中止作任何工做,而等待它的輸入。在這個例子中,也就是等待從網絡下載文件的結果。因爲在等待下載完成的期間,計算機能夠作其餘有用的工做,所以這樣的語言一般爲程序員提供一種方法來建立多個線程,即並行執行本身算。它容許程序的一部分停下來等待(阻塞)一個低速的輸入,而程序的另外一部分能夠繼續進行獨立的工做。數組
在JavaScript中,大多的I/O操做都提供了異步的或非阻塞的API。promise
downloadAsync('http://example.com/file.txt', function (text) { console.log(text); });
該API初始化下載進程,而後在內部註冊表中存儲了回調函數後馬上返回,而不是被網絡請求阻塞。瀏覽器
JavaScript有時被稱爲提供一個運行到完成機制(run-to-completion)的擔保。任何當前正在運行於共享上下文的用戶代碼,好比瀏覽器中的單個Web頁面或者單個運行的Web服務器實例,只有在執行完成後才能調用下一個事件處理程序。實際上,系統維護了一個按事件發生順序排列的內部事件隊列,一次調用一個已註冊的回調函數。緩存
以客戶端(mouse moved、file downloaded)和服務器端(file read、path resolved)應用程序事件爲例,隨着事件的發生,它們被添加到應用程序的事件隊列的末尾。JavaScript系統使用一個內部循環機制來執行應用程序。該循環機制每次都拉取隊列底部的事件,也就是說,以接收到這些事件的順序來調用這些已註冊的JavaScript事件處理程序,並將事件的數據做爲改事件處理程序的參數。安全
運行到完成機制擔保的好處是當代碼運行時,你徹底掌握應用程序的狀態。你根本沒必要擔憂一些變量和對象屬性的改變因爲併發執行代碼而超出你的控制。併發編程在JavaScript中每每比使用線程和鎖的C++、Java或C#要容易得多。服務器
然而,運行到完成機制的不足是,實際上全部你編寫的代碼支撐着餘下應用程序的繼續執行。網絡
JavaScript併發的一個最重要的規則是毫不要在應用程序事件隊列中使用阻塞I/O的API。
相比之下,異步的API用在基於事件的環境中是安全的,由於它們迫使應用程序邏輯在一個獨立的事件循環「輪詢」中繼續處理。
提示:
理解操做序列的最簡單的方式是異步API是發起操做而不是執行操做。異步操做完成後,在事件循環的某個單獨的輪次中,被註冊的事件處理程序纔會執行。
若是你須要在發起一個操做後作一些事情,如何串聯已完成的異步操做。
最簡單的答案是使用嵌套。
db.lookupAsyc('url', function(url) { downloadAsyc(url, function(text) { console.log('contents of ' + url + ': ' + text); }); });
嵌套的異步操做很容易,但當擴展到更長的序列時會很快變得笨拙。
db.lookupAsync('url', function(url) { downloadAsync(url, function(file) { downloadAsync('a.txt', function(a) { downladAsync('b.txt', function(b) { downloadAsync('c.txt', function(c) { // ... }); }) }); }); });
減小過多嵌套的方法之一是將嵌套的回調函數做爲命名的函數,並將它們須要的附加數據做爲額外的參數傳遞。
db.lookupAsync('url', downloadURL); function downloadURL(url) { downloadAsync(url, function(text) { // still nested showContents(url, text); }); } function showContents(url, text) { console.log('contents of ' + url + ': ' + text); }
上述代碼仍然使用了嵌套的回調函數,可使用bind方法消除最深層的嵌套回調函數。
db.lookupAsync('url', downloadURL); function downloadURL(url) { downloadAsync(url, showContents.bind(null, url)); // => window.showContents(url) = function(url, text) { ... } } function showContents(url, text) { console.log('contents of ' + url + ': ' + text); }
這種作法致使了代碼看起來根據順序性,但須要爲操做序列的每一箇中間步驟命名,而且一步步地使用綁定,這可能致使尷尬的狀況。
更勝一籌的方法是使用一個額外的抽象來簡化。
function downloadFiles(url, file) { downloadAllAsync(['a.txt', 'b.txt', 'c.txt'], function(all) { var a = all[0], b = all[1], c = all[2]; // ... }); }
提示:
對於同步的代碼,經過使用try語句塊包裝一段代碼很容易一會兒處理全部的錯誤。
try { f(); g(); h(); } catch (e) { // handle any error that occurred... }
異步的API傾向於將錯誤表示爲回調函數的特定參數,或使用一個附加的錯誤處理回調函數(有時被稱爲errbacks)。
downloadAsync('a.txt', function(a) { downloadAsync('b.txt', function(b) { downloadAsync('c.txt', function(c) { console.log('Content: ' + a + b + c); }, function(error) { console.log('Error: ' + error); }) }, function(error) { // repeated error-handling logic console.log('Error: ' + error); }) }, function(error) { // repeated error-handling logic console.log('Error: ' + error); })
上述代碼中,每一步的處理都使用了相同的錯誤處理邏輯,咱們能夠在一個共享的做用域中定義一個錯誤處理的函數,將重複代碼抽象出來。
function onError(error) { console.log('Error: ' + error); } downloadAsync('a.txt', function(a) { downloadAsync('b.txt', function(b) { downloadAsync('c.txt', function(c) { console.log('Content: ' + a + b + c); }, onError) }, onError) }, onError)
另外一種錯誤處理API的風格只須要一個回調函數,該回調函數的第一個參數若是有錯誤發生那就表示爲一個錯誤,不然就位一個假值,好比null。
function onError(error) { console.log('Error: ' + error); } downloadAsync('a.txt', function(error, a) { if (error) return onError(error); downloadAsync('b.txt', function(error, b) { if (error) return onError(error); downloadAsync(url13, function(error, c) { if (error) return onError(error); console.log('Content: ' + a + b + c); }); }); });
提示:
設想有一個函數接收一個URL的數組並嘗試依次下載每一個文件。
function downloadOneSync(urls) { for (var i = 0, n = urls.length; i < n; i++) { downloadAsync(urls[i], onsuccess, function(error) { // ? }); // loop continues } throw new Error('all downloads failed'); }
上述代碼將啓動全部的下載,而不是等待一個完成再試下一個。
解決方案是將循環實現爲一個函數,因此咱們能夠決定什麼時候開始每次迭代。
function downloadOneAsync(urls, onsuccess, onfailure) { var n = urls.length; function tryNextURL(i) { if (i >= n) { onfailure('all downloads failed'); return; } downloadAsync(urls[i], onsuccess, function() { tryNextURL(i + 1); }); } tryNextURL(0); }
局部函數tryNextURL是一個遞歸函數。它的實現調用了其自身。目前典型的JavaScript環境中一個遞歸函數同步調用自身過屢次(例如10萬次)會致使失敗。
JavaScript環境一般在內存中保存一塊固定的區域,稱爲調用棧,用於記錄函數調用返回前下一步該作什麼。
function negative(x) { return abs(x) * -1; } function abs(x) { return Math.abs(x); } console.log(negative(42));
當程序使用參數42調用Math.abs
方法時,有好幾個其餘的函數調用也在進行,每一個都在等待另外一個的調用返回。
最新的函數調用將信息推入棧(被表示爲棧的最底層的幀),該信息也將首先從棧中彈出。當Math.abs
執行完畢,將會返回給abs函數,其將返回給negative函數,而後將返回到最外面的腳本。
當一個程序執行中有太多的函數調用,它會耗盡棧空間,最終拋出異常,這種狀況被稱爲棧溢出。
downloadOneAsync函數,不是直到遞歸調用返回後才被返回,downloadOneAsync只在異步回調函數中調用自身。記住異步API在其回調函數被調用前會當即返回。因此downloadOneAsync返回,致使其棧幀在任何遞歸調用將新的棧幀推入棧前,會從調用棧中彈出。事實上,回調函數總在事件循環的單獨輪次中被調用,事件循環的每一個輪次中調用其事件處理程序的調用棧最初是空的。因此不管downloadOneAsync須要多少次迭代,都不會耗盡棧空間。
提示:
若是你的應用程序須要執行代價高昂的算法你該怎麼辦呢?
也許最簡單的方法是使用像Web客戶端平臺的Worker API這樣的併發機制。
可是不是全部的JavaScript平臺都提供了相似Worker這樣的API,另外一種方法是算法分解爲多個步驟,每一個步驟組成一個可管理的工做塊。
Member.prototype.inNetwork = function(other){ var visited = {}, worklist = [this]; while (worklist.length > 0) { var member = worklist.pop(); // ... if (member === other) { // found? return true; } // ... } return false; };
若是這段程序核心的while循環代價太太高昂,搜索工做極可能會以不可接受的時間運行而阻塞應用程序事件隊列。
幸運的是,這種算法被定義爲一個步驟集的序列——while循環的迭代。咱們能夠經過增長一個回調參數將inNetwork轉換爲一個匿名函數,將while循環替換爲一個匿名的遞歸函數。
Member.prototype.inNetwork = function(other, callback) { var visited = {}, worklist = [this]; function next() { if (worklist.length === 0) { callback(false); return; } var number = worklist.pop(); // ... if (member === other) { // found? callback(true); return; } // ... setTimeout(next, 0); // schedule the next iteration } setTimeout(next, 0); // schedule the next iteration };
局部的next函數執行循環中的單個迭代而後調度應用程序事件隊列來異步運行下一次迭代。這使得在此期間已經發生的其餘事件被處理後才繼續下一次迭代。當搜索完成後,經過找到一個匹配或遍歷完整個工做表,咱們使用結果值調用回調函數並經過調用沒有調度任何迭代的next來返回,從而有效地完成循環。
要調度迭代,咱們使用多數JavaScript平臺均可用的、通用的setTimeout API來註冊next函數,是next函數通過一段最少時間(0毫秒)後運行。這具備幾乎馬上將回調函數添加到事件隊列上的做用。
提示:
function downloadAllAsync(urls, onsuccess, onerror) { var result = [], length = urls.length; if (length === 0) { setTimeout(onsuccess.bind(null, result), 0); return; } urls.forEach(function(url) { downloadAsync(url, function(text) { if (result) { // race condition reuslt.push(text); if (result.length === urls.length) { onsuccess(result); } } }, function(error) { if (result) { result = null; onerror(error); } }); }); }
上述代碼有錯誤。
當一個應用程序依賴於特定的事件順序才能正常工做時,這個程序會遭受數據競爭(data race)。數據競爭是指多個併發操做能夠修改共享的數據結構,這取決於它們發生的順序。
var filenames = [ 'huge.txt', 'tiny.txt', 'medium.txt' ]; downloadAllAsync(filenames, function(files) { console.log('Huge file: ' + files[0].length); // tiny console.log('Tiny file: ' + files[1].length); // medium console.log('Medium file: ' + files[2].length); // huge }, function(error) { console.log('Error: ' + error); });
因爲這些文件是並行下載的,事件能夠以任意的順序發生。例如,若是tiny.txt先下載完成,接下來是medium.txt文件,最後是huge.txt文件,則註冊到downloadAllAsync的回調函數並不會按照它們被建立的順序進行調用。但downloadAllAsync的實現是一旦下載完成就當即將中間結果保存在result數組的末尾。因此downloadAllAsync函數提供的保存下載文件內容的數組的順序是未知的。
下面的方式能夠實現downloadAllAsync不依賴不可預期的事件執行順序而總能提供預期結果。咱們不將每一個結果放置到數組末尾,而是存儲在其原始的索引位置中。
function downloadAsync(urls, onsuccess, onerror) { var length = urls.length, result = []; if (length === 0) { setTimeout(onsuccess.bind(null, result), 0); return; } urls.forEach(function(url, i) { downloadAsync(url, function(text) { if (result) { result[i] = text; // store at fixed index // race condition if (result.length === urls.length) { onsuccess(result); } } }, function(error) { if (result) { result = null; onerror(error); } }); }); }
上述代碼依然是不正確的。
假如咱們有以下的一個請求。
downloadAllAsync(['huge.txt', 'medium.txt', 'tiny.txt']);
根據數組更新的契約,即設置一個索引屬性,老是確保數組的length屬性值大於索引。
若是tiny.txt文件最早被下載,結果數組將獲取索引未2的屬性,這將致使result.length
被更新爲3。用戶的success回調函數被過早地調用,其參數爲一個不完整的結果數組。
正確地實現應該是使用一個計數器來追蹤正在進行的操做數量。
function downloadAsync(urls, onsuccess, onerror) { var pending = urls.length, result = []; if (pending === 0) { setTimeout(onsuccess.bind(null, result), 0); return; } urls.forEach(function(url, i) { downloadAsync(url, function(text) { if (result) { result[i] = text; // store at fixed index pending--; // register the success // race condition if (pedding === 0) { onsuccess(result); } } }, function(error) { if (result) { result = null; onerror(error); } }); }); }
提示:
設想有downloadAsync函數的一個變種,它持有一個緩存來避免屢次下載同一個文件。
var cache = new Dict(); function downloadCachingAsync(url, onsuccess, onerror) { if (cache.has(url)) { onsuccess(cache.get(url)); // synchronous call return; } return downloadAsync(url, function(file) { cache.set(url, file); onsuccess(file); }, onerror); };
一般狀況下,若是能夠,它彷佛會當即提供數據,但這以微妙的方式違反了異步API客戶端的指望。
首先,它改變了操做的預期順序。
downloadCachingAsync('file.txt', function(file) { console.log('finished'); // might happen first }); console.log('starting');
其次,異步API的目的是維持事件循環中每輪的嚴格分離。這簡化了併發,經過減輕每輪事件循環的代碼量而沒必要擔憂其餘代碼併發地修改共享的數據結構。同步地調用異步的回調函數違反了這一分離,致使在當前輪完成以前,代碼用於執行一輪隔離的事件循環。
downloadCachingAsync(remaining[0], function(file) { remaing.shift(); // ... }); status.display('Downloading ' + remaining[0] + '...');
若是同步地調用該回調函數,那麼將顯示錯誤的文件名的消息(或者更糟糕的是,若是隊列爲空會顯示undefined)。
同步地調用異步的回調函數甚至可能會致使一些微妙的問題。
爲了確保老是異步地調用回調函數,咱們可使用通用的庫函數setTimeout在每隔一個最小的時間的超時時間後給事件隊列增長一個回調函數。
var cache = new Dict(); function downloadCachingAsync(url, onsuccess, onerror) { if (cache.has(url)) { var cached = cache.get(url); setTimeout(onsuccess.bind(null, cached), 0); return; } return downloadAsync(url, function(file) { cache.set(url, file); onsuccess(file); }, onerror); };
提示:
構建異步API的一種流行的替代方式是使用promise(有時也被稱爲deferred或future)模式。
基於promise的API不接收回調函數做爲參數,相反,它返回一個promise對象,該對象經過其自身的then方法接收回調函數。
var p = downloadP('file.txt'); p.then(function(file) { console.log('file: ' + file); });
promise的力量在於它們的組合性。傳遞給then的回調函數不只產生影響,也能夠產生結果。經過回調函數返回一個值,能夠構造一個新的promise。
var fileP = downloadP('file.txt'); var lengthP = fileP.then(function(file) { return file.length; }); lengthP.then(function(length) { console.log('length: ' + length); });
promise能夠很是容易地構造一個實用程序來拼接多個promise的結果。
var filesP = join(downloadP('file1.txt'), downloadP('file2.txt'), downloadP('file3.txt')); filesP.then(function(files) { console.log('file1: ' + files[0]); console.log('file2: ' + files[1]); console.log('file3: ' + files[2]); });
promise庫也常常提供一個叫作when的工具函數。
var fileP1 = downloadP('file1.txt'), fileP2 = downloadP('file2.txt'), fileP3 = downloadP('file3.txt'); when([fileP1, fileP2, fileP3], function(files) { console.log('file1: ' + files[0]); console.log('file2: ' + files[1]); console.log('file3: ' + files[2]); });
使promise成爲卓越的抽象層級的部分緣由是經過then方法的返回值來聯繫結果,或者經過工具函數如join來構成promise,而不是在並行的回調函數間共享數據結構。這本質上是安全的,由於它避免了數據競爭。
有時故意建立某種類的數據競爭是有用的。promise爲此提供了一個很好的機制。例如,一個應用程序可能須要嘗試從多個不一樣的服務器上同時下載同一份文件,而選擇最早完成的那個文件。
var fileP = select(downloadP('http://example1.com/file.txt'), downloadP('http://example1.com/file.txt'), downloadP('http://example1.com/file.txt')); fileP.then(function(file) { console.log('file: ' + file); });
select函數的另外一個用途是提供超時來終止長時間的操做。
var fileP = select(downloadP('file.txt'), timeoutErrorP(2000)); fileP.then(function(file) { console.log('file: ' + file); }, function(error) { console.log('I/O error or timeout: ' + error); });
提示: