探索libuv(3) - 循环!循环!循环!

1,123 阅读7分钟

概述

本章主要介绍libuv最核心的loop以及任务加入loop的过程

探索

回顾

loop的初始化

loop的整体初始化在上一章已经讲过,忘记的读者可以回顾上一章,这里不再浪费篇幅。

uv_loop_t

神奇的数据结构,它储存了一个loop的所有信息。并且可以被uv_default_loop来初始化得到。

// typedef uv_loop_s uv_loop_t
struct uv_loop_s {
  /* User data - use this for whatever. */
  void* data;
  /* Loop reference counting. */
  unsigned int active_handles;
  void* handle_queue[2];
  union {
    void* unused;
    unsigned int count;
  } active_reqs;
  /* Internal storage for future extensions. */
  void* internal_fields;
  /* Internal flag to signal loop stop. */
  unsigned int stop_flag;
  // 不同平台各自私有的实现
  UV_LOOP_PRIVATE_FIELDS
};

我们可以看到,整个uv_loop_t分为两个部分:

  • 每种事件循环都有的共有成员(平台无关)

  • 不同平台独自拥有的私有成员UV_LOOP_PRIVATE_FIELDS(平台相关)

其中,共有成员里最重要的就是handle_queue[2]

handle_queue

顾名思义,handle_queue储存的是handle的队列,那handle又是什么呢?

我们都知道,在libuv里有著名的一幅图

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐  
│  │     pending callbacks     │ 
│  └─────────────┬─────────────┘      
│  ┌─────────────┴─────────────┐      
│  │       idle, prepare       │ 
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

其中几个需要检查几个重要的队列:

  • timer
  • pending
  • idle / prepare
  • i/o
  • check

实际上,这几个队列里都是储存了相对应的handle结构体。

单次loop时间

react里的调度一样,我们在任何循环里都会设计到一个重要的问题,一个循环的时间究竟是多少呢?

react里,它hack了requestCallbackIdle,使每次循环的可用的更新时间保持在30帧/s内,以压榨浏览器的资源。而nodejs里并没有帧这种概念,它不存在main thread来争抢线程使用时间,所以它只需要保证一个循环尽可能久就好了。

什么叫尽可能久?

我们都知道,timer是我们需要检查的第一个队列。也是用户感知最明显的队列,如果保证timer不超时的情况下,我们是否可以长期的维持在一个循环里。

事实上确实如此,libuv维持了一个最小堆的来储存uv_handle_timer,以最近的timer超时时间作为该次循环的时间。

i/o poll

作为循环里最重要的一轮,i/o poll是libuv里不得不提的东西。我们所有的,需要调用native与os交互的异步操作都会在这里被触发。在第一章也提到过,i/o poll是通过epoll 的i/o多路复用式实现的非空转cpu等待。

跟着代码,我们来分析 i/o poll做了什么事情。

首先是注册所有的在watcher_queue里的描述符

while (!QUEUE_EMPTY(&loop->watcher_queue)) {
    q = QUEUE_HEAD(&loop->watcher_queue);
    QUEUE_REMOVE(q);
    QUEUE_INIT(q);

    w = QUEUE_DATA(q, uv__io_t, watcher_queue);
    
    if ((w->events & POLLIN) == 0 && (w->pevents & POLLIN) != 0) {
      filter = EVFILT_READ;
      fflags = 0;
      op = EV_ADD;

      if (w->cb == uv__fs_event) {
        // 根据类型确定filter和po
      }

      EV_SET(events + nevents, w->fd, filter, op, fflags, 0, 0);

      if (++nevents == ARRAY_SIZE(events)) {
        if (kevent(loop->backend_fd, events, nevents, NULL, 0, NULL))
          abort();
        nevents = 0;
      }
    }
    // 根据不同的events & type 有不同的判断,不具体讲了

    w->events = w->pevents;
  }

熟悉epoll的同学可以看linux版本的,这里是freeBSD的kqueue实现,不同大同小异。

接下里就是熟悉调度的同学很熟悉的调度了...

void uv__io_poll(uv_loop_t* loop, int timeout) {

  // 一堆变量声明
  ...
  
  // 观察者为0,代表无异步函数需要执行,事件循环结束,直接返回
  if (loop->nfds == 0) {
    return;
  }

  nevents = 0;

  // 注册所有事件
  while (!QUEUE_EMPTY(&loop->watcher_queue)) {
    q = QUEUE_HEAD(&loop->watcher_queue);
    QUEUE_REMOVE(q);
    QUEUE_INIT(q);

    w = QUEUE_DATA(q, uv__io_t, watcher_queue);
    // 这个filter是内核过滤器,op是任务类型
    EV_SET(events + nevents, w->fd, filter, op, fflags, 0, 0);
    }

		// 根据不同的w->events类型进行不同的EV_SET
		...
    w->events = w->pevents;
  }

  for (;; nevents = 0) {
    /* Only need to set the provider_entry_time if timeout != 0. The function
     * will return early if the loop isn't configured with UV_METRICS_IDLE_TIME.
     */
    if (timeout != 0)
      uv__metrics_set_provider_entry_time(loop);

    if (timeout != -1) {
      spec.tv_sec = timeout / 1000;
      spec.tv_nsec = (timeout % 1000) * 1000000;
    }

    if (pset != NULL)
      pthread_sigmask(SIG_BLOCK, pset, NULL);

    // 等待唤醒
    nfds = kevent(loop->backend_fd,
                  events,
                  nevents,
                  events,
                  ARRAY_SIZE(events),
                  // timeout == -1则无超时时间,否则设定poll轮剩余时间为超时时间
                  timeout == -1 ? NULL : &spec);

    if (pset != NULL)
      pthread_sigmask(SIG_UNBLOCK, pset, NULL);

    /* Update loop->time unconditionally. It's tempting to skip the update when
     * timeout == 0 (i.e. non-blocking poll) but there is no guarantee that the
     * operating system didn't reschedule our process while in the syscall.
     */
    SAVE_ERRNO(uv__update_time(loop));

    // 超时返回
    if (nfds == 0) {
      if (reset_timeout != 0) {
        timeout = user_timeout;
        reset_timeout = 0;
        if (timeout == -1)
          continue;
        if (timeout > 0)
          goto update_timeout;
      }

      assert(timeout != -1);
      return;
    }

    if (nfds == -1) {
      if (errno != EINTR)
        abort();

      if (reset_timeout != 0) {
        timeout = user_timeout;
        reset_timeout = 0;
      }

      if (timeout == 0)
        return;

      if (timeout == -1)
        continue;

      /* Interrupted by a signal. Update timeout and poll again. */
      goto update_timeout;
    }

    have_signals = 0;
    nevents = 0;

    assert(loop->watchers != NULL);
    loop->watchers[loop->nwatchers] = (void*) events;
    loop->watchers[loop->nwatchers + 1] = (void*) (uintptr_t) nfds;

    // 执行
    for (i = 0; i < nfds; i++) {
      ev = events + i;
      fd = ev->ident;
      /* Skip invalidated events, see uv__platform_invalidate_fd */
      if (fd == -1)
        continue;

      // 找到对应的watcher,这个在uv__io_start时被绑定对应的uv__io_t映射
      w = loop->watchers[fd];

      // 对应的fd被停止观察了,跳过
      if (w == NULL) {
        /* File descriptor that we've stopped watching, disarm it.
         * TODO: batch up. */
        struct kevent events[1];

        EV_SET(events + 0, fd, ev->filter, EV_DELETE, 0, 0, 0);
        if (kevent(loop->backend_fd, events, 1, NULL, 0, NULL))
          if (errno != EBADF && errno != ENOENT)
            abort();

        continue;
      }

      revents = 0;

      if (revents == 0)
        continue;

      /* Run signal watchers last.  This also affects child process watchers
       * because those are implemented in terms of signal watchers.
       */
      if (w == &loop->signal_io_watcher) {
        have_signals = 1;
      } else {
        uv__metrics_update_idle_time(loop);
        w->cb(loop, w, revents);
      }

      nevents++;
    }

    if (reset_timeout != 0) {
      timeout = user_timeout;
      reset_timeout = 0;
    }

    if (have_signals != 0) {
      uv__metrics_update_idle_time(loop);
      loop->signal_io_watcher.cb(loop, &loop->signal_io_watcher, POLLIN);
    }

    loop->watchers[loop->nwatchers] = NULL;
    loop->watchers[loop->nwatchers + 1] = NULL;

    if (have_signals != 0)
      return;  /* Event loop should cycle now so don't poll again. */

    if (nevents != 0) {
      if (nfds == ARRAY_SIZE(events) && --count != 0) {
        /* Poll for more events but don't block this time. */
        timeout = 0;
        continue;
      }
      return;
    }

    // 超时返回
    if (timeout == 0)
      return;

    // 未超时继续执行调度
    if (timeout == -1)
      continue;

update_timeout:
    assert(timeout > 0);

    diff = loop->time - base;
    if (diff >= (uint64_t) timeout)
      return;

    timeout -= diff;
  }
}

上面是一个精简版的uv__io_poll函数,删除掉了对不同类型的fd执行的不同操作。

大概就是做的这几件事:

  1. 监听watcher_queue的描述符
  2. timeout允许范围内等待唤醒
  3. 唤醒后执行对应任务
  4. 更新剩余timeout
  5. 任务执行完后重复2,3,4两个步骤
  6. 超时后退出

定时

任何调度算法最核心的都是每一轮的计时时间

poll的超时时间是通过uv_backend_timeout函数计算得出的

// return 0 终止,-1 无限
int uv_backend_timeout(const uv_loop_t* loop) {
	// 循环被上一轮循环中某个任务终止
  if (loop->stop_flag != 0)
    return 0;
	
	// 检查循环是否在激活状态
  if (!uv__has_active_handles(loop) && !uv__has_active_reqs(loop))
    return 0;
	
	// 没有能响应的async_handle
  if (!QUEUE_EMPTY(&loop->idle_handles))
    return 0;
	
	// 有上轮循环 io poll阶段还未执行的任务
  if (!QUEUE_EMPTY(&loop->pending_queue))
    return 0;
  
  // handle关闭
  if (loop->closing_handles)
    return 0;

  return uv__next_timeout(loop);
}

这个计算非常简单,主要是判断一些不该进行循环的情况。

但必须提一下这个条件判断

if (!QUEUE_EMPTY(&loop->pending_queue))
    return 0;

这个代表着我们上一轮io poll阶段有任务还没执行到,这个不能再拖延到下一轮调度了,需要立马执行,所以跳过本轮的io poll阶段。

我们继续看下正常的时间计算

int uv__next_timeout(const uv_loop_t* loop) {
  const struct heap_node* heap_node;
  const uv_timer_t* handle;
  uint64_t diff;
	
	// timer是个最小堆
  heap_node = heap_min(timer_heap(loop));
  // 没有在运行的timer handle,此时不需要为循环计时
  if (heap_node == NULL)
    return -1; /* block indefinitely */

  handle = container_of(heap_node, uv_timer_t, heap_node);
  // 最近的定时器已经超时了,此时立马进入下一轮循环
  if (handle->timeout <= loop->time)
    return 0;
  
  // 返回剩余时间,INT_MAX作为最大时间打底
  diff = handle->timeout - loop->time;
  if (diff > INT_MAX)
    diff = INT_MAX;

  return (int) diff;
}

整体逻辑非常清晰,相信读者都有能力看懂。

令人震惊的process.nextTick

事实上,任何microTask都不是由libuv来控制的,而是交给v8接管。但这里必须要独立的讲nextTick,而且是令人震惊的nextTick

触发?

我们先来观察一下它的触发时机

void InternalCallbackScope::Close() {
  if (!tick_info->has_scheduled()) {
    env_->isolate()->RunMicrotasks();
  }
	
	// tick_callback_function 是在前一个函数被设置进来的js回调,也就是nextTick的回调
  if (env_->tick_callback_function()->Call(process, 0, nullptr).IsEmpty()) {
    env_->tick_info()->set_has_thrown(true);
    failed_ = true;
  }
}

我们可以看到,nextTick回调的触发是在InternalCallbackScope这样一个类被Close的时候触发

这个Close的行为可以被追溯到InternalMakeCallback被触发的时候

// src/node.cc
MaybeLocal<Value> InternalMakeCallback(Environment* env,
                                       Local<Object> recv,
                                       const Local<Function> callback,
                                       int argc,
                                       Local<Value> argv[],
                                       async_context asyncContext) {
  InternalCallbackScope scope(env, recv, asyncContext);
  scope.Close();//Close会调用_tickCall
}

在第一章node和libuv的结合中,我们提到过InternalMakeCallback实际上就是往asyncWrap的子类上设置回调的方法。所以每一个handle结束后,都会触发到这个方法。执行其对应的回调,并且触发我们的_tickCallback

小试牛刀?

setTimeout(function () {
    console.log(1);
    process.nextTick(function () {
        console.log(2);
        process.nextTick(function () {
            console.log(2);
            process.nextTick(function () {
                console.log(2);
            });
        });
    });
});
process.nextTick(function () {
    console.log(3);
    setTimeout(function () {
        console.log(4);
    })
});

它的最终输出是3,1,2,2,2,4,如果你明白nextTick触发的时机,并且清楚timeouttimeWrap封装,你就能明白为什么是这样输出的。

microTask?

setTimeout(function () {
    console.log(1);
    process.nextTick(function () {
        console.log(2);
        Promise.resolve(5).then((v) => console.log(v))
        process.nextTick(function () {
            console.log(2);
            process.nextTick(function () {
                console.log(2);
            });
        });
    });
});
process.nextTick(function () {
    console.log(3);
    setTimeout(function () {
        console.log(4);
    })
});

考虑上面的情况,我们添加了一个Promise,它的输出是什么呢?

别慌,我们先不要去试。刚才我们提到过InternalCallbackScope的关闭会触发_tickCallback,那么这个又是什么呢?

function _tickCallback() {
    let tock;
    do {
      while (tock = shift()) {
        // ... emit async_hooks
        if (tock.args === undefined)
          callback();//执行调用process.nextTick()时放置进来的callback
        else
          Reflect.apply(callback, undefined, tock.args);//执行调用process.nextTick()时放置进来的callback
 				// 链式执行
        emitAfter(asyncId);
      }
      
      runMicrotasks();//microtasks将会在此时执行
    } while (head.top !== head.bottom || emitPromiseRejectionWarnings());
    tickInfo[kHasPromiseRejections] = 0;
}

相信大家能明白了,nextTick阻塞了microTask的执行。所以上面的输出顺序大家可以写出来了。

探索libuv系列

探索libuv(1) - libuv与node通信

探索libuv(4) - 任务的挂载