Skip to content

Latest commit

 

History

History
796 lines (622 loc) · 31.1 KB

File metadata and controls

796 lines (622 loc) · 31.1 KB

解读点:timer的运行原理。

[TOC]

一.故事

接待顾客,是10010店铺最主要的功能;从外人看来,这就是店铺的全部。

然而一个店铺要正常运转,它内部还会有很复杂的流程,比如进货,货物加工处理,摆放货物,客户交易后的后续处理等等。

要把一个店铺经营到完美,对于时间控制,便是一个非常重要的能力,很多场景都会用到它。比如:

  • 客户一段时间没反应,店铺便将分配给该客户的资源(蓝色篮子)收回
  • 店铺后端资源一段时间后没有准备好(比如搜索不到某个货物),店铺便提前告知客户。
  • 客户要求对食物加热3分钟

1. 加热食物

我们拿客户要求加热食物来举例,详细看下。

某一天,“10010店铺”来了很多客人,基本上都是来买“八宝桂圆粥”的。由于需求量太大,因此店铺提前将粥准备好,放到了冰箱。 不巧,今天天气骤降,很多客人要求把粥加热一下。有的要求稍微加热,不那么凉即可;有的要求要喝滚烫的粥。

  • 客户A要求加热1分钟
  • 客户B要求加热5分钟
  • ...
  • 客户N要求加热2分钟20秒

2.第一个方案

“10010店铺”准备了很多加热的炉子,通过设定加热时间,来满足大家的需求。

加热食物

此时,处理流程如下:

  • 给每一位客人分配一个“带有计时器能力的炉子”
  • 炉子加热时间一到,便发出叮咚一声响,通知机器人
  • 机器人从一大片炉子中,找到发出声响的那几个炉子,将“加热好的食物”转交给客人

当客流量小的时候,这种方案可以正常运转。

但是问题来了。

当客人很多的时候,加热的炉子也很多。在某一个时刻(或者某一小段时间内),会有多个炉子加热完毕。此时将会出现一大片“叮咚”的响声。即使是机器人也会乱掉。

3. 第二个方案

为了有序管理,“10010店铺”改进了一下,把"加热完成的绝对时间"相同的放在一排,这样一旦加热完成,只会有一排炉子需要处理。这样机器人就轻松多了。

有序加热食物

改进后的处理流程如下:

  • 每一份要加热的食物,机器人首先计算出“加热完成的绝对时间”
  • 然后把“加热完成的绝对时间”相同的食物,放到同一排炉子。每一排仅设置一个闹钟。
  • 机器人手里拿着一张“登记表”,上面记录:
    • 2020年3月1日10点8分0秒:第一排
    • 2020年3月1日10点9分6秒:第二排
    • ...
    • 2020年3月1日10点15分12秒:第N排
  • 加热时间一到,机器人听到一声叮咚响声:
    • 机器人查看当前时间为2020年3月1日10点8分0秒
    • 机器人查看自己手里的“登记表”,查到是第一排
  • 机器人走到第一排,把这一排所有的炉子中的食物取出,交给客户

这个方案,也有一个隐藏的缺陷: 因为店铺要长久运行,所有会导致“加热完成的绝对时间”随着时间流逝,越来越多。因为顾客不断到来,越来越多新的“加热完成的绝对时间”产生。如果店铺要不间断运行半个月甚至半年,机器人记录的“登记表”将会爆炸。

程序运行也要考虑这个问题,用户请求越来越多,随着时间累积,代码内存中要维护的链表就回越来越多。

为了解决这个问题,就需要通过“时间换空间”这种模式,来解决“登记表”爆炸的情况。

4.第三个方案

考虑到大家对于食物加热的普适性需求,大多数人会说:

  • ”给我加热1分钟“
  • ”给我加热3分钟“
  • ”给我加热2分钟“

很少出现以下情形:

  • ”给我加热67秒“
  • ”给我加热38秒“

对应于程序,大多数setTimeout的时间设置会是:”200ms“、”1秒“、”60秒“、”1分钟“、”2分钟“。很少出现:”1017ms“、”21ms“(当然如果业务需要,这样的场景也要支持)。

因此,新方案将”加热完成的绝对时间“,改为”加热完成的相对时间“,我们来看下改进后的效果:

相对时间加热食物

细心的读者会发现,第一排是”2分钟“,第二排是”1分钟“。并不是严格按照相对时间进行排序的。

这是因为,相对时间短的,不一定绝对时间早。比如: ”10点29分30秒“来的顾客A要求加热”2分钟“,”10点31分00秒“来的顾客B要求加热”1分钟“。虽然顾客A加热相对时间是2分钟,但是却先于顾客B加热完成。

改进后的处理流程如下:

  • 新来顾客,要求加热“x分钟”
  • 机器人检测有没有“x分钟”的队列,如果没有,则新建一个“x分钟”的队列
  • 把新顾客的炉子,放到“x分钟”队列的“队尾”
  • 任何时候,整个店铺,维护有且只有一个“时间A”:“加热完成的最早时间(绝对时间)”。
  • 机器人检测新顾客的“加热完成的绝对时间”,然后和“时间A”对比。如果该顾客的加热完成时间早于“时间A”,那么便更新“时间A”(拿该顾客的加热完成时间覆盖时间A)
  • 机器人每隔一段时间,便看下时间,如果发现到了“时间A”,便来到“最左侧”的队列,然后从队列头部依次查看炉子
    • 如果炉子加热完成了,便交给顾客。
    • 如果炉子还没加热完成,那么该队列后面的炉子,以及其他的队列,肯定没有加热完成。所以机器人不再查看,就可以去休息了。

为了清晰易懂,这里举两个例子来讲解。

例子1:

  • 新来一个顾客A,要求加热2分钟,机器人检测已经有一排是2分钟,于是将顾客A的炉子,放到“2分钟”那一排的队尾。
  • 结束

此时结果如下图: 新增2顾客

例子2:

  • 新来一个顾客A,要求加热1.5分钟,机器人检测到还没有“1.5分钟”的队列,因此新建一个“1.5分钟”的队列
  • 把顾客B的炉子放到队列尾部(此时是第一个)
  • 将“1.5分钟”队列和已存在的队列(“1分钟”队列,“2分钟”队列,“3分钟”队列)对比,看放在第几排。
    • 因为每个队列的“队首”是最早的,因此只需要对比每个队列的第一个顾客的加热完成时间即可。
    • 经过计算,机器人发现“1.5分钟”队列, 其第一个用户的加热完成的绝对时间,处于“1分钟队列”和“3分钟队列”之间(对比第一个用户的加热完成的绝对时间),于是便放在二者之间
  • 结束

注:这里为方便理解,顺序上做了调整:先放置炉子,再对比队列。 实际nodejs是先对比队列,然后再放置timer对象。

此时结果如下图:

新增1.5分钟顾客

二.分析和对照

常见的timer有setTimeout,setInterval。setInterval其实是一种特殊的setTimeout。 每创建一个setTimeout,便对应故事场景中,机器人给客人分配一个炉子加热食物。

1.原理分析

一个项目代码中,可能会创建很多的setTimeout,少则十几个,多则上千个都有可能。要精确管理这些时间对象,在合适的时间触发回调,必须有合理严格的设计方案。

nodejs便是通过链表+堆,来完成timer对象的管理。具体如下:

  • 每创建一个timer对象,nodejs会先计算出该timer对象的过期时间(相对时间)。
  • 然后根据过期时间(相对时间),创建一个链表list(TimersList实例),并把该timer对象放到此链表中。
  • 给新创建的链表分配一个标识。其实就是往一个公用对象timerListMap中,设置一个key:value,key是过期时间(相对时间),value是链表本身

这样,后续再创建新的timer对象,nodejs还是先计算出过期时间(相对时间),然后去timerListMap查找有没有对应到期时间的链表。如果有,则直接插入到对应的链表中。如果没有,则重新创建链表,并塞到timerListMap中。

2.关联

  • 加热的炉子 --> timer对象
  • 炉子所在的排 --> 链表list

三. nodejs源码解读

1. 解读入口

先看一段常见的业务代码:

// 业务代码:
setTimeout(() => {
    console.log('do something after 3 mins');
}, 60 * 1000 * 3)

很简单,就是在3分钟后,执行一段逻辑。类似于故事场景中,微波炉设置3分钟。

2. 源码解读

setTimeout是全局函数,我们来看看它的定义:

// 文件位置:/lib/timers.js
function setTimeout(callback, after, arg1, arg2, arg3) {
  ... // 参数准备相关工作,这里不展开
  const timeout = new Timeout(callback, after, args, false, true);
  insert(timeout, timeout._idleTimeout);
  return timeout;
}

可以看到,setTimeout做了两件事:

  • 就是创建一个timer实例timeout
  • 然后将timeout插入到链表中。

先看第一部分。

2.1 创建一个timer实例

新创建的timer实例,其实是一个Timeout对象。我们来看下这个构建函数的代码:

// 文件位置: /lib/internal/timers.js
function Timeout(callback, after, args, isRepeat, isRefed) {
  ...

  this._idleTimeout = after;
  this._idlePrev = this;
  this._idleNext = this;
  this._idleStart = null;
  // This must be set to null first to avoid function tracking
  // on the hidden class, revisit in V8 versions after 6.2
  this._onTimeout = null;
  this._onTimeout = callback;
  this._timerArgs = args;
  this._repeat = isRepeat ? after : null;
  this._destroyed = false;

  ...
}

很简单,就是设置几个属性。

从属性上看(_idleNext,_idleNext),很明显,这个对象肯定是要插入链表的。

这里先剧透一下整体工作原理: nodejs会维持一个链表,每次调用setTimeout都会创建的实例,并插入到这个链表中。 然后libuv会轮询这个链表,并在合适的时候触发链表中每个实例对应的回调。

那么这个链表是在哪创建的呢?又是怎么插入的?下面一节我们来展开。

2.2 创建链表,将timer实例放进去

上一节中,创建完timer实例后,调用了以下代码:“insert(timeout, timeout._idleTimeout)”。

也就是在insert这里,同时完成了“创建链表”,“把timer对象放进链表”这两个任务。

下面我们来看代码。

// 文件位置:/lib/internal/timers.js
function insert(item, msecs, start = getLibuvNow()) {
  ...
  let list = timerListMap[msecs];
  if (list === undefined) {
    ...
    const expiry = start + msecs;
    // 1. 创建一个链表 list,并放到map中备用
    timerListMap[msecs] = list = new TimersList(expiry, msecs);
    // 2. 同时把链表插入到专用队列timerListQueue中
    timerListQueue.insert(list);

    if (nextExpiry > expiry) {
      // 3. 给libuv传递一个信号,表示用户设置了一个“msecs”的timer(注意这里只有时间信息,其他libuv不需要关注)
      scheduleTimer(msecs);
      nextExpiry = expiry;
    }
  }
  // 4. 将timer实例,插入到新建的链表list中
  L.append(list, item);
}

insert主要做了4件事:

  • 创建一个链表:list = new TimersList(expiry, msecs)
  • 将链表list存放到专用队列:timerListQueue.insert(list);
  • 给libuv传递一个信号,表示有一个“msecs”的timer实例:scheduleTimer(msecs);
  • 将timer实例插入到新建的链表list中。

接下来我们来一一分解这四件事。

2.2.1 创建链表

nodejs会用链表来存储创建的timer实例。但是有一点需要注意,nodejs不是只维护一个链表,而是根据timer的时间,维护多个链表。

举例来讲,setTimeout(fn1, 1000) 和setTimeout(fn2, 2000)两个timer实例,是在两个链表中维护的。

业务开发中,可能会创建很多不通时间的timer实例,nodejs对应的就会维护多个链表。所有的链表通过一个map对象维护起来,就是timerListMap。

timerListMap的key就是延迟时间,值就是链表。

// timerListMap结构:
{
  "1000": list1,
  "2000": list2,
  ...
  "3001": listn
}

所以insert函数第一步要做的事情,就是去timerListMap中查询是否已经有对应的链表存在;如果存在,那么取出这个存在的链表;如果不存在,则新建一个链表。

新建链表是通过“list = new TimersList(expiry, msecs);”这个语句实现的。可以看出,新链表就是TimerList实例。

TimerList是个典型的双向链表,有以下特征:

  • 链表中的节点首尾相连
  • 链表中有个“特殊节点”,代表这个链表自身(其实它和其他节点没什么区别,就是多存储了一些链表的信息,如链表id,过期时间等)
  • 节点都有两个属性,一个是_idlePrev,指向它的上一个节点;一个是_idleNext,指向它的下一个节点。

双向链表图解

还有一种解读视角,是将节点想象为表盘上的点:顺时针向下表示next;逆时针向上表示pre;其中12点或者0点,表示链表的根节点。

链表创建完成后,额外地将链表存放到映射对象timerListMap中,方便后续读取整个链表。

双向链表一般都会具备以下功能:

  • 追加一个新节点(在链表末尾):append
  • 删除一个节点: remove
  • 获取链表的头部节点(一般是最先插入的): peek

读到这里,js开发者会理所当然地认为,这些功能,应该是实例list下的方法,需要用的时候,直接调用即可。

比如追加一个节点,就是:list.append(node1)。

然而现实并不是这样。nodejs另外有一个专属工具来辅助完成。

这个专属工具就是L:

// 文件位置:/lib/internal/timers.js
const L = require('internal/linkedlist');

翻看L的源码,发现它没有任何业务含义,只是纯粹的工具函数,拥有四个方法,没有任何状态。

  • init,
  • peek,
  • remove,
  • append,
  • isEmpty

通过这个工具函数,我们便可以操作新建的链表,比如:

  • 追加一个节点:L.append(list, node1)
  • 删除一个节点:L.remove(node2)
  • 获取链表头部节点:L.peek(list);

2.2.2 将链表list存放到专用队列

链表是管理timer节点的。然而链表也会有多个,怎么管理链表呢?

因为可能会创建很多timer,并且每个的过期时间不同,所有nodejs会有很多的链表。 每个链表存放相同过期时间的timer。

答案是:nodejs额外维护了一个专用队列,这个队列是一个“优先队列”,新创建完链表都会插入到这个优先队列中进行管理。

然后nodejs会检测这个优先队列,找到最先过期的链表。如何快速找到最先过期的链表,是这个优先队列要解决的问题。

实现方式有很多,不过最常见的便是用最小二叉堆来实现这个优先队列。

2.2.2.1 最小二叉堆表示优先队列

我们先看下二叉堆。

二叉堆本质是一棵二叉树,且最常见的是一棵完全二叉树。只不过它有一个额外的要求:父节点必须大于/小于子节点。

如果是父节点大于子节点,那么这个二叉堆便是最大二叉堆; 如果是父节点小于子节点,那么这个二叉堆便是最小二叉堆;

由于nodejs这里的“优先队列”维护的是过期时间这个概念,最先过期的要优先找到,因此最小二叉堆便是我们选择的方案。

优先队列,我们采用最小二叉堆这个方案。

选定了方案,再来看实现最小二叉堆具体的实现方式。

实现二叉堆有两种方式,一种是链表,一种是数组。由于数组的简洁性,因此大多数二叉堆都是采用数组来实现。

2.2.2.2 采用数组实现最小二叉堆

先看算法设计:

二叉堆和数组之间存在一个奇妙的对应关系:

  • 将二叉堆中的节点,按照“从上到下”,“从左到右”地,一一放到数组中去(一般数组中第一个元素空置,从下标1位置开始放)
  • 数组中任何一个元素(假设index为n),它的子元素是2n、2n+1;它的父元素是"n/2 | 0"(即Math.floor(n/2))。

再看实现:

由于我们是用数组来实现最小二叉堆,因此最开始我们初始化一个空数组arr。

  • 最开始二叉堆是空的,新增一个节点,则往数组arr中push一个元素(从下标1开始)
  • 当继续新增节点时,还是往数组中push元素,然后执行percolateup算法,进行排序。

我们来看下percolateup代码,非常简单:

// 文件位置:/lib/internal/priority_queue.js
percolateUp(pos) {
    const heap = this[kHeap];
    const compare = this[kCompare];
    const setPosition = this[kSetPosition];
    const item = heap[pos];

    while (pos > 1) {
      // 找到父元素
      const parent = heap[pos / 2 | 0];
      // 如果比父元素小,则不用再排了,直接结束
      if (compare(parent, item) <= 0)
        break;
      // 如果比父元素大,现将父元素转移下来
      heap[pos] = parent;
      if (setPosition !== undefined)
        setPosition(parent, pos);
      // 接着讲pos换成父元素的位置,继续比较
      pos = pos / 2 | 0;
    }
    // 比较完成后,确定新节点的位置后,便把它插入。
    heap[pos] = item;
    if (setPosition !== undefined)
      setPosition(item, pos);
  }
2.2.2.3 将timer链表插入优先队列

我们来看一下nodejs中插入专用队列的代码:

// 文件位置:/lib/internal/timers.js
// list是刚刚创建的链表;timerListQueue就是专用队列
timerListQueue.insert(list);

timerListQueue是就是我们要的优先队列,它其实是一个PriorityQueue实例。

// 文件位置:/lib/internal/timers.js
const timerListQueue = new PriorityQueue(compareTimersLists, setPosition);

PriorityQueue的实例,即典型的二叉堆(binary heap)。

timerListQueue这个二叉堆的插入和排序如下:

// 文件位置:/lib/internal/priority_queue.js
insert(value) {
    // 1. 插入新节点
    const heap = this[kHeap];
    const pos = ++this[kSize];
    heap[pos] = value;

    if (heap.length === pos)
      heap.length *= 2;
    // 2. 排序
    this.percolateUp(pos);
  }

this.percolateUp参见上一小节的代码

2.2.3 给libuv传递一个信号,表示有一个“msecs”的timer实例:scheduleTimer(msecs);

创建完了链表,并且将链表管理起来(插入优先队列)后,就可以发送一个信号给libuv,告诉它,业务这里有新建了timer,以便libuv能适当处理。

怎么告诉libuv呢? 我们来看代码

// 文件位置:/lib/internal/timers.js
function insert(item, msecs, start = getLibuvNow()) {
  ...
  let list = timerListMap[msecs];
  if (list === undefined) {
    ...
    // 插入优先队列
    timerListQueue.insert(list);
    ...
    const expiry = start + msecs;
    ...
    // 如果新增的链表过期时间比上一次最小的过期时间还早,那么就通知livuv
    if (nextExpiry > expiry) {
      scheduleTimer(msecs);
      nextExpiry = expiry;
    }
  }
  ...
}

从代码中可以看出,如果新的链表的过期时间比最短的过期时间还早(比如上一次是12点5分, 而此次的过期时间是12点1分),那么就有必要告诉libuv,说最近的过期时间要调整一下啦。

我们来看下scheduleTimer函数:

// 文件位置:/src/timers.cc
void ScheduleTimer(const FunctionCallbackInfo<Value>& args) {
  auto env = Environment::GetCurrent(args);
  env->ScheduleTimer(args[0]->IntegerValue(env->context()).FromJust());
}

// 文件位置:/src/env.cc
void Environment::ScheduleTimer(int64_t duration_ms) {
  if (started_cleanup_) return;
  uv_timer_start(timer_handle(), RunTimers, duration_ms, 0);
}

可以看出,ScheduleTimer最终调用了libuv中的uv_timer_start。

uv_timer_start就比较纯粹了,它无法做了三件事:

  • 设置到期时要执行的回调函数
    • handle->timer_cb = cb;
    • 这里的cb就是uv_timer_start(timer_handle(), RunTimers, duration_ms, 0)中的RunTimers。
  • 往最小堆中插入新的节点
    • heap_insert(timer_heap(handle->loop), (struct heap_node*) &handle->heap_node, timer_less_than);
    • 注意这里的最小堆是libuv维持的,和nodejs中的优先队列最小堆不一样
  • 将handle设置为激活状态:
    • uv__handle_start(handle);
// 文件位置:/deps/uv/src/timers.c
int uv_timer_start(uv_timer_t* handle,
                   uv_timer_cb cb,
                   uint64_t timeout,
                   uint64_t repeat) {
  uint64_t clamped_timeout;

  if (uv__is_closing(handle) || cb == NULL)
    return UV_EINVAL;

  if (uv__is_active(handle))
    uv_timer_stop(handle);

  clamped_timeout = handle->loop->time + timeout;
  if (clamped_timeout < timeout)
    clamped_timeout = (uint64_t) -1;

  handle->timer_cb = cb;
  handle->timeout = clamped_timeout;
  handle->repeat = repeat;
  /* start_id is the second index to be compared in timer_less_than() */
  handle->start_id = handle->loop->timer_counter++;

  heap_insert(timer_heap(handle->loop),
              (struct heap_node*) &handle->heap_node,
              timer_less_than);
  uv__handle_start(handle);

  return 0;
}

新建timer对象的流程小结:

  • 创建一个timer对象
  • 插入链表 (如果对应的链表不存在则新建链表)
  • 如果新建了链表,则将新链表放到优先队列中
  • 如果新建的timer对象比以前的更快过期,则调用scheduleTimer, 告知libuv。

且慢,细心的网友可能会发现一个小小的疑问:每次有新的更短的timer,都要调用scheduleTimer,scheduleTimer 再调用uv_timer_start往libuv最小堆里插入一个timer handle的节点(handle->heap_node,此处的handle为timer handle),而且是同一个节点。这肯定不对。

这和已知的“新版本的nodejs中,libuv最小堆里面只有一个timer handle的节点”冲突了。

新版本的nodejs中,libuv的最小堆中仅仅维护一个节点,即最快到期的节点。 /lib/internal/timers.js中通过优先队列和链表,管理所有的各个过期时间的timer对象。

libuv中,一个timer handle对应有一个heap_node。由于我们每次调用scheduleTimer,都是取的同一个timer handle

说明: nodejs中只维护一个timer handle,每次调用,直接返回&timer_handle_。

// 文件位置:/src/env.cc
void Environment::ScheduleTimer(int64_t duration_ms) {
 ...
 uv_timer_start(timer_handle(), RunTimers, duration_ms, 0);
}
// 文件位置:/src/env-inl.h
(inline uv_timer_t* Environment::timer_handle() {
 return &timer_handle_;
})

所以,在调用uv_timer_start时,应该会做一个检查。

仔细查看,我们发现uv_timer_start会检测uv__is_active(handle),如果是已经激活的状态,那么会首先停止它。

// 文件位置:/deps/uv/src/timers.c
int uv_timer_start(uv_timer_t* handle,
                   uv_timer_cb cb,
                   uint64_t timeout,
                   uint64_t repeat) {
  uint64_t clamped_timeout;

  if (uv__is_closing(handle) || cb == NULL)
    return UV_EINVAL;

  if (uv__is_active(handle))
    uv_timer_stop(handle);

uv__is_active比较简单,直接判断handle的flag状态:

// 文件位置:/deps/uv/src/uv-common.h
#define uv__is_active(h)                                                      \
  (((h)->flags & UV_HANDLE_ACTIVE) != 0)

每个handle都会有flags,可能的取值为: UV_HANDLE_CLOSING = 0x00000001, UV_HANDLE_CLOSED = 0x00000002, UV_HANDLE_ACTIVE = 0x00000004, ...

假如同时设置了两个timer:

setTimeout(() => {}, 5000)
setTimeout(() => {}, 1000)

第一个5秒的timer会将全局唯一的timer handle激活,但是此时由于还没到过期时间,立马来了一个新的更快到期的timer(1秒),此时调用uv__is_active会返回true

此时uv__is_active返回true,因此libuv会调用uv_timer_stop(handle),来停止timer handle。其实停止timer handle主要是完成两件事:

  • 将timer handle变为非激活状态;同时将程序loop的active handles减1
  • 从libuv最小堆中将timer handle对于的节点删除
// 文件位置:/deps/uv/src/timer.c
int uv_timer_stop(uv_timer_t* handle) {
  if (!uv__is_active(handle))
    return 0;

  heap_remove(timer_heap(handle->loop),
              (struct heap_node*) &handle->heap_node,
              timer_less_than);
  uv__handle_stop(handle);

  return 0;
}
// 文件位置:/deps/uv/src/uv-common.h
#define uv__handle_stop(h)                                                    \
  do {                                                                        \
    if (((h)->flags & UV_HANDLE_ACTIVE) == 0) break;                          \
    (h)->flags &= ~UV_HANDLE_ACTIVE;                                          \
    if (((h)->flags & UV_HANDLE_REF) != 0) uv__active_handle_rm(h);           \
  }                                                                           \
  while (0)

删除完后,就可以重新再插入了。

到此为止,创建一个新的timer对象的准备工作就算全部完成了。接下来就交给libuv去决定什么时候触发回调了。

2.3 触发timer实例的回调

libuv是在uv__run_timers这个阶段来处理timer的。我们来看下它的代码:

// 文件位置:/deps/uv/src/timers.c
void uv__run_timers(uv_loop_t* loop) {
  struct heap_node* heap_node;
  uv_timer_t* handle;

  for (;;) {
    heap_node = heap_min(timer_heap(loop));
    if (heap_node == NULL)
      break;

    handle = container_of(heap_node, uv_timer_t, heap_node);
    if (handle->timeout > loop->time)
      break;

    uv_timer_stop(handle);
    uv_timer_again(handle);
    handle->timer_cb(handle);
  }
}

可以看到这里的逻辑很清晰,做了以下几件事:

  • 从最小堆中取出最早过期的节点:
    • heap_node = heap_min(timer_heap(loop));
  • 将该节点从最小堆中删除(如果是setInterval,再重新插入进去)
    • uv_timer_stop(handle);
    • uv_timer_again(handle);
  • 执行节点上绑定的回调
    • handle->timer_cb(handle);
    • 这里的timer_cb就是2.2小节中的RunTimers

到这里,接力棒给到了RunTimers,我们来看下它的代码:

// 文件位置:/src/env.cc
void Environment::RunTimers(uv_timer_t* handle) {
  Environment* env = Environment::from_timer_handle(handle);
  TraceEventScope trace_scope(TRACING_CATEGORY_NODE1(environment),
                              "RunTimers", env);

  ...
  Local<Function> cb = env->timers_callback_function();
  ...
  do {
    ...
    ret = cb->Call(env->context(), process, 1, &arg);
  } while (ret.IsEmpty() && env->can_call_into_js());
  ...
  if (ret.IsEmpty())
    return;
  ...

  if (expiry_ms != 0) {
    env->ScheduleTimer(...);
    ...
  } else {
    uv_unref(h);
  }
}

RunTimers的主要功能:

  • 执行env->timers_callback_function()
  • 判断是否有剩余的链表,如果有,再次编排上env->ScheduleTimer

我们主要看下env->timers_callback_function。它是什么呢?

回想一下,在nodejs进程启动的时候,执行过node.js这个模块,在它里面执行过以下代码:

// 文件位置:/lib/internal/bootstrap/node.js
{
  const { nextTick, runNextTicks } = setupTaskQueue();
  ...
  const { getTimerCallbacks } = require('internal/timers');
  const { setupTimers } = internalBinding('timers');
  const { processImmediate, processTimers } = getTimerCallbacks(runNextTicks);
  ...
  setupTimers(processImmediate, processTimers);
}
// 文件位置:/src/timers.cc
void SetupTimers(const FunctionCallbackInfo<Value>& args) {
  ...
  env->set_immediate_callback_function(args[0].As<Function>());
  env->set_timers_callback_function(args[1].As<Function>());
}

从上面代码中看到,processTimers是/lib/internal/timers.js中导出的一个函数;

而setupTimers,就是将processTimers设置为了timers_call_function。

也就是说,RunTimers中执行的函数,就是 processTimers。

那么我们来看看processTimers:

// 文件位置:/lib/internal/timers.js
  function processTimers(now) {
    debug('process timer lists %d', now);
    nextExpiry = Infinity;

    let list;
    let ranAtLeastOneList = false;
    while (list = timerListQueue.peek()) {
      if (list.expiry > now) {
        nextExpiry = list.expiry;
        return refCount > 0 ? nextExpiry : -nextExpiry;
      }
      if (ranAtLeastOneList)
        runNextTicks();
      else
        ranAtLeastOneList = true;
      listOnTimeout(list, now);
    }
    return 0;
  }

processTimers非常简单,就是从之前讲过的优先队列中取出一个timer链表,判断是否过期:

  • 如果没有过期(返回值为非0),则直接返回这个将来的过期时间(供后面判断)

processTimers是在RunTimers中被调用的,RunTimers执行完processTimers后,会判断其返回值,参见/src/env.cc中的注释: // To allow for less JS-C++ boundary crossing, the value returned from JS // serves a few purposes: // 1. If it's 0, no more timers exist and the handle should be unrefed // 2. If it's > 0, the value represents the next timer's expiry and there // is at least one timer remaining that is refed. // 3. If it's < 0, the absolute value represents the next timer's expiry // and there are no timers that are refed.

  • 如果过期(返回值为0),则清理链表中的timers, 即调用listOnTimeout(list, now);
    • 注意这里会适时地清理tick queue上的人物(runNextTicks())

上面的代码,还有一个重要的信息,即每处理完一次timer的回调逻辑,便清理一次nextTick队列上的任务。

那么最后,我们来看看,如果过期了,nodejs是如何清理timer链表的,即listOnTimeout:

// 文件位置:/lib/internal/timers.js
function listOnTimeout(list, now) {
    const msecs = list.msecs;

    debug('timeout callback %d', msecs);

    let ranAtLeastOneTimer = false;
    let timer;
    // 取出最先插入的timer
    while (timer = L.peek(list)) {
      ...
      L.remove(timer);
      ...

      try {
        const args = timer._timerArgs;
        if (args === undefined)
          timer._onTimeout();
        else
          timer._onTimeout(...args);
      } finally {
        ...
      }
      ...
    }
    ...
  }

这里隐藏其他逻辑,只看核心逻辑,可以看出listOnTimeout的功能:

  • 从链表中取出最先插入的timer:timer = L.peek(list)
  • 执行这个timer的回调:timer._onTimeout();

到此为止,nodejs触发timer回调的流程便结束了。

四.总结:

timer的生命周期可以简单划分为以下三个阶段:

  • 创建timer实例对象
  • 塞入链表
  • 在合适的时机清理链表

而这一切,是通过js世界中的【链表+最小堆】、c++世界中的【最小堆】配合起来,由libuv主导循环运作来实现的。

而c++世界中的【最小堆】,其实只有一个唯一的timer handle,它记录了最先到期的时间。一旦到期,便触发回调,由js世界的代码逻辑开始接手;

在js世界中,所有的timer对象都维护在【最小堆+链表】中;程序按照顺序逐一清理这些timer对象。