为什么 JS 是单线程?

众所周知,Javascript 语言的执行环境是”单线程”(single thread)。

所谓”单线程”,就是指一次只能完成一件任务。如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务,以此类推。

而浏览器是多线程的,JS 线程就是其中一个:

  • 浏览器 GUI 渲染线程
  • JavaScript 引擎线程
  • 浏览器定时触发器线程
  • 浏览器事件触发线程
  • 浏览器 http 异步请求线程

浏览器线程知识中重要的一点是:

GUI 渲染进程和 JavaScript 引擎进程是互斥的,因为如果这两个线程可以同时运行的话, JavaScript 的 DOM 操作将会扰乱渲染线程执行渲染前后的数据一致性。而且如果 DOM 一变化,界面就立刻重新渲染,效率必然很低

所以 JS 主线程执行任务时,浏览器渲染线程处于挂起状态。

同理,如果 JS 采用多线程同步的模型,那么如何保证同一时间修改了 DOM, 到底是哪个线程先生效呢?从操作系统调度多线程的上下文开销,到实际编程里的锁、线程同步等问题,都让开发变得比较困难。

所以 JS 最终采用了单线程的事件模型。

我之前的文章《JS 专题之事件循环》也有讲过这块内容,欢迎翻阅。

一、同步与异步

单线程模式这种排队执行的好处是实现起来比较简单,执行环境相对单纯;坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段 Javascript 代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。

为了解决这个问题,Javascript 语言将任务的执行模式分成两种:同步(Synchronous)和异步(Asynchronous)。

那同步和异步的区别是什么?

我们想象一个很常见的场景:我们去面馆吃牛肉面,柜台人很多,前面在排队下单。

这个时候,同步就是,收银员收了你的钱,告诉你要在柜台站着等面煮好,煮好后,就端面开吃,后面的人也只能等前面的人面煮好了才能付款下单然后等着面煮好端走~

而异步就是,收银员收了你的钱,然后给了你一张小票,小票上有一个你的编号,收银员告诉你,可以去座位上,你的面一煮好,会大声叫你,你就来端面开吃。

我们可以看出,我们是过程的调用者,面馆是被调用者,牛肉面煮好,是我们想要的结果,同步是调用者需要主动地等待这个结果。异步是被动的等待结果,当被调用者有结果了,就会通过消息机制或者回调机制告诉调用者结果。

同步和异步关注的是消息通信机制,同步就是在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。

而异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果, 而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。

以上:

  • 下单吃面是发起调用函数
  • 端面开吃的回调函数
  • 煮好的面是调用的结果,也是回调函数的参数

将例子抽象成伪代码:

1
2
3
4
5
6
orderNoodle("牛肉面", function(noodle) {
// 端面
getNoodle();
// 吃面
eatNoodle();
});

三、事件循环

关于事件循环如何执行异步代码可以翻阅前面的文章《JS 专题之事件循环》,这里大概提一下。

如果遇到异步事件,JS 引擎会把事件函数压入执行调用栈,但浏览器识别到它是异步事件后,会将其弹出执行栈,当异步函数有返回结果后,JS 引擎将异步事件的回调函数放入事件队列中,如果执行调用栈为空,就将回调函数压入执行调用栈执行。

四、回调函数

在 JavaScript 中,函数 function 作为一等公民,使用上非常自由,无论调用它,或者作为参数,或者作为返回值都可以。

因为单线程异步的特点,后来在 JS 中,慢慢将函数的业务重点转移到了回调函数中。

1
2
3
4
5
6
7
8
9
10
function step1(cb) {
console.log("step1");
cb();
}

function step2() {
console.log("step2");
}

step1(step2); // step1 step2

代码会按先后顺序执行 step1, step2。

现在假设我们有这样的需求:请求文件 1 后,获取文件 1 中的数据后请求文件 2,获取文件 2 中的数据后,又请求文件三。

1
2
3
4
5
6
7
var fs = require("fs");

fs.readFile("./file1.json", function(err, data1) {
fs.readFile("./file2.json", function(err, data2) {
fs.readFile("./file3.json", function(err, data3) {});
});
});

五、回调函数的问题

由第四节可以看出,回调函数的写法存在很多问题。

  1. 回调地狱(洋葱模型)

当多个异步事务多级依赖时,回调函数会形成多级的嵌套,被花括号一层层包括,代码变成
金字塔型结构,也被称为回调地狱和洋葱模型。

在回调地狱的情况下,代码逻辑的梳理,流程的控制,代码封装维护,错误处理都变得越来越困难。

  1. 异常处理

try…catch 是被设计成捕获当前执行环境的异常,意思是只能捕获同步代码里面的异常,异步调用里面的异常无法捕获。

1
2
3
4
5
6
7
8
9
10
11
function readFile(fileName) {
setTimeout(function() {
throw new Error("类型错误");
}, 1000);
}
try {
readFile("./file1.json");
} catch (e) {
// 如果异步事件出错,打印不出来错误信息
console.log("err", e);
}

在 nodejs 对回调函数采用 error first 的思想,回调函数的第一个参数保留给一个错误 error 对象,如果有错误发生,错误将通过第一个参数 err 返回。

原因是一个有回调函数的函数,执行分两段,第一段执行完之后,任务所在的上下文环境就已经结束了。在这以后抛出的错误,原来的上下文已经无法捕捉,只能当做参数,传入第二阶段。

1
2
3
4
5
6
fs.readFile("/etc/passwd", "utf8", function(err, data) {
if (err) {
console.log(err);
return;
}
});

总结

回调函数是 JS 异步编程中的基石,但同时也存在很多问题,不太适合人类自然语言的线性思维习惯。

接下来几篇文章,我将梳理 JS 中异步编程中的历史演进中 Promise, generator, async&await 相关的内容,欢迎关注。