如何编写非阻塞的JavaScript代码?

48

我该如何进行简单的、非阻塞的Javascript函数调用?例如:

  //begin the program
  console.log('begin');
  nonBlockingIncrement(10000000);
  console.log('do more stuff'); 

  //define the slow function; this would normally be a server call
  function nonBlockingIncrement(n){
    var i=0;
    while(i<n){
      i++;
    }
    console.log('0 incremented to '+i);
  }

输出

"beginPage" 
"0 incremented to 10000000"
"do more stuff"

我该如何编写这个简单循环以异步执行并通过回调函数输出结果?目的是不阻塞“做更多的事情”:

"beginPage" 
"do more stuff"
"0 incremented to 10000000"

我尝试过跟随回调和continuation的教程,但它们似乎都依赖于外部库或函数。没有一个以真空的状态回答这个问题:如何编写Javascript代码来实现非阻塞!


在提问之前,我已经很努力地搜索了这个答案,请不要假定我没有尝试。我找到的所有信息都是针对Node.js的([1], [2], [3], [4], [5]) 或者是特定于其他函数或库([6], [7], [8], [9], [10], [11]),特别是JQuery和setTimeout()。请帮我使用Javascript编写非阻塞代码,而不是像JQuery和Node这样的工具。 请在标记为重复之前再次阅读问题。


毫不费力。您必须实际告诉线程在一段时间内休眠以阻塞线程。 为避免休眠,请使用带有回调的定时器。http://www.sitepoint.com/settimeout-example/ - Andrew Hoffman
2
没有办法做到这一点。Javascript 不支持多线程,只能排队执行任务。您可以在稍后的时间执行长时间运行的任务,但不能与其他任务同时执行。 - Travis J
@AndrewHoffman 我不确定你是否理解。你无法让JS休眠,但你可以让它保持忙碌状态,以至于UI循环无法处理任何事件。 - Alnitak
1
你可以通过警告等方式来阻止线程,但我希望每个浏览器都能禁用它。糟糕的程序员会让我的浏览器冻结。-_- ' - Andrew Hoffman
我相信自己可能误解了问题,因为刚刚意识到你的“慢循环”只是一个例子。我给出的答案是将长时间运行的计算拆分成更小的部分的确切方法。然而,在服务器调用的情况下,Promises通常是正确的答案,并且现在已经包含在ES6中。也就是说,任何长时间运行的异步任务API都应该提供一种在完成时调用特定函数的方法。 - Alnitak
1
mozilla开发者网络上搜寻fork()exec()pthread(),你会找不到任何结果。为什么?因为支持子进程和线程的功能不是浏览器JavaScript的标准特性。Web workers 是一个试验性功能,旨在创建可以通信但不共享范围的附加进程。像你提议的同时运行CPU代码并不受支持。几乎所有“异步”JS代码都与I/O事件有关。对于I/O:blah() - Paul
8个回答

35
为使你的循环不阻塞,你必须将其分成若干段,并允许JS事件处理循环在继续下一段之前消耗用户事件。最简单的方法是完成一定量的工作,然后使用setTimeout(..., 0)来排队下一块工作。重要的是,这种排队允许JS事件循环在执行下一个工作之前处理在此期间排队的任何事件。
function yieldingLoop(count, chunksize, callback, finished) {
    var i = 0;
    (function chunk() {
        var end = Math.min(i + chunksize, count);
        for ( ; i < end; ++i) {
            callback.call(null, i);
        }
        if (i < count) {
            setTimeout(chunk, 0);
        } else {
            finished.call(null);
        }
    })();
}

使用方法:

yieldingLoop(1000000, 1000, function(i) {
    // use i here
}, function() {
    // loop done here
});

请参考http://jsfiddle.net/alnitak/x3bwjjo6/的演示,其中callback函数仅将一个变量设置为当前迭代计数,并且一个单独的基于setTimeout的循环轮询该变量的当前值并使用其值更新页面。


1
谢谢您在回答中花费了这么多精力,但是(就像您在评论中提到的那样),for 循环只是一个虚拟函数,模拟需要很长时间的操作。除非我误解了什么,否则该代码仅适用于该特殊情况。 - user1717828
1
@user1717828 哦,好吧。简短的回答是,你不能只像你现在这样写三行代码并期望它能工作 - 你必须调用你的长时间运行任务(异步地),然后像我在yieldingLoop示例中所做的那样安排另一个函数在完成时被调用。原始程序流将继续不间断地进行。 - Alnitak
在执行期间,你能在屏幕上画些什么吗? - Rami Alloush

14

使用回调函数的SetTimeout是正确的方法。不过需要明白,你的函数作用域与C#或其他多线程环境中的不同。

Javascript不会等待函数的回调完成。

如果你说:

function doThisThing(theseArgs) {
    setTimeout(function (theseArgs) { doThatOtherThing(theseArgs); }, 1000);
    alert('hello world');
}

你的警报将在你传递的函数之前触发。

区别在于alert会阻塞线程,但是你的回调函数不会。


2
我更喜欢这个答案的清晰度,而不是@Alnitak的。但是,正如@Alnitak指出的那样,值得注意的是,人们也可以使用setTimeout(...,0)来避免不必要的等待时间。它仍然是非阻塞的! - sgrubsmyon
我同意,setTimeout(..., 0)有助于在事件调用堆栈空闲时避免不必要的延迟。 - Osei-Owusu
setTimeout(callback, 0) 将在此处为测试目的打印相同的输出,而无需等待不必要的等待时间。 - Rohit Saini

4

就我所知,一般有两种方法可以做到这一点。第一种是使用setTimeout(如果在支持的环境中进行操作,则使用requestAnimationFrame)。@Alnitak在另一个答案中展示了如何实现。另一种方法是使用Web Worker在单独的线程中完成阻塞逻辑,以便主UI线程不会被阻塞。

Using requestAnimationFrame or setTimeout:

//begin the program
console.log('begin');
nonBlockingIncrement(100, function (currentI, done) {
  if (done) {
    console.log('0 incremented to ' + currentI);
  }
});
console.log('do more stuff'); 

//define the slow function; this would normally be a server call
function nonBlockingIncrement(n, callback){
  var i = 0;
  
  function loop () {
    if (i < n) {
      i++;
      callback(i, false);
      (window.requestAnimationFrame || window.setTimeout)(loop);
    }
    else {
      callback(i, true);
    }
  }
  
  loop();
}

使用Web Worker:

/***** Your worker.js *****/
this.addEventListener('message', function (e) {
  var i = 0;

  while (i < e.data.target) {
    i++;
  }

  this.postMessage({
    done: true,
    currentI: i,
    caller: e.data.caller
  });
});



/***** Your main program *****/
//begin the program
console.log('begin');
nonBlockingIncrement(100, function (currentI, done) {
  if (done) {
    console.log('0 incremented to ' + currentI);
  }
});
console.log('do more stuff'); 

// Create web worker and callback register
var worker = new Worker('./worker.js'),
    callbacks = {};

worker.addEventListener('message', function (e) {
  callbacks[e.data.caller](e.data.currentI, e.data.done);
});

//define the slow function; this would normally be a server call
function nonBlockingIncrement(n, callback){
  const caller = 'nonBlockingIncrement';
  
  callbacks[caller] = callback;
  
  worker.postMessage({
    target: n,
    caller: caller
  });
}

由于Web Worker解决方案需要单独的worker.js文件来托管工作线程逻辑,因此您无法运行该解决方案。


Ryan,请问你能否分享链接或者解释一下callback(i,true)callback(i,false)是干什么用的呢?我查过了但是没找到确切的我们所说的内容。 - Niso

2

你不能同时执行两个循环,记住JS是单线程。

因此,这样做永远不会起作用。

function loopTest() {
    var test = 0
    for (var i; i<=100000000000, i++) {
        test +=1
    }
    return test
}

setTimeout(()=>{
    //This will block everything, so the second won't start until this loop ends
    console.log(loopTest()) 
}, 1)

setTimeout(()=>{
    console.log(loopTest())
}, 1)

如果你想要实现多线程,你需要使用Web Workers,但是它们必须有一个单独的js文件,并且只能将对象传递给它们。
但是,我成功地使用了Web Workers而无需单独的文件,通过生成Blob文件,我也可以传递回调函数。

//A fileless Web Worker
class ChildProcess {
     //@param {any} ags, Any kind of arguments that will be used in the callback, functions too
    constructor(...ags) {
        this.args = ags.map(a => (typeof a == 'function') ? {type:'fn', fn:a.toString()} : a)
    }

    //@param {function} cb, To be executed, the params must be the same number of passed in the constructor 
    async exec(cb) {
        var wk_string = this.worker.toString();
        wk_string = wk_string.substring(wk_string.indexOf('{') + 1, wk_string.lastIndexOf('}'));            
        var wk_link = window.URL.createObjectURL( new Blob([ wk_string ]) );
        var wk = new Worker(wk_link);

        wk.postMessage({ callback: cb.toString(), args: this.args });
 
        var resultado = await new Promise((next, error) => {
            wk.onmessage = e => (e.data && e.data.error) ? error(e.data.error) : next(e.data);
            wk.onerror = e => error(e.message);
        })

        wk.terminate(); window.URL.revokeObjectURL(wk_link);
        return resultado
    }

    worker() {
        onmessage = async function (e) {
            try {                
                var cb = new Function(`return ${e.data.callback}`)();
                var args = e.data.args.map(p => (p.type == 'fn') ? new Function(`return ${p.fn}`)() : p);

                try {
                    var result = await cb.apply(this, args); //If it is a promise or async function
                    return postMessage(result)

                } catch (e) { throw new Error(`CallbackError: ${e}`) }
            } catch (e) { postMessage({error: e.message}) }
        }
    }
}

setInterval(()=>{console.log('Not blocked code ' + Math.random())}, 1000)

console.log("starting blocking synchronous code in Worker")
console.time("\nblocked");

var proc = new ChildProcess(blockCpu, 43434234);

proc.exec(function(block, num) {
    //This will block for 10 sec, but 
    block(10000) //This blockCpu function is defined below
    return `\n\nbla bla ${num}\n` //Captured in the resolved promise
}).then(function (result){
    console.timeEnd("\nblocked")
    console.log("End of blocking code", result)
})
.catch(function(error) { console.log(error) })

//random blocking function
function blockCpu(ms) {
    var now = new Date().getTime();
    var result = 0
    while(true) {
        result += Math.random() * Math.random();
        if (new Date().getTime() > now +ms)
            return;
    }   
}


1
对于非常长的任务,应该使用Web-Worker,但对于足够小的任务(<几秒钟)或者当您无法将任务移动到Worker中时(例如因为您需要访问DOM或其他内容),可以采用Alnitak的代码分块解决方案。

现在,这可以通过async/await语法以更清晰的方式重写。
此外,不要等待setTimeout()(在node-js中至少延迟1ms,在第5次递归调用后到任何地方都延迟4ms),最好使用MessageChannel

因此,我们得到了:

const waitForNextTask = () => {
  const { port1, port2 } = waitForNextTask.channel ??= new MessageChannel();
  return new Promise( (res) => {
    port1.addEventListener("message", () => res(), { once: true } );
    port1.start();
    port2.postMessage("");
  } );
};

async function doSomethingSlow() {
  const chunk_size = 10000;
  // do something slow, like counting from 0 to Infinity
  for (let i = 0; i < Infinity; i++ ) {
    // we've done a full chunk, let the event-loop loop
    if( i % chunk_size === 0 ) {
      log.textContent = i; // just for demo, to check we're really doing something
      await waitForNextTask();
    }
  }
  console.log("Ah! Did it!");
}

console.log("starting my slow computation");
doSomethingSlow();
console.log("started my slow computation");
setTimeout(() => console.log("my slow computation is probably still running"), 5000);
<pre id="log"></pre>


1
使用 ECMA 异步函数编写非阻塞异步代码非常容易,即使它执行 CPU 绑定操作。让我们在一个典型的学术任务上进行此操作 - 对于极大的值进行斐波那契计算。
你需要做的就是插入一个允许事件循环不时被触发的操作。使用这种方法,你永远不会冻结用户界面或 I/O。
基本实现:
const fibAsync = async (n) => {
  let lastTimeCalled = Date.now();

  let a = 1n,
    b = 1n,
    sum,
    i = n - 2;
  while (i-- > 0) {
    sum = a + b;
    a = b;
    b = sum;
    if (Date.now() - lastTimeCalled > 15) { // Do we need to poll the eventloop?
      lastTimeCalled = Date.now();
      await new Promise((resolve) => setTimeout(resolve, 0)); // do that
    }
  }
  return b;
};

现在我们可以使用它(实时演示):
let ticks = 0;

console.warn("Calulation started");

fibAsync(100000)
  .then((v) => console.log(`Ticks: ${ticks}\nResult: ${v}`), console.warn)
  .finally(() => {
    clearTimeout(timer);
  });

const timer = setInterval(
  () => console.log("timer tick - eventloop is not freezed", ticks++),
  0
);

正文翻译如下:
我们可以看到,计时器正常运行,这表明事件循环没有被阻塞。
我发布了一个改进后的帮助程序实现,作为 antifreeze2 npm 包发布。它在内部使用setImmediate,因此为了获得最大性能,在没有本地支持的环境中,您需要导入polyfillLive Demo
import { antifreeze, isNeeded } from "antifreeze2";

const fibAsync = async (n) => {
  let a = 1n,
    b = 1n,
    sum,
    i = n - 2;
  while (i-- > 0) {
    sum = a + b;
    a = b;
    b = sum;
    if (isNeeded()) {
      await antifreeze();
    }
  }
  return b;
};

0
如果你正在使用 jQuery,我创建了一个延迟实现 Alnitak's answer
function deferredEach (arr, batchSize) {

    var deferred = $.Deferred();

    var index = 0;
    function chunk () {
        var lastIndex = Math.min(index + batchSize, arr.length);

        for(;index<lastIndex;index++){
            deferred.notify(index, arr[index]);
        }

        if (index >= arr.length) {
            deferred.resolve();
        } else {
            setTimeout(chunk, 0);
        }
    };

    setTimeout(chunk, 0);

    return deferred.promise();

}

然后,您将能够使用返回的 Promise 来管理进度和完成回调:

var testArray =["Banana", "Orange", "Apple", "Mango"];
deferredEach(testArray, 2).progress(function(index, item){
    alert(item);
}).done(function(){
    alert("Done!");
})

0
我成功使用函数编写了一个非常简短的算法。以下是一个示例:
let l=($,a,f,r)=>{f(r||0),$((r=a(r||0))||0)&&l($,a,f,r)};

l
  (i => i < 4, i => i+1, console.log) 

/*
output:
0
1
2
3
*/

我知道这看起来非常复杂,所以让我解释一下这里实际发生了什么。

这是一个稍微简化过的 l 函数版本。

let l_smpl = (a,b,c,d) => {c(d||0);d=b(d||0),a(d||0)&&l_smpl(a,b,c,d)||0}

循环的第一步,l_smpl 调用您的回调并传递 d——索引。如果 d 未定义,就像在第一次调用时一样,它会将其更改为 0。

接下来,它通过调用您的更新函数来更新 d 并将其设置为结果。在我们的情况下,更新函数会将索引加 1。

下一步通过调用第一个函数并检查值是否为 true 来检查是否满足您的条件,这意味着循环尚未完成。如果是,则再次调用该函数;否则,返回 0 以结束循环。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接