使用 Async-hooks 集成 Envoy 和我们的 RPC 库(二)

前文再续,书接上一回。上回讲到,我们怎样利用 async_hook 将请求(Request)的上下文(Context)在我们的业务程序中传递,以及如何使用 Monkey Patch 去设置上下文。Monkey Patch 在某种程度上是一种好办法,但是并不适合所有情况。比如在 Tubi 的应用过程中,我们发现 node-grpc 并没有一个特别合适的地方可以打补丁。那现在,我们一起来看一下一种不用 Monkey Patch 的解决方案。

基于调用栈上的技巧

首先,让我们先看看调用栈的结构:

调用栈的依赖树

上图中的每一个节点代表一次函数调用。函数都是逐个依次调用的,从上往下,从左到右(中序遍历)。图中你可以看到在根节点上有两棵子树(两块蓝色阴影)。中间件(Middleware)总是在主体逻辑前执行的,所以你会看到它们在左下方。

如果我们写一个中间件放置于 HTTP 服务中间件列表的最前面,它的调用通常跟图中所指位置是类似的:放置于左下方,早于右边的其他调用。

然后,我们可以采取以下策略来管理上下文:

建立一棵树

首先,添加一个 async_hook 回调。在回调的实现中,构建一棵我们程序调用依赖的树:一个 asyncId 触发另外一个 asyncId,就跟上面的图一样。

设置上下文

实现一个新的中间件,同时保证它是所有中间件中的第一个。它的实现逻辑是:

  1. 对于中间件执行的 asyncId 设置需要的上下文信息。

  2. 对于中间件那个 asyncId 的所有祖先节点,依次回溯,如果它没有设置任何上下文信息,则复制中间件设置的那个上下文信息。

    扫描二维码关注公众号,回复: 16076546 查看本文章

看个图比较好理解:

设置上下文

图中涂黄色的部分就是被标记上下文信息的节点。正如你所看到的,第一个请求(左边),它会把一些本不属于请求本身的一些节点都给标记了。这是可以接受的,因为一旦有一个请求把这些节点标记了,后续的其他请求就会“尊重”它的“决定”,并且借此看到彼此之间的边界。很容易证明,只有第一个请求会这么做。

查询上下文

  1. currentId 等于 asyncId (当前的 asyncId)。

  2. 查找 currentId 的父节点有没有上下文信息:

    1. :复制父节点的上下文信息到 currentId,并返回上下文信息。

    2. :令 currentId 等于父节点的 asyncId,继续执行第 2 步,直到找到上下文信息并返回。

下图中的青色部分就是查询的工作原理:

查询工作原理

每次查询都是从查询的调用开始,然后依次到它的父节点以及所有祖先节点。一旦查询结束,所有途经的节点都会被标记正确的上下文信息。很容易发现,这种算法的平均时间复杂度时 O(1)(递归查询的次数不会多于程序的调用深度)。

优势

  • 无需关心业务程序用的是什么库,以及应该怎样打 Monkey Patch。

  • 计算复杂度与 Monkey Patch 一致。

劣势

  • 这显然是一个更复杂的实现。

  • 如果某个库的实现,对于两个独立的请求会在同一个 async resource 里面执行,那么这种方法是不奏效的。在我们的用例中,目前还没有发现这样的情况。

  • 需要精心管理上下文的存储以避免内存泄漏。

  • 如果需要避免上下文的混乱,要严格做到:

    • 上下文的设置必须先于查询。

    • 对于一个请求上下文,只可以被设置一次(否则本来属于同一个请求的信息会分裂成多个)。

生活并不尽如人意

我们说过这是一个更复杂的实现,对吧?尽管我们已经有了一个可以使用的实现,但是还存在一些问题需要解决。

问题一:async_hook 的生命周期

当你第一次看到 async_hook 的 API 的时候,你可能会有以下一些先入为主的假定:

  1. init /  before /  after /  destory 会且仅会执行一次。

  2. 祖先的  after / destory 一定会在后代的相应方法之后执行。

然而事实并非如此。跟直觉不大一致的是,是有可能不执行  before /  after的。同时,祖先的  after / destory 可能先于后代的发生。

为了证明这一点,请看这个代码:

/**
* This file is to demonstrate async_hooks life cycle is a little bit out of normal feelings:
* 1. ancestor's destroy may earlier than successor's before / after / destroy
* 2. there can have no before / after, but only have init / destroy
*
* BUT, the things we can believe are:
* 1. for the same stack:
*    init / before / execEnd / after / destroy are happening in strict order
* 2. ancestor's init / before / execStart is strictly earlier than successor's one
*/

const asyncHooks = require('async_hooks');
const treeify = require('treeify');
const fs = require('fs');


let timeId = 0;
let initAndNotDestroyed = 0;
const timeInfo = {};
const tree = {};
const nodeMap = {};


function travel(tree) {
  const ret = {};
  Object.keys(tree).forEach(key => {
      const subTree = tree[key];
      const info = timeInfo[key];
      let newName = key;
      if (info) {
          const order = Object.keys(info)
              .sort((a, b) => info[a] - info[b])
              // .filter(key => key === "init")
              .map(key => `${key}(${info[key]})`)
              .join(', ')
          newName = `${key}: ${order}`;
      }
      ret[newName] = travel(subTree);
  });
  return ret;
}

function init(asyncId, type, triggerAsyncId, resource) {
  timeInfo[asyncId] = {
      init: timeId++
  };
  const current = {};
  nodeMap[asyncId] = current;
  if (!nodeMap[triggerAsyncId]) {
      const tmp = {};
      nodeMap[triggerAsyncId] = tmp;
      tree[triggerAsyncId] = tmp;
  }
  nodeMap[triggerAsyncId][asyncId] = current;
  initAndNotDestroyed++;
}
function before (asyncId) {
  if (!timeInfo[asyncId]) {
      fs.writeSync(1, `before, not inited: ${asyncId}\n`);
      return;
  }
  timeInfo[asyncId].before = timeId++;
}
function after(asyncId) {
  if (!timeInfo[asyncId]) {
      fs.writeSync(1, `after, not inited: ${asyncId}\n`);
      return;
  }
  timeInfo[asyncId].after = timeId++;
}
function destroy(asyncId) {
  timeInfo[asyncId].destroy = timeId++;
  initAndNotDestroyed--;
}
function setExeStartTime() {
  timeInfo[asyncHooks.executionAsyncId()].execStart = timeId++;
}

function setExecEndTime() {
  timeInfo[asyncHooks.executionAsyncId()].execEnd = timeId++;
}


const asyncHook = asyncHooks.createHook({ init, before, after, destroy });
asyncHook.enable();


const ASYNC = 0;
const SET_TIMEOUT = 1;
const PROMISE = 2;

const TYPES = [ASYNC, SET_TIMEOUT, PROMISE];

const sleep = (time) => new Promise(resolve => {
  setTimeout(() => {
      resolve();
      setExecEndTime();
  }, time);
  setExecEndTime();
});

function chain(ctx, types, first, idx = 0) {
  if (idx > types.length) {
      return;
  }
  const time = (first ? 50 : 0) + 50 * idx;

  switch(types[idx]) {
      case ASYNC:
          (async function foo () {
              await sleep(time);
              setExeStartTime();
              chain(ctx, types, first, idx + 1);
              setExecEndTime();
          })();
          break;
      case SET_TIMEOUT:
          setTimeout(function() {
              setExeStartTime();
              chain(ctx, types, first, idx + 1);
              setExecEndTime();
          }, time);
          break;
      case PROMISE:
          sleep(time)
              .then(() => {
                  setExeStartTime();
                  chain(ctx, types, first, idx + 1);
                  setExecEndTime();
              })
          break;
  }
  setExecEndTime();
}

const typeOne = [];
const typeTwo = [];

for (let i = 0; i < TYPES.length; i++) {
  for (let j = 0; j < TYPES.length; j++) {
      typeOne.push(TYPES[i]);
      typeTwo.push(TYPES[j]);
  }
}

clearTimeout(setTimeout(() => {
  console.log("i never log");
}, 100))

setTimeout(function() {
  const ctx = '>>>'
  // set(ctx);
  chain(ctx, typeOne, true);
}, 0);

setTimeout(function() {
  const ctx = '<<<'
  // set(ctx);
  chain(ctx, typeTwo, false);
}, 0);

setTimeout(function() {
  asyncHook.disable();
  console.log(treeify.asTree(travel(tree)));
}, 10000);

它的输出:

$ node async_hook_life_cycle_test.js
└─ 1
 ├─ 5: init(0), destroy(7)
 ├─ 6: init(1), destroy(8)
 ├─ 7: init(2), before(10), execEnd(18), after(19), destroy(32)
 │  ├─ 12: init(11)
 │  │  └─ 16: init(16)
 │  │     └─ 17: init(17), before(66), execStart(67), execEnd(75), after(76)
 │  │        ├─ 30: init(68)
 │  │        │  └─ 33: init(72)
 │  │        │     └─ 34: init(73), before(104), execStart(105), execEnd(113), after(114)
 │  │        │        ├─ 43: init(106)
 │  │        │        │  └─ 46: init(110)
 │  │        │        │     └─ 47: init(111), before(138), execStart(139), execEnd(142), after(143)
 │  │        │        │        └─ 51: init(140), before(158), execStart(159), execEnd(162), after(163), destroy(166)
 │  │        │        │           └─ 56: init(160), before(172), execStart(173), execEnd(177), after(178), destroy(193)
 │  │        │        │              ├─ 57: init(174), before(196), execStart(197), execEnd(204), after(205), destroy(218)
 │  │        │        │              │  ├─ 65: init(198)
 │  │        │        │              │  │  └─ 68: init(202), before(236), execStart(237), execEnd(243), after(244)
 │  │        │        │              │  │     ├─ 74: init(238)
 │  │        │        │              │  │     │  └─ 76: init(241), before(261), execStart(262), execEnd(269), after(270)
 │  │        │        │              │  │     │     ├─ 77: init(263)
 │  │        │        │              │  │     │     │  └─ 80: init(267), before(279), execStart(280), execEnd(282), after(283)
 │  │        │        │              │  │     │     ├─ 78: init(264), before(275), execEnd(276), after(277), destroy(284)
 │  │        │        │              │  │     │     └─ 79: init(265), before(274), after(278), destroy(285)
 │  │        │        │              │  │     └─ 75: init(239), before(252), execEnd(253), after(254), destroy(272)
 │  │        │        │              │  ├─ 66: init(199), before(222), execEnd(223), after(224), destroy(245)
 │  │        │        │              │  └─ 67: init(200), before(221), after(235), destroy(247)
 │  │        │        │              └─ 58: init(175), before(195), after(209), destroy(220)
 │  │        │        ├─ 44: init(107)
 │  │        │        │  └─ 48: init(115), before(129), after(130)
 │  │        │        └─ 45: init(108), before(123), execEnd(124), after(125), destroy(145)
 │  │        ├─ 31: init(69)
 │  │        │  └─ 35: init(77), before(101), after(102)
 │  │        └─ 32: init(70), before(85), execEnd(86), after(87), destroy(117)
 │  ├─ 13: init(12)
 │  │  └─ 23: init(30), before(64), after(65)
 │  ├─ 14: init(13), before(50), execEnd(51), after(52), destroy(78)
 │  └─ 15: init(14), before(49), after(63), destroy(80)
 ├─ 8: init(3), before(34), after(38), destroy(48)
 ├─ 9: init(4), before(20), execEnd(27), after(28), destroy(33)
 │  ├─ 18: init(21)
 │  │  └─ 21: init(25)
 │  │     └─ 22: init(26), before(41), execStart(42), execEnd(45), after(46)
 │  │        └─ 25: init(43), before(53), execStart(54), execEnd(61), after(62), destroy(79)
 │  │           ├─ 26: init(55)
 │  │           │  └─ 29: init(59), before(89), execStart(90), execEnd(99), after(100)
 │  │           │     ├─ 36: init(91)
 │  │           │     │  └─ 40: init(96)
 │  │           │     │     └─ 41: init(97), before(131), execStart(132), execEnd(136), after(137)
 │  │           │     │        ├─ 49: init(133), before(148), execStart(149), execEnd(156), after(157), destroy(165)
 │  │           │     │        │  ├─ 52: init(150)
 │  │           │     │        │  │  └─ 55: init(154), before(180), execStart(181), execEnd(189), after(190)
 │  │           │     │        │  │     ├─ 59: init(182)
 │  │           │     │        │  │     │  └─ 62: init(186)
 │  │           │     │        │  │     │     └─ 63: init(187), before(212), execStart(213), execEnd(216), after(217)
 │  │           │     │        │  │     │        └─ 69: init(214), before(225), execStart(226), execEnd(233), after(234), destroy(246)
 │  │           │     │        │  │     │           ├─ 70: init(227)
 │  │           │     │        │  │     │           │  └─ 73: init(231), before(256), execStart(257), execEnd(259), after(260)
 │  │           │     │        │  │     │           ├─ 71: init(228), before(249), execEnd(250), after(251), destroy(271)
 │  │           │     │        │  │     │           └─ 72: init(229), before(248), after(255), destroy(273)
 │  │           │     │        │  │     ├─ 60: init(183)
 │  │           │     │        │  │     │  └─ 64: init(191), before(210), after(211)
 │  │           │     │        │  │     └─ 61: init(184), before(206), execEnd(207), after(208), destroy(219)
 │  │           │     │        │  ├─ 53: init(151), before(169), execEnd(170), after(171), destroy(192)
 │  │           │     │        │  └─ 54: init(152), before(168), after(179), destroy(194)
 │  │           │     │        └─ 50: init(134), before(147), after(164), destroy(167)
 │  │           │     ├─ 37: init(92)
 │  │           │     │  └─ 42: init(103), before(127), after(128)
 │  │           │     ├─ 38: init(93), before(120), execEnd(121), after(122), destroy(144)
 │  │           │     └─ 39: init(94), before(119), after(126), destroy(146)
 │  │           ├─ 27: init(56), before(82), execEnd(83), after(84), destroy(116)
 │  │           └─ 28: init(57), before(81), after(88), destroy(118)
 │  ├─ 19: init(22)
 │  │  └─ 24: init(31), before(39), after(40)
 │  └─ 20: init(23), before(35), execEnd(36), after(37), destroy(47)
 ├─ 10: init(5), before(287)
 └─ 11: init(6), before(286)

仔细观察,你会发现:

  1. before/  after 是可能没有的(见 5、6)。

  2. 祖先的 destory 会比后代早(见 7 vs 51,destroy(32)destroy(166) 早)。

所以,如果我们在 init 的时候初始化上下文,在 destory 的时候清理掉,就可能出现栈顶部(当前执行的代码,对于上面图中的树,就是叶子节点)的调用没办法找到对应的上下文信息。

但是,如果你不清理掉那些上下文信息,你就会有内存泄漏。

不过我们也发现,有两件事情是确定的:

  1. 对于同一个调用  init /  before /  execEnd /  after /  destory 是有顺序的。

  2. 祖先的  init /  before /  execStart 是早于后代的。

所以,这里我们介绍使用引用计数的方式解决这个问题:

init

当前的上下文信息是被当前执行引用的,同时对于父节点的引用计数加一。

destory

  1. 清除对自己上下文的引用。

  2. 如果当前的上下文信息没有其他子孙节点引用,则可以删除;同时删除对父节点的引用。

  3. 看看父节点是不是也需要这样的处理。

问题二:上下文错乱

正如前面提到的,上下文错乱只会发生在:

  1. 查询上下文发生在设置之前。

  2. 同一个请求设置了多次上下文。

更严重的是,这种错乱没办法检测到。不过如果第一个请求做对了,并且打上标记,后面的请求可以通过以下方式检测到上述问题:

  • 对于查询(Query):如果它发现自己碰到了第一个请求所设置的上下文(根据打上的标记),那么它就可以推断出当前请求的上下文没有设置。

  • 对于设置(Set):如果它发现自己碰到了第一个请求所设置的上下文,推断出没有在同一个请求中设置两次上下文。

所以,要避免错乱,我们只能设置上下文信息一次,并且要先于查询发生。这个对于我们的应用场景是没有问题的。换句话说,只要正确使用,我们就不会有错乱的问题。

问题三:Destory 并没有调用

这个问题当前(2018年12月)应该已经解决了,详细详情参阅Github/node#19859 [1]。

当我们把代码部署到生产环境,我们发现一些内存泄漏的问题:

作为比较,这个是一星期前的样子:

为了调研问题,我们弄了一个计数器:

  • 在  init 的时候 +1

  • 在  destroy 的时候 -1

下面是一天半时间的图:

未 destory 的异步资源随时间增长(暴跌是由于 pm2 自动重启)

我们可以看发现, destroy 并没有跟 init 一样等量地调用。当我们深挖这个问题的时候,我们发现,是我们使用了 Keep-alive 配置的 HTTP Client 导致 destroy 没有调用。它只会在连接断开的时候调用  destroy,但是生产环境有持续的请求,所以连接通常都不会断开的。

为了解决这个问题,我们使用了一个简单的分代垃圾回收算法:

  1. 两个 Map:old vs new。

  2. 对于 set,总是写到 new 那个 Map。

  3. 对于 get,在 new 里面看看有没有,如果没有,继续去 old 找,如果找到了,复制到 new。

  4. 在一定周期内,例如 5 分钟,删掉 old,old 指向 new,new 指向一个空的 Map。

因为我们绝大多数请求都是在几秒内结束的,所以每 5 分钟删掉没用过的数据是一个安全的选择。同时,这种实现并没有造成性能的下降。

结论

通过使用  async_hook ,使得我们统计和追踪都容易了很多。想要了解更多细节的话,你可以去看下我们基于这个算法实现的开源项目 envoy-node [2]。欢迎给我们留言或者提供反馈!

  • [1] Github Node Issue #19859 Reusing HTTP connection lead to no destroy triggered: https://github.com/nodejs/node/issues/19859

  • [2] envoy-node: https://github.com/Tubitv/envoy-node

关于作者

程颖宇,Tubi 中国团队高级后端工程师。毕业后曾在微软工作三年,负责必应 Ad Insights API 和 Middle Tier 平台的研发工作。16 年加入刚刚组建的 Tubi 中国团队,先后负责 Adbreak Finder、Clip Pipeline 等多个重点项目,现在主要从事微服务基础设施以及 Ad Server 的研发工作。

第八期 Scala Meetup 火热报名中,欢迎参与:抢位|Scala Meetup 线上回归! 预约报名-Tubi TV活动-活动行 

猜你喜欢

转载自blog.csdn.net/weixin_49193714/article/details/128410495