众所周知,nginx性能高,而nginx的高性能与其架构是分不开的。那么nginx究竟是怎么样的呢?这一节我们先来初识一下nginx框架吧。

3.1 Nginx进程模型

nginx在启动后,在unix系统中会以daemon的方式在后台运行,后台进程包含一个master进程和多个worker进程。

当然nginx也是支持多线程的方式的,只是我们主流的方式还是多进程的方式,也是nginx的默认方式。nginx采用多进程的方式有诸多好处,所以我就主要讲解nginx的多进程模式吧。

nginx在启动后,会有一个master进程和多个worker进程。master进程主要用来管理worker进程,包含:接收来自外界的信号,向各worker进程发送信号,监控worker进程的运行状态,当worker进程退出后(异常情况下),会自动重新启动新的worker进程。而基本的网络事件,则是放在worker进程中来处理了。多个worker进程之间是对等的,他们同等竞争来自客户端的请求,各进程互相之间是独立的。一个请求,只可能在一个worker进程中处理,一个worker进程,不可能处理其它进程的请求。worker进程的个数是可以设置的,一般我们会设置与机器cpu核数一致,这里面的原因与nginx的进程模型以及事件处理模型是分不开的。nginx的进程模型,可以由下图来表示:

Nginx-zh-3-01.png

nginx进程模型的好处:

  1. 对于每个worker进程来说,独立的进程,不需要加锁,所以省掉了锁带来的开销,同时在编程以及问题查找时,也会方便很多。
  2. 采用独立的进程,可以让互相之间不会影响,一个进程退出后,其它进程还在工作,服务不会中断,master进程则很快启动新的worker进程。
  3. 如果worker进程的异常退出,肯定是程序有bug了,异常退出,会导致当前worker上的所有请求失败,不过不会影响到所有请求,所以降低了风险。

当然,好处还有很多,大家可以慢慢体会。

3.2 Nginx的事件处理过程

有人可能要问了,nginx采用多worker的方式来处理请求,每个worker里面只有一个主线程,那能够处理的并发数很有限啊,多少个worker就能处理多少个并发,何来高并发呢?

首先,请求过来,要建立连接,然后再接收数据,接收数据后,再发送数据。具体到系统底层,就是读写事件,而当读写事件没有准备好时,必然不可操作,如果不用非阻塞的方式来调用,那就得阻塞调用了,事件没有准备好,那就只能等了,等事件准备好了,你再继续吧。阻塞调用会进入内核等待,cpu就会让出去给别人用了,对单线程的worker来说,显然不合适,当网络事件越多时,大家都在等待呢,cpu空闲下来没人用,cpu利用率自然上不去了,更别谈高并发了。好吧,你说加进程数,这跟apache的线程模型有什么区别,注意,别增加无谓的上下文切换。所以,在nginx里面,最忌讳阻塞的系统调用了。不要阻塞,那就非阻塞喽。非阻塞就是,事件没有准备好,马上返回EAGAIN,告诉你,事件还没准备好呢,你慌什么,过会再来吧。好吧,你过一会,再来检查一下事件,直到事件准备好了为止,在这期间,你就可以先去做其它事情,然后再来看看事件好了没。虽然不阻塞了,但你得不时地过来检查一下事件的状态,你可以做更多的事情了,但带来的开销也是不小的。所以,才会有了异步非阻塞的事件处理机制,具体到系统调用就是像select/poll/epoll/kqueue这样的系统调用。它们提供了一种机制,让你可以同时监控多个事件,调用他们是阻塞的,但可以设置超时时间,在超时时间之内,如果有事件准备好了,就返回。这种机制正好解决了我们上面的两个问题,拿epoll为例(在后面的例子中,我们多以epoll为例子,以代表这一类函数),当事件没准备好时,放到epoll里面,事件准备好了,我们就去读写,当读写返回EAGAIN时,我们将它再次加入到epoll里面。这样,只要有事件准备好了,我们就去处理它,只有当所有事件都没准备好时,才在epoll里面等着。这样,我们就可以并发处理大量的并发了,当然,这里的并发请求,是指未处理完的请求,线程只有一个,所以同时能处理的请求当然只有一个了,只是在请求间进行不断地切换而已,切换也是因为异步事件未准备好,而主动让出的。这里的切换是没有任何代价,你可以理解为循环处理多个准备好的事件,事实上就是这样的。与多线程相比,这种事件处理方式是有很大的优势的,不需要创建线程,每个请求占用的内存也很少,没有上下文切换,事件处理非常的轻量级。并发数再多也不会导致无谓的资源浪费(上下文切换)。更多的并发数,只是会占用更多的内存而已。 我之前有对连接数进行过测试,在24G内存的机器上,处理的并发请求数达到过200万。现在的网络服务器基本都采用这种方式,这也是nginx性能高效的主要原因。

我们之前说过,推荐设置worker的个数为cpu的核数,在这里就很容易理解了,更多的worker数,只会导致进程来竞争cpu资源了,从而带来不必要的上下文切换。而且,nginx为了更好的利用多核特性,提供了cpu亲缘性的绑定选项,我们可以将某一个进程绑定在某一个核上,这样就不会因为进程的切换带来cache的失效。像这种小的优化在nginx中非常常见,同时也说明了nginx作者的苦心孤诣。比如,nginx在做4个字节的字符串比较时,会将4个字符转换成一个int型,再作比较,以减少cpu的指令数等等。

现在,知道了nginx为什么会选择这样的进程模型与事件模型了。对于一个基本的web服务器来说,事件通常有三种类型,网络事件、信号、定时器。从上面的讲解中知道,网络事件通过异步非阻塞可以很好的解决掉。如何处理信号与定时器?

首先,信号的处理。对nginx来说,有一些特定的信号,代表着特定的意义。信号会中断掉程序当前的运行,在改变状态后,继续执行。如果是系统调用,则可能会导致系统调用的失败,需要重入。关于信号的处理,大家可以学习一些专业书籍,这里不多说。对于nginx来说,如果nginx正在等待事件(epoll_wait时),如果程序收到信号,在信号处理函数处理完后,epoll_wait会返回错误,然后程序可再次进入epoll_wait调用。

另外,再来看看定时器。由于epoll_wait等函数在调用的时候是可以设置一个超时时间的,所以nginx借助这个超时时间来实现定时器。nginx里面的定时器事件是放在一颗维护定时器的红黑树里面,每次在进入epoll_wait前,先从该红黑树里面拿到所有定时器事件的最小时间,在计算出epoll_wait的超时时间后进入epoll_wait。所以,当没有事件产生,也没有中断信号时,epoll_wait会超时,也就是说,定时器事件到了。这时,nginx会检查所有的超时事件,将他们的状态设置为超时,然后再去处理网络事件。由此可以看出,当我们写我们可以用一段伪代码来总结一下nginx的事件处理模型:nginx代码时,在处理网络事件的回调函数时,通常做的第一个事情就是判断超时,然后再去处理网络事件。

我们可以用一段伪代码来总结一下nginx的事件处理模型:

  1. while (true) {
  2. for t in run_tasks:
  3. t.handler();
  4. update_time(&now);
  5. timeout = ETERNITY;
  6. for t in wait_tasks: /* sorted already */
  7. if (t.time <= now) {
  8. t.timeout_handler();
  9. } else {
  10. timeout = t.time - now;
  11. break;
  12. }
  13. nevents = poll_function(events, timeout);
  14. for i in nevents:
  15. task t;
  16. if (events[i].type == READ) {
  17. t.handler = read_handler;
  18. } else { /* events[i].type == WRITE */
  19. t.handler = write_handler;
  20. }
  21. run_tasks_add(t);
  22. }

3.3 Nginx配置系统

Nginx的配置系统由一个主配置文件和一些辅助配置文件构成,这些配置文件默认在/usr/local/nginx/目录下。

nginx的配置系统由一个主配置文件和其他一些辅助的配置文件构成。这些配置文件均是纯文本文件,全部位于nginx安装目录下的conf目录下。

配置文件中以#开始的行,或者是前面有若干空格或者TAB,然后再跟#的行,都被认为是注释,也就是只对编辑查看文件的用户有意义,程序在读取这些注释行的时候,其实际的内容是被忽略的。

由于除主配置文件nginx.conf以外的文件都是在某些情况下才使用的,而只有主配置文件是在任何情况下都被使用的。所以在这里我们就以主配置文件为例,来解释nginx的配置系统。

在nginx.conf中,包含若干配置项。每个配置项由配置指令和指令参数2个部分构成。指令参数也就是配置指令对应的配置值。

指令概述

配置指令是一个字符串,可以用单引号或者双引号括起来,也可以不括。但是如果配置指令包含空格,一定要引起来。

指令参数

指令的参数使用一个或者多个空格或者TAB字符与指令分开。指令的参数有一个或者多个TOKEN串组成。TOKEN串之间由空格或者TAB键分隔。

TOKEN串分为简单字符串或者是复合配置块。复合配置块即是由大括号括起来的一堆内容。一个复合配置块中可能包含若干其他的配置指令。

如果一个配置指令的参数全部由简单字符串构成,也就是不包含复合配置块,那么我们就说这个配置指令是一个简单配置项,否则称之为复杂配置项。例如下面这个是一个简单配置项:

  1. error_page 500 502 503 504 /50x.html;

对于简单配置,配置项的结尾使用分号结束。对于复杂配置项,包含多个TOKEN串的,一般都是简单TOKEN串放在前面,复合配置块一般位于最后,而且其结尾,并不需要再添加分号。例如下面这个复杂配置项

  1. location / {
  2. root /home/jizhao/nginx-book/build/html;
  3. index index.html index.htm;
  4. }

指令上下文

nginx.conf中的配置信息,根据其逻辑上的意义对其进行分类,可以分成多个作用域或指令上下文,指令上下文层次关系如下:

Nginx-zh-3-02.png

  • main:Nginx在运行时与具体业务功能无关的参数,比如工作进程数、运行身份等。
  • http:与提供http服务相关的参数,比如keepalive、gzip等。
  • server:http服务上支持若干虚拟机,每个虚拟机一个对应的server配置项,配置
    项里包含该虚拟机相关的配置。
  • location:http服务中,某些特定的URL对应的一系列配置项。
  • mail:实现email相关的SMTP/IMAP/POP3代理时,共享的一些配置项。

更多配置信息可以参看Nginx安装后的缺省配置文件/usr/local/nginx/nginx.conf.default。

指令上下文,可能有包含的情况出现。例如:通常http上下文和mail上下文一定是出现在main上下文里的。在一个上下文里,可能包含另外一种类型的上下文多次。例如:如果http服务,支持了多个虚拟主机,那么在http上下文里,就会出现多个server上下文。

  1. user nobody;
  2. worker_processes 1;
  3. error_log logs/error.log info;
  4. events {
  5. worker_connections 1024;
  6. }
  7. http {
  8. server {
  9. listen 80;
  10. server_name www.linuxidc.com;
  11. access_log logs/linuxidc.access.log main;
  12. location / {
  13. index index.html;
  14. root /var/www/linuxidc.com/htdocs;
  15. }
  16. }
  17. server {
  18. listen 80;
  19. server_name www.Androidj.com;
  20. access_log logs/androidj.access.log main;
  21. location / {
  22. index index.html;
  23. root /var/www/androidj.com/htdocs;
  24. }
  25. }
  26. }
  27. mail {
  28. auth_http 127.0.0.1:80/auth.php;
  29. pop3_capabilities "TOP" "USER";
  30. imap_capabilities "IMAP4rev1" "UIDPLUS";
  31. server {
  32. listen 110;
  33. protocol pop3;
  34. proxy on;
  35. }
  36. server {
  37. listen 25;
  38. protocol smtp;
  39. proxy on;
  40. smtp_auth login plain;
  41. xclient off;
  42. }
  43. }

在这个配置中,上面提到个五种配置指令上下文都存在.

存在于main上下文中的配置指令如下:

  • user
  • worker_processes
  • error_log
  • events
  • http
  • mail

存在于http上下文中的指令如下:

  • server

存在于mail上下文中的指令如下

  • server
  • auth_http
  • imap_capabilities

存在于server上下文中的配置指令如下

  • listen
  • server_name
  • access_log
  • location
  • protocol
  • proxy
  • smtp_auth
  • xclient

存在于location上下文中的指令如下

  • index
  • root

当然,这里只是一些示例。具体有哪些配置指令,以及这些配置指令可以出现在什么样的上下文中,需要参考nginx的使用文档。

3.4 Nginx的模块化体系

一、模块体系

Nginx的内部结构是由核心部分和一系列功能模块组成的,这样可以使得每个模块的功能相对简单,便于对系统进行功能扩展,各模块之间的关系如下图:
Nginx-zh-3-03.png

nginx core实现了底层的通讯协议,为其它模块和Nginx进程构建了基本的运行时环境,并且构建了其它各模块的协作基础。

http模块和mail模块位于nginx core和各功能模块的中间层,这2个模块在nginx core之上实现了另外一层抽象,分别处理与http协议和email相关协议(SMTP/IMAP/POP3)有关的事件,并且确保这些事件能被以正确的顺序调用其它的一些功能模块。

nginx功能模块基本上分为如下几种类型:

(1)event module:搭建了独立于操作系统的事件处理机制的框架,以及提供了各具体事件的处理,包括ngx_event_module、ngx_event_core_module和ngx_epoll_module等,Nginx具体使用何种事件处理模块,这依赖于具体的操作系统和编译选项。

(2)phase handler:此类型的模块也被直接称为handler模块,主要负责处理客户端请求并产生待响应内容,比如ngx_http_module模块,负责客户端的静态页面请求处理并将对应的磁盘文件准备为响应内容输出。

(3)output filter:也称为filter模块,主要是负责对输出的内容进行处理,可以对输出进行修改,比如可以实现对输出的所有html页面增加预定义的footbar一类的工作,或者对输出的图片的URL进行替换之类的工作。

(4)upstream:实现反向代理功能,将真正的请求转发到后端服务器上,并从后端服务器上读取响应,发回客户端,upstream模块是一种特殊的handler,只不过响应内容不是真正由自己产生的,而是从后端服务器上读取的。

(5)load-balancer:负载均衡模块,实现特定的算法,在众多的后端服务器中,选择一个服务器出来作为某个请求的转发服务器。

(6)extend module:根据特定业务需要编写的第三方模块。

二、请求处理

下面将会以http请求处理为例来说明请求、配置和模块是如何串起来的。

当Nginx读取到一个HTTP Request的header时,首先查找与这个请求关联的虚拟主机的配置,如果找到了则这个请求将会经历以下几个阶段的处理(phase handlers):

NGX_HTTP_POST_READ_PHASE:读取请求内容阶段

NGX_HTTP_SERVER_REWRITE_PHASE:Server请求地址重写阶段

NGX_HTTP_FIND_CONFIG_PHASE:配置查找阶段

NGX_HTTP_REWRITE_PHASE:Location请求地址重写阶段

NGX_HTTP_POST_REWRITE_PHASE:请求地址重写提交阶段

NGX_HTTP_PREACCESS_PHASE:访问权限检查准备阶段

NGX_HTTP_ACCESS_PHASE:访问权限检查阶段

NGX_HTTP_POST_ACCESS_PHASE:访问权限检查提交阶段

NGX_HTTP_TRY_FILES_PHASE:配置项try_files处理阶段

NGX_HTTP_CONTENT_PHASE:内容产生阶段

NGX_HTTP_LOG_PHASE:日志模块处理阶段

在内容产生阶段,为了给一个request产生正确的response,Nginx必须把这个请求交给一个合适的content handler去处理。如果这个request对应的location在配置文件中被明确指定了一个content handler,那么Nginx就可以通过对location的匹配,直接找到这个对应的handler,并把这个request交给这个content handler去处理。这样的配置指令包括perl、flv、proxy_pass、mp4等。

如果一个request对应的location并没有直接配置的content handler,那么Nginx依次作如下尝试:

(1)如果一个location里面有配置random_index on,那么随机选择一个文件发送给客户端。

(2)如果一个location里面有配置index指令,那么发送index指令指定的文件给客户端。

(3)如果一个location里面有配置autoindex on,那么就发送请求地址对应的服务端路径下的文件列表给客户端。

(4)如果这个request对应的location上有设置gzip_static on,那么就查找是否有对应的.gz文件存在,如果有的话,就发送这个给客户端(客户端支持gzip的情况下)。

(5)请求的URI如果对应一个静态文件,static module就发送静态文件的内容到客户端。

内容产生阶段完成以后,生成的输出会被传递到filter模块去进行处理。filter模块也是与location相关的。所有的fiter模块都被组织成一条链。输出会依次穿越所有的filter,直到有一个filter模块的返回值表明已经处理完成。接下来就可以发送response给客户端了。