SijieCai

使用线程池让 NGINX 9x 跑的飞起

原文链接: www.nginx.com

众所周知,NGINX使用一种异步的、事件驱动的方法来处理连接。不像传统服务器架构每一个请求会创建独立进程或线程来处理,它用单个进程处理多个连接和请求。NGINX使用非堵塞的 sockets 并使用高效的epoll或者kqueue方法。

因为满载进程的数量较少(通常一对应 CPU 核的数量)且不变,内存消耗以及浪费在任务切换的 CPU 周期更小。这种实现的的优势已经被 NGINX 本身所公认。它能并发处理上百万的请求而且易于扩展。

Each process consumes additional memory, and each switch between them consumes CPU cycles and trashes L-caches

每个进程消耗独立的内存,进程切换的时候也要消耗 CPU 周期和 L-caches

但是基于时间的异步模型有一个问题。或者我喜欢称之为 “敌人”。这个敌人就是:堵塞。很不幸许多第三方模块使用堵塞方法,而用户(甚至很多模块的开发者)并没有意识到它的缺点。堵塞的操作会破坏 NGINX 的性能,必须尽一切可能避免使用。

即使现有 NGINX 的官方代码也不可能避免使用堵塞的操作,为了解决这个问题,NGINX 1.7.11NGINX Plus Release 7 实现了 “thread pools” 机制。它是什么以及如何使用,我接下来会介绍。是时候与敌人正面交锋了。

对 NGINX Plus R7 的介绍可以参考我们另一篇博客 Announcing NGINX Plus R7.

关于 NGINX Plus R7 新特性的详细介绍可以参考如下文章:

问题

为了更好理解我们先简单介绍 NGINX 是如何工作的。

一般而言,NGINX 是一个事件处理器,有一个控制器会接收 kernel 通知的连接发生的事件,并执行操作系统命令进行处理。实际上, NGINX 负责所有与操作系统交互的繁重工作,而操作系统只负责一般的读取和发送字节。所以 NGINX 快速和即时响应至关只要。

NGINX-Event-Loop2

工作进程监听并处理来自 kernel 的事件

事件可以超时,通知 sockets 可读写或者报告发生的异常。NGINX 接受一连串的时间并逐一处理,进行必要的操作。因此所有的处理是在一个线程中对队列进行简单的循环完成。NGINX 从队列里抽取事件并响应,比如,读和写 socket。大多数情况下很快(可能就占用一些 CPU 周期去复制内存中的数据)并且 NGINX 瞬时就能处理完整个队列的事件。

Events Queue Processing Cycle

在一个简单的 loop 用单线程完成处理

如果有长时间的繁重操作会是什么情况?整个时间处理周期会被堵塞等待操作完成。

这里“堵塞操作”指的是任何可能长时间停止事件循环的操作。操作的堵塞有几种原因。比如,NGINX 在进行 CPU 密集型处理,或者在等待访问资源(比如硬盘,一个排他锁或者从同步等待数据库返回的一个结果,等等)。关键是等待的过程中,工作线程不能做其他的事情或处理别的事件,及时有富余的计算资源可用于处理队列里的事件。

想象一个店的售货员前面排起长队。第一个顾客购买的东西在库房而不再店里。售货员去库房拿货。这时候所有队伍必须等几个小时售货员取货,大家都不高兴。你可以想象大家是什么心情?所有人等待的时间都延长了,但是他们想买的东西可能就在店里面。

Faraway Warehouse

队里所有人不得不等待第一个人的订单

几乎一样的情形当 NGINX 去访问一个没有被缓存的文件,需要从硬盘中读取。硬盘很慢(尤其是转的那种),与此同时所有事件队列都需要等待。用。结果就是延迟和系统资源没有得到充分的利

Blocking-Operation

一个堵塞操作导致所有后续操作长时间延迟

一些操作系统提供读写文件异步接口供 NGINX 使用 (参考 aio directive)。一个典型例子就是 FreeBSD。不幸的是 Linux 的情况又不一样。虽然 Linux 提供一系列读取文件的异步接口,都有不少缺陷。其中一个就是为文件访问配置 beffers,这点 NGINX 已经能处理很好。但是糟糕的是第二个问题。异步接口要求对文件设置O_DIRECT标记,意味着所有文件的访问不能通过内存从而增加了硬盘的负载。这点决定了大多数情况不适用。

针对这个问题,NGINX 1.7.11 和 NGINX Plus Release 7 引入了线程池

接下来我们深入介绍 线程池 (thread pools)已经他们如何工作

线程池

回到刚才可怜的售货员,正在从很远的仓库递送商品。但是他聪明地(也可能被一群生气的顾客教训的聪明了?)雇佣了一个送货员。现在如果有从仓库取货的需求,他就命令送货员代办而自己继续服务其他的顾客。这样只有购买物品在仓库的顾客需要等待,其他人得到及时的服务。

Your Order Next

给快递服务发送订单请求解除队伍的堵塞

对 NGINX 来说,线程池就是用来执行快递服务的。它包含一个任务队列和一些处理队列的线程组成。当工作线程需要执行耗时操作,就创建一个任务到队列,然后空闲的线程就会去获取并执行。

Thread pools help increase application performance by assigning a slow operation to a separate set of tasks

工作进程把耗时堵塞操作卸载到线程池

这样似乎我们有了另一个队列。的确。但在这种情况下队列数受到系统资源的限制。我们无法以磁盘读取速度的上限更快的获取数据。现在至少磁盘不会导致其它事件的延迟并且只有需要访问文件的事件在等待。

磁盘读取操作是一个常用的堵塞操作例子,但实际上 NGINX 实现的线程池可用于任何不适合在主循环处理的任务。

目前为止,线程池主要用于承载下面三种操作:大多数操作系统同步的read(),Linux 上的 sendFile(),以及 aio_write() 在 Linux 上用于缓存文件的写操作。我们接着对实现做基准测试,然后用最新版本把一些操作卸载到线程池并观察效果。

NGINX 1.9.13NGINX Plus R9 添加了 aio_write() 的支持。

基准测试

是时候来点干货了。为了展示线程池的效果,我们用符合基准程序来模仿堵塞和非堵塞操作的最差情况。

它要求数据集必须大于内存容量。在一个 48 GB 内存的机器上,我们确保有 256 GB 的随机数据分散在 4-MB 的小文件中,然后配置 NGINX 提供文件服务。

配置相当简单:

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 网络接口。磁盘系统有 4 个 Western Digital WD1003FBYX 硬盘组成的 RAID10 阵列。操作系统使用 Ubuntu Server 14.04.1 LTS。

Load Generators

配置测试的负载机器和 NGINX。

客户端用两台一样配置的机器模拟。在这些机器之一中,wrk使用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的输出上看所有的进程大多数时间花在等在堵塞 I/O 操作上(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]([http://nginx.org/en/docs/http/ngx_http_core_module.html#aio](http://nginx.org/en/docs/http/ngx_http_core_module.html#aio))指令就可以。

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!

可能还可以更多,但是已经到达网络带宽的上限了,所以这一次的测试瓶颈在网络接口。工作线程大部分时间在 sleep 和等待新的时间(在 tops 状态里):

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.42 秒降低到 226.32 毫秒(33倍),每秒请求数增加了 31 倍(250 vs 8)!

这是因为工作进程不再需要等待磁盘读写的结果,这是由空闲的线程池操作的。只要磁盘系统尽最大能力加载第一台客户端请求的随机文件数据,剩下的 CPU 资源和网络带宽都用来从内存读取数据响应第二台客户端的请求。只要磁盘系统尽最大能力加载第一台客户端请求的随机文件数据,剩下的 CPU 资源和网络带宽都用来从内存读取数据响应第二台客户端的请求。

还没到达到完美

在经过了堵塞操作的认识和畏惧并看到了这么振奋人心的测试结果之后,可能大多数已经准备在自己服务器上配置线程池了。先别急。

真实的情况是大多数读写操作不需要与较慢的硬盘打交道。如果你有足够的内存把数据缓存下来,操作系统会智能的把常用文件缓存到 “page cache” 里面。

大多数情况下 page cache 对 NGINX 性能提升的效果很明显。从 page cache 里读取数据非常快速并认为不是一个堵塞操作。另一方面,把操作卸载到线程池有几个额外开销。

因此如果你有合理大小的内存并且工作数据集并不是很大,NGINX 在没有线程池的情况下已经能工作很好。

把操作卸载到线程池是只针对特殊任务的一个技术手段。多数用在常用请求的数据不能匹配到操作系统的 VM cache。据其中一个例子,一台基于 NGINX 的高负债媒体流服务器。而我们在测试中模拟的就是这种环境。

最好我们可以把文件读取的操作更多的卸载到线程池。我们只需要知道这个文件是否在缓存中,如果不在就用线程池

回到销售的例子,当前售货员不知道货物是否在店里,所有要不都让递送员去哪或者自己去拿。

问题根本在于操作系统并没有提供这个特性。Linux 在 2010 第一次尝试加入fincore() 系统接口但是没有成功。后来有一些其它的实现比如在系统接口 preadv2() 上添加 RWF_NONBLOCK 标记(参考 LWN.net 上的 Nonblocking buffered file read operationsAsynchronous buffered read operations)。事实上所有的这些补丁都不确定。可悲的是补丁没有纳入到内核是因为不停的在打口水仗。

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

配置线程池

所以如果你确定你可以从你的用例中使用线程池获得一些好处,那么现在是深入深入配置的时候了。

配置非常简单灵活。您应该首先使用NGINX 1.7.11或更高版本,并使用配置参数进行编译。NGINX Plus用户需要7或更高版本。在最简单的情况下,配置看起来很简单。您只需要的是在适当的上下文中包含[aio](http://nginx.org/en/docs/http/ngx_http_core_module.html#aio)线程指令:

# 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](http://nginx.org/r/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 设置为零。在这种情况下,线程池只能处理与配置的线程一样多的任务;没有任务将在队列中等待。

现在我们假设你有一个带有三个硬盘驱动器的服务器,你希望这个服务器可以作为一个“高速缓存代理”来缓存你的后端的所有响应。缓存数据的预期数量远远超过可用 RAM。它实际上是个人 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;

        }

    }

}

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

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

proxy_cache_path 指令的 use_temp_path = off参数让 NGINX 将临时文件保存到相应缓存数据所在的相同目录中。在更新缓存时,就可以避免在硬盘驱动器之间复制响应数据。

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

我打赌你的客户喜欢这种定制的方法。确保你的硬盘也像这样。

这个例子很好地展示了如何灵活地为您的硬件专门调整 NGINX。这就像您正在向 NGINX 说明有关与机器和数据集进行交互的最佳方法。通过在用户空间微调 NGINX,您可以确保您的软件,操作系统和硬件以最佳模式一起工作,以尽可能有效地利用所有系统资源。

结论

总结起来,线程池是一个很好的功能,通过消除阻塞这个公认的最大敌人,是 NGINX 达到新的性能水平 - 特别是当我们谈论真正大量的内容时。

还有更多内容。如前所述,这种全新的接口可能允许卸载任何长时间和阻塞的操作,而不会造成任何性能的损失。NGINX 为大量新模块和功能开辟了新的视野。许多受欢迎的库仍然不提供异步非阻塞接口,以前使它们与NGINX不兼容。我们可能花费大量的时间和资源来开发原先模型的非堵塞版本的原型,但这些花费值得吗?现在,使用线程池,可以相对容易地使用这些库,从而使这些模块不会对性能产生影响。

敬请关注。

尝试在 NGINX Plus 中使用线程池,开始您的免费30天试用,或者联系我们进行现场演示。