当前位置: 首页 > 科技观察

使用Node.js的AsyncHooks模块跟踪异步资源

时间:2023-03-12 21:12:34 科技观察

作者简介:MayJun,软件设计师,公众号《Nodejs技术栈》作者。AsyncHooks功能是Node.jsv8.x版本新增的核心模块。它提供了一个API来跟踪Node.js程序中异步资源的声明周期,并且可以在多个异步调用之间共享数据。这篇文章从最基础的介绍章节开始,之后会有特定场景下的具体应用实践介绍。executionAsyncId和triggerAsyncIdasync钩子模块提供了executionAsyncId()函数来标记当前执行上下文的异步资源Id,下面用asyncId表示。还有一个triggerAsyncId()函数,用来标记当前执行上下文触发的异步资源Id,即当前异步资源是由哪个异步资源创建的。每个异步资源都会生成一个asyncId,这个asyncId是增量生成的,在当前的Node.js实例中是全局唯一的。constasyncHooks=require('async_hooks');constfs=require('fs');constasyncId=()=>asyncHooks.executionAsyncId();consttriggerAsyncId=()=>asyncHooks.triggerAsyncId();console.log(`GlobalasyncId:${asyncHooks.executionAsyncId()},GlobaltriggerAsyncId:${triggerAsyncId()}`);fs.open('hello.txt',(err,res)=>{console.log(`fs.openasyncId:${asyncId()},fs.opentriggerAsyncId:${triggerAsyncId()}`);});下面是我们运行的结果,全局asyncId为1,fs.open回调中打印的triggerAsyncId为1,全局触发。GlobalasyncId:1,GlobaltriggerAsyncId:0fs.openasyncId:5,fs.opentriggerAsyncId:1默认不开启Promise执行跟踪默认情况下,由于V8提供的promiseintrospectionAPI的相对性能消耗,一个Promise的执行是没有赋值的一个异步ID。这意味着默认情况下,使用Promise或Async/Await的程序将无法正确执行和触发Promise回调上下文ID。即获取不到当前异步资源的asyncId和创建当前异步资源的异步资源创建的triggerAsyncId,如下图:Promise.resolve().then(()=>{//PromiseasyncId:0.consthooks=asyncHooks.createHook({});hooks.enable();Promise.resolve().then(()=>{//PromiseasyncId:7.PromisetriggerAsyncId:6console.log(`PromiseasyncId:${asyncId()}./destory四个回调来标记一个异步资源从初始化,回调调用前,回调调用后,到销毁整个生命周期过程。init(初始化)在构造可能发出异步事件的类时调用。async:异步资源的唯一idtype:异步资源的类型,对应资源的构造函数名。Formoretypes,refertoasync_hooks_typetriggerAsyncId:theasynchronousresourceidcreatedbywhichasynchronousresourcethecurrentasynchronousresourceiscreatedby.*@paramasyncIdauniqueIDfortheasyncresource*@paramtypethetypeoftheasyncresource*@paramtriggerAsyncIdtheuniqueIDoftheasyncresourceinwhoseexecutioncontextthisasyncresourcewascreated*@paramresourcereferencetotheresourcerepresentingtheasyncoperation,needstobereleasedduringdestroy*/init?(asyncId:number,type:string,triggerAsyncId:number,resource:object):void;before(回调数调用前)当启动异常操作(比如TCP服务器接收到一个新的连接)或者完成一个异步操作(比如写数据到磁盘),系统会调用回调通知用户,也就是我们写的业务回调函数。在此之前会触发before回调。/***当异步操作被启动或完成时回调被调用以通知用户。*调用之前的回调只是在执行之前所说的回调。*@paramasyncIdtheuniqueidentifierassignedtotheresourceabouttoexecutethecallback.*/before?(asyncId:number):void;after(aftercallbackisnotcalled,ifthecallbackisnotcalled,triggertheafterTheaftercallbackisfiredafteruncaughtException事件被触发或在域处理之后。/***Calledimmediatelyafterthecallbackspecifiedinbeforeiscompleted.*@paramasyncIdtheuniqueidentifierassignedtotheresourcewhichhasecutedthecallback.*/after?(asyncId:number):void;destory(destroy)在asyncId对应的异步资源被销毁,有些资源的销毁依赖于垃圾回收,所以如果有资源对象的引用传递给init回调,有可能永远不会调用destroy,造成应用内存泄漏。这不是专业人士如果资源不依赖于垃圾收集,就会出现问题。/***CalledaftertheresourcecorrespondingtoasyncIdisdestroyed*@paramasyncIdauniqueIDfortheasyncresource*/destroy?(asyncId:number):void;promiseResolve当传递给Promise构造函数的resolve()函数执行时触发promiseResolve回调。/***Calledwhenapromisehasresolve()called.Thismaynotbeinthesameexecutionid*asthepromiseitself.*@paramasyncIdtheuniqueidforthepromisethatwasresolve()d.*/promiseResolve?(asyncId:number):void;下面的代码会触发两个promiseResolve()回调,第一次我们直接调用resolve()函数,第二次是在.then()中,虽然我们没有展示调用,但是它也会返回一个Promise所以它会被再次调用。consthooks=asyncHooks.createHook({promiseResolve(asyncId){syncLog('promiseResolve:',asyncId);}});newPromise((resolve)=>resolve(true)).then((a)=>{});//输出结果promiseResolve:2promiseResolve:3注意在init回调中写入日志会导致“栈溢出”问题异步资源生命周期的第一阶段。在构造可能发出异步事件的类时调用init回调。注意由于使用console.log()向控制台输出日志是一个异步操作,在AsyncHooks回调函数中使用类似的异步操作会再次触发init回调函数,导致出现RangeError:Maximumcallstacksizeexceeded错误无限递归,也就是“栈溢出”。调试时,一个简单的日志方式是使用fs.writeFileSync()同步写入日志,不会触发AsyncHooks的init回调函数。constsyncLog=(...args)=>fs.writeFileSync('log.txt',`${util.format(...args)}\n`,{flag:'a'});consthooks=asyncHooks。createHook({init(asyncId,type,triggerAsyncId,resource){syncLog('init:',asyncId,type,triggerAsyncId)}});hooks.enable();fs.open('hello.txt',(err,res)=>{syncLog(`fs.openasyncId:${asyncId()},fs.opentriggerAsyncId:${triggerAsyncId()}`);});输出如下内容,init回调只会被调用一次,因为fs.writeFileSync是同步的,不会触发钩子回调。init:2FSREQCALLBACK1fs.openasyncId:2,fs.opentriggerAsyncId:1异步共享上下文Node.jsv13.10.0增加了async_hooks模块的AsyncLocalStorage类,可用于在一系列异步调用中共享数据。如下例所示,asyncLocalStorage.run()函数的第一个参数是存放我们在异步调用中需要访问的共享数据,第二个参数是一个异步函数,我们在回调函数中再次调用setTimeout()test2函数,这一系列的异步操作不会影响我们在需要的地方获取到asyncLocalStorage.run()函数中存储的共享数据。const{AsyncLocalStorage}=require('async_hooks');constasyncLocalStorage=newAsyncLocalStorage();asyncLocalStorage.run({traceId:1},test1);asyncfunctiontest1(){setTimeout(()=>test2(),2000);}asyncfunctiontest2(){console.log(asyncLocalStorage.getStore().traceId);}AsyncLocalStorage有很多用处,比如服务器端必不可少的日志分析,如果从request到response的一个HTTP日志输出输出到整个系统交互就可以了通过一个traceId得到关联,在分析日志的时候,可以清楚的看到整个调用链路。下面是一个HTTP请求的简单例子,模拟异步处理,在日志输出中跟踪存储的id;functionlogWithId(msg){constid=asyncLocalStorage.getStore();console.log(`${id!==undefined?id:'-'}:`,msg);}letidSeq=0;http.createServer((req,res)=>{asyncLocalStorage.run(idSeq++,()=>{logWithId('start');setImmediate(()=>{logWithId('processing...');setTimeout(()=>{logWithId('finish');res.end();},2000)});});}).listen(8080);下面是运行结果,我在第一次调用后直接调用了第二次,可以看到我们存储的ID信息已与我们的日志一起成功打印。image.png下一节会详细介绍如何使用Node.js中asynchooks模块的AsyncLocalStorage类来处理请求上下文,同时也会详细说明AsyncLocalStorage类是如何实现本地存储的。参考https://nodejs.org/dist/latest-v14.x/docs/api/async_hooks.html