nodejs异步处理
我们先来看一个例子:
const fs = require('fs')
fs.readFile('a.txt', 'utf8', function(err, data) {
console.log(data)
})
这是一个从文件中读取文件的代码,fs.readFile
的第三个参数是个回调函数,当文件读取成功后,会调用改回调。
为什么会有回调函数
主要原因是因为js是单线程的,之所以是单线程,与它的用途有关。
js创立之初是作为浏览器脚本语言,主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定js同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,js就是单线程,这已经成了这门语言的核心特征,目前来看,这个特征将不会改变。
事件队列
既然是单线程,就会遇到一个问题,来看下一般的编程语言处理文件读取的方式(伪代码):
// 声明文件名
const filename = 'a.txt'
// 获取文件内容
const content = readFile(filename)
// 把文件内容按照空格分割成数组
const arr = content.split(' ')
// ......
当程序读取文件的时候,会进行一个漫长的IO操作,此时改线程处于等待状态,等待文件内容的返回,这导致我们程序的执行效率不高(因为有个等的过程)。
js采用了更加有效率的方式,使用了事件队列:
js的主线程在执行的时候,一旦发生了异步处理(文件读写、网络请求、定时任务等),一方面,js会请求操作系统让相关的部件(比如磁盘或者网卡等)处理这些异步事件,同时把这些异步处理包装成一个事件对象,放置到一个队列中(事件队列),然后继续执行后面的代码。
当主线程中的所有同步代码执行完毕后,js就会对事件队列进行循环检测,一旦某个事件被触发(比如网络请求返回数据了),js就会调用相应的事件对象里的处理函数,这个处理函数,就是回调函数。
注意,事件队列当中的事件,即使已经收到了事件完成的通知,也必须在js的主程序完成之后,才有机会被执行。
回到最开始的回调函数问题上,这里的回调函数,指的就是当事件完成时,需要执行的处理函数。
回调地狱
我们来考虑这样的一个例子,依次从a.txt
、b.txt
、c.txt
读取内容,并拼接在一起,要求串行:
const fs = require('fs')
fs.readFile('a.txt', 'utf8', function(err, dataa) {
fs.readFile('b.txt', 'utf8', function(err, datab) {
fs.readFile('c.txt', 'utf8', functioin(err, datac){
console.log(`${dataa}${datab}${datac}`)
})
})
})
可以看到,js的这种回调方式,对于处理异步的串行操作是很不优雅的,串行的异步越多,调用嵌套的越深,这种情况,我们称之为:回调地狱。
promise
Promise是异步编程的一种解决方案,可以帮助我们摆脱回调地狱的问题,Promise最早是由技术社区里面的一些散户提出并实现,后来官方觉得这个东西很不错,就把它纳入es6的标准中。
promise的代码风格如:
const fs = require('fs')
const readfilePromise = new Promise((resolve, reject) => {
fs.readFile('a.txt', 'utf8', function(err, data){
if (err) return reject(err)
resolve(data)
})
})
readfilePromise.then(data => console.log(data), err => console.log(err))
Promise可以认为是一个状态管理器,它有三种状态:pending
(进行中)、fulfilled
(已成功)和rejected
(已失败)状态切换的时机需要我们在构造的时候指定。
Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolve
和reject
,它们是两个函数,由js引擎内部提供,不用自己部署。
resolve
函数的作用是,将Promise对象的状态从“未完成”变为“成功”(即从pending
变为resolved
),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去;reject
函数的作用是,将Promise对象的状态从“未完成”变为“失败”(即从pending
变为rejected
),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。
Promise实例生成以后,可以用then
方法分别指定resolved
状态和rejected
状态的回调函数,并且回调函数分别可以拿到resolve
和rejected
传入的参数。
了解了promise之后,我们来写一个Promise版本的文件串行读取的实现:
// 依次从`a.txt`、`b.txt`、`c.txt`中读取文件
const fs = require('fs')
const aPromise = new Promise((resolve, reject) => {
fs.readFile('a.txt', 'utf8', function(err, data){
if (err) return reject(err)
resolve(data)
})
})
const bPromise = new Promise((resolve, reject) => {
fs.readFile('b.txt', 'utf8', function(err, data){
if (err) return reject(err)
resolve(data)
})
})
const cPromise = new Promise((resolve, reject) => {
fs.readFile('c.txt', 'utf8', function(err, data){
if (err) return reject(err)
resolve(data)
})
})
let content = ''
aPromise
.then(aContent => {
content += aContent
return bPromise
})
.then(bContent => {
content += bContent
return cPromise
})
.then(cContent => {
console.log(content + cContent)
})
再来看一个关于Promise的面试题目
// 请写出下面这段代码打印出来的内容
console.log(1)
const p = new Promise((resolve, reject) => {
console.log(2)
setTimeout(() => console.log(3), 0)
resolve(4)
})
p.then(v => console.log(v))
console.log(5)
promise实现原理
promise的实现原理是:
promise的
then
会被同步执行,then
中会将参数(onFulfilled
和onRejected
)包装成一个handler
,放到一个队列当中,当promise构造中的resolve
或reject
被调用的时候,会使用参数将队列里面的所有方法依次调用。
因此,我们来考虑一下下面的代码实际调用的流程:
var p = new Promise((resolve, reject) => {
resolve(3) // 这里是一定会异步操作
})
p.then(v => console.log(v))
p.then(v => console.log(v))
p.then(v => console.log(v))
console.log(4)
协程
在操作系统当中有两种概念,一个是进程,一个是线程,进程拥有独立的资源空间,也就是各个进程之间不共享资源,操作系统为了能够让多个进程“同时”运行,采用了时间片轮换机制,CPU某一时刻属于一个进程,下一时刻就切换到另外一个进程,当时间片足够短的时候,我们就可以感觉到,多个进程同时运行。
因为进程之间不共享资源,切换的代价就会比较的高,所以后来就设计了线程,一个进程可以含有多个线程,各个线程共享一个进程中的资源,线程的切换只是切换CPU中的执行代码,运行时所需要的资源几乎可以不用切换,大大提高了切换效率。
其实线程也有自己独立的资源,比如运行时创建的堆栈信息等,一般线程初始化的时候,会预分配1M左右的空间,用于存放这些资源,所以切换依然是有成本的。
目前比较流行的一种更加高效的方式是协程,协程是以多占用内存为代价,实现多任务的并行的,协程有多线程版本的,也有单线程版本的,以nodejs为例来说(js是单线程的),nodjes中的协程其实是多个可以并行执行的函数的协作。
怎么来理解这句话呢?传统的程序执行,采用堆栈式的执行方式,只有当调用的子函数完全执行完毕,才会结束执行父函数,执行信息保存在一个堆栈当中。
协程其实是突破了堆栈数的限制,主函数一个堆栈,每个异步的回调也有自己的堆栈,当程序执行发生异步的时候,执行权限可以切换给其他函数,因为堆栈信息一直保留在运行环境中(没有被切换出去),所以切换成本非常小,一般是直接修改执行函数的引用就可以完成切换。
nodejs协程的缺点
线程是基于时间片的,也就是说一个线程卡死,不会影响其他线程的执行,但是nodejs协程不同,一个协程卡死,将导致执行权限无法释放,导致其他的协程也无法执行。
go语言的协程
nodejs因为是单线程的,协程无法使用多核,也就是说,无法真正的并发执行多个函数。
go语言设计了多核版本的协程,也就是说,同一时间,多个协程可以被多个CPU真正的并发执行,或者你可以开多个线程,让多个线程去调度协程序,避免程序假死的问题。
因为go的优秀的协程处理机制,区块链优先采用了go作为开发语言。
async
es6中,为了实现协程,提供了Generator
函数和yield
关键字,但是语法比较繁琐,后来对其进行了包装,变成了async
函数和await
关键字,使用async
函数来实现串行文件读取操作:
const fs = require('fs')
const readFile = (fileName, encoding) => {
return new Promise((resolve, reject) => {
fs.readFile('a.txt', 'utf8', function(err, data){
if (err) return reject(err)
resolve(data)
})
})
}
async function readABC(encoding = 'utf8') {
const contenta = await readFile('a.txt', encoding)
const contentb = await readFile('b.txt', encoding)
const contentc = await readFile('c.txt', encoding)
return `${contenta}${contentb}${contentc}`
}