*non-blockingness* 的 libuv 实现是如何工作的?

How does the libuv implementation of *non-blockingness* work exactly?

所以我刚刚发现,就 C 库而言,libuv 是一个相当小的库(与 FFmpeg 相比)。在过去的 6 个小时里,我通读了源代码,以更深入地了解事件循环。但仍然没有看到 "nonblockingness" 的实现位置。在代码库中调用某些事件中断信号或诸如此类的东西。

我使用 Node.js 超过 8 年,所以我很熟悉如何 使用 异步非阻塞事件循环,但我从未真正研究过实施。

我的问题是双重的:

  1. 究竟是在 libuv 中出现的 "looping" 在哪里?
  2. 循环的每次迭代中使其成为 非阻塞async.
  3. 的关键步骤是什么

所以我们从一个你好世界的例子开始。所需要的只是:

#include <stdio.h>
#include <stdlib.h>
#include <uv.h>

int main() {
  uv_loop_t *loop = malloc(sizeof(uv_loop_t));
  uv_loop_init(loop); // initialize datastructures.
  uv_run(loop, UV_RUN_DEFAULT); // infinite loop as long as queue is full?
  uv_loop_close(loop);
  free(loop);
  return 0;
}

我一直在探索的关键功能是uv_runuv_loop_init 函数本质上是初始化数据结构,所以我不认为那里有太多的幻想。但真正的魔法似乎发生在 uv_run 某处 。来自 libuv 存储库的一组高级代码片段是 in this gist,显示了 uv_run 函数调用的内容。

基本上它似乎归结为:

while (NOT_STOPPED) {
  uv__update_time(loop)
  uv__run_timers(loop)
  uv__run_pending(loop)
  uv__run_idle(loop)
  uv__run_prepare(loop)
  uv__io_poll(loop, timeout)
  uv__run_check(loop)
  uv__run_closing_handles(loop)
  // ... cleanup
}

这些函数在要点中。

然后我们就完成了。而程序是存在的,因为没有"work"要做。

所以我认为在进行了所有这些挖掘之后我已经回答了问题的第一部分,循环具体在这 3 个函数中:

  1. uv__run_timers
  2. uv__run_pending
  3. uv__io_poll

但是我没有用 kqueue 或多线程实现任何东西,而且对文件描述符的处理相对较少,所以我不太了解代码。这可能也会帮助其他人学习这个。

所以问题的第二部分是这3个函数实现非阻塞的关键步骤是什么?假设这是所有循环存在的地方。

不是 C 专家,for (;;) { "block" 事件循环吗?或者 运行 是否可以无限期地以某种方式从 OS 系统事件或类似事件跳转到代码的其他部分?

所以 uv__io_poll 在无限循环中调用 poll(...)。我不认为是非阻塞的,对吗?这似乎是它主要做的。

查看 kqueue.c 也有一个 uv__io_poll,所以我假设 poll 实现是后备,并且使用 Mac 上的 kqueue,哪个是非阻塞的?

是吗?它只是在 uv__io_poll 中循环并且每次迭代都可以添加到队列中,只要队列中有东西它就会 运行 吗?我仍然不明白它是如何实现非阻塞和异步的。

能否概述一下它是如何异步和非阻塞的,以及代码的哪些部分需要看一下?基本上,我想看看 libuv 中 "free processor idleness" 的位置。在调用我们的初始 uv_run 时处理器在哪里空闲?如果它是免费的,它如何像事件处理程序一样被重新调用? (就像来自鼠标的浏览器事件处理程序,中断)。我觉得我在寻找中断但没有看到。

我问这个是因为我想在 C 中实现一个 MVP 事件循环,但只是不明白非阻塞性实际上是如何实现的。橡胶与道路相遇的地方。

我刚刚研究了 libuv 的源代码,起初发现它似乎做了很多设置,但没有太多实际的事件处理。

尽管如此,还是要看一下 src/unix/kqueue.c reveals 事件处理的一些内部机制:

int uv__io_check_fd(uv_loop_t* loop, int fd) {
  struct kevent ev;
  int rc;

  rc = 0;
  EV_SET(&ev, fd, EVFILT_READ, EV_ADD, 0, 0, 0);
  if (kevent(loop->backend_fd, &ev, 1, NULL, 0, NULL))
    rc = UV__ERR(errno);

  EV_SET(&ev, fd, EVFILT_READ, EV_DELETE, 0, 0, 0);
  if (rc == 0)
    if (kevent(loop->backend_fd, &ev, 1, NULL, 0, NULL))
      abort();

  return rc;
}

文件描述符轮询在此处完成,"setting"事件与 EV_SET(类似于您在使用 select() 检查之前使用 FD_SET 的方式),以及处理通过 kevent 处理程序完成。

这是特定于kqueue风格的事件(主要用于BSD-likes a la MacOS),还有许多其他针对不同Unices的实现,但它们都使用相同的函数名来做非阻塞IO 检查。使用 epoll.

的另一个实现见 here

回答您的问题:

1) libuv 中 "looping" 的确切位置?

QUEUE数据结构用于存储和处理事件。该队列由您注册侦听的特定于平台和 IO 的事件类型填充。在内部,它使用一个巧妙的链表,仅使用两个 void * 指针 (see here) 的数组:

typedef void *QUEUE[2];

我不打算详细介绍这个列表,您只需要知道它实现了一个类似队列的结构来添加和弹出元素。

一旦队列中有正在生成数据的文件描述符,前面提到的异步 I/O 代码就会获取它。 uv_loop_t 结构中的 backend_fd 是每个类型 I/O.

的数据生成器

2) 循环的每次迭代中使它成为非阻塞和异步的关键步骤是什么

libuv 本质上是一个包装器(带有一个很好的 API),围绕着这里真正的主力,即 kqueue, epoll, select 等。要完整地回答这个问题,你需要一个在内核级文件描述符实现方面有相当多的背景知识,根据问题我不确定这是否是您想要的。

简短的回答是底层操作系统都有内置的非阻塞(因此异步)功能I/O。每个系统的工作原理有点超出了这个答案的范围,我想,但我会留下一些好奇的阅读:

https://www.quora.com/Network-Programming-How-is-select-implemented?share=1

首先要记住的是,必须使用其 API 将工作添加到 libuv 的队列中;不能只加载 libuv,启动它的主循环,然后编码一些 I/O 并获得异步 I/O.

libuv维护的队列是循环管理的。 uv__run_timers 中的无限循环实际上并不是无限的;请注意,第一个检查验证是否存在最快到期的计时器(假设,如果列表为空,则为 NULL),如果不存在,则中断循环和函数 returns。如果当前(最快到期)计时器尚未到期,则下一次检查会中断循环。如果这些条件都没有中断循环,则代码继续:它重新启动计时器,调用其超时处理程序,然后再次循环以检查更多计时器。大多数时候当这段代码 运行s 时,它会中断循环并退出,允许其他循环 运行.

所有这些非阻塞的原因是 caller/user 遵循 libuv 的准则和 API:将您的工作添加到队列,并允许 libuv 在这些队列上执行其工作。处理密集型工作可能会阻止 运行ning 中的这些循环和其他工作,因此将您的工作分成块很重要。

我认为试图理解 libuv 会妨碍您理解如何在 C 中实现反应器(事件循环),而您需要理解的正是这一点,而不是 libuv 背后的确切实现细节。

(请注意,当我说 "in C" 时,我真正的意思是 "at or near the system call interface, where userland meets the kernel"。)

所有不同的后端(select、poll、epoll 等)或多或少都是同一主题的变体。它们会阻塞当前进程或线程,直到有工作要做,例如服务计时器、从套接字读取、写入套接字或处理套接字错误。

当当前进程被阻塞时,它确实没有得到 OS 调度程序分配给它的任何 CPU 周期。

IMO 理解这些东西背后的部分问题是糟糕的术语:JS 领域的异步、同步,它们并没有真正描述这些东西是什么。实际上,在 C 语言中,我们谈论的是非阻塞与阻塞 I/O.

当我们从一个阻塞的文件描述符中读取时,进程(或线程)被阻塞——阻止运行——直到内核有东西可以读取;当我们写入阻塞文件描述符时,进程将被阻塞,直到内核接受整个缓冲区。

在非阻塞I/O中,它完全一样,除了内核不会在无事可做时从运行停止进程:相反,当您读取或写入时,它会告诉您您读或写了多少(或者是否有错误)。

select 系统调用(和朋友)防止 C 开发人员不得不一遍又一遍地尝试从非阻塞文件描述符中读取——select() 是,在effect,一个阻塞的系统调用,当你正在观察的任何描述符或定时器准备就绪时,它就会解除阻塞。这让开发人员可以围绕 select 构建一个循环,为它报告的任何事件提供服务,例如过期的超时或可以读取的文件描述符。 这是事件循环。

所以,从本质上讲,在 JS 事件循环的 C 端发生的事情大致是这样的算法:

while(true) {
  select(open fds, timeout);
  did_the_timeout_expire(run_js_timers());
  for (each error fd)
    run_js_error_handler(fdJSObjects[fd]);
  for (each read-ready fd)
    emit_data_events(fdJSObjects[fd], read_as_much_as_I_can(fd));
  for (each write-ready fd) {
    if (!pendingData(fd))
      break;
    write_as_much_as_I_can(fd);
    pendingData = whatever_was_leftover_that_couldnt_write; 
  }
}

FWIW - 我实际上已经为基于 select() 的 v8 编写了一个事件循环:真的就是这么简单。

记住 JS 总是运行完成也很重要。因此,当您从 C 调用 JS 函数(通过 v8 api)时,您的 C 程序不会执行任何操作,直到 JS 代码 returns.

NodeJS 使用了一些优化,比如在单独的 pthread 中处理挂起的写入,但这些都发生在 "C space" 中,在试图理解这种模式时你不应该 think/worry 关于它们,因为它们'不相关。

在处理异步函数之类的事情时,您可能还会误以为 JS 不是 运行 完成的——但它绝对是,100% 的时间——如果你不是为了加快速度,请阅读有关事件循环和微任务队列的内容。异步函数基本上是一种语法技巧,它们 "completion" 涉及返回一个 Promise。

btw, uv__run_idle, uv__run_check, uv__run_prepare 的源代码定义在 src/unix/loop-watcher.c