标签: 异步任务队列

  • Nodejs-中的事件循环计时器和processnextTick

    前言

    本篇文章翻译自 Node.js 官网的同名文章也算是经典老物了, 不过官网的文章也随着 Node.js 的演化在修改, 这篇文章最后的编辑时间是 2019年9月10日请注意时效性, 地址在文章的最后有给出.

    首次翻译英语水平有限, 错误之处还请多多指教.

    什么是事件循环

    事件循环允许node.js执行非阻塞I/O操作. 虽然 JavaScript 是单线程的, 但是事件循环会尽可能的将操作转移到系统内核中来完成.

    现代的操作系统内核都是多线程的, 它们可以在后台处理多种操作. 一旦这些操作完成, 系统内核会通知 Node.js 以便将事件回调放入轮询队列中等待执行. (我们会在随后的内容讨论它们的具体工作细节)

    解析事件循环

    当 Node.js 启动的时候, 他会初始化事件循环, 处理输入的脚本内容 (或者进入 REPL), 脚本可能会调用异步接口, 设置定时器, 或者调用 process.nextTick(), 然后开始处理事件循环(eventloop).

    下面的简图中展示了事件循环的操作流程:

       ┌───────────────────────┐
    ┌─>│        timers         │
    │  └──────────┬────────────┘
    │  ┌──────────┴────────────┐
    │  │     I/O callbacks     │
    │  └──────────┬────────────┘
    │  ┌──────────┴────────────┐
    │  │     idle, prepare     │
    │  └──────────┬────────────┘      ┌───────────────┐
    │  ┌──────────┴────────────┐      │   incoming:   │
    │  │         poll          │<─────┤  connections, │
    │  └──────────┬────────────┘      │   data, etc.  │
    │  ┌──────────┴────────────┐      └───────────────┘
    │  │        check          │
    │  └──────────┬────────────┘
    │  ┌──────────┴────────────┐
    └──┤    close callbacks    │
       └───────────────────────┘

    每一个方框代表了事件循环中不同的阶段(所有阶段执行完成算是一次事件循环).

    每一个阶段都有一个由回调组成的 FIFO 队列被用于执行. 虽然不同的队列执行方式不同, 总的来看, 当事件循环进入该阶段后会执行该阶段对应的操作, 然后调用对应的回调直到队列耗尽或者达到了回调执行上限. 在到达上述情况后事件循环进入下一阶段, 然后继续这样的流程.

    由于处理单个操作可能会产生新的操作以及在轮询阶段产生的新事件会被内核排队, 在轮询事件(poll events)的过程中轮询事件会被排队. 因此, 执行一个长耗时的回调会超出在轮询阶段设定的定时器的阈值.

    Windows and the Unix/Linux 平台略有差别, 但是这不影响我们的讨论. 我们最关心的是 Node.js 实际执行的那部分也就是上面的内容.

    阶段总览

    • timer: 此阶段执行由 setTimeout()setInterval() 设定的回调.
    • pending callbacks: 执行被推迟到下一轮循环的 I/O 回调.
    • idle, prepare: 仅内部使用.
    • poll: 获取新的I/O事件; 执行 I/O 回调(除了 close 回调以及 timer 回调和 setImmediate 回调都会在这里执行), node会在适当条件下在这里阻塞.
    • check: setImmediate 回调将会在次执行.
    • close callbacks: 一些执行关闭的函数, 例如 socket.on('close', ...).

    Node 会在两次完整的事件循环间检查是否存在 I/O 操作和或者 timer, 如果没有就会退出执行.

    各阶段中的细节

    timer

    timer(计时器) 指定了执行给定回调的阈值时间, 而不是人们所想的准确执行时间. 定时器回调将会在指定的时间到达后尽快的执行, 不过 timer 的执行会受到操作系统调度和其他回调执行的影响被延后.

    从技术上讲, 决定是否执行 timer 回调是在轮询阶段控制的, 在 timer 阶段才会执行这些回调.

    举例来说, 你制定了一个延时 100ms 的 timer, 然后异步进行读取文件花费了 95ms:

    const fs = require('fs');
    
    function someAsyncOperation(callback) {
      // Assume this takes 95ms to complete
      fs.readFile('/path/to/file', callback);
    }
    
    const timeoutScheduled = Date.now();
    
    setTimeout(() => {
      const delay = Date.now() - timeoutScheduled;
    
      console.log(`${delay}ms have passed since I was scheduled`);
    }, 100);
    
    
    // do someAsyncOperation which takes 95 ms to complete
    someAsyncOperation(() => {
      const startCallback = Date.now();
    
      // do something that will take 10ms...
      while (Date.now() - startCallback < 10) {
        // do nothing
      }
    });

    当事件循环进入轮询队列后, 此时队列是空的(fs.readFile() 还未完成), 现在我们等待计时器到达指定的阈值. 过了 95ms 后 fs.readFile 读取完毕并且执行回调共花费 10ms. 当回调执行完成, 轮询队列中没有任何内容了, 此时事件循环会看到已经到达阈值的 timer, 然后在 timer阶段去执行回调. 所以在这个例子中的延时函数会在 105ms 后执行.

    为了防止事件循环被长时间空置, libuv 有一个最大限值(取决于操作系统)用于限制轮询队列的执行次数.

    pending callbacks

    系统操作例如: TCP类型错误执行回调会安排在这个阶段执行. 例如当尝试 TCP 连接的时候接收到了一个 ECONNREFUSED 错误, 有些 *nix 系统会进行等待而不是立即抛出错误. 这些回调会被添加到队列中在 pending callbacks 阶段执行.

    poll

    事件轮询阶段主要有两大功能:

    1. 计算需要阻塞多长时间, 并且进行I/O轮询, 然后
    2. 处理轮询队列中的事件

    当事件轮询到了 poll 阶段的时候发现没有计时器到达阈值, 此时会发生两种情况:

    1. 如果轮询队列中有内容, 事件循环会遍历轮询队列然后同步调用其中的回掉, 直到队列清空或接近轮询阶段的回调执行上限(上限取决于操作系统).
    2. 如果轮询队列为空, 此时

      • 如果存在 setImmediate() 任务, 事件循环会结束轮询阶段直接跳入 check 阶段去执行那些 setImmediate() 任务.
      • 如果没有需要处理的 setImmediate() 任务, 事件循环会在轮询阶段等待新的任务被添加到轮询队列中, 然后立即处理这些添加进来的任务.

    轮询队列为空后, 事件循环将检查已达到时间阈值的计时器. 如果有计时器到达阈值, 事件循环会移动到 timer 阶段然后执行那些计时器回调.

    check

    这个阶段允许在轮询阶段完成后执行回调. 如果轮询阶段进入等待, 并且有被 setImmediate() 设定的回调, 那么事件循环有可能会移动到 check 阶段而不是继续在轮询阶段等待.

    setImmediate() 实际上是一个特殊的计时器, 在事件循环的一个单独阶段中执行. 它通过 libuv API 在轮询阶段结束后执行由 setImmediate() 设定的回调.

    通常来说, 随着代码的运行事件循环终将进入事件轮询阶段并在此等待连接的传入或者请求等. 但是如果存在使用 setImmediate() 设定的任务且时间轮询进入了等待(idle 阶段), 事件循环会进入到 check 阶段而不是继续等待下去.

    close

    如果 socket 或者 handle 突然的关闭, 它们的 close 事件会在这个阶段执行. 否则它会经由 process.nextTick() 执行.

    setImmediate() vs setTimeout()

    setImmediate()setTimeout() 很像, 但根据调用时机的差异它们的行为方式有所区别.

    • setImmediate() 被设计在当前的事件轮询阶段(poll phase)结束后执行脚本一次.
    • setTimeout() 借助于设定阈值(毫秒)规划脚本的执行.

    执行计时器的顺序将根据调用它们的上下文而有所不同. 如果两者都在主模块中运行, 执行的时机会受到进程性能的影响(机器上的其他程序会影响到进程的性能).

    例如我们在不受 I/O 循环(例如主模块中)的地方执行下方的代码, 这两个计时器的执行顺序是不确定的, 因为会受到进程性能的影响:

    // timeout_vs_immediate.js
    setTimeout(() => {
      console.log('timeout');
    }, 0);
    
    setImmediate(() => {
      console.log('immediate');
    });
    $ node timeout_vs_immediate.js
    timeout
    immediate
    
    $ node timeout_vs_immediate.js
    immediate
    timeout

    译者说明: 在脚本执行首次执行完成后, setImmediatesetTimeout 被添加到了事件循环中. 在第二轮事件循环中如果进程性能一般已经到达 timer 的阈值了就会在 timer 阶段执行定时器任务, 随后执行 setImmediate 设定的任务. 如果线程性能足够就会因为不够计时器阈值跳过 timer 阶段去执行 setImmediate 设定的任务.

    但是如果你将这两个计时器移动到 I/O 循环中, setImmediate 始终会第一个执行:

    // timeout_vs_immediate.js
    const fs = require('fs');
    
    fs.readFile(__filename, () => {
      setTimeout(() => {
        console.log('timeout');
      }, 0);
      setImmediate(() => {
        console.log('immediate');
      });
    });
    $ node timeout_vs_immediate.js
    immediate
    timeout
    
    $ node timeout_vs_immediate.js
    immediate
    timeout

    译者说明: 文件操作是 I/O操作实在 poll 阶段执行的, 回调执行完成后 poll 队列是空着的, 此时 timer 已经在 poll 阶段被设定完成(timer 阶段执行), 此时存在 setImmediate 任务所以直接进入到了 check 阶段.

    使用 setImmediate 的优点是始终在定时器前执行(在 I/O循环中), 而不管设置了多少个定时器.

    process.nextTick()

    你可能注意到了 process.nextTick() 没有出现在之前的图中, 虽然它是异步 API 的组成部分. 从技术角度来看 process.nextTick() 并不是事件循环的一部分. nextTickQueue 总是在当前操作执行完成后执行

    水平有限, 有关 “操作” 的定义在原文如下:

    Here, an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed.

    回到刚才的流程图上, 你可以在图上的任意阶段执行 process.nextTick(), 所有通过 process.nextTick() 注册的回调都会在事件循环进入到下一个阶段前处理. 这种设计会造成一些不好的情况, 如果你递归调用 process.nextTick() 他会 “饿死” I/O, 因为这会阻止事件循环进入到事件轮询阶段.

    为什么允许这样的设计?

    为什么这样的设计被包含到了 Node.js 中?这是 Node.js 设计理念的一部分, 接口永远应该是异步的即使是它同步也没有问题, 举例来说:

    function apiCall(arg, callback) {
      if (typeof arg !== 'string'){
        return process.nextTick(
                callback,
                new TypeError('argument should be string')
            );
      }
    }

    这段代码会对参数进行检查当类型错误会抛出 error. process.nextTick() 在最近的更新中允许传入参数, 然后将参数传入到回调中而不必嵌套一个函数来包装实现类似的功能.

    上段代码中我们会向用户通知错误, 但是只有用户的代码执行完成后这个错误才会被执行. 借助于 process.nextTick() 我们可以确保 apiCall() 调用的 callback 永远在当前用户代码执行完成之后以及在事件循环进入下一阶段前执行代码. 为了达到这一点, JS 调用栈允许展开后立即执行那些给定的回调, 这样做允许用户通过 process.nextTick 创建递归的代码但是不会造成 V8 引擎的栈溢出错误 RangeError: Maximum call stack size exceeded from v8.

    水平有限, 原文如下:

    To achieve this, the JS call stack is allowed to unwind then immediately execute the provided callback which allows a person to make recursive calls to process.nextTick() without reaching a RangeError: Maximum call stack size exceeded from v8.

    这种理念可能会导致问题出现, 举例来说:

    let bar;
    
    // 这是拥有异步接口设计的函数, 在其内部确实同步的
    function someAsyncApiCall(callback) { callback(); }
    
    // 内部的回调会在 someAsyncApiCall 执行完成前调用
    someAsyncApiCall(() => {
      // 由于 someAsyncApiCall 立即执行, 此时的 bar 还未被指定值
      console.log('bar', bar); // undefined
    });
    
    bar = 1;

    用户定义了一个拥有异步接口的函数, 但是内部却是同步的. 当提供给 someAsyncApiCall 的回调执行后, 回调和 someAsyncApiCall 在事件循环的执行阶段是一样的, 因为 someAsyncApiCall 本质上没有做任何的异步操作. 结果是回调试图引用 bar 即使在作用域中还没有该变量的值, 因为脚本还未全部解析完成.

    通过将回调放入到 process.nextTick(), 其余的脚本才会有机会执行完成, 解析所有的函数和变量等等, 以在回调调用前初始化. 在事件循环进入到下一阶段前收到错误也是非常有用的.

    这里有一个先前使用 process.nextTick() 的示例:

    let bar;
    
    function someAsyncApiCall(callback) {
      process.nextTick(callback);
    }
    
    someAsyncApiCall(() => {
      console.log('bar', bar); // 1
    });
    
    bar = 1;

    这里还有一个实际的应用示例:

    const server = net.createServer(() => {}).listen(8080);
    
    server.on('listening', () => {});

    当端口被传入后, 端口被立即绑定. 所以 listening 可能会立即执行. 但问题是此时 .on('listening') 回掉还未被注册.

    通过使用 nextTick() 来将内部的 listening 事件排队, 让脚本有机会去执行完成. 这才可以让用户去注册它们想要的监听器.

    process.nextTick() vs setImmediate()

    对于使用者来说这两个接口的功能是类似的, 但是它们的名称却令人难以琢磨.

    • process.nextTick() 在事件循环的某个阶段中全部执行
    • setImmediate() 在事件循环的随后的迭代中触发

    原文:

    • process.nextTick() fires immediately on the same phase
    • setImmediate() fires on the following iteration or ‘tick’ of the event loop

    从本质上看, 它们应该交换名称. process.nextTick() 从调用到出发所花费的时间比 setImmediate() 还要短, 但是这个坑已经被埋了太久了很难再被修复了. 如果要是修改命名会让 npm 上的大部分包挂掉. 随着 npm 上的包越来越多尝试修复的代价也越来越高. 虽然命名有问题, 但是也无法修改了.

    我们开发者在所有的情况下都使用 setImmediate 因为它更加容易推理(也可以让代码更具兼容性, 比如在浏览器中运行).

    为什么使用process.nextTick()?

    主要原因有两个:

    1. 运行用户处理错误, 清理不需要的资源, 或者在事件循环进入下一阶段前尝试再次发送请求.
    2. 有时需要回掉在栈展开(unwind)后但是事件循环还未进入到下一阶段前执行.

    有一个符合用户预期的例子:

    const server = net.createServer();
    server.on('connection', (conn) => { });
    
    server.listen(8080);
    server.on('listening', () => { });

    假设事件循环中第一个运行的是 listen(), 但是用于监听的回调是使用 setImmediate 设置的. 除非主机名称已经被传入, 否则将立即绑定到端口. 要使事件循环继续, 它必须进入到轮询阶段. 这意味着在 listening 前建立的连接会在 listening 事件触发前执行 connection 事件.

    另一个例子是运行一个函数构造函数, 继承自 EventEmitter 并且它想在构造函数中调用一个事件.

    const EventEmitter = require('events');
    const util = require('util');
    
    function MyEmitter() {
      EventEmitter.call(this);
      this.emit('event');
    }
    util.inherits(MyEmitter, EventEmitter);
    
    const myEmitter = new MyEmitter();
    myEmitter.on('event', () => {
      console.log('an event occurred!');
    });

    你无法在构造函数中立即触发事件, 因为对应的事件监听器还未挂载. 通过使用 process.nextTick() 可以在构造函数执行完成后在触发事件, 就可以实现我们的目标了:

    const EventEmitter = require('events');
    const util = require('util');
    
    function MyEmitter() {
      EventEmitter.call(this);
    
      // use nextTick to emit the event once a handler is assigned
      process.nextTick(() => {
        this.emit('event');
      });
    }
    util.inherits(MyEmitter, EventEmitter);
    
    const myEmitter = new MyEmitter();
    myEmitter.on('event', () => {
      console.log('an event occurred!');
    });

    参考

    The Node.js Event Loop, Timers, and process.nextTick()

    由setTimeout和setImmediate执行顺序的随机性窥探Node的事件循环机制

    Node探秘之事件循环(2)–setTimeout/setImmediate/process.nextTick的差别

    Node 定时器详解

    nodejs的eventloop,timers和process.nextTick()【译】

  • Java并发16-CompletionService批量执行异步任务

    我们思考下这个场景:从三个电商询价,然后保存在自己的数据库里。通过之前所学,我们可能这么实现。

    // 创建线程池
    ExecutorService executor =
      Executors.newFixedThreadPool(3);
    // 异步向电商 S1 询价
    Future<Integer> f1 = 
      executor.submit(
        ()->getPriceByS1());
    // 异步向电商 S2 询价
    Future<Integer> f2 = 
      executor.submit(
        ()->getPriceByS2());
    // 异步向电商 S3 询价
    Future<Integer> f3 = 
      executor.submit(
        ()->getPriceByS3());
        
    // 获取电商 S1 报价并保存
    r=f1.get();
    executor.execute(()->save(r));
      
    // 获取电商 S2 报价并保存
    r=f2.get();
    executor.execute(()->save(r));
      
    // 获取电商 S3 报价并保存  
    r=f3.get();
    executor.execute(()->save(r));
    

    上面的这个方案本身没有太大问题,但是有个地方的处理需要你注意,那就是如果获取电商 S1 报价的耗时很长,那么即便获取电商 S2 报价的耗时很短,也无法让保存 S2 报价的操作先执行,因为这个主线程都阻塞在了 f1.get(),那我们如何解决了?

    我们可以增加一个阻塞队列,获取到 S1、S2、S3 的报价都进入阻塞队列,然后在主线程中消费阻塞队列,这样就能保证先获取到的报价先保存到数据库了。下面的示例代码展示了如何利用阻塞队列实现先获取到的报价先保存到数据库。

    // 创建阻塞队列
    BlockingQueue<Integer> bq =
      new LinkedBlockingQueue<>();
    // 电商 S1 报价异步进入阻塞队列  
    executor.execute(()->
      bq.put(f1.get()));
    // 电商 S2 报价异步进入阻塞队列  
    executor.execute(()->
      bq.put(f2.get()));
    // 电商 S3 报价异步进入阻塞队列  
    executor.execute(()->
      bq.put(f3.get()));
    // 异步保存所有报价  
    for (int i=0; i<3; i++) {
      Integer r = bq.take();
      executor.execute(()->save(r));
    }  
    

    利用 CompletionService 实现询价系统

    不过在实际项目中,并不建议你这样做,因为 Java SDK 并发包里已经提供了设计精良的 CompletionService。利用 CompletionService 能让代码更简练。

    CompletionService 的实现原理也是内部维护了一个阻塞队列,当任务执行结束就把任务的执行结果加入到阻塞队列中,不同的是 CompletionService 是把任务执行结果的 Future 对象加入到阻塞队列中,而上面的示例代码是把任务最终的执行结果放入了阻塞队列中。

    那到底该如何创建 CompletionService 呢?

    CompletionService 接口的实现类是 ExecutorCompletionService,这个实现类的构造方法有两个,分别是:

    1. ExecutorCompletionService(Executor executor)
    2. ExecutorCompletionService(Executor executor, BlockingQueue<Future<V>> completionQueue)

    这两个构造方法都需要传入一个线程池,如果不指定 completionQueue,那么默认会使用无界的 LinkedBlockingQueue。任务执行结果的 Future 对象就是加入到 completionQueue 中。

    下面的示例代码完整地展示了如何利用 CompletionService 来实现高性能的询价系统。其中,我们没有指定 completionQueue,之后通过 CompletionService 接口提供的 submit() 方法提交了三个询价操作,这三个询价操作将会被 CompletionService 异步执行。最后,我们通过 CompletionService 接口提供的 take() 方法获取一个 Future 对象,调用 Future 对象的 get() 方法就能返回询价操作的执行结果了。

    // 创建线程池
    ExecutorService executor = 
      Executors.newFixedThreadPool(3);
    // 创建 CompletionService
    CompletionService<Integer> cs = new 
      ExecutorCompletionService<>(executor);
    // 异步向电商 S1 询价
    cs.submit(()->getPriceByS1());
    // 异步向电商 S2 询价
    cs.submit(()->getPriceByS2());
    // 异步向电商 S3 询价
    cs.submit(()->getPriceByS3());
    // 将询价结果异步保存到数据库
    for (int i=0; i<3; i++) {
      Integer r = cs.take().get();
      executor.execute(()->save(r));
    }
    

    CompletionService 接口说明

    下面我们详细地介绍一下 CompletionService 接口提供的方法,CompletionService 接口提供的方法有 5 个,这 5 个方法的方法签名如下所示。

    Future<V> submit(Callable<V> task);
    Future<V> submit(Runnable task, V result);
    Future<V> take() 
      throws InterruptedException;
    Future<V> poll();
    Future<V> poll(long timeout, TimeUnit unit) 
      throws InterruptedException;
    

    CompletionService 后3 个方法,都是和阻塞队列相关的,take()、poll() 都是从阻塞队列中获取并移除一个元素;它们的区别在于如果阻塞队列是空的,那么调用 take() 方法的线程会被阻塞,而 poll() 方法会返回 null 值。

    利用 CompletionService 实现 Dubbo 中的 Forking Cluster

    Dubbo 中有一种叫做Forking 的集群模式,这种集群模式下,支持并行地调用多个查询服务,只要有一个成功返回结果,整个服务就可以返回了。例如你需要提供一个地址转坐标的服务,为了保证该服务的高可用和性能,你可以并行地调用 3 个地图服务商的 API,然后只要有 1 个正确返回了结果 r,那么地址转坐标这个服务就可以直接返回 r 了。这种集群模式可以容忍 2 个地图服务商服务异常,但缺点是消耗的资源偏多。

    geocoder(addr) {
      // 并行执行以下 3 个查询服务, 
      r1=geocoderByS1(addr);
      r2=geocoderByS2(addr);
      r3=geocoderByS3(addr);
      // 只要 r1,r2,r3 有一个返回
      // 则返回
      return r1|r2|r3;
    }
    

    利用 CompletionService 可以快速实现 Forking 这种集群模式,比如下面的示例代码就展示了具体是如何实现的。首先我们创建了一个线程池 executor 、一个 CompletionService 对象 cs 和一个Future<Integer>类型的列表 futures,每次通过调用 CompletionService 的 submit() 方法提交一个异步任务,会返回一个 Future 对象,我们把这些 Future 对象保存在列表 futures 中。通过调用cs.take().get(),我们能够拿到最快返回的任务执行结果,只要我们拿到一个正确返回的结果,就可以取消所有任务并且返回最终结果了。

    // 创建线程池
    ExecutorService executor =
      Executors.newFixedThreadPool(3);
    // 创建 CompletionService
    CompletionService<Integer> cs =
      new ExecutorCompletionService<>(executor);
    // 用于保存 Future 对象
    List<Future<Integer>> futures =
      new ArrayList<>(3);
    // 提交异步任务,并保存 future 到 futures 
    futures.add(
      cs.submit(()->geocoderByS1()));
    futures.add(
      cs.submit(()->geocoderByS2()));
    futures.add(
      cs.submit(()->geocoderByS3()));
    // 获取最快返回的任务执行结果
    Integer r = 0;
    try {
      // 只要有一个成功返回,则 break
      for (int i = 0; i < 3; ++i) {
        r = cs.take().get();
        // 简单地通过判空来检查是否成功返回
        if (r != null) {
          break;
        }
      }
    } finally {
      // 取消所有任务
      for(Future<Integer> f : futures)
        f.cancel(true);
    }
    // 返回结果
    return r;
    

    总结

    当需要批量提交异步任务的时候建议你使用 CompletionService。CompletionService 将线程池 Executor 和阻塞队列 BlockingQueue 的功能融合在了一起,能够让批量异步任务的管理更简单。除此之外,CompletionService 能够让异步任务的执行结果有序化,先执行完的先进入阻塞队列,利用这个特性,你可以轻松实现后续处理的有序性,避免无谓的等待,同时还可以快速实现诸如 Forking Cluster 这样的需求。

  • js异步从入门到放弃-三-异步任务队列task-queues

    前言

    本文是对于异步系列第一篇里提到的evenloop模型中,所提到的任务队列(task queues)的展开分析

    正文

    说明:以下代码均使用chrome浏览器运行 关于浏览器表现的差异在最后做补充。

    引子-奇怪的执行顺序

    先看一个典型的例子:

    console.log('script start')
    // 第一个异步任务
    setTimeout(()=>{
        console.log('setTimeout')
    },0)
    
    // 第二个异步任务
    Promise.resolve().then(()=>{
        console.log('promise1')
    }).then(()=>{ 
      console.log('promise2');
    })
    console.log('script end')
    // 实际输出结果: 
    // script start
    // script end
    // promise1
    // promise2
    // setTimeout

    根据之前说过的evenloop模型,先输出script startscript end,但是接下来却发现,先执行了Promise指定的callback而不是setTimeoutcallback

    两种任务队列(microtask queue&macrotask queue)

    在之前讨论evenloop模型时,提到了任务队列有2种类型:microtask queuemacrotask queue,他们的区别在于:

    • macrotask的执行:是在evenloop的每次循环过程,取出macrotask queue中可执行的第一个(注意不一定是第一个,因为我们说过例如setTimeout可以指定任务被执行的最少延迟时间,当前macrotask queue的首位保存的任务可能还没有到执行时间,所以queue只是代表callback插入的顺序,不代表执行时也要按照这个顺序)。
    • microtask的执行:在evenloop的每次循环过程之后,如果当前的执行栈(call stack)为空,那么执行microtask queue中所有可执行的任务

    (某些文献内容中 直接把macrotask称为task,或者某些中文文章中把它们翻译成”微任务”和”宏任务”,含义都是相似的:macrotask或者task代表相对单独占据evenloop过程一次循环的任务,而microtask有可能在一次循环中执行多个)

    现在回头来解析前面的例子:

    1. 第一次执行主函数,输出script start
    2. 遇到setTimeout,将对应的callback插入macrotask queue
    3. 遇到promise,将对应的callback插入microtask queue
    4. 输出script end,主函数运行结束,执行栈清空,此时开始检查microtask queue,发现里面有可运行的任务,因此按顺序输出promise1promise2
    5. microtask queue执行完,开始新一轮循环,从macrotask queue取出setTimeout任务并执行,输出setTimeout
    6. 结束,呈现上面的输出结果。

    常见异步操作对应的回调函数任务类型如下:

    • macrotask: setTimeout, setInterval, setImmediate, requestAnimationFrame, I/O, UI rendering
    • microtask: process.nextTick, Promises, Object.observe, MutationObserver

    大概可以这样区分:和html交互密切相关的异步操作,一般是macrotasks;由emcascript的相关接口返回的异步操作,一般是microtasks

    如何判断执行顺序

    接下来看一个更复杂的例子,帮助理解不同异步任务的执行顺序

    <style>
        .outer {
            padding: 30px;
            background-color: aqua;
    
        }
    
        .inner {
            height: 100px;
            background-color: brown;
        }
    </style>
    
    <body>
        <div class="outer">outer
             <div class="inner">inner</div> 
        </div>
    </body>
    <script>
        var outer = document.querySelector('.outer');
        var inner = document.querySelector('.inner');
    
        // Let's listen for attribute changes on the
        // outer element
        new MutationObserver(function () {
            console.log('mutate');
        }).observe(outer, {
            attributes: true
        });
    
        // Here's a click listener…
        function onClick() {
            console.log('click');
    
            setTimeout(function () {
                console.log('timeout');
            }, 0);
    
            Promise.resolve().then(function () {
                console.log('promise');
            });
    
            outer.setAttribute('data-random', Math.random());
        }
    
        // …which we'll attach to both elements
        inner.addEventListener('click', onClick);
        outer.addEventListener('click', onClick);

    运行以上代码,可以在浏览器看到两个嵌套的div(如图):

    点击inner部分,打开chrome的调试器,可以看到console打出的结果是:

    click promise mutate click promise mutate timeout timeout

    接下来分析运行过程 (建议配合单步调试进行分析):

    1. 点击inner,触发对应的onClick事件,此时inner对应的onClick函数进入执行栈
    2. 运行console.log('click'),输出click
    3. 运行setTimeout,macrotask queue添加对应的console函数
    4. 运行Promise,此时microtask queue添加对应的console函数
    5. 运行outer.setAttribute,触发MutationObserver,microtask queue添加对应的console函数(前面注明了MutationObserver创建的回调任务类型是microtask)
    6. 当前函数执行完毕,由于执行栈清空,此时开始调度microtask queue,因此依次输出promisemutate,此时当前执行栈call stackmicrotask queue均为空,但是macrotask queue里依然存储着两个东西–inner的Click触发的任务,以及先前setTimeout的回调函数。
    7. inner的onclick函数虽然执行完毕,但是由于事件冒泡,紧接着要触发outeronClick的执行函数,因此setTimeout的回调暂时还无法执行。
    8. outeronClick函数执行过程,重复前面的2-5步骤,因此再次输出click promise mutate
    9. 此时执行栈call stackmicrotask queue均为空,macrotask queue存储着两个setTimeout的回调函数。,根据evenloop模型,开始分别执行这两个task,于是输出了两个timeout
    10. 结束。

    再次建议在调试器查看上面的步骤,尤其要注意观察call stackmicrotask queue macrotask queue的变化,会更加直观

    如果已经理解了上面的例子,在上面的基础上,我们把点击inner部分的这个操作,改成直接在js代码的末尾加上innner.click(),结果是否一致呢?

    控制台的结果:
    click click promise mutate promise timeout timeout

    与前一次的结果完全不同!
    接下来再次进入调试分析:

    1. 由于是直接执行inner.click(),这次进入inner绑定的onclick函数时,与前面是有所不同的:通过chrome调试器可以看到,此时的call stack有两层-除了onClick函数之外,还有一层匿名函数,这层函数其实就是整个script,相当于window.onload绑定的处理函数。 这是很关键的一点,因为前面的例子的执行顺序是:页面加载后先运行了整个匿名函数,之后该函数出栈,到点击时触发inner的onclcik,此时onClick对应的函数进栈。这一个区别导致了整个执行结果的差异。两次执行到onclick时的callstck区别如图:

    点击触发:

    代码直接触发

    1. 接下来重复前面例子中,步骤2-5,输出一个click
    2. inner的onClick函数执行完毕,但是这次执行栈并未清空,因为当前匿名函数还在执行栈里,因此无法开始调度microtask queue!!!(前面说了microtask queue的调度必须在当前执行栈为空的情况下),因此这时候会先进入冒泡事件触发的onClick
    3. 类似的,输出clcik之后,promise的回调函数进入microtask queue
    4. 运行outer.setAttribute,触发MutationObserver,但是此时microtask queue无法再次添加对应的回调函数了,因为已经有一个存在的监听函数在pengding
    5. 两个onclick执行完毕,执行栈清空,接下来开始调度microtask queue,输出promise mutate promise
    6. 此时当前执行栈call stackmicrotask queue均为空,macrotask queue存储着两个setTimeout的回调函数。
    7. 结束

    这两个例子的对比,着重说明了一点:microtask queue存储的任务,必须要在当前函数执行栈为空时才会开始调度
    ,完整内容可参见html标准中的8.1.4部分

    结论

    1. macrotask会按顺序执行,并且有可能被中途插入浏览器render,例如上面的冒泡事件
    2. microtask的执行有两个条件:

      1. 在每个macrotask结束之后
      2. 当前call stack为空

    ps:浏览器差异

    上述代码在chrome的浏览器下测试结果,可能和在某些版本的firefox和ie浏览器下不一致,在某些浏览器中可能会把promise的回调函数当做mascrotask,但是:

    普遍的共识把 Promise当做是miscrotask,并且有比较充分的理由:如果把promose当做是task(即mascrotask)将会导致一些性能问题–因为task的调度是可以被其他task相关的任务如Render打断,还会因为与其他任务源的交互导致不确定性。

    参考文献

    1. Tasks, microtasks, queues and schedules
    2. HTML Living Standard

    如果觉得写得不好/有错误/表述不明确,都欢迎指出
    如果有帮助,欢迎点赞和收藏,转载请征得同意后著明出处。如果有问题也欢迎私信交流,主页有邮箱地址
    如果觉得作者很辛苦,也欢迎打赏~

  • JavaScript事件循环(Event Loop)

    1、为什么要有事件循环?
    因为js是单线程的,事件循环是js的执行机制,也是js实现异步的一种方法。
    既然js是单线程,那就像只有一个窗口的银行,客户需要排队一个一个办理业务,同理js任务也要一个一个顺序执行。如果一个任务耗时过长,那么后一个任务也必须等着。那么问题来了,假如我们想浏览新闻,但是新闻包含的超清图片加载很慢,难道我们的网页要一直卡着直到图片完全显示出来?因此聪明的程序员将任务分为两类:

    同步任务
    异步任务

    当我们打开网站时,网页的渲染过程就是一大堆同步任务,比如页面骨架和页面元素的渲染。而像加载图片音乐之类占用资源大耗时久的任务,就是异步任务。
    2、宏任务与微任务
    JavaScript中除了广泛的同步任务和异步任务,我们对任务有更精细的定义:

    macro-task(宏任务): 包括整体代码script,setTimeout,setInterval

    micro-task(微任务): Promise,process.nextTick

    不同的类型的任务会进入不同的Event Queue(事件队列),比如setTimeout、setInterval会进入一个事件队列,而Promise会进入另一个事件队列。
    一次事件循环中有宏任务队列和微任务队列。事件循环的顺序,决定js代码执行的顺序。进入整体代码(宏任务-<script>包裹的代码可以
    理解为第一个宏任务),开始第一次循环,接着执行所有的微任务。然后再次从宏任务开始,找到其中一个任务队列的任务执行完毕,
    再执行所有的微任务。如:
    <script>
    setTimeout(function() {
    console.log(‘setTimeout’);
    })

    new Promise(function(resolve) {
    console.log(‘promise’);
    }).then(function() {
    console.log(‘then’);
    })

    console.log(‘console’);

    /* —————————-分析 start——————————— */

    1、`<script>`中的整段代码作为第一个宏任务,进入主线程。即开启第一次事件循环
    2、遇到setTimeout,将其回调函数放入Event table中注册,然后分发到宏任务Event Queue中
    3、接下来遇到new Promise、Promise,立即执行;将then函数分发到微任务Event Queue中。输出: promise
    4、遇到console.log,立即执行。输出: console
    5、整体代码作为第一个宏任务执行结束,此时去微任务队列中查看有哪些微任务,结果发现了then函数,然后将它推入主线程并执行。
    输出: then
    6、第一轮事件循环结束
    开启第二轮事件循环。先从宏任务开始,去宏任务事件队列中查看有哪些宏任务,在宏任务事件队列中找到了setTimeout对应的回调函数,
    立即执行之。此时宏任务事件队列中已经没有事件了,然后去微任务事件队列中查看是否有事件,结果没有。此时第二轮事件循环结束;
    输出:setTimeout

    /* —————————-分析 end——————————— */
    </script>
    3、分析更复杂的代码
    <script>
    console.log(‘1’);

    setTimeout(function() {
    console.log(‘2’);
    process.nextTick(function() {
    console.log(‘3’);
    })
    new Promise(function(resolve) {
    console.log(‘4’);
    resolve();
    }).then(function() {
    console.log(‘5’)
    })
    })
    process.nextTick(function() {
    console.log(‘6’);
    })
    new Promise(function(resolve) {
    console.log(‘7’);
    resolve();
    }).then(function() {
    console.log(‘8’)
    })

    setTimeout(function() {
    console.log(‘9′);
    process.nextTick(function() {
    console.log(’10’);
    })
    new Promise(function(resolve) {
    console.log(’11’);
    resolve();
    }).then(function() {
    console.log(’12’)
    })
    })
    </script>
    一、第一轮事件循环
    a)、整段<script>代码作为第一个宏任务进入主线程,即开启第一轮事件循环
    b)、遇到console.log,立即执行。输出:1
    c)、遇到setTimeout,将其回调函数放入Event table中注册,然后分发到宏任务事件队列中。我们将其标记为setTimeout1
    d)、遇到process.nextTick,其回调函数放入Event table中注册,然后被分发到微任务事件队列中。记为process1
    e)、遇到new Promise、Promise,立即执行;then回调函数放入Event table中注册,然后被分发到微任务事件队列中。记为then1。
    输出: 7
    f)、遇到setTimeout,将其回调函数放入Event table中注册,然后分发到宏任务事件队列中。我们将其标记为setTimeout2
    此时第一轮事件循环宏任务结束,下表是第一轮事件循环宏任务结束时各Event Queue的情况


    宏任务事件队列
    微任务事件队列

    第一轮事件循环
    (宏任务已结束)
    process1、then1

    第二轮事件循环(未开始)
    setTimeout1

    第三轮事件循环(未开始)
    setTimeout2

    可以看到第一轮事件循环宏任务结束后微任务事件队列中还有两个事件待执行,因此这两个事件会被推入主线程,然后执行
    g)、执行process1。输出:6
    h)、执行then1。输出:8
    第一轮事件循环正式结束!

    二、第二轮事件循环
    a)、第二轮事件循环从宏任务setTimeout1开始。遇到console.log,立即执行。输出: 2
    b)、遇到process.nextTick,其回调函数放入Event table中注册,然后被分发到微任务事件队列中。记为process2
    c)、遇到new Promise,立即执行;then回调函数放入Event table中注册,然后被分发到微任务事件队列中。记为then2。输出: 5
    此时第二轮事件循环宏任务结束,下表是第二轮事件循环宏任务结束时各Event Queue的情况


    宏任务事件队列
    微任务事件队列

    第一轮事件循环(已结束)

    第二轮事件循环
    (宏任务已结束)
    process2、then2

    第三轮事件循环(未开始)
    setTimeout2

    可以看到第二轮事件循环宏任务结束后微任务事件队列中还有两个事件待执行,因此这两个事件会被推入主线程,然后执行
    d)、执行process2。输出:3
    e)、执行then2。输出:5
    第二轮事件循环正式结束!

    三、第三轮事件循环
    a)、第三轮事件循环从宏任务setTimeout2开始。遇到console.log,立即执行。输出: 9
    d)、遇到process.nextTick,其回调函数放入Event table中注册,然后被分发到微任务事件队列中。记为process3
    c)、遇到new Promise,立即执行;then回调函数放入Event table中注册,然后被分发到微任务事件队列中。记为then3。输出: 11
    此时第三轮事件循环宏任务结束,下表是第三轮事件循环宏任务结束时各Event Queue的情况


    宏任务事件队列
    微任务事件队列

    第一轮事件循环(已结束)

    第二轮事件循环(已结束)

    第三轮事件循环(未开始)
    (宏任务已结束)
    process3、then3

    可以看到第二轮事件循环宏任务结束后微任务事件队列中还有两个事件待执行,因此这两个事件会被推入主线程,然后执行
    d)、执行process3。输出:10
    e)、执行then3。输出:12

    4、参考文章
    https://juejin.im/post/59e85e…

  • 你与弄懂promise之间可能只差这篇文章(一)

    promise诞生之前:
    因为JS引擎在执行js代码时只分配了一个线程去执行,所以Javascript是单线程的。由于有这个前置设定,前端er在书写代码时绕不开的一件事是就是—-如何处理异步,即处理“现在和稍后”关系的问题,事实上我们每一天都在与异步逻辑打交道。
    在promise出现之前,前端er基本上都是通过callback的方式来解决“稍后”的问题,例如有经典的“发布-订阅”模式,观察者模式,他们都运用了传入回调函数的高阶函数。vue2.x源码在实现数据双向绑定时就是运用的发布-订阅模式。
    我们先来看看三个例子。(例子均在node环境中运行, 其中name.txt中的内容是”kk”, age.txt中的内容是10。)
    1 . 回调函数(callback)。fs读取文件的先后顺序是不固定的,我们无法判断哪个文件先读取完成。此例实现的是,在完全读取两个文件的内容之后进行某个操作(例如console个啥的)。
    let fs = require(‘fs’);

    let arr = [];
    let after = (times, cb) => {
    return (data) => {
    arr.push(data);
    if (–times === 0) {
    cb(arr)
    }
    }
    }

    let on = after(2, (arr) => {
    console.log(‘我是在全部读取了2个文件内容之后打印出来的, ‘, arr)
    })
    fs.readFile(‘name.txt’, ‘utf8’, (err, data) => {
    on(data)
    })

    fs.readFile(‘age.txt’, ‘utf8’, (err, data) => {
    on(data)
    })

    结果:
    我是在全部读取了2个文件内容之后打印出来的, [ ‘kk’, ’10’ ]。

    说明:
    这种写法的问题在于,需要依靠计数来执行回调函数里面的内容。我们先得这计算出有几个异步操作,然后统计出来在全部的异步操作完成后再执行回调。

    2 .发布-订阅模式。订阅的时候添加订阅者,发布的时候执行相应的订阅函数。此例实现的是,在特定的时候emit了某事件,订阅了该事件的回调函数继而执行。

    class EventEmitter {
    constructor () {
    this.subs = {}
    }
    on (eventName, cb) {
    if (!this.subs[eventName]) {
    this.subs[eventName] = []
    }
    this.subs[eventName].push((…args) => cb(…args))
    }
    emit (eventName, …args) {
    if (this.subs[eventName]) {
    this.subs[eventName].forEach(cb => cb(…args))
    } else {
    throw Error(`没有订阅${eventName}这个事件`)
    }
    }
    }

    const event = new EventEmitter();
    let fs = require(‘fs’);
    event.on(‘kk-event’, (…args) => {
    fs.readFile(‘name.txt’, ‘utf8’, (err, data) => {
    console.log(‘data1’, data, …args)
    })
    })
    event.on(‘kk-event’, (…args) => {
    fs.readFile(‘age.txt’, ‘utf8’, (err, data) => {
    console.log(‘data2’, data, …args)
    })
    })
    event.emit(‘kk-event’, 123, 456)

    结果:

    data1 kk 123 456
    data2 10 123 456

    3 . 观察者模式。它与发布-订阅两者本质是一样的,只不过观察者模式在写法上强调观察者和被观察者之间的关系,而发布-订阅模式则没有这样的关系。此例实现的是,在被观察者的状态发生变化后,观察者执行自己的update方法进行更新。

    class Subject {
    constructor() {
    this.observers = [];
    this.state = ”; // 假设观察者观察的是被观察者的state
    }
    setState (status) { // 当state变化时出发观察者的update方法
    this.state = status;
    this.notify();
    }
    attach (observer) {
    this.observers.push(observer) // 与发布-订阅不同的是,这里添加的是一个个观察者实例,这就将被观察者和观察者之间关联了起来
    }
    notify () {
    this.observers.forEach(observe => observe.update()) // 在被观察者状态变化时,调用更新的是观察者的update方法
    }
    }

    class Observer {
    constructor (name, target) {
    this.name = name;
    this.target = target;
    }
    update () {
    console.log(`通知${this.name},被观察者状态变化,所以观察者${this.name}跟着变化`)
    }
    }

    let fs = require(‘fs’);
    let subject = new Subject();
    let observer1 = new Observer(‘kk1’, subject);
    let observer2 = new Observer(‘kk2’, subject);
    subject.attach(observer1);
    subject.attach(observer2);
    subject.setState(‘B’);

    结果:

    通知kk1,被观察者状态变化,所以观察者kk1跟着变化
    通知kk2,被观察者状态变化,所以观察者kk2跟着变化

  • 类script标签,异步加载,顺序执行

    主要是想实现把压缩加密后的js文件存储在本地,网上找了下没找到理想的,所以自己动手写了一个,主要是仿照script标签的功能,实现异步加载,顺序执行。如果本地已经有该文件,则不重新加载,直接调用本地数据。
    jsFile是存储文件信息的数组,其中,path是文件路径,name是存储在本地的名字,active表示当前文件是否已经执行,load表示当前文件手已经加载完成,还要一个隐藏的content属性,存储文件内容。
    !function loadJS(){
    var jsFile = [
    {name:’file1′,path:’js/file1.js’,active:false,load:false},
    {name:’file2′,path:’js/file2.js’,active:false,load:false},
    {name:’file3′,path:’js/file3.js’,active:false,load:false},
    {name:’file4′,path:’js/file4.js’,active:false,load:false},
    {name:’file5′,path:’js/file5.js’,active:false,load:false}
    ]
    jsFile.forEach(function(item, index){
    if(localStorage[‘file_’ + item.name]){
    item.load = true
    implementJS(item, index)
    }else{
    $.ajax({
    type:”get”,
    url:item.path,
    dataType:’text’,
    success:function(data){
    item.content = data
    item.load = true
    implementJS(item, index)
    }
    });
    }
    })

    function implementJS(item, index){
    //如果上一个文件已经执行了,则执行这个js文件
    if(index == 0 || jsFile[index – 1].active){
    storageJS(item, index)
    //尝试执行下一个js文件
    jsFile[index + 1] && jsFile[index + 1].load && implementJS(jsFile[index + 1], index + 1)
    }
    }
    function storageJS(item, index){
    //存储并执行js文件
    var name = ‘file_’ + item.name
    localStorage[name] = item.content || localStorage[name] || ”
    //这里要使用window.eval或者eval.call(window),否则eval里面的变量就不是全局变量
    window.eval(localStorage[name])
    item.active = true
    }
    }()