[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应用程序中的事件发生是不确定的,即顺序是不可预测的
-
使用计数器避免并行操作中的数据竞争
翻译的文章,版权归原作者所有,只用于交流与学习的目的。
原创文章,版权归作者所有,非商业转载请注明出处,并保留原文的完整链接。