同步与异步
所谓的同步就是在执行某段代码时,在该代码没有得到返回结果之前,其他代码暂时是无法执行的,但是一旦执行完成拿到返回值之后,就可以执行其他代码了。换句话说,在此段代码执行完未返回结果之前,会阻塞之后的代码执行,这样的情况称为同步
所谓异步就是当某一代码执行异步过程调用发出后,这段代码不会立刻得到返回结果。而是在异步调用发出之后,一般通过回调函数处理这个调用之后拿到结果。异步调用发出后,不会影响阻塞后面的代码执行,这样的情形称为异步。
why
JavaScript 是单线程的,如果 JS 都是同步代码执行意味着什么呢?这样可能会造成阻塞,如果当前我们有一段代码需要执行时,如果使用同步的方式,那么就会阻塞后面的代码执行;而如果使用异步则不会阻塞,我们不需要等待异步代码执行的返回结果,可以继续执行该异步任务之后的代码逻辑。因此在 JS 编程中,会大量使用异步来进行编程
异步的发展
回调函数--> Promise--> Generator-->async/await
回调函数
早些年为了实现 JS 的异步编程,一般都采用回调函数的方式,比如比较典型的事件的回调,或者用 setTimeout/ setInterval 来实现一些异步编程的操作,但是使用回调函数来实现存在一个很常见的问题,那就是回调地狱
fs.readFile(A, "utf-8", function(err, data) {
fs.readFile(B, "utf-8", function(err, data) {
fs.readFile(C, "utf-8", function(err, data) {
fs.readFile(D, "utf-8", function(err, data) {
//....
});
});
});
});
回调实现异步编程的场景也有很多,比如:
- ajax 请求的回调;
- 定时器中的回调;
- 事件回调;
- Nodejs 中的一些方法回调。
异步回调如果层级很少,可读性和代码的维护性暂时还是可以接受,一旦层级变多就会陷入回调地狱,上面这些异步编程的场景都会涉及回调地狱的问题
Promise
为了解决回调地狱的问题,之后社区提出了 Promise 的解决方案,ES6 又将其写进了语言标准,采用 Promise 的实现方式在一定程度上解决了回调地狱的问题
function read(url) {
return new Promise((resolve, reject) => {
fs.readFile(url, "utf8", (err, data) => {
if (err) reject(err);
resolve(data);
});
});
}
read(A)
.then((data) => {
return read(B);
})
.then((data) => {
return read(C);
})
.then((data) => {
return read(D);
})
.catch((reason) => {
console.log(reason);
});
针对回调地狱进行这样的改进,可读性的确有一定的提升,优点是可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数,但是 Promise 也存在一些问题,即便是使用 Promise 的链式调用,如果操作过多,其实并没有从根本上解决回调地狱的问题,只是换了一种写法,可读性虽然有所提升,但是依旧很难维护。不过 Promise 又提供了一个 all 方法,对于这个业务场景的代码,用 all 来实现可能效果会更好
function read(url) {
return new Promise((resolve, reject) => {
fs.readFile(url, "utf8", (err, data) => {
if (err) reject(err);
resolve(data);
});
});
}
// 通过 Promise.all 可以实现多个异步并行执行,同一时刻获取最终结果的问题
Promise.all([read(A), read(B), read(C)])
.then((data) => {
console.log(data);
})
.catch((err) => console.log(err));
Generator
Generator 也是一种异步编程解决方案,它最大的特点就是可以交出函数的执行权,Generator 函数可以看出是异步任务的容器,需要暂停的地方,都用 yield 语法来标注。Generator 函数一般配合 yield 使用,Generator 函数最后返回的是迭代器
function* gen() {
let a = yield 111;
console.log(a);
let b = yield 222;
console.log(b);
let c = yield 333;
console.log(c);
let d = yield 444;
console.log(d);
}
let t = gen();
t.next(1); //第一次调用next函数时,传递的参数无效,故无打印结果
t.next(2); // a输出2;
t.next(3); // b输出3;
t.next(4); // c输出4;
t.next(5); // d输出5;
async/await
ES6 之后 ES7 中又提出了新的异步解决方案:async/await,async 是 Generator 函数的语法糖,async/await 的优点是代码清晰(不像使用 Promise 的时候需要写很多 then 的方法链),可以处理回调地狱的问题。async/await 写起来使得 JS 的异步代码看起来像同步代码,其实异步编程发展的目标就是让异步逻辑的代码看起来像同步一样容易理解
function testWait() {
return new Promise((resolve, reject) => {
setTimeout(function() {
console.log("testWait");
resolve();
}, 1000);
});
}
async function testAwaitUse() {
await testWait();
console.log("hello");
return 123; // 输出顺序:testWait,hello // 第十行如果不使用await输出顺序:hello , testWait
}
console.log(testAwaitUse());
执行上面的代码,从结果中可以看出,在正常的执行顺序下,testWait 这个函数由于使用的是 setTimeout 的定时器,回调会在一秒之后执行,但是由于执行到这里采用了 await 关键词,testAwaitUse 函数在执行的过程中需要等待 testWait 函数执行完成之后,再执行打印 hello 的操作。但是如果去掉 await ,打印结果的顺序就会变化。
因此,async/await 不仅仅是 JS 的异步编程的一种方式,其可读性也接近于同步代码,让人更容易理解。
js 异步编程方式 | 简单总结 |
---|---|
回调函数 | 早些年 js 异步编程采用的方式 |
Promise | ES6 新增异步编程方式,解决回调地狱问题 |
Generator | 和 yield 配合使用,返回的是迭代器 |
async/await | 二者配合使用,async 返回的是 Promise 对象,await 控制执行顺序 |
Promise
Promise 介绍
如果一定要解释 Promise 到底是什么,简单来说它就是一个容器,里面保存着某个未来才会结束的事件(通常是异步操作)的结果。从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。
Promise 提供统一的 API,各种异步操作都可以用同样的方法进行处理
function read(url) {
return new Promise((resolve, reject) => {
fs.readFile(url, "utf8", (err, data) => {
if (err) reject(err);
resolve(data);
});
});
}
read(A)
.then((data) => {
return read(B);
})
.then((data) => {
return read(C);
})
.then((data) => {
return read(D);
})
.catch((reason) => {
console.log(reason);
});
Promise 对象在被创建出来时是待定的状态,它让你能够把异步操作返回最终的成功值或者失败原因,和相应的处理程序关联起来。
一般 Promise 在执行过程中,必然会处于以下几种状态之一。
- 待定(pending):初始状态,既没有被完成,也没有被拒绝。
- 已完成(fulfilled):操作成功完成。
- 已拒绝(rejected):操作失败。
待定状态的 Promise 对象执行的话,最后要么会通过一个值完成,要么会通过一个原因被拒绝。当其中一种情况发生时,我们用 Promise 的 then 方法排列起来的相关处理程序就会被调用。因为最后 Promise.prototype.then 和 Promise.prototype.catch 方法返回的是一个 Promise, 所以它们可以继续被链式调用。
关于 Promise 的状态流转情况,有一点值得注意的是,内部状态改变之后不可逆
Promise 如何解决回调地狱
回调地狱有两个主要的问题:
- 多层嵌套的问题;
- 每种任务的处理结果存在两种可能性(成功或失败),那么需要在每种任务执行结束后分别处理这两种可能性。
这两种问题在“回调函数时代”尤为突出,Promise 的诞生就是为了解决这两个问题。Promise 利用了三大技术手段来解决回调地狱:回调函数延迟绑定、返回值穿透、错误冒泡
let readFilePromise = (filename) => {
return new Promise((resolve, reject) => {
fs.readFile(filename, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
};
readFilePromise("1.json").then((data) => {
return readFilePromise("2.json");
});
从上面的代码中可以看到,回调函数不是直接声明的,而是通过后面的 then 方法传入的,即延迟传入,这就是回调函数延迟绑定。接下来我们针对上面的代码做一下微调,如下所示
let x = readFilePromise("1.json").then((data) => {
return readFilePromise("2.json"); //这是返回的Promise
});
x.then(/* 内部逻辑省略 */);
我们根据 then 中回调函数的传入值创建不同类型的 Promise,然后把返回的 Promise 穿透到外层,以供后续的调用。这里的 x 指的就是内部返回的 Promise,然后在 x 后面可以依次完成链式调用。这便是返回值穿透的效果,这两种技术一起作用便可以将深层的嵌套回调写成下面的形式
readFilePromise("1.json")
.then((data) => {
return readFilePromise("2.json");
})
.then((data) => {
return readFilePromise("3.json");
})
.then((data) => {
return readFilePromise("4.json");
});
这样就显得清爽了许多,更重要的是,它更符合人的线性思维模式,开发体验也更好,两种技术结合产生了链式调用的效果。
这样解决了多层嵌套的问题,那另外一个问题,即每次任务执行结束后分别处理成功和失败的情况怎么解决的呢?Promise 采用了错误冒泡的方式
readFilePromise("1.json")
.then((data) => {
return readFilePromise("2.json");
})
.then((data) => {
return readFilePromise("3.json");
})
.then((data) => {
return readFilePromise("4.json");
})
.catch((err) => {
// xxx
});
这样前面产生的错误会一直向后传递,被 catch 接收到,就不用频繁地检查错误了。从上面的这些代码中可以看到,Promise 解决效果也比较明显:实现链式调用,解决多层嵌套问题;实现错误冒泡后一站式处理,解决每次任务中判断错误、增加代码混乱度的问题
Promise 的静态方法
all 方法
语法: Promise.all(iterable)
参数: 一个可迭代对象,如 Array。
描述: 此方法对于汇总多个 promise 的结果很有用,在 ES6 中可以将多个 Promise.all 异步请求并行操作,返回结果一般有下面两种情况。
当所有结果成功返回时按照请求顺序返回成功。
当其中有一个失败方法时,则进入失败方法。
我们来看下业务的场景,对于下面这个业务场景页面的加载,将多个请求合并到一起,用 all 来实现可能效果会更好,请看代码片段。
//1.获取轮播数据列表
function getBannerList() {
return new Promise((resolve, reject) => {
setTimeout(function() {
resolve("轮播数据");
}, 300);
});
}
//2.获取店铺列表
function getStoreList() {
return new Promise((resolve, reject) => {
setTimeout(function() {
resolve("店铺数据");
}, 500);
});
}
//3.获取分类列表
function getCategoryList() {
return new Promise((resolve, reject) => {
setTimeout(function() {
resolve("分类数据");
}, 700);
});
}
function initLoad() {
Promise.all([getBannerList(), getStoreList(), getCategoryList()])
.then((res) => {
console.log(res);
})
.catch((err) => {
console.log(err);
});
}
initLoad();
从上面代码中可以看出,在一个页面中需要加载获取轮播列表、获取店铺列表、获取分类列表这三个操作,页面需要同时发出请求进行页面渲染,这样用 Promise.all 来实现,看起来更清晰、一目了然。
allSettled 方法
Promise.allSettled 的语法及参数跟 Promise.all 类似,其参数接受一个 Promise 的数组,返回一个新的 Promise。唯一的不同在于,执行完之后不会失败,也就是说当 Promise.allSettled 全部处理完成后,我们可以拿到每个 Promise 的状态,而不管其是否处理成功。
我们来看一下用 allSettled 实现的一段代码。
const resolved = Promise.resolve(2);
const rejected = Promise.reject(-1);
const allSettledPromise = Promise.allSettled([resolved, rejected]);
allSettledPromise.then(function(results) {
console.log(results);
});
// 返回结果:
// [
// { status: 'fulfilled', value: 2 },
// { status: 'rejected', reason: -1 }
// ]
从上面代码中可以看到,Promise.allSettled 最后返回的是一个数组,记录传进来的参数中每个 Promise 的返回值,这就是和 all 方法不太一样的地方。你也可以根据 all 方法提供的业务场景的代码进行改造,其实也能知道多个请求发出去之后,Promise 最后返回的是每个参数的最终状态。
any 方法
语法: Promise.any(iterable)
参数: iterable 可迭代的对象,例如 Array。
描述: any 方法返回一个 Promise,只要参数 Promise 实例有一个变成 fulfilled 状态,最后 any 返回的实例就会变成 fulfilled 状态;如果所有参数 Promise 实例都变成 rejected 状态,包装实例就会变成 rejected 状态。
还是对上面 allSettled 这段代码进行改造,我们来看下改造完的代码和执行结果。
const resolved = Promise.resolve(2);
const rejected = Promise.reject(-1);
const allSettledPromise = Promise.any([resolved, rejected]);
allSettledPromise.then(function(results) {
console.log(results);
});
// 返回结果:
// 2
从改造后的代码中可以看出,只要其中一个 Promise 变成 fulfilled 状态,那么 any 最后就返回这个 Promise。由于上面 resolved 这个 Promise 已经是 resolve 的了,故最后返回结果为 2。
race 方法
语法: Promise.race(iterable)
参数: iterable 可迭代的对象,例如 Array。
描述: race 方法返回一个 Promise,只要参数的 Promise 之中有一个实例率先改变状态,则 race 方法的返回状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给 race 方法的回调函数。
我们来看一下这个业务场景,对于图片的加载,特别适合用 race 方法来解决,将图片请求和超时判断放到一起,用 race 来实现图片的超时判断。请看代码片段。
//请求某个图片资源
function requestImg() {
var p = new Promise(function(resolve, reject) {
var img = new Image();
img.onload = function() {
resolve(img);
};
img.src = "http://www.baidu.com/img/flexible/logo/pc/result.png";
});
return p;
}
//延时函数,用于给请求计时
function timeout() {
var p = new Promise(function(resolve, reject) {
setTimeout(function() {
reject("图片请求超时");
}, 5000);
});
return p;
}
Promise.race([requestImg(), timeout()])
.then(function(results) {
console.log(results);
})
.catch(function(reason) {
console.log(reason);
});
从上面的代码中可以看出,采用 Promise 的方式来判断图片是否加载成功,也是针对 Promise.race 方法的一个比较好的业务场景。
Promise 方法 | 简单总结 |
---|---|
all | 参数返回所有结果成功才返回 |
allSettled | 参数不论返回结果是否成功,都会返回每个参数执行状态 |
any | 参数中只要有一个成功,就返回该成功的执行结果 |
race | 顾名思义返回最先返回执行成功的参数的执行结果 |
Generator
Generator(生成器)是 ES6 的新关键词,学习起来比较晦涩难懂,那么什么是 Generator 的函数呢?通俗来讲 Generator 是一个带星号的“函数”(它并不是真正的函数,下面的代码会为你验证),可以配合 yield 关键字来暂停或者执行函数。我们来看一段使用 Generator 的代码
function* gen() {
console.log("enter");
let a = yield 1;
let b = yield (function() {
return 2;
})();
return 3;
}
var g = gen(); // 阻塞住,不会执行任何语句
console.log(typeof g); // 返回 object 这里不是 "function"
console.log(g.next());
console.log(g.next());
console.log(g.next());
console.log(g.next());
// output:
// { value: 1, done: false }
// { value: 2, done: false }
// { value: 3, done: true }
// { value: undefined, done: true }
Generator 中配合使用 yield 关键词可以控制函数执行的顺序,每当执行一次 next 方法,Generator 函数会执行到下一个存在 yield 关键词的位置。
总结下来,Generator 的执行有这几个关键点。
- 调用 gen() 后,程序会阻塞住,不会执行任何语句。
- 调用 g.next() 后,程序继续执行,直到遇到 yield 关键词时执行暂停。
- 一直执行 next 方法,最后返回一个对象,其存在两个属性:value 和 done。
这就是 Generator 的基本内容,其中提到了 yield 这个关键词,下面我们就来看看它的基本情况
yield
yield 同样也是 ES6 的新关键词,配合 Generator 执行以及暂停。yield 关键词最后返回一个迭代器对象,该对象有 value 和 done 两个属性,其中 done 属性代表返回值以及是否完成。yield 配合着 Generator,再同时使用 next 方法,可以主动控制 Generator 执行进度。
前面说 Generator 的时候,我举的是一个生成器函数的示例,下面我们看看多个 Generator 配合 yield 使用的情况
function* gen1() {
yield 1;
yield* gen2();
yield 4;
}
function* gen2() {
yield 2;
yield 3;
}
var g = gen1();
console.log(g.next());
console.log(g.next());
console.log(g.next());
console.log(g.next());
// output:
// { value: 1, done: false }
// { value: 2, done: false }
// { value: 3, done: false }
// { value: 4, done: false }
// {value: undefined, done: true}
使用 yield 关键词的话还可以配合着 Generator 函数嵌套使用,从而控制函数执行进度。这样对于 Generator 的使用,以及最终函数的执行进度都可以很好地控制,从而形成符合你设想的执行顺序。即便 Generator 函数相互嵌套,也能通过调用 next 方法来按照进度一步步执行
Generator 和异步编程有什么联系?怎么才可以把 Generator 函数按照顺序一次性执行完呢?
thunk 函数介绍
通过一段代码来了解一下什么是 thunk 函数,就拿判断数据类型来举例
let isString = (obj) => {
return Object.prototype.toString.call(obj) === '[object String]';
};
let isFunction = (obj) => {
return Object.prototype.toString.call(obj) === '[object Function]';
};
let isArray = (obj) => {
return Object.prototype.toString.call(obj) === '[object Array]';
};
....
可以看到,其中出现了非常多重复的数据类型判断逻辑,平常业务开发中类似的重复逻辑的场景也同样会有很多。我们将它们做一下封装,如下所示。
let isType = (type) => {
return (obj) => {
return Object.prototype.toString.call(obj) === `[object ${type}]`;
};
};
那么封装了之后我们可以这么来使用,从而来减少重复的逻辑代码,如下所示。
let isString = isType("String");
let isArray = isType("Array");
isString("123"); // true
isArray([1, 2, 3]); // true
相应的 isString 和 isArray 是由 isType 方法生产出来的函数,通过上面的方式来改造代码,明显简洁了不少。像 isType 这样的函数我们称为 thunk 函数,它的基本思路都是接收一定的参数,会生产出定制化的函数,最后使用定制化的函数去完成想要实现的功能。
这样的函数在 JS 的编程过程中会遇到很多,尤其是你在阅读一些开源项目时,抽象度比较高的 JS 代码往往都会采用这样的方式。
Generator 和 thunk 函数的结合是否能为我们带来一定的便捷性呢?
Generator 和 thunk 结合
const readFileThunk = (filename) => {
return (callback) => {
fs.readFile(filename, callback);
};
};
const gen = function*() {
const data1 = yield readFileThunk("1.txt");
console.log(data1.toString());
const data2 = yield readFileThunk("2.txt");
console.log(data2.toString);
};
let g = gen();
g.next().value((err, data1) => {
g.next(data1).value((err, data2) => {
g.next(data2);
});
});
readFileThunk 就是一个 thunk 函数,上面的这种编程方式就让 Generator 和异步操作关联起来了。上面第三段代码执行起来嵌套的情况还算简单,如果任务多起来,就会产生很多层的嵌套,可读性不强,因此我们有必要把执行的代码封装优化一下
function run(gen) {
const next = (err, data) => {
let res = gen.next(data);
if (res.done) return;
res.value(next);
};
next();
}
run(g);
改造完之后,我们可以看到 run 函数和上面的执行效果其实是一样的。代码虽然只有几行,但其包含了递归的过程,解决了多层嵌套的问题,并且完成了异步操作的一次性的执行效果。这就是通过 thunk 函数完成异步操作的情况
Generator 和 Promise 结合
// 最后包装成 Promise 对象进行返回
const readFilePromise = (filename) => {
return new Promise((resolve, reject) => {
fs.readFile(filename, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
}).then((res) => res);
};
// 这块和上面 thunk 的方式一样
const gen = function*() {
const data1 = yield readFilePromise("1.txt");
console.log(data1.toString());
const data2 = yield readFilePromise("2.txt");
console.log(data2.toString);
};
// 这块和上面 thunk 的方式一样
function run(gen) {
const next = (err, data) => {
let res = gen.next(data);
if (res.done) return;
res.value.then(next);
};
next();
}
run(g);
thunk 函数的方式和通过 Promise 方式执行效果本质上是一样的,只不过通过 Promise 的方式也可以配合 Generator 函数实现同样的异步操作
co 函数库
co 函数库是著名程序员 TJ 发布的一个小工具,用于处理 Generator 函数的自动执行。核心原理其实就是上面讲的通过和 thunk 函数以及 Promise 对象进行配合,包装成一个库,它使用起来非常简单,比如还是用上面那段代码,第三段代码就可以省略了,直接引用 co 函数,包装起来就可以使用了
const co = require("co");
let g = gen();
co(g).then((res) => {
console.log(res);
});
那么为什么 co 函数库可以自动执行 Generator 函数,它的处理原理是什么呢?
- 因为 Generator 函数就是一个异步操作的容器,它需要一种自动执行机制,co 函数接受 Generator 函数作为参数,并最后返回一个 Promise 对象。
- 在返回的 Promise 对象里面,co 先检查参数 gen 是否为 Generator 函数。如果是,就执行该函数;如果不是就返回,并将 Promise 对象的状态改为 resolved。
- co 将 Generator 函数的内部指针对象的 next 方法,包装成 onFulfilled 函数。这主要是为了能够捕捉抛出的错误。
- 关键的是 next 函数,它会反复调用自身。
终极解决方案 async/await
JS 的异步编程从最开始的回调函数的方式,演化到使用 Promise 对象,再到 Generator+co 函数的方式,每次都有一些改变,但又让人觉得不彻底,都需要理解底层运行机制。
而 async/await 被称为 JS 中异步终极解决方案,它既能够像 co+Generator 一样用同步的方式来书写异步代码,又得到底层的语法支持,无须借助任何第三方库
// readFilePromise 依旧返回 Promise 对象
const readFilePromise = (filename) => {
return new Promise((resolve, reject) => {
fs.readFile(filename, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
}).then((res) => res);
};
// 这里把 Generator的 * 换成 async,把 yield 换成 await
const gen = async function() {
const data1 = await readFilePromise("1.txt");
console.log(data1.toString());
const data2 = await readFilePromise("2.txt");
console.log(data2.toString);
};
虽然我们简单地将 Generator 的 *
号换成了 async,把 yield 换成了 await,但其实 async 的内部做了不少工作。我们根据 async 的原理详细拆解一下,看看它到底做了哪些工作。
总结下来,async 函数对 Generator 函数的改进,主要体现在以下三点。
-
内置执行器:Generator 函数的执行必须靠执行器,因为不能一次性执行完成,所以之后才有了开源的 co 函数库。但是,async 函数和正常的函数一样执行,也不用 co 函数库,也不用使用 next 方法,而 async 函数自带执行器,会自动执行。
-
适用性更好:co 函数库有条件约束,yield 命令后面只能是 Thunk 函数或 Promise 对象,但是 async 函数的 await 关键词后面,可以不受约束。
-
可读性更好:async 和 await,比起使用 * 号和 yield,语义更清晰明了。
说了这么多优点,我们还是通过一段简单的代码来看下 async 返回的结果,是不是使用起来更方便
async function func() {
return 100;
}
console.log(func());
// Promise {<fulfilled>: 100}
async 函数 func 最后返回的结果直接是 Promise 对象,比较方便让开发者继续往后处理。而之前 Generator 并不会自动执行,需要通过 next 方法控制,最后返回的也并不是 Promise 对象,而是需要通过 co 函数库来实现最后返回 Promise 对象
这样看来,ES7 加入的 async/await 的确解决了之前的问题,使开发者在编程过程中更容易理解,语法更清晰,并且也不用再单独引用 co 函数库了。因此用 async/await 写出的代码也更加优雅,相比于之前的 Promise 和 co+Generator 的方式更容易理解,上手成本也更低,不愧是 JS 异步的终极解决方案
异步编程方法 | 特点 |
---|---|
Generator | 生成器函数配合着 yield 关键词使用,不自动执行,需要执行 next 方法一步一步往下执行 |
Generator+co | 通过引入开源 co 函数库,实现异步编程,并且还能控制返回结果为 Promise 对象,方便后续继续操作,但要求 yield 后面,只能是 thunk 函数或者 Promise 对象 |
async/await | ES7 引入的终极异步编程解决方案,不用引入去其它任何库,对于 await 后面的类型无限制,可读性更好,容易理解 |
Nodejs 中的异步
在听到 nodejs 相关的特性时,经常会对 异步 I/O、非阻塞 I/O 有所耳闻,听起来好像是差不多的意思,但其实是两码事,下面我们就以原理的角度来剖析一下对 nodejs 来说,这两种技术底层是如何实现的
什么是 I/O
首先,我想有必要把 I/O 的概念解释一下。I/O 即 Input/Output, 输入和输出的意思。在浏览器端,只有一种 I/O,那就是利用 Ajax 发送网络请求,然后读取返回的内容,这属于网络 I/O。回到 nodejs 中,其实这种的 I/O 的场景就更加广泛了,主要分为两种:
- 文件 I/O。比如用 fs 模块对文件进行读写操作
- 网络 I/O。比如 http 模块发起网络请求
阻塞和非阻塞 I/O
阻塞和非阻塞 I/O 其实是针对操作系统内核而言的,而不是 nodejs 本身。阻塞 I/O 的特点就是一定要等到操作系统完成所有操作后才表示调用结束,而非阻塞 I/O 是调用后立马返回,不用等操作系统内核完成操作。
对前者而言,在操作系统进行 I/O 的操作的过程中,我们的应用程序其实是一直处于等待状态的,什么都做不了。那如果换成非阻塞 I/O,调用返回后我们的 nodejs 应用程序可以完成其他的事情,而操作系统同时也在进行 I/O。这样就把等待的时间充分利用了起来,提高了执行效率,但是同时又会产生一个问题,nodejs 应用程序怎么知道操作系统已经完成了 I/O 操作呢?
为了让 nodejs 知道操作系统已经做完 I/O 操作,需要重复地去操作系统那里判断一下是否完成,这种重复判断的方式就是轮询。对于轮询而言,有以下这么几种方案:
-
一直轮询检查 I/O 状态,直到 I/O 完成。这是最原始的方式,也是性能最低的,会让 CPU 一直耗用在等待上面。其实跟阻塞 I/O 的效果是一样的。
-
遍历文件描述符(即 文件 I/O 时操作系统和 nodejs 之间的文件凭证)的方式来确定 I/O 是否完成,I/O 完成则文件描述符的状态改变。但 CPU 轮询消耗还是很大。
-
epoll 模式。即在进入轮询的时候如果 I/O 未完成 CPU 就休眠,完成之后唤醒 CPU。
总之,CPU 要么重复检查 I/O,要么重复检查文件描述符,要么休眠,都得不到很好的利用,我们希望的是:
nodejs 应用程序发起 I/O 调用后可以直接去执行别的逻辑,操作系统默默地做完 I/O 之后给 nodejs 发一个完成信号,nodejs 执行回调操作。
这是理想的情况,也是异步 I/O 的效果,那如何实现这样的效果呢?
异步 I/O 的本质
Linux 原生存在这样的一种方式,即(AIO), 但两个致命的缺陷:
- 只有 Linux 下存在,在其他系统中没有异步 I/O 支持。
- 无法利用系统缓存。
nodejs 中的异步 I/O 方案
是不是没有办法了呢?在单线程的情况下确实是这样,但是如果把思路放开一点,利用多线程来考虑这个问题,就变得轻松多了。我们可以让一个进程进行计算操作,另外一些进行 I/O 调用,I/O 完成后把信号传给计算的线程,进而执行回调,这不就好了吗?没错,异步 I/O 就是使用这样的线程池来实现的。
只不过在不同的系统下面表现会有所差异,在 Linux 下可以直接使用线程池来完成,在 Window 系统下则采用 IOCP 这个系统 API(其内部还是用线程池完成的)。
有了操作系统的支持,那 nodejs 如何来对接这些操作系统从而实现异步 I/O 呢?
以文件为 I/O 我们以一段代码为例:
let fs = require("fs");
fs.readFile("/test.txt", function(err, data) {
console.log(data);
});
执行流程
执行代码的过程中大概发生了这些事情:
- 首先,fs.readFile 调用 Node 的核心模块 fs.js
- 接下来,Node 的核心模块调用内建模块 node_file.cc,创建对应的文件 I/O 观察者对象(这个对象后面有大用!)
- 最后,根据不同平台(Linux 或者 window),内建模块通过 libuv 中间层进行系统调用
libuv 调用过程拆解
重点来了!libuv 中是如何来进行进行系统调用的呢?也就是 uv_fs_open() 中做了些什么?
- 创建请求对象
以 Windows 系统为例来说,在这个函数的调用过程中,我们创建了一个文件 I/O 的请求对象,并往里面注入了回调函数。
req_wrap->object_->Set(oncomplete_sym, callback);
req_wrap
便是这个请求对象,req_wrap
中 object_
的 oncomplete_sym
属性对应的值便是我们 nodejs 应用程序代码中传入的回调函数。
- 推入线程池,调用返回
在这个对象包装完成后,QueueUserWorkItem() 方法将这个对象推进线程池中等待执行。
好,至此现在 js 的调用就直接返回了,我们的 js 应用程序代码可以继续往下执行,当然,当前的 I/O 操作同时也在线程池中将被执行,这不就完成了异步么:)
等等,别高兴太早,回调都还没执行呢!接下来便是执行回调通知的环节。
- 回调通知
事实上现在线程池中的 I/O 无论是阻塞还是非阻塞都已经无所谓了,因为异步的目的已经达成。重要的是 I/O 完成后会发生什么。
在介绍后续的故事之前,给大家介绍两个重要的方法: GetQueuedCompletionStatus 和 PostQueuedCompletionStatus。
在每一个 Tick(一轮事件循环) 当中会调用 GetQueuedCompletionStatus 检查线程池中是否有执行完的请求,如果有则表示时机已经成熟,可以执行回调了。
PostQueuedCompletionStatus 方法则是向 IOCP 提交状态,告诉它当前 I/O 完成了。
当对应线程中的 I/O 完成后,会将获得的结果存储起来,保存到相应的请求对象中,然后调用 PostQueuedCompletionStatus()向 IOCP 提交执行完成的状态,并且将线程还给操作系统。一旦 EventLoop 的轮询操作中,调用 GetQueuedCompletionStatus 检测到了完成的状态,就会把请求对象塞给 I/O 观察者(之前埋下伏笔,如今终于闪亮登场)。
I/O 观察者现在的行为就是取出请求对象的存储结果,同时也取出它的 oncomplete_sym 属性,即回调函数(不懂这个属性的回看第 1 步的操作)。将前者作为函数参数传入后者,并执行后者。
这里,回调函数就成功执行啦!
- 阻塞和非阻塞 I/O 其实是针对操作系统内核而言的。阻塞 I/O 的特点就是一定要等到操作系统完成所有操作后才表示调用结束,而非阻塞 I/O 是调用后立马返回,不用等操作系统内核完成操作。
- nodejs 中的异步 I/O 采用多线程的方式,由 EventLoop、I/O 观察者,请求对象、线程池四大要素相互配合,共同实现。