天天看點

[Effective JavaScript 筆記]第66條:使用計數器來執行并行操作

第63條建議使用工具函數downloadAllAsync接收一個URL數組并下載下傳所有檔案,結果傳回一個存儲了檔案内容的數組,每個URL對應一個字元串。downloadAllAsync并不隻有清理嵌套回調函數的好處,其主要好處是并行下載下傳檔案。我們可以在同一個事件循環中一次啟動所有檔案的下載下傳,而不用等待每個檔案完成下載下傳。

并行邏輯是微妙的,很容易出錯。下面有實作有一個隐藏的缺陷。

function downloadAllAsync(urls,onsuccess,onerror){
  var result=[],length=urls.length;
  if(length === 0){
    setTimeout(onsuccess.bind(null,result),0);
  }
  urls.forEach(function(url){
    downloadAsync(url,function(text){
      if(result){
        reslut.push(text);
        if(result.length===urls.length){
          onsuccess(result);
        }
      }
    },function(error){
      if(result){
        result=null;
        onerror(error);
      }
    });
  });
}
           

這個函數有嚴重的錯誤,但首先讓我們看看它是如何工作的。先確定如果數組是空的,則會使用空結果數組調用回調函數。如果不這樣做,這兩個回調函數将不會被調用,因為forEach循環是空的。接下來,周遊整個URL數組,為每個URL請求一個異步下載下傳。每次下載下傳成功,就将檔案内容加入到result數組中。如果所有URL都被成功下載下傳,使用result數組調用onsuccess回調函數。如果有任何失敗的下載下傳,使用錯誤值調用onerror回調函數。如果有多個下載下傳失敗,設定result數組為null,進而保證onerror隻被調用一次,即在第一次錯誤發生時。

錯誤示例

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檔案,最後是buge.txt檔案,則注冊到downloadAllAsync的回調函數并不會按照它們被建立的順序進行調用。但downloadAllAsync的實作是一旦下載下傳完成就立即将中間結果儲存在result數組的末尾。是以downloadAllAsync函數提供的儲存下載下傳檔案内容的數組的順序是未知的。這個API幾乎不可用,因為無法确認哪個結果對應哪個檔案。

程式的執行順序不能保證與事件發生的順序一緻。

當一個應用程式依賴于特定的事件順序才能正常工作時,這個程式會遭受資料競争。資料競争是指多個并發操作可以修改共享的資料結構,這取決于它們發生的順序。資料競争是真正棘手的錯誤。它們可能不會出現于特定的測試中,因為運作相同的程式兩次,每次可能會得不到不同的結果。例如downloadAllAsync的使用者可能會對檔案重新排序,基于的順序是哪個檔案可能會最先完成下載下傳。

downloadAllAsync(filenames,function(files){
  console.log('Huge file:'+files[2].length);
  console.log('Tiny file:'+files[0].length);
  console.log('Medium file:'+files[1].length);
},function(error){
  console.log('Error: '+error);
});
           

在這種情況下大多數時候結果是相同的順序,但偶爾由于改變了伺服器負載均衡或網絡緩存,檔案可能不是期望的順序。我們可以順序下載下傳檔案,但也失去了并發的性能優勢。

下面實作downloadAllAsync不依賴不可預期的事件執行順序而總能提供預期結果。我們不将每個結果放置到數組末尾,而是存儲在其原始的索引位置。

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){
        reslut[i]=text;
        if(result.length===urls.length){
          onsuccess(result);
        }
      }
    },function(error){
      if(result){
        result=null;
        onerror(error);
      }
    });
  });
}
           

該實作利用了forEach回調函數的第二個參數。第二個參數為目前疊代提供了數組索引。這也不正确。第51條描述數組更新的契約,即設定一個索引屬性,總是確定數組的length屬性值大于索引。假設有如下的一個請求。

downloadAllAsync(['huge.txt','medium.txt','tiny.txt']);
           

如果tiny.txt檔案最先被下載下傳,結果數組将擷取索引為2的屬性,這将導緻result.length被更新為3。使用者的success回調函數将被過早地調用,其參數為一個不完整的結果數組。

正确的實作應該是使用一個計數器來追蹤正在進行的操作數量。

function downloadAllAsync(urls,onsuccess,onerror){
  var pending=urls.length;
  var result=[];
  if(pending === 0){
    setTimeout(onsuccess.bind(null,result),0);
    return;
  }
  urls.forEach(function(url){
    downloadAsync(url,function(text){
      if(result){
        reslut[i]=text;
        pending--;
        if(pending===0){
          onsuccess(result);
        }
      }
    },function(error){
      if(result){
        result=null;
        onerror(error);
      }
    });
  });
}
           

現在不論事件以什麼樣的順序發生,pending計數器都能準确地指出何時所有的事件會被完成,并以适當的順序傳回完整的結果。

提示

  • js應用程式中的事件發生是不确定的,即順序是不可預測的
  • 使用計數器避免并行操作中的資料競争

版權聲明

翻譯的文章,版權歸原作者所有,隻用于交流與學習的目的。

原創文章,版權歸作者所有,非商業轉載請注明出處,并保留原文的完整連結。

繼續閱讀