异步

  • 异步是什么?为什么要有异步?
  • 异步的表现形式?怎么实现异步编程?
  • 异步编程是怎么发展的?还能怎么发展?

异步编程阐述了程序中现在运行(同步代码块)的部分和将来运行(异步代码块)的部分之间的关系。单线程事件循环是并发(任务级并行)的一种形式。

事件循环

事件循环

事件循环(Event Loop)中的事件指 JavaScript 代码的执行,循环则是指一种调度模式。宿主环境(浏览器)提供了一种机制来处理程序中多个代码块的执行,执行每个块时调用 JavaScript 引擎,这种机制被称为事件循环。换句话说,JavaScript 引擎本身所做的只不过是在给定的任意时刻执行程序中特定的单个代码块,事件调度总是由宿主环境决定。

如何处理高优先级的任务

基于消息队列的事件循环无法使高优先级的任务插队,同步又会影响当前任务的执行效率。为了兼顾实时性和效率,浏览器引入了微任务

微任务如何兼顾实时性和效率

通常我们把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,因此也就解决了执行效率的问题。

等宏任务中的主要功能都直接完成之后,这时候,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务,因为 DOM 变化的事件都保存在这些微任务队列中,这样也就解决了实时性问题。

如何解决单个任务执行时长过久

浏览器通过回调功能来规避这种问题,也就是让要执行的 JavaScript 任务滞后执行。

回调

把一段代码封装成一个函数,并指定它在响应某个事件(定时器、鼠标点击、Ajax 响应等)时执行,你就在代码中创建了一个将来执行的代码块,它就是回调函数,也由此在这个程序中引入了异步机制。

人话:如果一个函数作为参数传递给另一个函数,它就是回调函数。

setTimeout 是怎么实现的

我们知道 DOM 解析、窗口 resize、鼠标 click 等事件会被浏览器进程放入消息队列等待事件循环系统调度,分配给渲染主线程执行。那定时任务该怎么处理?

为了确保任务延迟处理,setTimeout 并不是直接放入消息队列。浏览器给定时器单独设置了 callback queue,渲染进程会将事件在指定延迟时间后推入回调队列,其实就是宏任务队列。

当通过 JavaScript 调用 setTimeout 设置回调函数的时候,渲染进程将会创建一个回调任务,包含了回调函数、当前发起时间、延迟执行时间,结构体如下:

struct DelayTask{
  int64 id;
  CallBackFunction cbf;
  int start_time;
  int delay_time;
};
DelayTask timerTask;
timerTask.cbf = showName;
timerTask.start_time = getCurrentTime(); // 获取当前时间
timerTask.delay_time = 200;// 设置延迟执行时间

// 创建好回调任务后添加到延迟队列
delayed_incoming_queue.push(timerTask)

事件循环怎么触发回调队列

void ProcessDelayTask(){
  // 从 delayed_incoming_queue 中取出已经到期的定时器任务
  // 依次执行这些任务
}

TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainThread(){
  while(true){
    Task task = task_queue.takeTask();
    ProcessTask(task);  // 执行消息队列中的任务
    ProcessDelayTask(); // 执行延迟队列中的任务

    if(!keep_running) // 如果设置了退出标志,那么直接退出线程循环
        break;
  }
}










 
 
 





从上述高亮代码来看,Event Loop 执行完消息队列中的一个任务后会立即执行延迟队列内到期的任务。等到期的任务执行完成之后,再继续下一个循环过程。通过这样的方式,一个完整的定时器就实现了。

从中也可以看出来,如果消息队列中的任务占用渲染主线程过长时间,即使延迟队列中有任务到期,也不会立即得到执行!所以,一些实时性较高的需求就不太适合使用 setTimeout 了,比如你用 setTimeout 来实现 JavaScript 动画就不是一个很好的主意。

setTimeout 需要注意什么

  • 当前任务执行过久,会推迟定时器任务的执行
  • setTimeout 存在嵌套调用,系统会设置最短时间间隔为 4 毫秒
  • 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒
  • 延时执行时间有最大值(2^31)
  • setTimeout 设置的回调函数中的 this 指向全局

XMLHttpRequest 异步请求

XHR请求流程

function GetWebData(URL) {
  /**
   * 1: 新建 XMLHttpRequest 请求对象
   */
  let xhr = new XMLHttpRequest();

  /**
   * 2: 注册相关事件回调处理函数
   */
  xhr.onreadystatechange = function () {
    switch (xhr.readyState) {
      case 0: // 请求未初始化
        console.log(" 请求未初始化 ");
        break;
      case 1: //OPENED
        console.log("OPENED");
        break;
      case 2: //HEADERS_RECEIVED
        console.log("HEADERS_RECEIVED");
        break;
      case 3: //LOADING
        console.log("LOADING");
        break;
      case 4: //DONE
        if (this.status == 200 || this.status == 304) {
          console.log(this.responseText);
        }
        console.log("DONE");
        break;
    }
  };

  xhr.ontimeout = function (e) {
    console.log("ontimeout");
  };
  xhr.onerror = function (e) {
    console.log("onerror");
  };

  /**
   * 3: 打开请求
   */
  xhr.open("Get", URL, true); // 创建一个 Get 请求, 采用异步

  /**
   * 4: 配置参数
   */
  xhr.timeout = 3000; // 设置 xhr 请求的超时时间
  xhr.responseType = "text"; // 设置响应返回的数据格式
  xhr.setRequestHeader("X_TEST", "vuejs.org");

  /**
   * 5: 发送请求
   */
  xhr.send();
}

微任务

页面中的大部分任务都是在主线程上执行:

  • 渲染事件(如解析 DOM、计算布局、绘制);
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
  • JavaScript 脚本执行事件;
  • 网络请求完成、文件读写完成事件。

这些任务会被添加到消息队列中等待渲染主线程调度,它们也被称为宏任务。当 JavaScript 执行一段脚本的时候,V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个微任务队列。也就是说每个宏任务都关联了一个微任务队列。

微任务是怎么产生的

通常有两种方式会产生微任务:

  • Promise.then
  • MutationObserver

微任务在什么时候被执行

宏任务中的 JavaScript 代码执行完后,会销毁全局上下文,在此之前,JS 线程会检查微任务队列是否存在微任务,(这个时间点被称作检查点)如果存在就会执行微任务直到微任务队列为空为止。

MutationObserver 怎么确保实时性和性能

MutationObserver 被设计用来监听 DOM 变更这一频繁需求。最开始我们只能使用 setTimeout 或者 setInterval 轮询检测 DOM 变化,间隔时间设置的小了会存在严重的性能问题;设置的大了又不能保证实时性。

在公元后的第二个千禧年,Web 出台了 Mutation Event,它采用同步回调的方式监听 DOM 变更。每次 DOM 变化就添加回调到消息队列,虽然确保了监听的实时性,但是同样会产生频繁更新的性能问题;所以不可避免的被后起之秀 Mutation Observer 取代了。

Mutation Observer 充分吸取前辈的教训,大胆采用“异步回调 + 微任务”的设计。异步回调再多次 DOM 变化后再一次触发调用保证性能,并使用一个数据结构来记录这期间所有的 DOM 变化。异步如何能保证实时性呢?根据经验,不能采用 setTimeout 等宏任务回调,所以 Mutation Observer 采用微任务的形式。在每次 DOM 节点发生变化的时候,渲染引擎将变化记录封装成微任务,并将微任务添加进当前的微任务队列中。这样当执行到检查点的时候,V8 引擎就会按照顺序执行微任务了。

Promise

JS 为什么引入 Promise

Promise 之前的异步机制是基于 Event Loop + Callback 模式的:

异步编程模型

这会造成回调地狱的问题,代码维护起来很困难,原因在于:

  1. 多个依赖请求嵌套调用,可读性差
  2. 不得不在每个请求中处理成功和失败回调

Promise 的使用

综上,Promise 解决的是异步编码风格的问题。

Promise 将事件循环的责任划分到 JavaScript 引擎的势力范围之内。

Promise 三状态:

  • pending
  • fulfilled
  • rejected

状态一旦改变就无法更改,只能由 pending 到 fulfilled 或 rejected。为什么成功不叫 resolved?

这是因为 fulfilled 并不表示成功,更确切的表达应该是完成,Promise.reject()的值会被 then 的第二个回调函数捕获。被 catch 回调捕获的 reject() 也属于 fulfilled 状态。

const p1 = new Promise((resolve, reject) => {
  resolve("success");
})
  .then((result) => result)
  .catch((e) => e);

const p2 = new Promise((resolve, reject) => {
  throw new Error("error");
})
  .then((result) => result)
  .catch((e) => e);

Promise.all([p1, p2])
  .then((result) => console.log(result)) // ['success', Error: error]
  .catch((e) => console.log(e));

值透传

Promise.resolve(1)
  .then(2)
  .then(Promise.resolve(3))
  .then(console.log)
  • .then 或者 .catch 的参数期望是函数,传入非函数则会发生值透传
  • Promise 方法链通过 return 传值
Promise.resolve()
  .then(() => {
    return new Error("error!!!");
  })
  .then((res) => {
    console.log("then: ", res);
  })
  .catch((err) => {
    console.log("catch: ", err);
  });
  • 返回任意一个非 promise 的值都会被包裹成 promise 对象。
  • .then.catch 返回的值不能是 promise 本身,否则会造成死循环。

finally

Promise.resolve("1")
  .then((res) => {
    console.log(res);
  })
  .finally(() => {
    console.log("finally");
  });
Promise.resolve("2")
  .finally(() => {
    console.log("finally2");
    return "我是finally2返回的值";
  })
  .then((res) => {
    console.log("finally2后面的then函数", res);
  });
  1. .finally()方法不管 Promise 对象最后的状态如何都会执行
  2. .finally()方法的回调函数不接受任何的参数,也就是说你在.finally()函数中是无法知道 Promise 最终的状态是 resolved 还是 rejected 的
  3. 它最终返回的默认会是一个上一次的 Promise 对象值,不过如果抛出的是一个异常则返回异常的 Promise 对象。
  4. finally 本质上是 then 方法的特例
const p1 = new Promise((resolve) => {
  setTimeout(() => {
    resolve("resolve3");
    console.log("timer1");
  }, 0);
  resolve("resolve1");
  resolve("resolve2");
})
  .then((res) => {
    console.log(res);
    setTimeout(() => {
      console.log(p1);
    }, 1000);
    return "finally result in then";
  })
  .finally((res) => {
    console.log("finally", res);
  });

promise 如何解决嵌套回调

回调函数延迟绑定

产生嵌套函数的一个主要原因是在发起任务请求时会带上回调函数,这样当任务处理结束之后,下个任务就只能在回调函数中来处理了。

/**
 * 实现简单的Promise
 */
function MyPromise(executor) {
  let onResolve_ = null;
  let onReject_ = null;

  // 模拟实现 resolve 和 then,暂不支持 reject
  this.then = function (onResolve, onReject) {
    onResolve_ = onResolve;
    onReject_ = onReject;
  };
  function resolve(value) {
    // 异步执行 不然onResolve is not a function
    setTimeout(() => {
      onResolve_(value);
    }, 0);
  }

  executor(resolve, null);
}

function executor(resolve, reject) {
  resolve(100);
}
function onResolve(value) {
  console.log(value);
}

let demo = new MyPromise(executor);
// then回调的延迟绑定
demo.then(onResolve);

回调函数返回值穿透

function onResolve(value) {
  console.log(value);
  return new MyPromise((resolve, reject) => {});
}


 

回调函数返回新的 Promise 暴露在最外层,这样就避免在回调里嵌套回调了。

错误冒泡

合并多个任务的错误处理,只需在最后用 catch 或 onReject 捕获异常。

执行完.then(resolved,rejected)后会判断 onReject_ 是否为函数,若是函数,错误时使用 rejected 处理错误;若不是,则错误时直接 throw 错误,一直传递到最后的捕获,若最后没有被捕获,则会触发 window 对象上的 unhandledrejection 事件,无情报错。

const promise = new Promise(function (resolve, reject) {
  resolve("ok");
  throw new Error("test");
});
promise
  .then(function (value) {
    console.log(value);
  })
  .catch(function (error) {
    console.log(error);
  });

若 Promise 状态已然变成 fulfilled,再 throw 就无法被 catch 到了。

promise.then 为什么是微任务

前一节提过:为了解决异步回调的嵌套问题,promise 采用了回调函数延迟绑定的方案。只不过上面采用了定时器来推迟 onResolve 的执行,在 Event Loop 中使用定时器的效率并不是太高,好在有微任务,所以 Promise 又把这个定时器改造成了微任务了,这样既可以让 onResolve_ 延时被调用,又提升了代码的执行效率。

生成器

什么是生成器函数

在生成器函数出现之前,JavaScript 函数具有完整运行(run-to-completion)特性。生成器函数是一个带星号函数,而且是可以暂停执行和恢复执行的。我们可以看下面这段代码:

function* genDemo() {
  console.log(" 开始执行第一段 ");
  yield "generator 1";

  console.log(" 开始执行第二段 ");
  yield "generator 2";

  console.log(" 开始执行第三段 ");
  yield "generator 3";

  console.log(" 执行结束 ");
  return "generator 4";
}

console.log("main 0");
let gen = genDemo();
console.log(gen.next().value);
console.log("main 1");
console.log(gen.next().value);
console.log("main 2");
console.log(gen.next().value);
console.log("main 3");
console.log(gen.next().value);
console.log("main 4");

执行过程涉及协程的概念,简而言之,协程是比线程更细的任务。

协程执行流程

  • 通过 genDemo 函数创建协程
  • 要让协程执行,需要调用 next()
  • 通过 yield 关键字来暂停 gen 协程的执行,并返回主要信息给父协程
  • 协程在执行期间,遇到了 return 关键字,JavaScript 引擎会结束当前协程,并将 return 后面的内容返回给父协程

协程间是如何切换的

gen 协程和父协程是在主线程上交互执行的,并不会并发执行,同一时刻只有一个协程在执行,它们通过 yield 和 gen.next 配合完成切换。

当在 gen 协程中调用了 yield 方法时,JavaScript 引擎会保存 gen 协程当前的调用栈信息,并恢复父协程的调用栈信息。同样,当在父协程中执行 gen.next 时,JavaScript 引擎会保存父协程的调用栈信息,并恢复 gen 协程的调用栈信息。

得益于生成器的线程控制权转移,利用生成器配合执行器,就能实现以同步的方式写异步代码。

async

有了 Promise 为什么还要 async

Promise 虽然解决了异步回调嵌套的问题,但多次链式 then 调用还是显得不够清晰。

以同步方式写异步代码,ES7 的 async 在生成器和 Promise 的基础上,提供了良好的解决方案。

  • await 后面的语句相当于放到了 new Promise 中
  • 下一行及之后的语句相当于放在 Promise.then 中
  • 如果 async 函数中抛出了错误,就会终止错误结果,不会继续向下执行
  • 如果 await 后面的 Promise 没有返回值,也就是它是 pending 状态,那么 await 之后的内容是不会执行的
async function async1() {
  await async2();
  console.log("async1");
  return "async1 success";
}
async function async2() {
  return new Promise((resolve, reject) => {
    console.log("async2");
    reject("error");
  });
}
async1().then((res) => console.log(res));

async 到底是什么

async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。

async function foo() {
  return 2;
}
console.log(foo());
// Promise {<resolved>: 2}




 

await 到底在等什么

async function foo() {
  console.log(1);
  let a = await 100;
  console.log(a);
  console.log(2);
}
console.log(0);
foo();
console.log(3);

当主线程执行到 foo 函数中的 await 100 这个语句时,JavaScript 引擎会暂停当前协程的执行,将主线程的控制权转交给父协程执行。await 会默认创建一个 Promise 对象 👇

let promise_ = new Promise((resolve,reject){
  resolve(100)
})

所以 await 等待的是一个承诺、promise 的结果。

Node

Node 架构

node架构

Node 中的事件循环

Node 把一次完整的事件循环 Tick 分成六个阶段,它们会按照顺序反复运行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。

Node中的事件循环

  • 定时器(Timers):执行到期的 setTimeoutsetInterval 定时器任务
  • 待定回调(Pending Callback):对某些系统操作(如 TCP 错误类型)执行回调,比如 TCP 连接时接收到ECONNREFUSED
  • Idle/Prepare:仅系统内部使用
  • 轮询(Poll):检索新的 I/O 事件;执行与 I/O 相关的回调
  • 检测(Check):setImmediate 回调函数在这里执行
  • Close callbacks:执行一些关闭回调,如:socket.on('close',...)

Node 中的宏任务和微任务

  • 微任务队列
    • next tick queue:process.nextTick;
    • other queue:Promise 的 then 回调、queueMicrotask;
  • 宏任务队列
    • timer queue:setTimeout、setInterval;
    • poll queue:IO 事件;
    • check queue:setImmediate;
    • close queue:close 事件;

Node 中任务的执行顺序

  1. next tick microtask queue;
  2. other microtask queue;
  3. timer queue;
  4. poll queue;
  5. check queue;
  6. close queue;

小结

参考: 深入了解 Promise 执行机制open in new window

  • 对事件循环的理解?
  • 浏览器和 Node 中的 Event Loop 有什么区别?
  • 怎么理解同步回调和异步回调?
  • node 中的事件循环机制?
  • 为什么 Promise.then 是微任务
小试牛刀
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");
  });
});