ngin Finding Everything About Nginx Here

NGINX使用线程池提升性能9x倍

发表于 2015-06-23 阅读数 3624

原文:Thread Pools in NGINX Boost Performance 9x!

介绍

大家都知道NGINX使用异步事件驱动的机制处理连接。这意味着它不用创建专门的进程或线程来处理每个请求(像一些传统架构的服务器),它在一个工作进程里处理多个连接和请求。为实现这样效果,NGINX使用了非阻塞模式的sockets和有效的事件方法如epoll和kqueue。
因为进程的数目少且是固定的(通常一核一个进程),消耗更少的内存,任务切换也没浪费CPU周期。所以这是NGINX本身被认可的优势。它成功地同时处理了上百万的请求并且伸缩性极好。

每个进程消耗额外的内存,并且每次切换都要消耗CPU周期和清理L-caches

但是这种异步事件驱动机制仍然还有问题。或者我喜欢把它说成'敌人'。这个敌人的名字就是:阻塞。不幸的是,很多第三方模块使用阻塞方式调用,用户(有时包括一些模块的开发者)也不关心这个缺陷。阻塞操作毁坏了NGINX的性能,必须被完全的阻止。
虽然现在的NGINX官方代码不能阻止任何情况下的阻塞操作,但是为了解决这个问题,新的线程池机制已经在NGINX 1.7.11版本实现了。它是什么样的,如何被使用,我们稍后再说明。现在让我们先面对面会会我们的敌人。

问题

首先,为了更好理解这个问题,我们先简短讲下NGINX是如何工作的。
一般而言,NGINX是一个事件处理器,是一个控制器,它接收内核里发生在连接上的事件信息,然后给操作系统发命令告诉它需要做什么。事实上,NGINX通过协调操作系统做了所有的复杂工作,
而操作系统只做常规的读取和发送字节的工作。所以对NGINX而言,快速及时响应是非常重要的。

工作进程监听和处理从内核来的事件

事件可能超时,或者通知sockets准备读或写,或者通知错误发生了。NGINX接收一堆事件,然后一个个处理,并且做必要的操作。在这个这样简单循环队列,所有的处理就在一个线程里完成了。NGINX从队列里拿出一个事件处理它,比如写或读socket。大多数情况下,这是过程是非常快速的(可能只需要一些CPU周期和复杂一点数据到内存),然后NGINX就在一瞬间里处理了队列内的所有事件。

一个线程跑完了一个简单循环队列里的所有处理

但是如果一些超长且任务繁重的操作发生会怎么样呢?整个事件处理周期将会因为这个操作而陷入停滞直到它完成。
所以当说'阻塞操作'时,我们指任何需要花很长时间的事件处理操作。操作可能因为各种原因而引起阻塞。举例,NGINX可能因为CPU在漫长紧张的处理变得繁忙,或者它不得不访问资源(比如硬
盘,或者锁,或者一些通过同步行为访问数据库的函数库等等。)。这个关键的地方是当处理这些操作时,工作进程不能做其它的任何东西,也不能处理其它的事件,虽然这个时候的系统资源是足够的并且队列里的一些事件可以利用这些资源。
想象下一个商店里的售货员,他的面前排了一个很长的队。第一个人要求他拿不在商店里但在仓库里的物品。这个售货员跑去仓库取这件物品,现在整个队列必须因为这个取的行为陷入停滞,并
且搞的所有人很不愉快。你能想象这些人的反应吗?每个人不得不在那里等待好几个钟头,虽然他们想买的东西可能就摆在商店里。

每个人不得不因为第一个人而陷入等待

同样的情况在NGINX也会发生,当它要求去读一个没有缓存在内存里的文件,必须从硬盘里读取。硬盘读取是慢的,但同时其它在队列里等待的请求可能不需要访问硬盘,总之他们必须在那里等待
。结果就是延迟变长并且系统资源没有更好的得到利用。


仅仅因为一个阻塞操作让所有后面的操作延迟了非常大的时间。

一些操作系统提供了异步接口用来读写文件,并且NGINX也可以使用这些接口(参考aio指令)。FreeBSD就是一个好的例子。不幸的是,不是所有的linux都有这种接口。虽然Linux提供了异步接口来读写文件,它仍然有不少缺陷。其中一个就是对齐要求以访问文件和缓冲,但是NGINX也处理的很好。第二个问题更糟。异步接口需要O_DIRECT标记设置到文件描述符,这意味着任何访问文件必须绕过内存里的缓存,这样就增加了硬盘的负载。所以这个在大多数情况下都不能让它变的最佳。
为了特别解决这个问题,NGINX 1.7.11引入了线程池。现在让我们深入分析什么是线程池是什么,它们是如何工作的。这个还未引入NGINX Plus,如果你喜欢尝试在NGINX Plus里使用这个功能可以联系客服人员。

线程池

让我们回到那个要从远处的仓库取物品的可怜的售货员。现在他已经变的更加聪明了(也许他是被一群愤怒的顾客打击后才改变的)。他雇了一个运输员。现在每当有人要求他从仓库取物品时,他不用亲自去处理,只需要下一个订单给运输员,他们就替售货员去处理物品,而售货员仍然可以处理他面前的顾客。这样只有那些需要从仓库的物品的顾客稍加等待,其它人可以立即被服务。

传达订单给运输员不会阻塞这个队列

就NGINX而言,线程池扮演着运输员的角色。它由任务队列和处理这个队列的线程组成。当一个工作进程需要做潜在长操作时,它将一个任务放进这个线程池列队里,然后空闲的线程将处理它们。


工作进程将阻塞操作放到线程池里

这看起来我们还有一个队列。是的,但是这种情况下这个队列由特殊资源限制着。我们从驱动读数据不能比驱动产生数据快。但现在至少这个驱动不会延迟处理其它事件,并且只有需要访问文件的请求才在等待。
这个读硬盘操作是最常使用的阻塞操作,但实际上NGINX里实现的线程池可以放任何不适合主工作线程里的操作。
目前,放到线程里实现的只有两个主要的操作:主流操作系统的read系统调用和Linux上的sendfile调用。我们将测试这个实现,如果有其它能产生更有价值的操作,未来我们将把它们放到线程里去。

基准测试

现在从理论转到实践。为了演示使用线程池的效果,我们将进行一个组合测试:模拟最差情况下的阻塞和非阻塞操作。
这需要一个保证不适合在内存里的数据集,在一个48G RAM的机器里,我们产生256G个随机的4M大小的文件,然后在NGINX1.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;
        }
    }
}


正如你看到的,为了达到更好的性能,一些选项被关闭了:日志和锁,sendfile打开,并且设置了sendfile_max_chunk的大小。最后这个指令可以减少阻塞的sendfile调用的时间,因为NGINX不会一次性发完整个文件,但每次将处理512KB大小的块。
这个机器有两个Intel Xeon E5645(12核,24HT-threads)处理器和一个10-Gbps网络接口。硬盘是4个Western Digital WD1003FBYX的RAID10。所有这些硬件由Ubuntu Server 14.04.1 LTS支撑着

为测试的压力和NGINX的配置

客户端是两个相同配置的机器。其中一个使用Lua脚本产生压测。这个脚本产生200个并发连接的文件请求,并且每个请求都是不命中缓存和阻塞的读硬盘操作。我们称这个压测为随机压测。
另一个客户端机器我们用wrk的复本多次请求同样的文件,用50个并发连接。因为这些文件频繁的被访问,它将一直存在内存中。普通情况下,NGINX服务这些请求非常的快,但是如果工作进程被其它请求阻塞,性能将马上下降。我们称这个压测为常量压测。
性能的计算将通过监控服务器的吞吐量输出而来,服务器使用ifstat和第二个客户端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

正如你看到的,使用这个配置服务器总共产生1 Gbps的流量。由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个连接产生的,所以不能很好的处理我们的请求。
现在是时候用到线程池了。我们只需要把aio threads指令放到location块里:
location / {
    root /storage;
    aio threads;
}
然后热启动NGINX。

经过同样的测试:
% 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限制在网络接口。这些工作进程大部份时间花在休眠和等待新的事件(他们在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

服务一个4-MB文件的平均时间从7.42s降至226.32 milliseconds(少了33倍),每秒处理请求数提升了31倍(250 vs 8)!
解释下是因为当工作进程阻塞在读操作时,我们的请求不用在事件队列里等待处,而是由空闲的线程处理了。只要硬盘系统做好它的工作即服务第一台客户端机器产生的负载,NGINX将使用剩下的CPU资源和网络服务第二台客户端机器。


仍然不是银弹

经过对阻塞操作的恐惧和一些令人兴奋的结果后,你可能已经想配置线程池到你的服务器上了。不要急。
事实上好在大部份读取和发送文件的操作在处理硬盘上不会慢。如果你有足够的内存来存储这些数据,操作系统将会足够聪明的缓存这些频繁访使用的文件到称为页缓存的地方上。
页缓存工作的非常好并且让NGINX在大部分用例下表现出极好的性能。从页缓存读是非常快的并且不会引起任何此类的阻操作。另一方面,放到内存池反而增加一些开销。
所以如果你有合理足够大的内存并且你的数据不会非常的大,NGINX不用线程池已经达到最优状态了。
对特定任务而言将读操作放到线程池是个技术应用。对那些频繁访问的且不会被缓存操作系统的虚拟页的内容卷是非常有实用的。这种情况可能发生在高负载的基于NGINX的流媒体服务器。这也是我们刚模拟的基本测试。
如果我们将读操作卸载到线程池,这是非常酷的事情。我们只需要知道被用到的文件有没有在内存中,如果不在内存中把读操作放到一个专门的线程里。
回到我们售货员的比喻,现在这个售货员不知道请求的货品是否在商店里,他必须一直通过下订单的方式让运输员的方式或他亲自处理。
这个罪魁祸首是操作系统现在缺少这个功能。2000年第一次尝试在Linux里添加fincore()系统调用,但后来没有实施。不久后又有一些尝试实现同样的功能,也就是preadv2()系统调用,它有个RW
F_NONBLOCK标记(参考回阻塞缓冲文件读操作和异步缓存读操作,在LWN.net)。事实是这些补丁仍然是不清晰的。不幸的是看起来这些补丁未被内核采纳的主要原因是不断的细节争议。
另一方面,FreeBSD的用户一点不用担心。FreeBSD已经有足够优秀的异步接口来读取文件,而不用线程池的操作。

配置线程池

好了,如果你确认使用线程池能带来好处,我们将深入到配置中。
aio threads:

这个是线程池的极简配置。事实上,它是下面这个配置的缩写版本:
thread_pool default threads=32 max_queue=65536;
aio threads=default;

它定义了一个有着302工作线程和最大请求数为65536的任务队列的线程池。如果这个任务队列超负荷了,NGINX记录错误并拒绝请求:
thread pool "NAME" queue overflow: N tasks waiting

这个错误意思是线程可能无法及时处理添加到队列的请求。你可以尝试增加队列最大值,但如果没有效果,这意味着你的操作系统无法服务这么多的请求。
正如你已经注意到的,用thread_pool指令你可以配置线程数,最大的队列大小,和线程池的名称。最后这个暗示着你可以配置多个独立的线程池,并且在不用的地方使用它们以服务不同的需求。
http {
    thread_pool one threads=128 max_queue=0;
    thread_pool two threads=32;

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

        location /two {
            aio threads=two;
        }
    }
}

如果max_queue选项没有声明,默认值将是65536。如上显示的,它也可能被设置成0。这种情况下线程池将只能处理尽可能多的任务。队列里不会有任务在那等待着。
你可以配置一个RAID列。这种方式有优缺点。现在用NGINX将是另一种方式:
# 我们假设每个驱动器都挂载到一个目录下:
# /mnt/disk1, /mnt/disk2, 或者 /mnt/disk3
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

thread_pool pool_1 threads=16;
thread_pool pool_2 threads=16;
thread_pool pool_3 threads=16;

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

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

在这个配置里3个独立的缓存被使用,用来处理每个对应的硬盘,还有3个独立的线程池也用来处理每个对应的硬盘。
这个split_clients模块用来做缓存间的负载均衡,非常合适这个任务。
proxy_cache_path指令里的use_temp_path=off选项指示NGINX保存相同的临时文件到同个目录下。这个在更新缓存时可以用来阻止响应数据在硬盘里相互复制。
这些组合在一起让我们从当前的硬盘系统获得最大的性能,因为NGINX通过分离的线程池,让它们与驱动器并行独立的交互。每个驱动器有专门的16个独立的线程用来读取和发送文件。
我打赌你的客户们肯定喜欢这种量身定制的方式。还有你的硬件驱动器也是如此。
这个例子极好的演示了NGINX是可以如何扩展你的硬件的。它喜欢你指示NGINX用最好的方式跟机器和数据交互。通过微调用户空间下的NGINX,你能确保你的软件,操作系统和硬件在最优模式下一起工作,以最有效的方式利用系统资源。


总结

总的来说线程池是个很好的功能,它通过消除一个众所周知且长久的敌人将NGINX推到新的性能高度,也就是阻塞,特别是当我们谈及真正大容量的内容。
它将会有更多的。正如前面提到的,这个新品牌接口潜在地允许卸掉任何长且阻塞的操作而不用损失任何性能。NGINX打开了新的窗口来处理大量的新模块和功能。许多受欢迎的库仍然不提升异步非阻塞的掊,这些在以前跟NGINX是不相容的。我们可能花很多的时间和资源来开发我们自己的一些库非阻塞的原型,但是这是效果值得吗?现在,线程池登台后,使用这些相关的模块变的很容易,并且不会对性能产生影响。


保持调整