架构

更新于2018.06.22

高性能服务器设计

// TODO

  • c10k问题
  • 高性能服务器编写的关键原则
  • 事件驱动的核心引擎
  • 定时器管理

进程模型

Nginx支持多进程与多线程的运行方式, 主流一般使用多进程. 多进程分为一个master进程与多个worker进程.

master进程主要用来管理worker进程, 包含:接收来自外界的信号, 向各worker进程发送信号监控worker进程的运行状态, 当worker进程退出后(异常情况下), 会自动重新启动新的worker进程.

而基本的网络事件, 则是放在worker进程中来处理了. 多个worker进程之间是对等的, 他们同等竞争来自客户端的请求, 各进程互相之间是独立的. 一个请求, 只可能在一个worker进程中处理, 一个worker进程, 不可能处理其它进程的请求. worker进程的个数是可以设置的, 一般我们会设置与 机器cpu核数一致 , 这里面的原因与nginx的进程模型以及事件处理模型是分不开的. nginx的进程模型, 可以由下图来表示:

../../_images/nginx_arch.png

Nginx进程模型

master进程

在nginx启动后, 如果我们要操作nginx, 要怎么做呢?从上文中我们可以看到, master来管理worker进程, 所以我们只需要与master进程通信就行了. master进程会接收来自外界发来的信号, 再根据信号做不同的事情. 所以我们要控制nginx, 只需要通过kill向master进程发送信号就行了. 比如 kill -HUP pid , 则是告诉nginx, 从容地 重启nginx, 我们一般用这个信号来重启nginx, 或重新加载配置, 因为是从容地重启, 因此服务是不中断的.

master进程在接收到HUP信号后是怎么做的呢?首先master进程在接到信号后, 会先重新加载配置文件, 然后再启动新的worker进程, 并向所有老的worker进程发送信号, 告诉他们可以光荣退休了. 新的worker在启动后, 就开始接收新的请求, 而老的worker在收到来自master的信号后, 就不再接收新的请求, 并且在当前进程中的所有未处理完的请求处理完成后, 再退出.

当然, 直接给master进程发送信号, 这是比较老的操作方式, nginx在0.8版本之后, 引入了一系列命令行参数, 来方便我们管理. 比如, ./nginx -s reload , 就是来重启nginx, ./nginx -s stop , 就是来停止nginx的运行. 如何做到的呢?我们还是拿reload来说, 我们看到, 执行命令时, 我们是启动一个新的nginx进程, 而新的nginx进程在解析到reload参数后, 就知道我们的目的是控制nginx来重新加载配置文件了, 它会向master进程发送信号, 然后接下来的动作, 就和我们直接向master进程发送信号一样了.

worker进程

worker进程之间是平等的, 每个进程, 处理请求的机会也是一样的. 当我们提供80端口的http服务时, 一个连接请求过来, 每个进程都有可能处理这个连接, 怎么做到的呢?首先, 每个worker进程都是从master进程fork过来, 在master进程里面, 先建立好需要listen的socket(listenfd)之后, 然后再fork出多个worker进程.

所有worker进程的listenfd会在新连接到来时变得可读, 为保证只有一个进程处理该连接, 所有worker进程在注册listenfd读事件前 抢accept_mutex , 抢到互斥锁的那个进程注册listenfd读事件, 在读事件里调用accept接受该连接. 当一个worker进程在accept这个连接之后, 就开始读取请求, 解析请求, 处理请求, 产生数据后, 再返回给客户端, 最后才断开连接, 这样一个完整的请求就是这样的了. 我们可以看到, 一个请求, 完全由worker进程来处理, 而且只在一个worker进程中处理.

进程模型的优点

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

事件处理模型

有人可能要问了, nginx采用多worker的方式来处理请求, 每个worker里面只有一个主线程, 那能够处理的并发数很有限啊, 多少个worker就能处理多少个并发, 何来高并发呢?非也, 这就是nginx的高明之处, nginx采用了 异步非阻塞 的方式来处理请求, 也就是说, nginx是可以同时处理成千上万个请求的. 想想apache的常用工作方式(apache也有异步非阻塞版本, 但因其与自带某些模块冲突, 所以不常用), 每个请求会独占一个工作线程, 当并发数上到几千时, 就同时有几千的线程在处理请求了. 这对操作系统来说, 是个不小的挑战, 线程带来的内存占用非常大, 线程的上下文切换带来的cpu开销很大, 自然性能就上不去了, 而这些开销完全是没有意义的.

先看一个请求的完整过程. 看看一个请求的完整过程. 首先, 请求过来, 要建立连接, 然后再接收数据, 接收数据后, 再发送数据. 具体到系统底层, 就是读写事件, 而当读写事件没有准备好时, 必然不可操作, 如果不用非阻塞的方式来调用, 那就得阻塞调用了, 事件没有准备好, 那就只能等了, 等事件准备好了, 你再继续吧. 阻塞调用会进入内核等待, 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的指令数等等.

不同事件的处理

对于一个基本的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的事件处理模型:

while (true) {
    for t in run_tasks:
        t.handler();
    update_time(&now);
    timeout = ETERNITY;
    for t in wait_tasks: /* sorted already */
        if (t.time <= now) {
            t.timeout_handler();
        } else {
            timeout = t.time - now;
            break;
        }
    nevents = poll_function(events, timeout);
    for i in nevents:
        task t;
        if (events[i].type == READ) {
            t.handler = read_handler;
        } else { /* events[i].type == WRITE */
            t.handler = write_handler;
        }
        run_tasks_add(t);
}

模块化

nginx的内部结构是由核心部分和一系列的功能模块所组成. 这样划分是为了使得每个模块的功能相对简单, 便于开发, 同时也便于对系统进行功能扩展. 为了便于描述, 下文中我们将使用nginx core来称呼nginx的核心功能部分.

nginx提供了web服务器的基础功能, 同时提供了web服务反向代理, email服务反向代理功能. nginx core实现了底层的通讯协议, 为其他模块和nginx进程构建了基本的运行时环境, 并且构建了其他各模块的协作基础. 除此之外, 或者说大部分与协议相关的, 或者应用相关的功能都是在这些模块中所实现的.

nginx将各功能模块组织成一条链, 当有请求到达的时候, 请求依次经过这条链上的部分或者全部模块, 进行处理. 每个模块实现特定的功能. 例如, 实现对请求解压缩的模块, 实现SSI的模块, 实现与上游服务器进行通讯的模块, 实现与FastCGI服务进行通讯的模块.

注解

HOOK?

有两个模块比较特殊, 他们居于nginx core和各功能模块的中间. 这两个模块就是http模块和mail模块. 这2个模块在nginx core之上实现了另外一层抽象, 处理与HTTP协议和email相关协议(SMTP/POP3/IMAP)有关的事件, 并且确保这些事件能被以正确的顺序调用其他的一些功能模块.

目前HTTP协议是被实现在http模块中的, 但是有可能将来被剥离到一个单独的模块中, 以扩展nginx支持SPDY协议.

模块分类

nginx的模块根据其功能基本上可以分为以下几种类型:

  • event module

    搭建了独立于操作系统的事件处理机制的框架, 及提供了各具体事件的处理. 包括ngx_events_module, ngx_event_core_module和ngx_epoll_module等. nginx具体使用何种事件处理模块, 这依赖于具体的操作系统和编译选项.

  • phase handler

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

  • output filter

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

  • upstream

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

  • load-balancer

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

请求处理机制

另见request概念中的请求处理介绍.

所有实际上的业务处理逻辑都在worker进程. worker进程中有一个函数, 执行无限循环, 不断处理收到的来自客户端的请求, 并进行处理, 直到整个nginx服务被停止.

worker进程中, ngx_worker_process_cycle()函数就是这个无限循环的处理函数. 在这个函数中, 一个请求的简单处理流程如下:

  1. 操作系统提供的机制(例如epoll, kqueue等)产生相关的事件.
  2. 接收和处理这些事件, 如是接受到数据, 则产生更高层的request对象.
  3. 处理request的header和body.
  4. 产生响应, 并发送回客户端.
  5. 完成request的处理.
  6. 重新初始化定时器及其他事件.

参考资料