rest api 的异步方法如何减少线程数?

How async approach to rest api can reduce thread count?

许多人说现代 rest api 应该是 "async",作为主要论据,他们说在某些平台上,例如 Java,"blocking" 做事方式事情会产生很多线程,"async" 方式允许限制线程数和开销。

我不明白的是,它是如何实现的。

假设我在 vert.x 这样的框架中有一个应用程序(但实际上这并不重要,您也可以考虑 NodeJS),然后说 1_000_000 服务的并发连接向数据库发出一些请求。该框架允许每个请求本身在长任务 i|o 操作上异步处理,因此数据库数据交换在业务逻辑代码中看起来在句法上是异步的。但。据我了解,数据库请求不是在真空中进行的——它是在其他线程中处理的,并且该线程实际上会阻塞,直到数据库请求完成。所以这意味着,尽管请求业务逻辑看起来是异步和非阻塞的,但从这种逻辑调用的长时间操作实际上阻塞了框架下的某个地方,并且执行的此类操作越多,线程应该越多无论如何消耗(对于 NodeJS,你可以想到线程,在框架本身的 C++ 代码中创建)

因此,正如我所看到的大图 - 在异步方法中,只有一个线程处理所有请求,没关系,但是有一堆线程在做实际的 I/O 工作无论如何在后台,如果不限制它们的数量,那么线程数将与阻塞方法相同 + 1。另一方面,如果您以编程方式限制后台线程池的数量,那么什么会与结合了用户请求队列和请求处理线程数限制的阻塞方法相比有什么好处?

这对于一些 JDBC 客户端仍然同步的异步框架来说是正确的。 在 Vert.x 中查询数据库时,您会重复使用相同的应用程序线程。 请看下面的例子:

@Test
public void testMultipleThreads() throws InterruptedException {
    Vertx vertx = Vertx.vertx();

    System.out.println("Before starting server: " + Thread.activeCount());

    // Start server
    vertx.createHttpServer().
            requestHandler(httpServerRequest -> {
                //      System.out.println("Request");
                httpServerRequest.response().end();
            }).
            listen(8080, o -> {
                System.out.println("Server ready");
            });

    // Start counting threads
    vertx.setPeriodic(500, (o) -> {
        System.out.println(Thread.activeCount());
    });

    // Create requests
    HttpClient client = vertx.createHttpClient();

    int loops = 1_000_000;
    CountDownLatch latch = new CountDownLatch(loops);

    for (int i = 0; i < loops; i++) {
        client.getNow(8080, "localhost", "/", httpClientResponse -> {
            // System.out.println("Response received");
            latch.countDown();
        });
    }

    latch.await();
}

您会注意到线程数没有改变,即使您提供任意数量的连接也是如此。您也可以添加 Vert.x JDBC 客户端进行测试。

既然你问的是一个相当低级的问题,我会用一个低级的答案来回答。希望你对 C 感到满意。

首先,免责声明:我将主要讨论网络代码,因为据我所知,使用文件 I/O 的唯一广泛使用的数据库是 sqlite。既然你问的是 postgres,我可以假设你对套接字 I/O(无论是 TCP 套接字还是 unix 本地套接字)如何只在一个线程上工作感兴趣。

几乎所有异步系统和库的核心都是一段代码,如下所示:

while (1)
{
  read_fd_set = active_fd_set;

  // This blocks until we receive a packet or until timeout expires:
  select(FD_SETSIZE, &read_fd_set, NULL, NULL, timeout);

  // Process timed events:
  timeout = process_timeout();

  // Process I/O:
  for (i = 0; i < FD_SETSIZE; ++i) {
    if (FD_ISSET(i, &read_fd_set)) {
      if (i == sock) {
        /* Connection arriving on listening socket */
        int new;
        size = sizeof(clientname);
        new = accept (sock,(struct sockaddr *) &clientname, &size);
        FD_SET (new, &active_fd_set);
      }
      else {
        /* Data arriving on an already-connected socket. */
        if (read_from_client(i) < 0) {
          close (i);
          FD_CLR (i, &active_fd_set);
        }
      }
    }
  }
}

(代码示例转述自 GNU socket programming example

如您所见,上面的代码没有使用任何线程。然而它可以同时处理许多连接。如果你看一下 for 循环,很明显它基本上是一个简单的状态机,如果套接字有任何数据包等待读取(如果没有,它会被 if (FD_ISSET...) 跳过)声明)。

非I/O事件在逻辑上只能来自定时事件。这就是超时管理(为清楚起见未显示细节)的用武之地。所有 I/O 相关的东西(基本上几乎所有异步代码)都从 read_from_client() 函数回调(同样,为清楚起见省略了细节).

零代码运行并发

并行化从何而来?

基本上是您要连接的服务器。大多数数据库都支持某种形式的并行性。有些支持多线程。有些甚至通过支持异步磁盘 I/O(如 postgres)来支持 node.js 或 vert.x 风格的并行性。一些 配置 数据库允许更高级别的并行性,通过分区 and/or 分片 and/or master/slave 服务器将数据存储在多个服务器上。

这就是大并行的来源——并行计算。大多数数据库对读取并行性有很强的支持,但对写入并行性的支持较弱(例如 master/slave 设置允许您只写入 master 数据库)。但这仍然是一个巨大的胜利,因为大多数应用程序读取的数据多于写入的数据。

磁盘并行度从何而来?

硬件。这主要与 DMA 有关,它可以在没有 CPU 的情况下传输数据。 DMA 不是一回事。它更像是一个概念。不同的系统,如 PCI 总线、SATA、USB,甚至 CPU RAM 总线本身都有各种 DMA,可以将数据直接传输到 RAM(在 RAM 的情况下,可以将数据传输到更高级别的 CPU缓存)或更快的缓冲区。

在等待 DMA 完成时。 CPU 没有做任何事情。虽然它什么都不做,但恰好有网络数据包进入或 setTimeout() 过期处理它们的代码可以在 CPU 上执行。一直在将文件读入 RAM 的过程中。

但是 Node.js 文档不断提到 I/O 个线程

仅适用于磁盘 I/O。用单线程做async disk I/O也不是不可能。 Tcl 多年来一直这样做,许多其他编程语言和框架也这样做了。它只是非常非常混乱,因为 BSD 以不同的形式 Linux 与 Windows 不同,甚至 OSX 可能与 BSD 略有不同,即使它是从它派生的等等。 .

为了简单和可靠,节点开发人员选择在单独的线程中处理磁盘 I/O。

注意,即使是套接字I/O,也不像我上面给出的代码示例那么简单。由于 select() 有一些局限性(例如,您被迫遍历所有套接字来检查传入数据,即使大多数套接字都没有传入数据),人们想出了更好的 API。显然,不同的操作系统会以不同的方式执行此操作。这就是为什么创建了许多库来处理跨平台事件处理,例如 libevent 和 libuv(node.js 使用的库)。

好的。但是 postgres 仍然 运行 在我的电脑上

异步的、面向事件的系统不会自动给你超强的性能。他们给你的是选择:应用程序服务器速度非常快,所以你把你的数据库服务器放在哪里以及你使用什么数据库由你决定。

好的。但我可以用线程来做到这一点。为什么异步?

基准。

自 1999 年以来,许多人拥有 运行 许多基准测试,并且在大多数情况下,单线程(或低线程数)、面向事件的系统优于简单的多线程系统。在过去的单一 CPU、单核服务器时代尤其如此。现在仍然部分正确(因为内核仍然有限)。

这就是为什么将 Apache 重新写入 Apache2 以使用异步侦听器线程池以及为什么从头开始编写 Nginx 以使用异步代码线程池的原因。

是的,理想情况下,在现代服务器上,您仍然需要一些线程才能使用所有 CPU。替代方案是一个进程池,就像集群模块在 node.js 中的工作方式一样。但是您希望 threads/processes 的数量保持不变或尽可能保持不变,以避免上下文切换和线程创建的开销。