Javascript与CLS(Continuation-local Storage)

4,025 阅读8分钟

前言

最近在做公司serverless相关需求的时候,需要封装调用链上报的组件,在传入traceId和userId等上下文信息时,需要从框架层逐层往下传递,比如打印一个log,需要这样:

// 基于koa的某个工具包内部
log.info('这是一个log', req)

所有需要上下文的地方都需要传入,导致代码严重耦合,我们有什么办法可以优雅的解决这个问题呢?

一个简单的http server

const http = require('http');

//create a server object:
http.createServer(function (req, res) {
    logicA(req, res)
}).listen(8080); //the server object listens on port 8080

function logicA(req, res) {
    logicA1(req, res)
}
// 无所不在req, res
function logicA1(req, res) {
    res.write('Hello World!'); //write a response to the client
    res.end(); //end the response   
}

无所不在的req res的透传

其他语言是怎么处理的呢?

以java为例,java中有一个功能叫局部线程存储(Thread-local Storage)例如在某些网络模型中比如当一个请求来的时候(本人对java了解不多,不详细展开),程序会在线程池里分配一个线程去处理这个请求,在这个线程中有局部变量是当前请求线程内共享的,线程内都能访问的。

Continuation-local Storage与TLS类似,不过是基于Nodejs风格的回调调用。它得名于函数式编程中的Continuation-passing style,旨在链式函数调用过程中维护一个持久的数据。

从浏览器入手

直接讲Node可能有的同学不理解,我们可以从浏览器举例。Node web server的一次请求,其实也是一个事件,可以类比浏览器的一次点击事件。在浏览器端,我们处理复杂逻辑的时候,可能会遇到以下的代码

<html>
<header></header>
<body>
<button id="button" />
<script>
button.addEventListener('click', event => {
    logicA(event)
    // 其他处理逻辑
})
function logicA(event) {
    logicA1(event)
    // 其他处理逻辑
}
// logicA1 maybe in other module
function logicA1(event) {
    console.log(`x: ${event.x}, y: ${event.y}`)
}

效果如下

demo
有同学可能会想,既然event无处不在,那我放在全局变量上不就好了?代码如下

const gloabContext = {}
button.addEventListener('click', event => {
    gloabContext.event = event
    logicA()
    // 其他处理逻辑
})
function logicA() {
    logicA1()
    // 其他处理逻辑
}
// logicA1 maybe in other module
function logicA1() {
    console.log(`x: ${gloabContext.event.x}, y: ${gloabContext.event.y}`)
}

globalContext和我们透传的event一致,看起来好像也没啥问题

其实问题很大

这个例子之所以没问题,是因为我们的逻辑函数是同步的,如果我们加入异步逻辑会发生什么事呢?

const gloabContext = {}
button.addEventListener('click', event => {
    gloabContext.event = event
    logicA(event)
    // 其他处理逻辑
})
async function logicA(event) {
    await delay(3000)
    logicA1(event)
    // 其他处理逻辑
}
// logicA1 maybe in other module
function logicA1(event) {
    console.log(`gloabContext x: ${gloabContext.event.x}, y: ${gloabContext.event.y}`)
    console.log(`event x: ${event.x}, y: ${event.y}`)
    console.log('\n')
}
async function delay(timeout) {
    return new Promise(resolve => {
        setTimeout(() => {
            resolve(true)
        }, timeout);
    })
}

效果如下:

demo3
可以发现 globalContext中的值已经和我们期望保存的event不一致了。我们需要的是一个可以关联异步操作的数据,任意的异步操作可以访问这些数据,却互不影响。

浏览器的解决方案Zone.js

Zone.js 是Angular团队在Angular2中引入的,google团队Zones可以帮助开发者做到以下的事情:

  • 把一些数据关联到 zone 中,类似于某些语言中的本地线程存储(thread local storage),这样在 zone 中的任意异步操作都可以访问这些数据。

  • 自动追踪指定 zone 还未执行完的异步任务,以便执行类似清理、渲染或者测试断言等。

  • 分析发生在当前 zone 中异步执行的总时间,用于分析工作。

  • 处理 zone 中所有未捕获的异常或者未处理的 promise reject,阻断他们往上层冒泡。

废话少说,先看用Zone.js如何解决我们刚才的问题


button.addEventListener('click', event => {
    Zone.current.fork({
        name: 'clickZone',
        properties: {
            event
        }
    }).run(
        () => logicA(event)
    )
    // 其他处理逻辑
})
function logicA(event) {
    delay(3000).then(() => {
        logicA1(event)
    })
    // 其他处理逻辑 
}
// logicA1 maybe in other module
function logicA1(event) {
    console.log(Zone.current.name)
    console.log(`gloabContext x: ${Zone.current.get('event').x}, y: ${Zone.current.get('event').y}`)
    console.log(`event x: ${event.x}, y: ${event.y}`)
    console.log('\n')
}
function delay(timeout) {
    return new Promise(resolve => {
        setTimeout(() => {
            resolve(true)
        }, timeout);
    })
}

效果如下:

Amazing!

Zone.current.get('event')与我们传递的event一致了!而且Zone.js还封装了Nodejs相关的API,我们在服务端也能使用。

Zone.js还提供大量的钩子,有更多强大的用法,比如用来追踪未完成的异步宏任务和微任务,可以参考这篇文章翻阅源码后,我终于理解了Zone.js

我们的问题解决了吗?

不幸的是,没有。

细心的同学已经发现,我在使用Zone.js的时候并没有使用async函数,我们试试改成async函数后会发生什么。代码如下

button.addEventListener('click', event => {
    Zone.current.fork({
        name: 'clickZone',
        properties: {
            event
        }}).run( () => logicA(event))
    // 其他处理逻辑
})
async function logicA(event) {
    await delay(3000)
    logicA1(event)
    // 其他处理逻辑
}
// logicA1 maybe in other module
function logicA1() {
    console.log(Zone.current.name)
    console.log(`gloabContext x: ${Zone.current.get('event').x}, y: ${Zone.current.get('event').y}`)
    console.log(`event x: ${event.x}, y: ${event.y}`)
    console.log('n')
}
async function delay(timeout) {
    return new Promise(resolve => {
        setTimeout(() => {
            resolve(true)
        }, timeout);
    })
}

报错了,Zone.current.name也不是我们的clickZone,变成了<root>了,怎么回事呢?

这和Zone.js的实现有关

Zone.js采用猴子补丁(Monkey-patched)的暴力方式将Javascript中的异步函数都包装了一层,在浏览器中,包括RequestAnimationFrame,addEventListener,XMLHttpRequest等一系列异步方法。在Nodejs里,所有的异步api也被封装了一层(google工程师真暴力 = =!;今天的主角不是Zone.js,也就不展开了,有兴趣的同学可以去查看源码)。

当然,这里被改写的对象也包括我们的Promise对象。

未引入Zone.js前,我们打印Promise显示如下:

引入Zone.js后,打印如下:

可以看到window.Promise对象已经被修改了,系统原生Promise被放在了__zone_symbol__Promise

那为什么我们用Pormise.then的方式调用Zone.js是正常的,用async函数的形式不行呢?

这又扯到了v8对async函数的实现,在v8中 async函数返回的结果是一个promise,如果 return 的不是一个promise,也会封装成一个promise对象,效果如下:

那么,引入Zone.js后呢,发生了什么

async函数返回的promise包装对象不是全局Promise的实例,而是native的!

更确凿的证据在这里

可以这么理解v8的行为:

async 函数的返回值如果不是native的promise,则v8会将其封装成native的promise,而与js中全局的Promise对象无关。

这么一来,Zone.js中的Monkey-patched就失效了。(其实关于async await还有一些有意思的东西,如果后面有时间会写一篇文章讲讲这里)

这个Issues里面也提到了,由于v8的实现机制,导致zone.js无法支持async await语法,只有使用babel或者ts将async await编译成generator的形式。其实angular团队也早将Zones for JavasSript提到TC39 process,但是至今是stage 0的状态,感觉希望渺茫。

对于前端来说用babel还能解释为兼容浏览器版本,但对于Node应用来说,编译async函数增加了调试的复杂度,那还有什么解决办法吗?

Nodejs的解决方案

Domain模块

domain模块早在node v0.8版本的时候就发布了。这个模块最早是用于捕捉异步回调中出现的异常,在腾讯开源的TSW中使用了domain来实现保存请求上下文:

通过process.domain始终指向当前执行栈所在的domain以及Object.defineProperty,实现了全局变量保存执行上下文

domain现在已被node官方标识为Stability: 0 - Deprecated(废弃的)状态,现在我们去看domain模块的源码可以发现,该模块已经用async_hooks重写了,意味着即使最后从node api中移除,我们通过async_hooks也能自己实现domain。

Async_hooks模块

在node8.0版本之后引入了async_hooks模块,该模块的状态是Stability: 1 - Experimental(实验性的),并且在github上有对async_hooks使用的性能问题的讨论,在基于koa框架下,性能损失在10%左右。除了性能损失,还有部分使用者出现了cpu暴涨的情况,这里因为信息有限,无法得知是否和使用者自身的编码有关。

除了async_hooks模块有性能损失,domain模块在基于async_hooks重写前自身也存在大约15%的性能损失。

阿里的Nodejs应用管理器 Pandora.js 就是用的async_hooks来做链路追踪的,其源码里依赖的cls-hooked包就是基于async_hooks模块实现。

Pandora.js源码,可以看到使用了cls-hooked

这里不对async_hooks模块的使用做过多展开,感兴趣的同学去看看api就知道了。

结论

Zone.js: 支持浏览器,Nodejs,无法直接使用async await语法,需要编译。

Domain模块:支持Nodejs,已废弃,已用async_hooks实现。

Async_hooks模块: 支持Nodejs,存在性能损耗,可能存在内存泄漏,cpu暴涨的问题。

为了性能安全,我们可以增加一个开关,在必要时候关闭async_hooksdomain的功能,同时做到不影响业务主流程。


写完下班,最后祝大家多拿年终奖。

参考

NodeJS async_hooks API与CLS

NodeJS与ThreadLocal

Zone for NodeJS API

用正确的方式为NodeJS打日志

angular with tsconfig target ES2017 async/await will not work with zone.js

剑走偏锋!domain模块居然还能这样用!

翻阅源码后,我终于理解了Zone.js

zone.js —— 暴力之美

Node.js 异常捕获的一些实践

JavaScript异步机制详解

V8中更快的异步函数和promise