2

在nginx的官网看到一篇介绍nginx原理的文章,这篇文章比较老了是15年发布的,国内有人翻译过但是有些小瑕疵,这里更正出来发布在我个人的文章里供大家参考,这篇文章详细的介绍了nginx线程池的原理以及设计思路,在最后通过详细的实验数据来告诉我们通过线程池提升的性能以及分析了应该使用线程池的场景。在日后的其他领域依然很有借鉴意义。

点我看源文

大家都知道NGINX使用异步以及事件驱动的方式来处理请求,这意味着,我们不会为每个请求创建另一个专用的进程或线程(比如像那些使用了传统架构的服务器)。而是选择一个工作进程来处理多个连接请求。为了实现这样的特性,NGINX使用非阻塞模式下的socket以及选择了更有效率的系统调用比如epollkqueue
满负载的进程数量很少(通常是每个cpu核心只占一个)而且是恒定的,这样消耗了更少的内存以及cpu时间片没有被浪费在任务切换上。这个方法的优点可以通过nginx这个例子来反映出来。它可以非常好的并发处理上百万的请求规模并且处理的效果还不错。

clipboard.png
每个进程消耗额外的内存,每次进程之间的切换会消耗CPU周期以及产生cpu缓存垃圾

但是异步,事件驱动这类模型同样存在问题。或者我更喜欢把这样的问题称作敌人。敌人的名字叫做:阻塞。不幸的是,许多第三方模块都使用了阻塞式的调用,而且用户(有时候甚至模块的开发者)没有意识到这么做的弊端。阻塞式的操作会毁掉NGINX的性能所以无论如何一定要被阻止。

但是在现在的官方版本的NGINX源代码中也不可能在任何情况下避免阻塞,为了解决这个问题新的“线程池”特性被引入到NGINX version 1.7.11以及NGINX PLUS Release 7当中来.它是什么以及它如何使用这个我们稍后讨论,现在我们来面对我们的敌人了。

编辑注-想对NGINX PLUS R7有个大概了解可以在我们的博客看到更多
想看NGINX PLUS R7其他特性的具体分析,可以看下边列出来的博客:

问题

首先,为了更好的了解NGINX我们会用几句话解释一下它是如何工作的。
大体来说,NGINX是一个事件处理器,一个从内核接收目前的连接信息然后发送接下来做的什么命令给操作系统的控制器。实际上NGINX做的脏活累活是通过协调操作系统来做的,本质上是操作系统在做周期性的读或者写。所以对NGINX来说反应的快速及时是很重要的。

clipboard.png
工作进程监听以及处理从内核传过来的事件

这些事件可能会超时,通知socket读就绪或者写就绪,或者通知一个错误的产生。NGINX接收到一串事件接着一个一个的处理它们。所以所有的操作可以在一个线程的一个队列的一次简单循环当中完成。NGINX从队列当中弹出一个事件然后通过比如读写socket来做后续处理。在大多数情况下,这个操作会很快(也许这个操作只需要很少的cpu时间片去从内存当中copy一些数据)并且NGINX可以用很短的时间在这个队列当中处理完所有的事件。

clipboard.png
所有的操作都在一个线程的简单循环当中做完了。
但是如果一个长时间并且重量级的操作到来会发生啥呢?答案显而易见,整个事件处理的循环都会被这个操作所阻塞直到这个操作完成。

因此,所谓“阻塞操作”是指任何导致事件处理循环显著停止一段时间的操作。操作会因为各种各样的原因而被阻塞。比如说,NGINX可能忙于处理冗长的CPU密集型处理,或者可能需要等待访问资源(例如硬盘驱动器,或一个库函数以同步方式从数据库获取响应,等等等等)。关键的问题在于,处理这样的事情,工作线程不能做其他别的事情,即使其他的系统资源可以获取到而且队列当中的其他一些事件会用到这些资源。

想象一下商店销售员面前有长长的一长队人。 队列中的第一个人需要一个不在商店但是在仓库里的东西。 销售人员跑到仓库去提货。 现在,整个队列必须等待几个小时才能进行交付,排队当中的每个人都不满意。 想想如果是你你会如何反应? 队员中的每个人的等待时间都增加了这几个小时,但他们打算买的东西有可能就在店里。

clipboard.png
队列里的每个人都必须因为第一个的订单而等待。

相同的情况发生在NGINX当中,想想当它想要读一个没有缓存在内存中的文件而不得不去访问硬盘的时候。硬盘驱动器很慢(特别是机械硬盘),而等待队列中的其他请求可能不需要访问驱动器,所以它们也是被迫等待的。 因此,延迟增加,系统资源未得到充分利用。

clipboard.png
只有一个阻塞会大幅延迟所有的接下来所有的操作

一些操作系统提供用于读取和发送文件的异步接口,NGINX可以使用此接口(请参阅aio指令)。 这里有个好例子就是FreeBSD。坑爹的是,Linux可能不如左边这位那么友好。 虽然Linux提供了一种用于读取文件的异步接口,但它有一些显著的缺点。 其中一个是文件访问和缓冲区的对齐要求,当然NGINX可以把这个问题处理得很好。 但第二个问题更糟糕,异步接口需要在文件描述符上设置O_DIRECT标志,这意味着对文件的任何访问将绕过内存中的缓存并增加硬盘上的负载。这无形中干掉了很多原本可以使用这个调用的场景。

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

现在我们来看看什么线程池是关于它们以及它们的工作原理。

线程池

让我们回到上个问题,倒霉的销售助理从遥远的仓库配货这个用例。 这次他变得更聪明(也许是被愤怒的客户群殴后变得更聪明了),他雇佣了送货服务。 现在,当有人需要遥远的仓库里的一些东西的时候,他不会亲自去仓库而只不过下了一个订单到送货服务,他们会处理订单,而我们的销售助理会继续为其他客户服务。 因此,只有那些货物不在商店的客户正在等待交货,而售货员可以马上继续为其他客户提供服务。

图片描述
将订单传递给运送服务从而解除阻塞队列

在NGINX方面,线程池正在执行运送服务的功能。 它由一个任务队列和多个处理队列的线程组成。 当一个工作进程需要做一个潜在的长时间操作时,它不会自己处理这个操作,而是将一个任务放在线程池的队列中,任何空闲的线程都可以从中进行处理。

clipboard.png
工作进程将阻塞操作装载到线程池

看来我们还有一个队列。是的,但是在这种情况下,队列受到特定资源的限制。我们从磁盘读取资源速度永远比磁盘生成数据要慢。但是现在至少磁盘操作不会延迟其他事件的处理,只有需要访问文件的请求正在等待。

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

目前,提交到线程池仅用于三个基本操作:大多数操作系统上的read()系统调用,Linux上的sendfile()和Linux上的在编写一些临时文件比如缓存时使用到的aio_write()。我们将继续测试和评估,如果有明显的好处,我们可能会在未来的版本中将其他操作也提交到线程池。

编辑注: 在NGINX 1.9.13和NGINX Plus R9中添加了对aio_write()系统调用的支持。

评估基准

现在到了理论通往实践的时候了。 为了演示使用线程池的效果,我们将执行一个合成基准,模拟阻塞和非阻塞操作的最糟糕组合。

它需要一个确保不适合内存贮存的数据集。 在具有48 GB内存的机器上,我们已经生成了256 GB的随机4M分割数据,然后配置了NGINX version 1.9.0来为其提供服务。

配置非常简单:

worker_processes 16;

events {
    accept_mutex off;
}

http {
    include mime.types;
    default_type application/octet-stream;

    access_log off;
    sendfile on;
    sendfile_max_chunk 512k;

    server {
        listen 8000;

        location / {
            root /storage;
        }
    }
}

可以看到的是,为了获得更好的性能,一些调优已经提前做完:logging和accept_mutex被禁用,sendfile被启用,并且sendfile_max_chunk被设置。 最后一个指令可以减少阻止sendfile()调用所花费的最大时间,因为NGINX不会一次尝试发送整个文件,而是分割成512 KB的数据块来执行相应操作。

该机器有两块Intel Xeon E5645(12核24线程)处理器和10 Gbps网络接口。 磁盘子系统由安装在RAID10阵列中的四个西数WD1003FBYX硬盘驱动器表示。 操作系统是Ubuntu Server 14.04.1 LTS。

clipboard.png
相应基准下负载生成和NGINX的配置。

客户由两台相同的规格的机器组成。其中一个机器上,wrk使用Lua脚本创建负载。脚本以200的并发连接从服务器以随机顺序请求文件,和每个请求都可能会导致缓存缺失从而导致从磁盘读取产生的阻塞。我们就叫它“加载随机载荷”。

第二客户端机器我们将运行另一个副本的wrk,但是这个脚本我们使用50的并发连接来请求相同的文件。因为这个文件被经常访问的,它将保持在内存中。在正常情况下,NGINX很快的处理这些请求,但是工作线程如果被其他的请求阻塞性能将会下降。所以我们暂且叫它“加载恒定负载”。

性能将由服务器上ifstat监测的吞吐率(throughput)和从第二台客户端获取的wrk结果来度量。

现在,第一次没有线程池给了我们不是那么让人赛艇的结果:

% ifstat -bi eth2
eth2
Kbps in  Kbps out
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

如你所见,上述的配置可以产生一共1G的流量,从top命令上我们可以看到所有的工作线程在阻塞io上花费了大量的时间(下图D状态):

top - 10:40:47 up 11 days,  1:32,  1 user,  load average: 49.61, 45.77 62.89
Tasks: 375 total,  2 running, 373 sleeping,  0 stopped,  0 zombie
%Cpu(s):  0.0 us,  0.3 sy,  0.0 ni, 67.7 id, 31.9 wa,  0.0 hi,  0.0 si,  0.0 st
KiB Mem:  49453440 total, 49149308 used,   304132 free,    98780 buffers
KiB Swap: 10474236 total,    20124 used, 10454112 free, 46903412 cached Mem

  PID USER     PR  NI    VIRT    RES     SHR S  %CPU %MEM    TIME+ COMMAND
 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获得的结果来看也非常低:

Running 1m test @ http://192.0.2.1:8000/1/1/1
  12 threads and 50 connections
  Thread Stats   Avg    Stdev     Max  +/- Stdev
    Latency     7.42s  5.31s   24.41s   74.73%
    Req/Sec     0.15    0.36     1.00    84.62%
  488 requests in 1.01m, 2.01GB read
Requests/sec:      8.08
Transfer/sec:     34.07MB

请记住,文件是从内存送达的!第一个客户端的200个连接创建的随机负载,使服务器端的全部的工作进程忙于从磁盘读取文件,因此产生了过大的延迟,并且无法在合适的时间内处理我们的请求。

然后亮出线程池了。为此,我们只需在location块中添加aio threads指令:

location / {
root /storage;
aio threads;
}

接着,执行NGINX reload重新加载配置。

然后,我们重复上述的测试:

% ifstat -bi eth2
eth2
Kbps in  Kbps out
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的测试中,NGINX受限于网络接口。工作进程的大部分时间只是休眠和等待新的事件(它们在下图处于top的S状态):

top - 10:43:17 up 11 days,  1:35,  1 user,  load average: 172.71, 93.84, 77.90
Tasks: 376 total,  1 running, 375 sleeping,  0 stopped,  0 zombie
%Cpu(s):  0.2 us,  1.2 sy,  0.0 ni, 34.8 id, 61.5 wa,  0.0 hi,  2.3 si,  0.0 st
KiB Mem:  49453440 total, 49096836 used,   356604 free,    97236 buffers
KiB Swap: 10474236 total,    22860 used, 10451376 free, 46836580 cached Mem

  PID USER     PR  NI    VIRT    RES     SHR S  %CPU %MEM    TIME+ COMMAND
 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的结果:

Running 1m test @ http://192.0.2.1:8000/1/1/1
  12 threads and 50 connections
  Thread Stats   Avg      Stdev     Max  +/- Stdev
    Latency   226.32ms  392.76ms   1.72s   93.48%
    Req/Sec    20.02     10.84    59.00    65.91%
  15045 requests in 1.00m, 58.86GB read
Requests/sec:    250.57
Transfer/sec:      0.98GB

服务器处理4MB文件的平均时间从7.42秒降到226.32毫秒(减少了33倍),每秒请求处理数提升了31倍(250 vs 8)!

对此,我们的解释是请求不再因为工作进程被阻塞在读文件而滞留在事件队列中等待处理,它们可以被空闲的线程(线程池当中的)处理掉。只要磁盘子系统能撑住第一个客户端上的随机负载,NGINX可以使用剩余的CPU资源和网络容量,从内存中读取,以服务于上述的第二个客户端的请求。

没有什么灵丹妙药

在抛出我们对阻塞操作的担忧并给出一些令人振奋的结果后,可能大部分人已经打算在你的服务器上配置线程池了。但是先别着急。

实际上很幸运大多数的读或者写文件操作都不会和硬盘打交道。如果我们有足够的内存来存储数据集,那么操作系统会聪明地在被称作“页面缓存”的地方缓存那些频繁使用的文件。

“页面缓存”的效果很好,可以让NGINX在几乎所有常见的用例中展示优异的性能。从页面缓存中读取比较快,没有人会说这种操作是“阻塞”。另一方面,装载任务到线程池是有一定开销的。

因此,如果你的机器有合理的大小的内存并且待处理的数据集不是很大的话,那么无需使用线程池,NGINX已经工作在最优化的方式下。

装载读操作到线程池是一种适用于非常特殊任务的技巧。只有当经常请求的内容的大小不适合操作系统的虚拟机缓存时,这种技术才是最有用的。至于可能适用的场景,比如,基于NGINX的高负载流媒体服务器。这正是我们已经上文模拟的基准测试的场景。

我们如果可以改进装载读操作到线程池,将会非常有意义。我们只需要知道所需的文件数据是否在内存中,只有不在内存中的时候读操作才应该装载到线程池的某个单独的线程中。

再回到售货员的场景中,这回售货员不知道要买的商品是否在店里,他必须要么总是将所有的订单提交给运货服务,要么总是亲自处理它们。

是的,问题的本质就是操作系统没有这样的特性。2010年人们第一次试图把这个功能作为fincore()系统调用加入到Linux当中,但是没有成功。后来还有一些是使用RWF_NONBLOCK标记作为preadv2()系统调用来实现这一功能的尝试(详情见LWN.net上的非阻塞缓冲文件读取操作异步缓冲读操作)。但所有这些补丁的命运目前还不明朗。悲催的是,这些补丁尚没有被内核接受的主要原因你可以看这里:(bikeshedding)。

译者著:我觉得没加入内核完全就是开发组里面一派人有类似“要啥自行车”这样的想法.....

另一方面,FreeBSD的用户完全不必担心。FreeBSD已经具备足够好的异步读取文件接口,我们应该用它而不是线程池。

配置线程池

所以如果你确信在你的用例中使用线程池会带来好处,那么现在就是时候深入了解线程池的配置了。

线程池的配置非常简单、灵活。首先,获取NGINX 1.7.11或更高版本的源代码,使用--with-threads配置参数编译。在最简单的场景中,配置也看起来很简单。所有你需要的所有事就是在合适的情况下把aio线程的指令include进来:

# in the 'http', 'server', or 'location' context
aio threads;

这是线程池的最简配置。实际上边的配置是下边的精简版:

# in the 'main' context
thread_pool default threads=32 max_queue=65536;

# in the 'http', 'server', or 'location' context
aio threads=default;

这里定义了一个名为“default”,包含32个线程,任务队列最多支持65536个请求的线程池。如果任务队列过载,NGINX将拒绝请求并输出如下错误日志:

thread pool "NAME" queue overflow: N tasks waiting

错误输出意味着线程处理作业的速度有可能低于任务入队的速度了。你可以尝试增加队列的最大值,但是如果这无济于事,那这意味着你的系统没有能力处理这么多的请求了。

正如你已经注意到的,你可以使用thread_pool指令,配置线程的数量、队列的最大长度,以及特定线程池的名称。最后要说明的是,可以配置多个相互独立的线程池,并在配置文件的不同位置使用它们来满足不同的用途:

# in the 'main' context
thread_pool one threads=128 max_queue=0;
thread_pool two threads=32;

http {
    server {
        location /one {
            aio threads=one;
        }

        location /two {
            aio threads=two;
        }

    }
    #...
}

如果没有指定max_queue参数的值,默认使用的值是65536。如上所示,可以设置max_queue为0。在这种情况下,线程池将使用配置中全部数量的线程来尽可能地同时处理多个任务;队列中不会有等待的任务。

现在,假设我们有一台服务器,挂了3块硬盘,我们希望把该服务器用作“缓存代理”,缓存后端服务器的全部响应。预期的缓存数据量远大于可用的内存。它实际上是我们个人CDN的一个缓存节点。毫无疑问,在这种情况下,最重要的事情是发挥硬盘的最大性能。

我们的选择之一是配置一个RAID阵列。这种方法毁誉参半,现在,有了NGINX,我们可以有另外的选择:

# We assume that each of the hard drives is mounted on one of these directories:
# /mnt/disk1, /mnt/disk2, or /mnt/disk3

# in the 'main' context
thread_pool pool_1 threads=16;
thread_pool pool_2 threads=16;
thread_pool pool_3 threads=16;

http {
    proxy_cache_path /mnt/disk1 levels=1:2 keys_zone=cache_1:256m max_size=1024G
                     use_temp_path=off;
    proxy_cache_path /mnt/disk2 levels=1:2 keys_zone=cache_2:256m max_size=1024G
                     use_temp_path=off;
    proxy_cache_path /mnt/disk3 levels=1:2 keys_zone=cache_3:256m max_size=1024G
                     use_temp_path=off;

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

    server {
        #...
        location / {
            proxy_pass http://backend;
            proxy_cache_key $request_uri;
            proxy_cache cache_$disk;
            aio threads=pool_$disk;
            sendfile on;
        }
    }
}

在这份配置中,使用了3个独立的缓存,每个缓存专用一块硬盘,另外,3个独立的线程池也各自专用一块硬盘,proxy_cache_path指令在每个磁盘定义了一个专用、独立的缓存

split_clients模块用于高速缓存之间的负载平衡(以及磁盘之间的结果),它完全适合这类任务。

在 proxy_cache_path指令中设置use_temp_path=off,表示NGINX会将临时文件保存在缓存数据的同一目录中。这是为了避免在更新缓存时,磁盘之间互相复制响应数据。

这些调优将发挥磁盘子系统的最优性能,因为NGINX通过单独的线程池并行且独立地与每块磁盘交互。每个磁盘由16个独立线程提供支持,并且线程具有用于读取和发送文件的专用任务队列。

我们相信你的客户会喜欢这种量身定制的方法。请确保你的磁盘撑得住。

这个示例很好地证明了NGINX可以为硬件专门调优的灵活性。这就像你给NGINX下了一道命令,要求机器和数据最优配合。而且,通过NGINX在用户空间中细粒度的调优,我们可以确保软件、操作系统和硬件工作在最优模式下并且尽可能有效地利用系统资源。

总结

综上所述,线程池是个好功能,它将NGINX的性能提高到新的高度并且干掉了一个众所周知的长期隐患:阻塞,尤其是当我们真正面对大量吞吐的情况下这种优势更加明显。

但是还有更多的惊喜。正如前面所述,这种全新的接口可能允许装载任何耗时和阻塞的操作而不会造成任何性能的损失。 NGINX在大量新模块和功能方面开辟了新的天地。 许多受欢迎的库仍然没有提供异步非阻塞接口,以前这使得它们与NGINX不兼容。 我们可能花费大量的时间和精力来开发自己的非阻塞原型库,但是这么做可能并不值得。 现在,使用线程池,我们可以相对容易地使用这些库,并且这些模块不会对性能产生影响。

敬请期待下篇文章。


已注销
214 声望5 粉丝

写代码不要局限某种语言,解决问题才是最重要的