在JavaScript中异步迭代大型数组,而不会触发超出堆栈大小

asynchronously iterate over massive array in JavaScript without triggering stack size exceeded

本文关键字:堆栈 异步 JavaScript 迭代 大型 数组      更新时间:2024-02-25

我的环境是NodeJS,尽管这也可能是一个与web相关的问题。我有一大组来自数据库的数据,我正试图列举这些数据。然而,为了便于讨论,我们假设我有一个20000个字符串的数组:

var y = 'strstrstrstrstrstrstrstrstrstr';
var x = [];
for(var i = 0; i < 20000; i++)
  x.push(y);

我想异步枚举这个列表,比如说使用异步库,因为我非常谨慎,我甚至将枚举限制为一次5次迭代:

var allDone = function() { console.log('done!') };
require('async').eachLimit(x, 5, function(item, cb){
  ...
  someAsyncCall(.., cb);
}, allDone);

预期x的5个项目将在上面同时迭代,最终所有20000个项目都将迭代,控制台将打印"done!"。实际情况是:

Uncaught exception: [RangeError: Maximum call stack size exceeded]

在这一点上,我认为这一定是异步库的某种错误,所以我编写了自己的eachLimit版本,如下所示:

function eachLimit(data, limit, iterator, cb) {
    var consumed = 0;
    var consume;
    var finished = false;
    consume = function() {
        if(!finished && consumed >= data.length) {
            finished = true;
            cb();
        }else if(!finished) {
            return iterator(data[consumed++], consume);
        }
    };
    var concurrent = limit > data.length ? data.length : limit;
    for(var i = 0; i < concurrent; i++)
        consume();
}

有趣的是,这解决了我的问题。但是,当我把我的实验从nodeJS转移到Chrome时,即使使用了上面的解决方案,我仍然会收到超出的堆栈大小。

很明显,我的方法并没有增加async中包含的eachLimit方法那么大的堆栈。然而,我仍然认为我的方法很糟糕,因为可能不适用于20k个项目,但对于一些大小的数组,我仍然可以使用我的方法超过堆栈大小。我觉得我需要使用尾部递归设计某种解决方案来解决这个问题,但我不确定v8是否会针对这种情况进行优化,或者考虑到这个问题是否可能。

我觉得我需要使用尾部递归设计某种解决方案来解决这个问题,但我不确定v8是否会针对这种情况进行优化,或者考虑到这个问题是否可能。

您正在使用的延续传递样式已经是尾部递归(或者接近)。问题是,在这种情况下,大多数JS引擎确实倾向于进行堆叠式流。

有两种主要的方法来解决这个问题:

1) 使用setTimeout强制代码异步

代码中发生的情况是,在原始函数返回之前调用返回回调。在一些异步库中,这最终会导致stackoverflow。一个简单的解决方法是通过将回调封装在setTimeout中,强制回调仅在事件处理循环的下一次迭代中运行。翻译

//Turns out this was actually "someSyncCall"...
someAsyncCall(.., cb);

进入

someAsyncCall(..., function(){
    setTimeout(cb, 0)
});

这里的主要优点是这样做非常简单。缺点是这会给循环增加一些延迟,因为实现了setTimeout,所以回调总是会有一些非零延迟(即使您将其设置为零)。在服务器上,你也可以使用nextTick(或者类似的设计,忘记了确切的名称)来做类似的事情。

也就是说,有一个大的顺序异步操作循环已经有点奇怪了。如果你的操作实际上都是异步的,那么由于网络延迟,它将需要数年才能完成。

2) 使用蹦床来处理同步代码

100%避免stackoverflow的唯一方法是使用真正的while循环。有了promise,这将更容易为编写伪代码

//vastly incomplete pseudocode
function loopStartingFrom(array, i){
    for(;i<array.length; i++){
        var x = run_next_item(i);
        if(is_promise(x)){
            return x.then(function(){
                loopStartingFrom(array, i+1)
            });
        }
    }
}

基本上,您可以在实际的循环中运行循环,并通过某种方式来检测您的迭代之一是立即返回还是推迟到异步计算。当事情立即返回时,您可以保持循环运行,当您最终获得真正的异步结果时,您停止循环,并在异步迭代结果完成时恢复它。

使用蹦床的缺点是它有点复杂。也就是说,有一些异步库可以保证不会发生stackoverflow(通过使用我在后台提到的两个技巧之一)。

为了防止堆栈溢出,需要避免consume递归到自身中。你可以使用一个简单的标志:

function eachLimit(data, limit, iterator, cb) {
    var consumed = 0,
        running = 0,
        isAsync = true;
    function consume() {
        running--;
        if (!isAsync)
            return;
        while (running < limit && consumed < data.length) {
            isAsync = false;
            running++;
            iterator(data[consumed++], consume);
            isAsync = true;
        }
        if (running == 0)
            cb();
    }
    running++;
    consume();
}

您考虑过使用promise吗?他们应该解决堆栈不断增加的问题(而且你可以使用promise,这在我的书中是一个很大的优势):

// Here, iterator() should take a single data value as input and return
// a promise for the asynchronous behavior (if it is asynchronous)
// or any value if it is synchronous
function eachLimit(data, limit, iterator) {
    return Promise(function (resolve, reject) {
        var i = 0;
        var failed = false;
        function handleFailure(error) {
            failed = true;
            reject(error);
        }
        function queueAction() {
            try {
                Promise.when(iterator(data[i]))
                .then(handleSuccess, handleFailure);
            } catch (error) {
                reject(error);
            }
        }
        function handleSuccess() {
            if (!failed) {
                if (i < data.length) {
                    queueAction();
                    i += 1;
                } else {
                    resolve();
                }
            }
        }
        for (; i < data.length && i < limit; i += 1) {
            queueAction();
        }
    });
}