[译文]NGINX加入线程池特性,性能可提升9倍

介绍

NGINX采用异步、事件驱动的服务器架构来处理HTTP请求。与为每个HTTP请求创建或分配另一个服务进程/线程的传统服务器架构不同,NGINX使用非阻塞套接字和epoll/kqueue模式在一个工作进程中处理多个HTTP请求。而且NGINX的进程数量是一个非常小的常量,使用的内存不多,进程切换频率低,因此这种服务器架构可以同时处理上百万的模拟HTTP请求。

即便如此,异步、事件驱动的服务器架构仍不完美。产生缺陷的原因是:阻塞。不幸的是,很多NGINX的第三方模块使用阻塞调用,而用户并不了解这些特性,导致其NGINX服务性能严重下降。我们必须不惜一切代价避免阻塞。 但是现阶段在NGINX官方代码里都不能做到完全没有阻塞调用。因此NGINX采用另一个方案来解决阻塞问题,这个方案就是“线程池”。线程池已经在NGINX版本1.7.11中实现。在介绍线程池前,我们先来聊聊阻塞。

阻塞

为了更好地理解这一问题,先简要介绍下NGINX的工作机制。 总的来说,NGINX是事件处理程序,从内核读取链接产生的事件信息,然后告诉操作系统该如何处理。NGINX把所有繁重任务都交给操作系统,使NGINX可以快速、及时响应。

事件可以是超时、通知套接字读写数据、通知错误告警等。NGINX收到一堆的事件并放入处理队列中,然后一个接一个的处理它们。事件队列在进程的主循环中。在大多数情况下,事件处理非常快,也许只需要几个CPU周期。

如果有些事件处理非常耗时,将会导致什么情况发生呢?这个事件会一直在处理状态,直到完成后,主循环才能继续处理下一个事件。 所以,阻塞意味着主循环将会被这个操作暂停一段较长的时间。阻塞操作有很多种,例如,CPU密集型计算、等待锁、磁盘IO、同步的网络操作等。关键是当进行阻塞操作时,工作进程不能处理其他事件,即使系统资源空闲。 举个例子,有一队顾客正在排队向销售员购买商品。第一个顾客需要购买的商品在这间商店没有库存,于是销售员立马跑到仓库去取货。这个队伍的其他人将要等待两个小时才能购买商品,即使他们需要的商品就在商店中。你能想象后面的等待的人的表情吗?那会是非常烦躁不安的。

类似的情况同样会发生NGINX中。当读取的数据不在内存中,需要从磁盘的文件中获取。磁盘IO耗时几百个CPU周期,而队列中的其他事件需要的数据可能就在内存中,但是他们必须等待。结果大部分的请求的延迟增加,系统资源利用率下降。

有些操作系统,例如FreeBSD,提供读写文件的异步接口。NGINX就可以利用这些接口避免阻塞。并不是所有的Linux系统都提供这类接口。虽然Linux提供另一种读取文件的异步接口(译者注:这里应该是指mmap),但是这里有两个明显的坑。一个是需要把文件映射在内存中,另一个是文件描述符需要有O_DIRECT标记。第一个坑NGINX能很好处理,而第二个坑意味着文件的所有访问都会增加磁盘负载。显然这是不科学的。 为了解决这个问题,NGINX 1.7.11版本引入了线程池技术。但是现在默认的NGINX Plus并没有包含这个特性,如果你有需要,请联系NGINX的销售顾问帮你构建包含线程池的NGINX Plus R6版本。

线程池

我们回到笨销售员的例子。这回销售员变聪明了,他叫了个快递去取仓库中的商品,并让这个顾客在一旁等待,继续处理后面顾客的购买请求。因此只有需要购买仓库中商品的顾客需要等待,其他顾客可以马上购买到他需要的商品。

在NGINX中,线程池的作用和快递类似。它由一个任务队列和几个工作线程组成。当工作进程需要处理事件有耗时的操作时,就把任务放进线程池的任务队列,然后这个任务就会被空闲的工作线程处理。

这个任务队列被一些特定的资源限制,可能会处理不过来。但是至少耗时的事件不会阻碍其他事件的处理。 磁盘IO是阻塞操作最常见的例子。NGINX中的线程池可以处理任何不适合在工作进程主循环中处理的任务。 现在,线程池中只实现了两个常见的操作,大多数系统中的read()系统调用和Linux系统中的sendfile()系统调用。NGINX将会继续在未来的版本中增加其他操作。

基准测试

实践是检验真理的唯一标准。我们将模拟阻塞和非阻塞的混合请求来对线程池的效率做基准测试。 HTTP请求获取数据,但是数据不能全部缓存在内存中。测试机有48GB内存,我们在磁盘上生成了一共256GB的文件,每个文件4MB,然后配置NGINX 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;
        }
    }
}	

为了达到最好的性能loggingaccept_mutex已经被禁用,sendfilesendfile_max_chunk被设置。sendfile_max_chunk可以减少sendfile()的耗时,因为NGINX每次只发送512KB的块。 测试机有两个Intel Xeon E5645(12 cores,24 HT-threads in total)处理器,10Gbps网卡,有四块Western Digital WD1003FBYX硬盘,并组成RAID10阵列。操作系统是Ubuntu Server 14.04.1 LTS。

客户端有两台一样的测试机。其中一台并发200,请求随机的文件。这些请求可能会导致缓存未命中而产生磁盘IO。这台机器我们称为随机负载。另一台并发50,请求相同的文件。由于该文件频繁访问,会被缓存在内存中。NGINX可以快速处理这类请求,但是可能会被其他请求阻塞。这台机器我们称为常量负载。 在服务器上使用ifstat监测性能。并在第二台测试机上使用WRK监测性能。

没有线程池的版本性能一般,吞吐量为1Gbps。

top输出的信息显示工作线程都在阻塞状态。

吞吐量被磁盘IO限制,CPU大部分时间都处于空闲状态。 WRK的结果非常糟糕。

常量负载请求的数据应该是从内存中获取,但是延迟非常大。这是由于所有的工作进程都在处理随机负载产生的磁盘IO。

现在使用线程池版本测试。在配置的location块中加入aio threads


location / {
    root /storage;
    aio threads;
}	

测试结果

线程池的版本吞吐量为9.5Gbps! 可能可以达到更高的吞吐量,但是9.5Gbps已经接近网卡的极限吞吐量。这个测试的瓶颈在网卡。 top输出的信息显示工作线程都在休眠等待新的事件。

CPU大部分时间仍然处于空闲状态。 WRK的结果显示平均延迟已经由7.42秒下降到226.32毫秒,速度提升33倍。每秒的请求由8上升到250,数量提升了31倍!

这结果是由于请求不再受到其他阻塞操作的影响。只要磁盘系统工作正常,随机负载就能得到很好的服务。NGINX使用剩下的资源来服务常量负载。

依旧没有银弹

如果NGINX中有阻塞操作和有提升性能的需求,你可能会迫不及待的使用线程池。但是先别急。

NGINX中大部分read()sendfile()操作不会产生过多的磁盘IO。如果你有足够的内存,操作系统会自动把访问频率高的文件缓存在内存中,我们称为页高速缓存。

页高速缓存非常有效,使NGINX在大部分场景下有良好性能。页高速缓存中取数据十分快,如果使用线程池会有点多余。

所以当你有足够的内存并且所处理的数据总量不大,NGINX在没有线程池的情况下已经可以很完美的工作了。

只有在特定的场景下,才需要把read()交给线程池处理。当出现大量请求获取数据,但数据不能保存在系统的缓存中时,使用线程池会有明显的效果。最常见的例子就流媒体服务,这个和我们基准测试情况差不多。

我们非常欣喜,能把read()操作交给线程池处理从而降低负载。判断是否使用线程池的标准是文件数据是否能保存在内存中。目前遇到的情况中,只有上面的例子需要使用线程池来处理read()事件。

再说说刚刚的销售员例子。现在销售员不知道商品是否在商店中,因此他要么把每个商品都让快递员去取,要么都自己去取。

问题的根源是操作系统忽略了这个特性的开发。在2010年Linux尝试加入fincore()系统调用,但是最终未能实现。之后数度尝试了在preadv2()中加入RWF_NONBLOCK标记。但是这些情况仍不明朗。内核还没加入这个特性的主要原因是拖延。

FreeBSD已经有非常好的异步read()接口,因此不需要使用线程池。

配置线程池

你确定要使用线程池能提高NGINX性能,下面来介绍下线程池的配置。

配置十分简单灵活。首先你需要使用NGINX 1.7.11版本或者之后的版本,带上--with-threads标志编译。在最简单的例子是在httpserverlocation块中加入aio threads


aio threads;

这是最简单的线程池配置,其实是以下配置的简化版。


thread_pool default threads=32 max_queue=65536;
aio threads=default;

配置定义了长度为65536的任务队列和32个工作线程。如果任务队列满载,NGINX会打印错误日志并拒接新的请求。


thread pool "NAME" queue overflow: N tasks waiting

出现错误日志,意味着HTTP请求的处理速度已经远远低于HTTP请求增加的速度,你可以考虑增加工作线程,如果这样也不能解决问题,说明你的系统已经不能承载这么大量的HTTP请求。

使用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的值,NGXIN将使用默认的65536。max_queue可以设置为0,这时没有任务会在队列中等待,线程池只能处理线程数量的任务,其余的会被拒绝。

现在我们设定一个场景,一台有三个磁盘的缓存代理,缓存所有从后端来的回包,但是需要缓存的数据量将会超过内存的容量。这是一台CDN的节点,目标是最大化磁盘的性能。

你可以选择配置RAID阵列或者选择配置一个特殊的NGINX。


# We assume that each of the hard drives is mounted on one of the directories:
# /mnt/disk1, /mnt/disk2, or /mnt/disk3 accordingly
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;

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;
}

这个配置使用了三个独立的缓存和三个独立的线程池分别服务三个磁盘。

split_clients配置起到了负载均衡的作用。

proxy_cache_pathuse_temp_path=off参数指示NGINX将临时文件保存在对应的目录,避免相同的请求使文件在不同的磁盘中拷贝。

这样就能使磁盘达到最大的性能,因为NGINX通过三个线程池独立并行的服务对应的磁盘。每个磁盘有16个独立的线程和对应的任务队列来读写数据。

这个例子说明NGINX可以根据你的硬件需求进行配置。一个优秀的NGINX配置能使你的软件、操作系统、硬件协同工作达到最大的资源利用率。

总结

总的来说,线程池是一个很好的特性,它会使NGINX处理阻塞操作尤其是读取大量零碎的数据时性能达到一个新的高度。

不仅如此,前面提到其他的阻塞操作使用这个特性会同样提高性能。NGINX将会继续完善相关组件。NGINX将会不再兼容没有提供异步非阻塞的接口的函数库。NGINX将会投入更多的时间和资源开发自己的非阻塞原型库。随着线程池特性的上线,这些库在不影响模块性能的前提下会更加简单易用。

阅读原文

Tags: 网络 服务端技术