博客 | NGINX

NGINX 中的线程池将性能提升 9 倍!

NGINX-F5-horiz-black-type-RGB 的一部分
Valentin Bartenev 缩略图
瓦伦丁·巴尔捷涅夫
2015 年 6 月 19 日发布

众所周知,NGINX 使用异步、事件驱动的方法来处理连接。 这意味着它不需要为每个请求创建另一个专用进程或线程(就像具有传统架构的服务器一样),而是在一个工作进程中处理多个连接和请求。 为了实现这一点,NGINX 以非阻塞模式使用套接字,并使用epollkqueue等高效方法。

由于全重进程的数量很少(通常每个 CPU 核心只有一个)且恒定,因此消耗的内存要少得多,并且不会在任务切换上浪费 CPU 周期。 通过 NGINX 本身的例子,这种方法的优点是众所周知的。 它成功处理了数百万个同时发生的请求,并且扩展性非常好。

每个进程都会消耗额外的内存,并且每次进程之间的切换都会消耗 CPU 周期并浪费 L 缓存

但是异步、事件驱动的方法仍然存在问题。 或者,我喜欢这么认为,“敌人”。 而敌人的名字是:阻挡。 不幸的是,许多第三方模块使用阻塞调用,而用户(有时甚至是模块的开发人员)并没有意识到其缺点。 阻塞操作会破坏 NGINX 性能,必须不惜一切代价避免。

即使在目前的官方 NGINX 代码中,也不可能在任何情况下都避免阻塞操作,为了解决这个问题,在 NGINX 版本 1.7.11NGINX Plus Release 7中实现了新的“线程池”机制。 它是什么以及如何使用它,我们将在后面介绍。 现在让我们与敌人面对面吧。

编辑器 – 有关 NGINX Plus R7 的概述,请参阅我们博客上的“宣布 NGINX Plus R7”

有关 NGINX Plus R7 中其他新功能的详细讨论,请参阅以下相关博客文章:

 

问题

首先,为了更好地理解这个问题,我们来介绍一下 NGINX 的工作原理。

一般来说,NGINX 是一个事件处理程序,一个控制器,它从内核接收有关连接上发生的所有事件的信息,然后向操作系统发出命令,告诉操作系统要做什么。 事实上,NGINX 通过协调操作系统完成了所有艰苦的工作,而操作系统则完成了读取和发送字节的日常工作。 所以对于NGINX来说快速及时的响应非常重要。

NGINX-事件循环2
工作进程监听并处理来自内核的事件

这些事件可以是超时、有关套接字准备读取或写入的通知、或有关发生的错误的通知。 NGINX 接收一堆事件,然后逐一处理它们,执行必要的操作。 因此,所有处理都是在一个线程中通过队列的简单循环完成的。 NGINX 从队列中出队一个事件,然后通过诸如写入或读取套接字的方式对其做出反应。 在大多数情况下,这非常快(可能只需要几个 CPU 周期来复制内存中的一些数据)并且 NGINX 会立即处理队列中的所有事件。

事件队列处理周期
所有处理均由一个线程在一个简单的循环中完成

但是如果发生了一些长时间、繁重的操作,会发生什么情况? 整个事件处理周期将会卡住,等待此操作完成。

因此,我们说的“阻塞操作”是指任何在相当长的时间内停止处理事件循环的操作。 操作可能会因多种原因而被阻塞。 例如,NGINX 可能忙于长时间、CPU 密集型处理,或者可能需要等待访问资源(例如硬盘驱动器、互斥锁或以同步方式从数据库获取响应的库函数调用等)。 关键在于,在处理此类操作时,工作进程无法执行任何其他操作,也无法处理其他事件,即使有更多的系统资源可用并且队列中的某些事件可以利用这些资源。

想象一下商店里的销售员面前排着长队。 队列中的第一个人想要的东西不在商店里但在仓库里。 业务员去仓库发货。 现在整个队列中的人都必须等待几个小时才能收到货,队列中的每个人都不高兴。 你能想象人们的反应吗? 排队中每个人的等待时间都增加了几个小时,但他们想要购买的物品可能就在商店里。

队列中的每个人都必须等待第一个人的订单

当 NGINX 要求读取未缓存在内存中但需要从磁盘读取的文件时,几乎会发生相同的情况。 硬盘速度很慢(尤其是旋转的硬盘),虽然队列中等待的其他请求可能不需要访问该硬盘,但它们还是被迫等待。 结果导致延迟增加并且系统资源未得到充分利用。

仅一个阻塞操作就可能使所有后续操作延迟相当长的时间

一些操作系统提供了用于读取和发送文件的异步接口,NGINX 可以使用该接口(参见aio指令)。 FreeBSD 就是一个很好的例子。遗憾的是,Linux 却不是这样。 尽管 Linux 提供了一种用于读取文件的异步接口,但它有几个明显的缺点。 其中之一是文件访问和缓冲区的对齐要求,但 NGINX 可以很好地处理这个问题。 但第二个问题更严重。 异步接口需要在文件描述符上设置O_DIRECT标志,这意味着对文件的任何访问都将绕过内存中的缓存并增加硬盘上的负载。 对于许多情况来说,这肯定不是最佳选择。

为了特别解决这个问题,NGINX 1.7.11 和 NGINX Plus Release 7 中引入了线程池。

现在让我们深入了解线程池的含义以及它们的工作原理。

线程池

让我们回到从很远的仓库送货的可怜销售助理的话题。 但他变得更聪明了(或者可能是在被一群愤怒的客户殴打后变得更聪明了?)并雇佣了送货服务。 现在,当有人需要从远方仓库购买某件商品时,他不用亲自去仓库,只需将订单交给送货服务公司,他们会处理订单,而我们的销售助理将继续为其他客户提供服务。 这样,只有那些商品不在商店的客户才需要等待送货,其他客户则可以立即得到服务。

将订单传递给配送服务可解除队列阻塞

对于NGINX来说,线程池承担着递送服务的功能。 它由一个任务队列和若干个处理该队列的线程组成。 当工作进程需要执行可能耗时较长的操作时,它不会自行处理该操作,而是将任务放入池的队列中,任何空闲线程都可以从中取出并处理该任务。

线程池通过将缓慢的操作分配给一组单独的任务来帮助提高应用性能
工作进程将阻塞操作卸载到线程池

看来我们又有另一个队列了。 正确的。 但在这种情况下,队列受到特定资源的限制。 我们从驱动器读取数据的速度不能超过驱动器生成数据的速度。 现在至少驱动器不会延迟处理其他事件,只有需要访问文件的请求在等待。

“从磁盘读取”操作通常被用作阻塞操作最常见的例子,但实际上 NGINX 中的线程池实现可用于任何不适合在主工作周期中处理的任务。

目前,仅针对三个基本操作实现了卸载到线程池:大多数操作系统上的read()系统调用、Linux 上的sendfile()以及 Linux 上的aio_write() ,后者用于写入一些临时文件(例如用于缓存的文件)。 我们将继续测试和基准测试实现,如果有明显的好处,我们可能会在未来的版本中将其他操作卸载到线程池中。

编辑器 – NGINX 1.9.13NGINX Plus R9中添加了aio_write()系统调用的支持。

基准测试

现在是时候从理论转向实践了。 为了展示使用线程池的效果,我们将执行一个综合基准测试,模拟阻塞和非阻塞操作的最糟糕组合。

它需要保证不适合内存的数据集。 在一台具有 48 GB RAM 的机器上,我们在 4 MB 文件中生成了 256 GB 的随机数据,然后配置了 NGINX 1.9.0 来为其提供服务。

配置非常简单:

工作进程 16;
事件 {
accept_mutex 关闭;
}

http {
包括 mime.types;
默认类型应用/八位字节流;

访问登录关闭;
发送文件;
发送文件最大块 512k;

服务器 {
听8000;

地点 / {
根/存储;
}
}
}

如您所见,为了获得更好的性能,进行了一些调整:禁用日志记录accept_mutex ,启用sendfile ,并设置sendfile_max_chunk 。 最后一个指令可以减少阻止sendfile()调用所花费的最大时间,因为 NGINX 不会尝试一次发送整个文件,而是以 512 KB 的块进行发送。

该机器有两个 Intel Xeon E5645(总共 12 个核心、24 个 HT 线程)处理器和一个 10 Gbps 网络接口。 磁盘子系统由四块 Western Digital WD1003FBYX 硬盘组成,以 RAID10 阵列排列。 所有这些硬件均由 Ubuntu Server 14.04.1 LTS 提供支持。

为基准测试配置负载生成器和 NGINX

客户端由两台具有相同规格的机器代表。 在其中一台机器上, wrk使用 Lua 脚本创建负载。 该脚本使用 200 个并行连接以随机顺序向我们的服务器请求文件,并且每个请求都可能会导致缓存未命中和磁盘阻塞读取。 我们将这种负载称为随机负载。

在第二台客户端机器上,我们将运行wrk的另一个副本,它将使用 50 个并行连接多次请求同一个文件。 由于此文件会被频繁访问,因此它将一直保留在内存中。 在正常情况下,NGINX 会非常快速地处理这些请求,但如果工作进程被其他请求阻塞,性能就会下降。 我们将这种负载称为恒定负载。

将通过使用ifstat监控服务器的吞吐量并从第二个客户端获取wrk结果来衡量性能。

现在,第一次没有使用线程池的运行并没有给我们带来非常令人兴奋的结果:

% ifstat -bi eth2 eth2 Kbps 输入 Kbps 输出 5531.24 1.03e+06 4855.23 812922.7 5994.66 1.07e+06 5476.27 981529.3 6353.62 1.12e+06 5166.17 892770.3 5522.81 978540.8 6208.10 985466.7 6370.79 1.12e+06 6123.33 1.07e+06

如您所见,通过这种配置,服务器总共能够产生约 1 Gbps 的流量。 从top的输出中我们可以看到,所有的工作进程大部分时间都花在阻塞 I/O 上(它们处于D状态):

顶部 - 10:40:47 启动 11 天,1:32,1 个用户,平均负载: 49.61, 45.77 62.89任务:375全部的,2跑步,373睡眠,0停止了,0僵尸 %Cpu(s):0.0我们,0.3西,0.0妮,67.7 ID,31.9哇,0.0你好,0.0是,0.0 st KiB 内存:49453440全部的,49149308用过的,304132自由的,98780缓冲区 KiB 交换:10474236全部的,20124用过的,10454112自由的,46903412缓存的内存 PID 用户 PR NI VIRT RES SHR S %CPU %MEM TIME+ 命令 4639 vbart 20 0 47180 28152 496 D 0.7 0.1 0:00.17 nginx 4632 vbart 20 0 47180 28196 536 D 0.3 0.1 0:00.11 nginx 4633 vbart 20 0 47180 28324 540 D 0.3 0.1 0:00.11 nginx 4635 vbart 20 0 47180 28136 480 D 0.3 0.1 0:00.12 nginx 4636 vbart 20 0 47180 28208 536 D 0.3 0.1 0:00.14 nginx 4637 vbart 20 0 47180 28208 536 D 0.3 0.1 0:00.10 nginx 4638 vbart 20 0 47180 28204 536 D 0.3 0.1 0:00.12 nginx 4640 vbart 20 0 47180 28324 540 D 0.3 0.1 0:00.13 nginx 4641 vbart 20 0 47180 28324 540 D 0.3 0.1 0:00.13 nginx 4642 vbart 20 0 47180 28208 536 D 0.3 0.1 0:00.11 nginx 4643 vbart 20 0 47180 28276 536 D 0.3 0.1 0:00.29 nginx 4644 vbart 20 0 47180 28204 536 D 0.3 0.1 0:00.11 nginx 4645 vbart 20 0 47180 28204 536 D 0.3 0.1 0:00.17 nginx 4646 vbart 20 0 47180 28204 536 D 0.3 0.1 0:00.12 nginx 4647 vbart 20 0 47180 28208 532 D 0.3 0.1 0:00.17 nginx 4631 vbart 20 0 47180 756 252 S 0.0 0.1 0:00.00 nginx 4634 vbart 20 0 47180 28208 536 D 0.0 0.1 0:00.11 nginx< 4648 vbart 20 0 25232 1956 1160 R 0.0 0.0 0:00.08 top 25921 vbart 20 0 121956 2232 1056 S 0.0 0.0 0:01.97 sshd 25923 vbart 20 0 40304 4160 2208 S 0.0 0.0 0:00.53 zsh

在这种情况下,吞吐量受到磁盘子系统的限制,而 CPU 大部分时间处于空闲状态。 wrk的结果也很低:

运行 1m 测试 @ http://192.0.2.1:8000/1/1/1 12 个线程和 50 个连接
线程统计 平均值 标准差 最大值 +/- 标准差
延迟 7.42s 5.31s 24.41s 74.73%
请求/秒 0.15 0.36 1.00 84.62%
1.01m 内有 488 个请求,读取 2.01GB
请求/秒:      8.08
传输/秒:     34.07MB

请记住,这是应从内存中提供的文件! 延迟过大是因为所有工作进程都忙于从驱动器读取文件以应对来自第一个客户端的 200 个连接产生的随机负载,并且无法及时处理我们的请求。

现在是时候让我们的线程池发挥作用了。 为此,我们只需将aio线程指令添加到位置块:

位置 / { 根 /存储;
aio 线程;
}

并要求 NGINX 重新加载其配置。

之后我们重复测试:

% ifstat -bi eth2 eth2 Kbps 输入 Kbps 输出 60915.19 9.51e+06 59978.89 9.51e+06 60122.38 9.51e+06 61179.06 9.51e+06 61798.40 9.51e+06 57072.97 9.50e+06 56072.61 9.51e+06 61279.63 9.51e+06 61243.54 9.51e+06 59632.50 9.50e+06

现在我们的服务器产生9.5 Gbps 的速度,而没有线程池时速度仅为 ~1 Gbps!

或许它还能产生更多,但是它已经达到了实际的最大网络容量,因此在这次测试中 NGINX 受到网络接口的限制。 工作进程大部分时间都处于睡眠和等待新事件的状态(它们在top中处于S状态):

顶部 - 10:43:17 启动 11 天,1:35,1 个用户,平均负载: 172.71, 93.84, 77.90任务:376全部的,1跑步,375睡眠,0停止了,0僵尸 %Cpu(s):0.2我们,1.2西,0.0妮,34.8 ID,61.5哇,0.0你好,2.3是,0.0 st KiB 内存:49453440全部的,49096836用过的,356604自由的,97236缓冲区 KiB 交换:10474236全部的,22860用过的,10451376自由的,46836580缓存的内存 PID 用户 PR NI VIRT RES SHR S %CPU %MEM TIME+ 命令 4654 vbart 20 0 309708 28844 596 S 9.0 0.1 0:08.65 nginx 4660 vbart 20 0 309748 28920 596 S 6.6 0.1 0:14.82 nginx 4658 vbart 20 0 309452 28424 520 S 4.3 0.1 0:01.40 nginx 4663 vbart 20 0 309452 28476 572 S 4.3 0.1 0:01.32 nginx 4667 vbart 20 0 309584 28712 588 S 3.7 0.1 0:05.19 nginx 4656 vbart 20 0 309452 28476 572 S 3.3 0.1 0:01.84 nginx 4664 vbart 20 0 309452 28428 524 S 3.3 0.1 0:01.29 nginx 4652 vbart 20 0 309452 28476 572 S 3.0 0.1 0:01.46 nginx 4662 vbart 20 0 309552 28700 596 S 2.7 0.1 0:05.92 nginx 4661 vbart 20 0 309464 28636 596 S 2.3 0.1 0:01.59 nginx 4653 vbart 20 0 309452 28476 572 S 1.7 0.1 0:01.70 nginx 4666 vbart 20 0 309452 28428 524 S 1.3 0.1 0:01.63 nginx 4657 vbart 20 0 309584 28696 592 S 1.0 0.1 0:00.64 nginx 4655 vbart 20 0 30958 28476 572 S 0.7 0.1 0:02.81 nginx 4659 vbart 20 0 309452 28468 564 S 0.3 0.1 0:01.20 nginx 4665 vbart 20 0 309452 28476 572 S 0.3 0.1 0:00.71 nginx 5180 vbart 20 0 25232 1952 1156 R 0.0 0.0 0:00.45 top 4651 vbart 20 0 20032 752 252 S 0.0 0.0 0:00.00 nginx 25921 vbart 20 0 121956 2176 1000 S 0.0 0.0 0:01.98 sshd 25923 vbart 20 0 40304 3840 2208 S 0.0 0.0 0:00.54 zsh

CPU资源仍然充足。

wrk的结果:

运行 1m 测试 @ http://192.0.2.1:8000/1/1/1 12 个线程和 50 个连接
线程统计 平均值 标准差 最大值 +/- 标准差
延迟 226.32ms 392.76ms 1.72s 93.48%
请求/秒 20.02 10.84 59.00 65.91%
1.00m 内有 15045 个请求,读取 58.86GB
请求/秒:    250.57
传输/秒:      0.98GB

提供 4 MB 文件的平均时间从 7.42 秒减少到 226.32 毫秒(减少了 33 倍),每秒请求数增加了 31 倍(250 个对 8 个)!

解释是,当工作进程在读取时被阻止时,我们的请求不再在事件队列中等待处理,而是由空闲线程处理。 只要磁盘子系统能够尽力满足第一台客户端机器的随机负载,NGINX 就会使用剩余的 CPU 资源和网络容量从内存中满足第二台客户端的请求。

仍不是灵丹妙药

在我们对阻塞操作的所有担忧和一些令人兴奋的结果之后,可能大多数人已经打算在服务器上配置线程池了。 别着急。

事实是,幸运的是,大多数读取和发送文件操作不需要处理慢速硬盘。 如果您有足够的 RAM 来存储数据集,那么操作系统就会足够聪明,将经常使用的文件缓存在所谓的“页面缓存”中。

页面缓存运行良好,使 NGINX 在几乎所有常见用例中都表现出色。 从页面缓存中读取的速度非常快,没有人能将这样的操作称为“阻塞”。 另一方面,卸载到线程池会产生一些开销。

因此,如果您拥有合理数量的 RAM 并且工作数据集不是很大,那么 NGINX 已经可以以最优方式工作,而无需使用线程池。

将读取操作卸载到线程池是一种适用于非常特定任务的技术。 当频繁请求的内容量无法容纳在操作系统的 VM 缓存中时,它最有用。 例如,负载很重的基于 NGINX 的流媒体服务器可能就是这种情况。 这是我们在基准测试中模拟的情况。

如果我们能够改进将读取操作卸载到线程池中的做法,那就太好了。 我们需要的是一种有效的方法来知道所需的文件数据是否在内存中,并且只有在后一种情况下才应该将读取操作卸载到单独的线程。

回到我们的销售类比,目前销售员无法知道所请求的商品是否在商店中,并且必须始终将所有订单转交给送货服务或始终自己处理。

罪魁祸首是操作系统缺少此功能。 第一次尝试将其作为fincore()系统调用添加到 Linux 是在 2010 年,但是没有实现。 后来,人们多次尝试将其实现为带有RWF_NONBLOCK标志的新preadv2()系统调用(有关详细信息,请参阅 LWN.net 上的非阻塞缓冲文件读取操作异步缓冲读取操作)。 所有这些补丁的命运仍不明朗。 令人悲伤的是,这些补丁还没有被内核接受的主要原因似乎是持续不断的bikeshedding

另一方面,FreeBSD 的用户根本不必担心。 FreeBSD 已经有足够好的用于读取文件的异步接口,您应该使用它来代替线程池。

配置线程池

因此,如果您确信在您的用例中可以使用线程池获得一些好处,那么现在是时候深入研究配置了。

配置非常简单和灵活。 您首先应该拥有的是 NGINX 1.7.11 或更高版本,使用configure命令的--with-threads参数进行编译。 NGINX Plus 用户需要版本 7 或更高版本。 在最简单的情况下,配置看起来非常简单。 您只需要在适当的上下文中包含aio线程指令:

# 在“http”、“server”或“location”contextaio 线程中;

这是线程池的最小可能配置。 事实上,它是以下配置的简短版本:

# 在 'main' 上下文中thread_pool defaultthreads=32max_queue=65536;

# 在 'http'、'server' 或 'location' 上下文中
aiothreads=default;

它定义了一个名为default的线程池,有32个工作线程,任务队列的最大长度为65536个任务。 如果任务队列超载,NGINX 将拒绝请求并记录此错误:

线程池“ NAME ”队列溢出: N 个任务正在等待

该错误意味着线程可能无法以最快的速度处理添加到队列中的工作。 您可以尝试增加最大队列大小,但如果这没有帮助,则表明您的系统无法处理如此多的请求。

正如您已经注意到的,使用thread_pool指令您可以配置线程数、队列的最大长度以及特定线程池的名称。 最后意味着您可以配置多个独立的线程池,并在配置文件的不同位置使用它们来实现不同的目的:

# 在“主”上下文中
thread_pool onethreads=128max_queue=0;
thread_pool twothreads=32;
http {
server{
location/one{
aiothreads=one;
}

location/two{
aiothreads=two;
}

}
#...
}

如果未指定max_queue参数,则默认使用值 65536。 如图所示,可以将max_queue设置为零。 在这种情况下,线程池只能处理与配置的线程数相同的任务;没有任务会在队列中等待。

现在让我们假设您有一台具有三个硬盘的服务器,并且您希望该服务器作为“缓存代理”工作,缓存来自后端的所有响应。 预期的缓存数据量远远超出了可用的 RAM。 它实际上是您的个人 CDN 的缓存节点。 当然,在这种情况下,最重要的是实现驱动器的最大性能。

您的选择之一是配置 RAID 阵列。 这种方法有其优点和缺点。 现在使用 NGINX 你可以采取另一种方式:

# 我们假设每个硬盘都安装在以下目录之一上:# /mnt/disk1、/mnt/disk2 或 /mnt/disk3

# 在“主”上下文中
thread_pool pool_1threads=16;
thread_poolpool_2threads=16;
thread_poolpool_3threads=16;

http {
proxy_cache_path/mnt/disk1levels=1:2keys_zone=cache_1:256mmax_size=1024G
use_temp_path=off;
proxy_cache_path/mnt/disk2levels=1:2keys_zone=cache_2:256mmax_size=1024G
use_temp_path=off;
proxy_cache_path/mnt/disk3levels=1:2keys_zone=cache_3:256mmax_size=1024G
use_temp_path=off;

split_clients $request_uri $disk {
33.3% 1;
33.3% 2;
* 3;
}

服务器 {
# ...
位置 / {
proxy_pass http://backend;
proxy_cache_key $request_uri;
proxy_cache cache_$disk;
aio 线程=pool_$disk;
sendfile on;
}
}
}

在此配置中, thread_pool指令为每个磁盘定义一个专用的、独立的线程池,而proxy_cache_path指令在每个磁盘上定义一个专用的、独立的缓存。

split_clients模块用于缓存之间(以及磁盘之间)的负载均衡,非常适合这项任务。

proxy_cache_path指令的use_temp_path=off参数指示 NGINX 将临时文件保存到相应缓存数据所在的同一目录中。 更新缓存时,需要避免在硬盘之间复制响应数据。

所有这些使我们能够从当前磁盘子系统中获得最大的性能,因为 NGINX 通过单独的线程池并行且独立地与驱动器交互。 每个驱动器由 16 个独立线程提供服务,并有一个用于读取和发送文件的专用任务队列。

我敢打赌你的客户会喜欢这种定制方法。 确保您的硬盘也喜欢它。

这个例子很好地演示了 NGINX 如何灵活地针对您的硬件进行调整。 这就像您正在向 NGINX 发出有关与机器和数据集交互的最佳方式的指令。 通过在用户空间对 NGINX 进行微调,您可以确保您的软件、操作系统和硬件以最优模式协同工作,以尽可能有效地利用所有系统资源。

结论

总而言之,线程池是一项很棒的功能,它通过消除 NGINX 众所周知的长期敌人之一——阻塞,将 NGINX 推向新的性能水平,特别是当我们谈论大量内容时。

接下来还会有更多。 如前所述,这个全新的接口可以卸载任何长时间阻塞的操作,而不会造成任何性能损失。 NGINX 在拥有大量新模块和功能方面开辟了新视野。 许多流行的库仍然没有提供异步非阻塞接口,这导致它们以前与 NGINX 不兼容。我们可能会花费大量时间和资源来开发某个库自己的非阻塞原型,但这总是值得的吗? 现在,有了线程池,就可以相对轻松地使用此类库,并且此类模块不会影响性能。

敬请关注。

亲自尝试 NGINX Plus 中的线程池 - 立即开始30 天免费试用联系我们讨论您的用例


“这篇博文可能引用了不再可用和/或不再支持的产品。 有关 F5 NGINX 产品和解决方案的最新信息,请探索我们的NGINX 产品系列。 NGINX 现在是 F5 的一部分。 所有之前的 NGINX.com 链接都将重定向至 F5.com 上的类似 NGINX 内容。”