Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

cls-hooked异步上下文存储实现与PromiseHook在node层源码解析 #9

Open
HardenSG opened this issue Apr 8, 2024 · 0 comments
Assignees

Comments

@HardenSG
Copy link
Owner

HardenSG commented Apr 8, 2024

写作背景

笔者目前正在xxxxx技术部工作,在参与团队内技术基础建设的时候偶然发现并怀疑SSR服务端SDK在使用cls-hooked进行连续上下文存储的时候有内存泄漏的风险,抱着验证的心态,在翻阅了资料和代码实现并清楚了前因后果后决定还是写一个科普文章来大致介绍下这项技术。

此外关于这项技术以及真正的前因后果和底层实现,笔者在国内外的社区资料中几乎未发现完整内容。此篇文章的关注点在于科普和底层实现的解析,相信这份“非常稀有”的文章能帮助你了解底层原理,若有偏颇还请斧正!!!

技术背景:

对于团队内部的 SSR服务,每次请求都会构造出一个独属于该用户的请求上下文并注入到SSR产物中去执行,对于SSR来说渲染器都是同一个所以执行的产物也都是一样的,只不过上下文的数据可能是不同的,所以如果想在SSR服务中存储一些公共的数据就不能采用常规的全局唯一对象实现,因为有可能此次还没处理完下一个请求进来就把数据覆盖了!

为了解决这个问题,SSR服务和公共服务端SDK在技术上都采用了 async_hooks 技术,应用 cls-hooked 这个包实现多用户连续请求的上下文存储。

本篇主要介绍:

  • 为什么要写这个文档?
  • 先行铺垫:async_hooks是什么?
  • cls-hooked如何使用?它的构造原理是怎么样的?
  • cls-hooked是否有内存泄漏?
  • 在node14和18版本中AsyncHook是否有区别?
  • 有更好的解法尝试吗?

读者可以从此篇科普性文档中了解到:

  • node环境连续上下文存储方案,以及npm周下载量160w+的cls-hooked三方库的底层实现
  • 进一步了解async_hooks处理Promise资源的底层原理
  • 更现代的als(AsyncLocalStorage)存储方案有哪些异同

一、为什么要写这个文档

背景

笔者这段时间在参与团队内服务端SDK基础函数的开发,希望可以:

  • 将请求链路的初始化收口进SDK,
  • 预先处理好某些业务数据,提前准备一些可能在客户端需要用到的数据

对于现状来说,SSR的链路初始化是由SSR服务的renderContext中的某个方法提供的,需要业务在代码中手动调用;在SSR客户端hydration执行代码的时候需要再额外处理比如取出分享数据再进行initShare,也就说对于SSR来说无论是请求链路初始化还是分享数据的处理都是交给业务自闭环,SDK希望可以代替业务实现。

为了实现上述的能力,SDK需要一份SSR服务的renderContext并拿到里面的请求函数,利用SSR服务的请求能力去实现init

比如(已脱敏):

image.png

发现

就上述需求我在实现了一个初版效果之后在本地补全jest单测的时候发现Promise异步创建并存储在cls-hooked中的上下文映射中竟然被添加了N个一模一样的上下文,尽管我只执行了一个case也是一样的效果,在社区初步查阅发现:cls-hooked在处理Promise资源的时候会存在内存泄漏的问题,不过事实果真如此吗,下面会给出结论。

原本只是为了明确SDK是否存在内存泄漏的问题,在翻阅了资料和代码实现并清楚了前因后果后决定还是写一个科普文章来大致介绍下这项技术。

二、先行铺垫:async_hooks是什么?

举个例子,了解发展历程

在这里先抛给大家一个我们在日常开发中经常接触的东西:traceID 也就是常说的全链路日志追踪,可以追根溯源到比如一个请求经过了哪些机器有哪些入参返回了哪些结果执行了多长时间等等等等,这就会涉及到多机器或本地多线程或远程rpc的一系列过程需要id在其中串联。

以Java语言举例,在Java中实现链路追踪可以通过底层提供的ThreadLocal及它的派生去实现本地线程的存储。

那我们可以在Node中实现类似的需求吗?对于以前来说这不行!因为在不启动worker的角度考虑js本身就是一个单线程的语言,他没有办法做到像Java那样的底层支持,所以他不能。

但是JS语言出名的就是异步了,为什么不可以通过异步机制存储某种东西然后再利用某种方式去拿?

i. cls

确实可以!在2013年 node-continuation-local-storage (CLS)横空出世,在README中的介绍就写到:

  • Continuation-local storage works like thread-local storage in threaded programming, but is based on chains of Node-style callbacks instead of threads.

    • 翻译:CLS的工作方式像拥有线程的程序那样使用 thread-local-stroage(线程本地存储)但是它是基于node风格的回调链而不是线程
  • this module allows you to set and get values that are scoped to the lifetime of these chains of function calls.

    • 翻译:你set或get的值,是在这些异步callback的生命周期调用链里面的

也就是说CLS允许你在异步的函数里面存储和设置值,它承诺可以实现像其他语言那样local-stroage。

cls会维护一份上下文栈,它会监听process.addAsyncListener也就是监测到有异步资源添加的时候就会存一份资源,在销毁的时候会把它删掉。这种设计在后面的cls-hooked也是一致的。

ii. async_hooks

这个技术和前面说的cls就不是一个概念了,因为cls本质上还是一个三方库而async_hooks其实就是底层的一种技术了,它是在2017年node8推出的官方api。

async_hooks从命名大意上就能大概了解到看起来像是某种异步机制的周期回调,就像在Vue的代码里面存在的那种 setupmountedunmounted 生命周期一样只不过这个针对的不是组件实例而是异步资源,事实证明它其实就是这个意思。

下面引用官方介绍。

文档:https://nodejs.org/docs/latest-v14.x/api/async_hooks.html#async_hooks_async_hooks

The async_hooks module provides an API to track asynchronous resources

下面是晦涩的介绍:

不需要关注图片的晦涩的术语,看第一句的解释就大概可以了解:The async_hooks module provides an API to track asynchronous resources,翻译过来就是:async_hooks模块提供了一个API去追踪异步资源。

不过它为什么要追踪异步资源呢?

为什么要追踪异步资源?我能用来干什么?

相信经过上面的介绍其实已经很容易理解了

  • 为什么要追踪异步资源?

    • 因为我可以通过模拟异步资源的生命周期比如像Vue的setup、mounted一样让异步资源有属于自己的一套生命周期,那么我能够追踪到异步资源的生命周期之后我就可以在其中做一些操作比如我可以将请求的context在异步资源创建的时候存起来在异步资源销毁的时候再删掉防止内存泄漏
  • 我能用来干什么?

    • 在异步资源创建的时候我已经将context存起来了那么在异步访问context的时候我只需要一个key就可以去存储context的那个数据结构中找到,如此便可基于async_hooks实现一个连续的上下文存储方案。

这其实就是三方库cls-hooked的实现原理,详细的实现方式会放在第三部分介绍,这里先大概清楚有这个概念。

如何使用async_hooks

i. hook

并不是所有的资源async_hooks都能识别,官网文档中枚举了可识别的集合

具体可参照官方文档 AsyncHook章节,下面只简单介绍都有哪些hook

  • init:当一个类的构造器被调用且会发生一些异步行为的时候也就是我们所谓的异步资源被创建的时候会触发init hook,底层会透传给init hook四个参数,分别是:

    • asyncId:对于异步资源的一个唯一ID
    • type:这个异步资源是什么,比如定时器:Timeout
    • triggerAsyncId:在执行上下文中间的唯一id
    • resource:异步资源
  • before:在异步资源执行之前触发,底层会透传一个参数:asyncId,表示异步资源的唯一ID

  • after:相同的,这个表示在异步资源执行之后触发,asyncId为入参

  • destroy:这个hook表示资源被销毁的时候被触发

  • promiseResolve:用于跟踪Promise本身当Promise被resolve或者then处理的时候

ii. enable

使用createHook方法并传入我们预期想要追踪的几个hook之后调用enable方法去开启hook追踪。

(!!!!这个很重要!!!)第四节会主要说这里

那现在我们大概能明白了,简单来梳理一下:

  • cls是什么

    • 是一种基于node风格的异步回调连续存储方案,允许你在一个异步的callback里面存储或获取一些值,它是一个第三方库。
  • async_hooks是什么

    • 是node8发布的官方API,用以监控或追踪异步资源的完整生命周期,是一种底层提供的技术
  • 一开始就提到的cls-hooked是什么

    • 它是用来解决连续上下文存储的一种方案,比如可以用来实现服务端请求上下文存储、全链路日志追踪等
    • 它本质上是一个基于async_hooks的第三方库,其目的是通过使用async_hooks提供的API来监控异步资源的生命周期并存储业务想让它保存的context以便需要的时候取出供给使用。

这还有个小插曲:

cls-hooked是从cls仓库里面fork出来的,所以之前的时候它的底层实现是像cls那样借助listener监听一些东西。

async_hooks发布之后,作者又马不停蹄的使用async_hooks实现了一遍,所以在cls-hooked的源码中可以看到有这么一行😂:

三、cls-hooked如何使用?它的构造原理是怎么样的?

如何使用cls-hooked

在这里先来介绍下cls-hooked是如何使用的,然后从使用的角度切入代码实现,同时也是对上面介绍的间接诠释。

i. 如何存储context

  • 定义一个独属于你系统的命名空间
  • 通过runPromise存储context
const cls = require('cls-hooked')
// 1. 定义命名空间:sdk
const CLS = cls.createNamespace('sdk')


// 2. 通过runPromise存储context
const setCLS = (options) => {
    return CLS.runPromise(() => {
        Object.entries(options).forEach(([key, value]) => {
            CLS.set(key, value)
        })
        return Promise.resolve({ errno: 0, msg: 'success' })
    })
}

先解释一下前两条:

  • 为什么要创建新的命名空间,这个其实很好理解,为了你set的context只存储于你的nameSpace上面

  • 为什么通过runPromise存储context,从两个问题来解释一下

    • runPromise它是什么东西?

      • 它是一个函数用来执行并维护属于当前scoped的context,不知道看到这个你有没有想起来cls的原理其实就是允许你在一个异步函数里面存储或获取context,cls-hooked会负责维护这份上下文的正确传递。
      • 当然除了runPromise还有runrunAndReturn等执行函数
    • 为什么不能用runrunAndReturn等方法

      • 首先说明runPromise需要返回一个Promise,此外从它的命名上也可以看出,它是专门用来存储Promise返回的一个函数,在需要处理Promise的场景下需要使用runPromise而不是run或其他方法,因为runPromise会做一些操作帮助你在Promise的resolve或reject中正确的自动的传递context

ii. 如何获取context

// 在异步callback中获取context
const getCLS = key => {
    return CLS.get(key)
}


// 模拟在异步回调中使用context
export const prodInit = async bizParams => {
  const api = getCLS('api')
  const reqFn = await api
  
  // context中存储了一个Promise
  return await reqFn({ ...bizParams })
}

你可能会好奇,在获取context的时候甚至什么都没有传入仅仅是利用实例的get方法就能获取到context。

下面我们会从

创建命名空间 -> runPromise构造与存储 -> context的正确获取 -> 异步资源销毁

的宏观链路角度来逐步介绍cls-hooked的神秘面纱........

cls-hooked的实现原理

其实它并不神秘,在第二部分的时候我们已经介绍过了其实它的原理就是基于async_hooks和上下文栈的依次配合,一切的出发点都是从这两个点开始的。

i. 熟悉数据结构(context stack)

我们先来熟悉一下cls-hooked的命名空间结构,他具体长这样:

其实他就是个类或者准确来说是个构造函数,所以我们创建出来的命名空间其实就是一个大对象,里面有若干个属性用来完成某些事情:

  • name:命名空间的名称,它可用于标识和区分不同的命名空间。
  • active:当前活动的上下文对象,上下文对象是命名空间中存储的数据。当执行异步操作时,可以通过设置和获取这个属性来传递上下文信息。
  • _set:命名空间的上下文对象栈,每个上下文对象在进入或离开时都会被推入或弹出这个栈中。
  • id:命名空间的唯一标识符,这个属性可以用于在多个命名空间之间进行区分。
  • _contexts:命名空间内所有上下文对象的映射,每个上下文对象都有一个唯一的标识符,并与其关联。
  • _indent:用于控制上下文对象栈的缩进级别,这个key对我们没有实际性的作用是库本身用来打log的。

ii. 创建命名空间

createNamespace方法中主要会完成两件事情:

  • 实例化Namespace构造器
  • 监听AsyncHook回调并在其中完成一些事情并enable开启hook
let currentUid = -1;


function createNamespace(name) {


  // 1. 实例化Namespace构造器
  let namespace = new Namespace(name);
  namespace.id = currentUid;


  // 2. 监听hook
  const hook = async_hooks.createHook({
    init(asyncId, type, triggerId, resource) {
      // do somethings
    },
    before(asyncId) {
      // do somethings
    },
    after(asyncId) {
      // do somethings
    },
    destroy(asyncId) {
      // do somethings
    }
  });
  
  // 3. 开启hook监听
  hook.enable();


  // 存储并返回
  process.namespaces[name] = namespace;
  return namespace;
}

在cls-hooked的代码实现中并没有使用到promiseResolve这个hook

iii. runPromise做了什么

为了更好的理解hook里面干了什么,这里先说runPromise干了什么?

前面提到如果想要存储本次请求的context需要在一个回调函数里面,这里我们以执行异步的runPromise函数为例展开下面的内容。

const CLS = cls.createNamespace('sdk')
runPromise(() => {
  CLS.set('name', 'SG')
  return Promise.resolve()
})

可以看出runPromise接受一个callback,然后我们在里面可以利用CLS依次设置key,那么其实它的实现很简单,步骤大致可以分为以下几步:

  • 准备一个用于此次请求的context容器
  • 将context入栈
  • set & 执行callback更新context值
  • context出栈

context构造器

每个context实际上就是一个对象,所以context构造器其实就是为了造个空对象出来

Namespace.prototype.createContext = function createContext() {
  let context = Object.create(Object.prototype);
  context._ns_name = this.name;
  context.id = currentUid;
  return context;
};

前面提到在Namespace实例上还有一个属性:active,相当于一个用来标识当前活跃的context的指针,那么假设这个active有值的话直接继承这个值就好了,所以稍微改动一点就变成了这样

let context = Object.create(this.active ? this.active : Object.prototype);

在context构造出来以后就可以先行入栈了。

context 入栈

创建出来的上下文我们需要有一个地方可以去存储它,那么对于这种嵌套或者连续函数调用的场景最适合的数据结构其实就是,我们可以模拟一个上下文栈结构来保存数据,这个栈结构其实就是Namespace实例的 _set属性。

这其实和前面提到的cls实现是类似的:通过调用栈维护上下文。

据此可以构造出来一个入栈函数

Namespace.prototype.enter = function enter(context) {
  // 把active推入栈
  this._set.push(this.active);
  // 将拿到的context赋予active
  this.active = context;
};

set & 执行callback更新context值

在调用runPromise的时候传入了一个回调函数,里面使用了CLS的set函数来设置context,其实经过了上面的内容大概可以猜出来set函数的行为其实就是用来更新本次存储的context的键值的,由于context是引用数据所以更新active还是更新上下文栈中对应索引的item效果是一样的,不过当然还是active最省事。

Namespace.prototype.set = function set(key, value) {
  if (!this.active) {
    throw new Error('No context available. ns.run() or ns.bind() must be called first.');
  }
  this.active[key] = value;
  return value;
};

context出栈

维护context上下文栈是cls-hooked能实现连续存储的核心,所以及时入栈和及时出栈非常重要。

但由于Promise是一个异步的行为所以不能像run函数那样在按顺序执行完逻辑之后就返回,所以使用runPromise函数必须返回一个Promise这是因为runPromise函数需要处理这个返回值,这样cls-hooked就知道什么时候可以出栈了。

Namespace.prototype.exit = function exit(context) {
  // active 就是当前的context
  if (this.active === context) {
    // 只要上下文栈还有余量就可以pop
    if (this._set.length) {
       this.active = this._set.pop();
       return;
    }
  }


  // Fast search in the stack using lastIndexOf
  let index = this._set.lastIndexOf(context);
  // 如果找得到就剪裁掉
  if (index > 0) {
    this._set.splice(index, 1); 
  }
};

把上面的四个步骤结合起来就是runPromise的实现逻辑啦:

Namespace.prototype.runPromise = function runPromise(fn) {
  // 创建此次上下文容器
  let context = this.createContext();
  // 上下文入栈
  this.enter(context);
  
  // 执行callback
  let promise = fn(context);
  
  // 为了捕捉何时出栈要求cb必须返回Promise
  if (!promise || !promise.then || !promise.catch) {
    throw new Error('fn must return a promise.');
  }


  return promise
    .then(result => {
      // 出栈
      this.exit(context);
      return result;
    })
    .catch(err => {
      err[ERROR_SYMBOL] = context;
      // 出栈
      this.exit(context);
      throw err;
    });
};

iv. get

get函数用来在异步过程中获取存储的上下文,简单带过即可。

Namespace.prototype.get = function get(key) {
  if (!this.active) {
    return undefined;
  }
  return this.active[key];
};

v. 四个hook

现在我们进入了cls-hooked里面最核心的部分之一了,也就是context的存取操作了。现在让我们再次回顾cls-hooked的能力是什么?

允许你连续存储context并且在需要的地方获取与本次请求关联的context。

但是你可能会好奇了:我刚刚不是在runPromise里面把context入栈了吗?那我在其他地方直接get一下去上下文栈找一下不就可以吗?

按道理是可以的但是前提是你只能在runPromise返回的Promise被处理之前做这件事,因为只要cb返回的Promise被处理了本次存储的context就会出栈,这样的话还去哪里找呢?

比如下面这个case

const initFn = async () => {
  // 等待Promise被处理
  await runPromise(() => {
    CLS.set('name', 'SG')
    return Promise.resolve()
  })
  
  const res = await (async () => {
    return new Promise((resolve) => {
      // 拿不到上一个Promise中CLS存储的name啦!!!
      console.log(CLS.get('name'))
    })
  })
}

那我能不能在创建的时候把active的对象也存起来一份然后我在其他地方需要用的时候使用某个方式找到我之前存的context可不可以?

当然可以!这就是async_hooks要做的事情!

init

异步资源创建永远是init被调用,那么我就在init的时候把active的对象给存起来一份完全没有问题,为了方便找到它cls-hooked使用了Map,也就是Namespace里面的_context属性。

本次存储的key也很好解决呀,前面我们也有提到init的时候会透传四个参数其中有一个参数就是用来标识异步资源唯一id的:asyncId

async_hooks.createHook({
  // 用一个入参就够了
  init(asyncId) {
      if(namespace.active) {
        namespace._contexts.set(asyncId, namespace.active);
      }
  },
})

但是有个地方需要注意下,异步资源还包括TCPWrap类型,它代表一个请求被建立。这就不属于JS调用栈里面的内容了它对我们没用:

可以通过async_hooks获取到当前正在执行的异步上下文ID,如果是0就需要格外处理下

async_hooks.createHook({
  // 用一个入参就够了
  init(asyncId, type, triggerId, resource) {
      // 获取到正在执行的ID
      currentUid = async_hooks.executionAsyncId();
      
      // 如果有active直接存就好了
      if(namespace.active) {
        namespace._contexts.set(asyncId, namespace.active);
      } else if(currentUid === 0){
        // 如果是TCPWrap取map里面的context重新存一下(若有)
        const triggerId = async_hooks.triggerAsyncId();
        const triggerIdContext = namespace._contexts.get(triggerId);
        if (triggerIdContext) {
          namespace._contexts.set(asyncId, triggerIdContext);
        }
      }
    }
})

其实这里还有一个点就是一次请求过程中可能会涉及到多个Promise、定时器甚至是console.log,这就意味着只要遇见一次异步资源就会在上下文映射里面存储一份数据,存储的数量和Promise的数量成正相关。

before

init是为了存储本次创建的context但是它没有做任何其他的行为。

那么before就是为了让刚刚的那个问题得到解决的,它的执行时机是异步函数的执行前,它有一个入参是asyncId用来标识是哪个异步资源准备执行了,那么我只需要到映射里面找到这个asyncId存储的context然后让它重新入栈就解决这个问题了,得益于init hook存储了context,我们使用asyncId很容易就找到了。

async_hooks.createHook({
  before(asyncId) {
    currentUid = async_hooks.executionAsyncId();
    let context = namespace._contexts.get(asyncId)
    if (context) {
      namespace.enter(context);
    }
  }
})

after

有入栈就要有出栈,这很重要,所以after做了什么也非常容易理解。

async_hooks.createHook({
  after(asyncId) {
    currentUid = async_hooks.executionAsyncId();
    let context; // = namespace._contexts.get(currentUid);
    context = namespace._contexts.get(asyncId)
    if (context) {
      namespace.exit(context);
    }
  }
})

destroy

映射Map中肯定不能一直存着没用的context呀,不然这不就是内存泄漏了嘛😂,如果异步资源都被销毁了那么还留着它岂不无用,所以在触发destroy hook的时候就delete掉就好了

async_hooks.createHook({
  destroy(asyncId) {
    currentUid = async_hooks.executionAsyncId();
    namespace._contexts.delete(asyncId);
  }
})

捋一下

所以现在cls-hooked的实现就比较好理解了,再来宏观的捋一下:

  • 在首次使用cls-hooked需要声明一个命名空间来存储数据,后续要维护的上下文栈、映射map、active指针都包含在其中

  • 使用runPromise包裹住一个返回Promise的回调函数,在里面使用CLS依次存储context

    • runPromise需要创建一个新的context
    • context入栈 并且更新 active指针
    • set 函数更新active的值
    • context出栈
  • 利用hook机制

    • init:在异步资源创建的时候将context和异步资源的asyncId绑定在一起存储进映射Map中
    • before:在执行异步资源前夕通过asyncId去映射Map中找到context并重新入栈更新active指针
    • after:context出栈
    • destroy:销毁无用context,避免内存泄漏

整体来说cls-hooked的实现就是合理的利用了上下文栈以及hook回调,整体来说比较好理解。

四、是否有内存泄漏?

回到最初的困扰你肯定会好奇我为什么要提到是否有内存泄漏这个问题?

仔细看async_hooks支持追踪的资源集合中根本就没有Promise类型,这其实会令人感到很诧异,这也是我最初困惑的地方,因为这就变相的表明cls-hooked根本就不支持Promise追踪但是这显然是不可能的,线上的代码都在跑呢......

有误导性的issue

关于这个问题社区上的提问和回答简直众说纷纭,提取信息犹如抽丝剥茧。下面随便列两个issue大家供娱乐:

  • 社区上的提问都在关注于为什么map中的context没有被destroy的时候销毁,以至于context map的大小不断增长,所以就有了这样的话:runPromise会导致内存泄漏。这也是我被第一次误导的方向。

  • 被误导后我关注于destroy为什么没有被调用(实际调用了),所以又查询了一堆资料结论又变成了Promise没有开放destroy接口导致async_hooksdestroy的时候并不能调用,这是第二次被误导方向。

但其实事实并不是这样的,async_hooks连Promise这个资源都不能识别怎么可能只有destroy出不来呢?

node:async_hooks对Promise的处理

下面全部以node14.15.0为例

无奈只能把目光从cls-hooked移向了node:async_hooks的实现。

在第二节的时候我提到async_hooks.enable非常重要,现在来解答它为什么重要?因为它就是拯救async_hooks的关键因素,没了它Promise是真的监听不了!

以下是node的领域

i. enable做了什么

看蓝框即可

  • 第一个enable是用来开启普通异步资源监听的
  • 第二个enable就是用来开启Promise监听的关键了

ii. updatePromiseHookMode

来看看updatePromiseHookMode里面做了什么:

进入函数会存在两个分支,判断在createHook的时候是否传入了destroy hook

  • 如果destroy hook存在,那么会开启全部的PromiseHook
  • 如果不存在,则会开启快速的PromiseHookfastPromiseHook

其实这个也很好理解,因为Promise它本身是一种基于异步回调绑定的技术,它的生命周期相当于是自闭环的,对于GC来说追踪Promise是非常大的成本,如果你不需要destroy那我索性开启一个快速的PromiseHook就不带着destroy一起玩了。

所以这其实也变相的说明了对于Promise去监听destroy是会影响性能的。

iii. 解释题目:什么是PromiseHook

说了半天PromiseHook先来解释一下它是什么:

PromiseHookV8::Context 提供的一个API,目的是用来监测Promise状态的钩子API其实和async_hooks差不多不过就是一个专属于Promise的底层hook api

不过遗憾的是PromiseHook也没有提供destroy的监测,这就需要node在底层代码上额外针对destroy做处理了.......

更多的PromiseHook相关内容感兴趣的朋友可以参考:Promise lifecycle hooks in V8:
https://docs.google.com/document/d/1rda3yKGHimKIhg5YeoAmCOtyURgsbTH_qaYR79FELlk/edit#heading=h.sdpzjcyw06a5

下面是CPP的领域

iv. async_wrap.cc

下面我们又深入了一层来到了node:cpp的层面

刚刚调用的EnablePromiseHook方法其实就是一个原生的CPP方法,JS通过internalBinding将原生模块加载进来,所以在async_wrap.cc会被loader经过种种处理之后最终加载为async_wrap.js。

EnablePromiseHook

因为在使用CreateHook的时候我们需要destroy所以在enablePromiseHook的时候并没有传递入参意味着需要开启一个FullHook,具体来看下FullPromiseHook逻辑

先插个题外话:FastPromiseHookFullPromiseHook差别并不大:

  • 蓝框的部分是相似的都是监测各个hook
  • 红色框部分表示Init需要做一些额外的事..

FullPromiseHook

对于非init部分也就是PromiseHookType支持的resolve、before、after枚举的处理十分简单,就是在检测到对应的type触发的时候去emit对应的函数即可。

PromiseHookType::KInit便是额外处理Destroy的分支代码,这块其实实现的逻辑也并不复杂

关键的便是蓝色框的部分,也就是对于需要开启全部Hook检测的Promise会额外给它包裹一个Wrap类:PromiseWrap, 创建一个wrap实例以便操作

我觉得包装类的概念比较好理解,你可以想象这是一层针对Promise的代理,Promise做的操作都会经过PromiseWrap,你需要做什么事情都可以由包装类替你去完成

  • 前提:

    • PromiseWrap类继承自AsyncWrap类,AsyncWrap类作为异步包装基类已经定义了包括析构函数在内的方法,PromiseWrap可以直接继承
    • AsyncWrap类继承自BaseObject类,BaseObject类是基础对象的模型,可以实现诸如JS对象转C++对象、内存操作等行为

PromiseWrap是继承AsyncWrap异步包装类的一个构造模型,new方法是用来创建对象实例的静态方法,本质上还是需要初始化构造函数,并且构造函数中只做了一件事:将指针数据变更为弱引用(MakeWeak),以便GC收集的时候直接delete this删除释放内存

v. 所以Promise的destroy是如何被执行的

现在离【为什么Promise资源的destroy被执行】的真相已经越来越近了

明确:Promise的周期已经在我们FullPromiseHook的时候交给了PromiseWrap包装类去管理,那么什么时候销毁应该是完全取决于它的吧,是这样的。

在实例化PromiseWrap的时候,构造器将存储的指针数据变成了弱引用,与此同时他其实还注册了一个回调:

AsyncWrap类上可以看到确确实实的有一个方法叫做WeakCallback

我们就只关注蓝色框的部分,可以看到有一个EmitDestroy的行为就像after的EmitAfter一样,那大概也能猜出来它里面应该是执行了某些销毁的行为,来看下EmitDestroy做了什么。

为了方便描述分别为蓝框标了序号

  • 1:如果当前待销毁的集合(destroy_async_id_list)为空会在异步的下一个loop去执行DestroyAsyncIdsCallback回调

  • 2:如果待销毁的集合的size达到了node设定的阈值,就会强制请求一个中断(RequestInterrupt)去执行DestroyAsyncIdsCallback

  • 3:向待销毁的集合中推入一个asyncId

    • asyncId应该很熟悉,大概能理解这个推入的id就是那些异步的资源,那些即将需要被底层回收的异步资源唯一标识

整个EmitDestroy方法都在围绕着一个方法展开:DestroyIdsCallback,下面来揭幕以下:

恭喜🎉,破案了!DestroyIdsCallback方法的第一行便是调用了async_hooksdestroy回调,所以这就是为什么Promise在async_hooks中仍然能被触发destroy的原因。

第二个部分就是执行一些回收逻辑咯~

其实能被销毁的地方还不止这一个地方

  • AsyncWrap的析构函数:

  • AsyncWrap构造器中执行的AsyncReset方法,目的应该是reset 一些东西,这个东西会在AsyncWrap的构造函数中调用,用于初始化。

    • EmitDestroy是一个重载函数,但是两个逻辑都是一样的,可以宏观上的认为它们是一个东西

vi. 其他hook

其他hook指的是PromiseHookType除了Kinit的其他枚举(resolve、after、before)的事件触发比如EmitAfter等,其实都和async_hooks_destroy_function行为是一致的,下面只放截图:

vii. gc log和heapSnapShot验证

根据EmitDestroyDestroyIdsCallback的逻辑,待销毁的异步资源id会被push到destroy_async_id_list集合中如果该集合拥有一定数量那就可以证明这块是需要被清理的异步资源并且他一定会执行,这也就意味着async_hooks_destroy_function也一定会被调用。

所以只需要确认destroy_async_id_list的存量与否即可证明Promise在async_hooks中的destroy是否能被触发。

验证流程

  • 为了验证destroy_async_id_list,使用heapdump分析堆内存快照,使用如下
const heapdump = require('heapdump');

function generateHeapSnapshot() {
    heapdump.writeSnapshot((err, filename) => {
        if (err) {
            console.error(err);
            return;
        }
        console.log(`Heap snapshot has been generated: ${filename}`);
    });
}
  • 为了避免产生冗余内存影响,在启动前手动gc
app.listen(port, () => {
    global.gc()
    // 启动时存一下快照
    generateHeapSnapshot()
    console.log(`Example app listening on port ${port}`)
})

app.get('/', (req, res) => {
    // 模拟CLS存储和异步使用
    xxxxAPI()
    
    // 一定请求数量后再拍一次堆快照
    if (i % 2000 === 1) {
        setTimeout(() => {
            generateHeapSnapshot()
        }, 5000);
    }
})
  • node命令允许gc追踪和手动回收 并将gc日志保存起来

node --trace_gc --expose-gc index.js > gc.log

  • 使用压测模拟工具:autocannon 模拟5s连续2000个请求,查看堆状态

autocannon -c 2000 -d 5 http://localhost:3000

验证结果

  • gc日志

    • 从第3000ms到8000ms heap的大小从9MB上涨到了38+MB,这是因为在这5000ms内 压测模拟的Socket句柄一直没有释放,可以看到从8000ms之后内存会逐渐下降,正常业务请求不会这样。
    • 从log上来看经过数次gc,堆内存的大小会趋于稳定
  • 堆快照

    • 第一份快照是请求前,第二份快照是请求一定数量之后经过对比发现我们预期的destroy_async_id_list存在并拥有一定数量,这些都是即将或下次被销毁的无用资源

结论:经过gc和堆快照验证,可证实假设成立

viii. 为什么没有泄漏的结论

首先并不存在内存泄漏的巨大风险,因为真正的原因并不是第一节贴出的那几个有误导性的issue。

而是在async_hooks中监听Promise的hook需要由node底层监听v8提供的PromiseHook,还需要对Promise进行一层PromiseWrap的包裹,把销毁触发destroy的逻辑交给了包装类。所以不需要担心Promise是否会被销毁,一定会的,Promise都被销毁了destroy这个回调也一定会被执行,那么context映射中存储的对应的asyncId的上下文就会被删除,所以不存在内存泄漏的问题。

不过虽然不存在内存泄漏的风险,还还是可能会存在内存占用高的问题,因为这和我们代码中的Promise数量以及存储的上下文大小是呈现正相关的:

  • promise越多,一次请求导致node创建的包裹类的数量就会越多
  • 包裹类越多,一次完整请求占用的内存就会越大

但是从EmitDestroy中的逻辑可以看到是否强制执行清除是有一个阈值的,也就说除非已经完全超过了node设定的阈值,它才会请求操作系统中断先去gc然后才能继续接受http请求,否则并不会强制中断回收内存,简单来说就是在连续并发请求的时候什么时候清理内存完全取决于gc策略,只不过鉴于faas上可以配置qps的最大限度,所以按道理内存连续占用过高的可能性并不是很大这点倒是可以放心。

五、在node14和18版本中AsyncHook是否有区别?

答案是一定的,而且针对于async_hooks的底层实现也有一定的变化,下面会分别列举改动的部分。

updatePromiseHookMode

相信经过了上面的铺垫,updatePromiseHookMode函数用来干什么你应该比较熟悉了,没错就是在async_hooks的enable方法中用来开启PromiseHook,接下来先看一下14和18的diff

相似点

先看蓝框的部分,其实可以看出整体逻辑并没有什么变化,

  • 在14中是判断存不存在destroy hook从而决定是否开启一个全量的监听还是开启一个没有destroy监听的fastPromiseHook
  • 而在18中相当于换了个名字,如果有destroy hook我就初始化init也初始化destroy,变量简称:promiseInitHookWithDestroyTracking,否则还是默认的promiseInitHook
  • 或者没有init有destroy那么只追踪destroy

可以简单看下promiseInitHookWithDestroyTracking逻辑是什么:

当然除了init、destroy还有after、before、promiseResolve这几个callback整体逻辑并没有什么区别,如果感兴趣可以具体查看:https://github.com/nodejs/node/blob/v18.18.0/lib/internal/async_hooks.js,14和18实现相同

不同点

可以看diff的红框部分。在node14中updatePromiseHookMode函数的逻辑就是在确定好是否支持destroy之后就调用了原生C++层面提供的能力来初始化某些东西比如包裹类。

但可以看到在18中并不是这样的,18会在调用底层前再抽象出一层关于promise的hook去初始化某些东西,给这块逻辑集合起个名字,就是文件的名字:promise_hooks,简单看下里面做了什么:

总结来看按照顺序就是这四点:

  • updatePromiseHookMode中调用promise_hooks.createHook将处理好的hook推入createHooks的hooks集合中

  • 调用makeUseHook函数对createHook的四个hook入参做一层包裹再推入全局hooks队列

  • makeUseHook函数将传入的hook包裹起来先做一层处理,包括校验是否为function、推入全局hooks对象中对应key的队列中,hooks结构如下:

  • makeUseHook中会调用update 方法,在该方法中会调用到async_wrap.cc底层提供的方法去设置处理好的hook

全部细节感兴趣可以看下这块内容:https://github.com/nodejs/node/blob/v18.18.0/lib/internal/promise_hooks.js

async_wrap.cc/setPromiseHook

调用setPromiseHook方法将处理好的hook设置进运行的环境中。

Environment类可以理解为一个桥梁,一个连接JS底层和C++之间的桥梁,可以通过env对象管理内存、获取对象执行方法、管理isolate执行js代码等。

通过这样的方式,就可以把回调交给底层去监管并触发执行,其余部分的逻辑和14版本相似

destroy hook

另一个不同就是destroy的注册逻辑也变了(看来全都变了啊....)

回顾一下:在14版本中destroy hook的注册其实是在FullPromiseHook的时候通过创建PromiseWrap变相的把destroy交给了包装类,由包装类的销毁去控制callback执行。

但在18完全不一样了~,准确来说是前面的版本就将PromiseWrap类从async_wrap文件中彻底移除了

https://github.com/nodejs/node/pull/39135
nodejs/node#49335

回看一下updatePromiseHookMode

在internal/async_hooks.js文件中

在创建destroy hook的时候有一个API看起来不起眼但其实它才是完成destory注册的核心:

registerDestroyHook是由wrap底层提供的api,从命名上就可以清晰了解它是用来注册destroy hook的。

其实在14版本中这个API和PromiseWrap是共存的,只不过并没有使用

下面来看下RegisterDestroyHook的逻辑,我会以划分的三大块蓝框逻辑来展开

校验

这块其实没有什么特别的,在调用registerDestroyHook的时候传入了两个入参:一个是Promise资源、一个是asyncId,CHECK就是用来校验是否符合条件的。

关联

将注册的资源和DestroyParam关联起来,从名字上也能看出来DestroyParam看起来像是用来记录销毁时需要用到的参数,它的用处应该也就是这样的。

  • 记录asyncId

  • 记录env

  • isolate实例实际上是一个V8引擎的实例,一个隔离的实例,用来执行JS代码的,那么Reset函数大概就是想把C++对象和JS对象关联起来,至于有什么用我也不知道,但是我给你搜了一下。

虚引用

在14版本中PromiseWrap的构造器中主要做了一件事:MakeWeak。也就是将指针数据虚引用以确保在没有其他引用的时候可以保证数据被正常回收。

那么SetWeak函数的逻辑其实是一样的,将DestroyParam实例创建虚引用并且在被销毁之后执行WeakCallback回调,这个东西看起来很眼熟:

这下想起来了吧,其实就是在这份数据没有强引用的时候执行这个回调,里面会执行destroy逻辑,会触发destroy回调的执行也会执行destroy_async_id_list的回收

如果忘记请回看 第四节/2小节/v部分的描述

小插曲

那么至此14和18版本的主要diff就差不多了。

这里面有一个小插曲一直没有说,不知道大家有没有好奇:

  1. 这个函数是怎么来的:

  2. 还有internal/async_hooks中定义的Native方法是给谁用的?

先看第二个,从名字上来看:每个名字都以native结尾。

  • 什么是native?

    • 原产的、原生的

提供给谁的原生函数?这是node代码总不能是给node提供的吧,嗯应该不是,那么就好理解这个应该是提供给C++来用的。

在哪用的呢,在lib/internal/boostrap/node.js,在启动的时候会把native方法注入给C++模块

注入的逻辑交给了async_wrap里面的一个setupHooks函数,其他都别看,就看红框你就能明白了,通过使用SET_HOOK_FNnativeHook转换成了async_hook_xxxx_function这样的格式,以至于你才能直接调用如async_hook_destroy_function(),所以第一个问题通过了第二个问题的解答也就豁然开朗了。

在14和18版本中关于native hook的注入逻辑是一模一样的,所以才打算放在最后来写..

六、有更好的解法尝试吗?

据调研发现AsyncHooks存在一定的性能缺陷,而且在官网文档中有明确标记,大概含义是这样:在async_hooks中使用destroy hook会导致gc追踪Promise的回收情况,会造成额外的开销。
https://nodejs.org/docs/v18.18.0/api/async_hooks.html#destroyasyncid

具体影响没有实际测试,此外也可以尝试一下社区从16版本开始推的ALS,不过这应该被列为实验性方案具体效果需要实验才能给出结论,内容待完善.......

性能issue:nodejs/node#34493 (comment)

关于ALS部分涉及实践,大家如果感兴趣欢迎在评论区cue我,我会尽快更新……

如果对你有帮助,或者开拓了新的视野求帮点赞推广,不胜感激!

@HardenSG HardenSG self-assigned this Apr 8, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant