学一点 Nginx 架构
本文收录在我的博客中,欢迎大家关注。
写在前面
最近正在学习性能分析与优化相关的知识,自然也涉及到 Nginx 相关的调优,而了解 Nginx 底层原理会让我们知其然知其所以然,因此,我个人也阅读了《Nginx 底层设计与源码分析》以及《深入理解 Nginx:模块开发与架构解析》这两本书,也做了比较多的笔记,因此,基于已有的笔记进行整理并作出分享。
本次分享涉及的主题:
- 模块化设计
- 多进程设计
- 事件驱动模型
- 进程间通信
- 池化技术:连接池、内存池、事件池
Nginx 模块化设计
模块化设计
Nginx 主框架中只提供了少量的核心代码,大量强大的功能是在各模块中实现的。模块设计完全遵循「高内聚」、「低耦合」的原则。每个模块只处理自己职责之内的配置项,专注完成某项特定的功能。各类型的模块实现了统一的接口规范,这大大增强了 Nginx 的灵活性与可扩展性。
实现模块多态性
Nginx 封装了一个统一的模块化接口(C 语言结构体),统一提供了钩子函数接口以及实现模块化的公共接口 (ctx),如下:
struct ngx_module_s {
// ctx :公共接口
void *ctx;
// 钩子方法
ngx_int_t (*init_master)(ngx_log_t *log);
ngx_int_t (*init_module)(ngx_cycle_t *cycle);
ngx_int_t (*init_process)(ngx_cycle_t *cycle);
ngx_int_t (*init_thread)(ngx_cycle_t *cycle);
void (*exit_thread)(ngx_cycle_t *cycle);
void (*exit_process)(ngx_cycle_t *cycle);
void (*exit_master)(ngx_cycle_t *cycle);
....
};
何为公共接口?简单点讲,就是每类模块都有各自家族特有的协议规范,通过 void 类型的 ctx 变量进行抽象,同类型的模块只需要遵循这一套规范即可。
Nginx 的模块接口设计兼顾统一化与差异化思想,以最简单、实用的方式实现了模块的「多态性」。
Nginx 多进程设计
多进程设计
Nginx 采用一个 Master 管理进程、多个 Worker 工作进程的设计方式,完全相同的 Worker 进程、1个可选的cache manager 进程以及1个可选的 cache loader 进程。
设计优点
- 利用多核系统的并发处理能力
- Nginx 中所有的
worker进程都是「完全平等」的,避免了某一级同一地位的进程成为瓶颈,也就提高了网络性能,降低了请求的时延
- Nginx 中所有的
- 实现进程间的负载均衡
- 多个
worker工作进程间通过进程间通信来实现负载均衡,也就是说,一个请求到来时更容易被分配到负载较轻的worker工作进程中处理。这将降低请求的时延,并在一定程度上提高网络性能。
- 多个
- 管理进程会负责监控工作进程的状态,并负责管理其行为
- 管理进程不会占用多少系统资源,它只是用来启动、停止、监控或使用其他行为来控制工作进程。首先,这提高了系统的可靠性,当工作进程出现问题时,管理进程可以启动新的工作进程来避免系统性能的下降。其次,管理进程支持 Nginx 服务运行中的程序升级、配置项的修改等操作,这种设计使得动态可扩展性、动态定制性、动态可进化性较容易实现。
惊群问题
master 进程开始监听 Web 端口,fork 出多个 worker 子进程,这些子进程开始同时监听同一个 Web 端口。一般情况下,有多少 CPU 核心,就会配置多少个 worker 子进程,这样所有的 worker 子进程都在承担着 Web 服务器的角色。在这种情况下,就可以利用每一个 CPU 核心可以并发工作的特性,充分发挥多核机器的「威力」。
造成「惊群」的场景: 在没有用户访问服务器,某一时刻恰好所有的
worker子进程都休眠且等待新连接的系统调用,这时有一个用户向服务器发起了连接,内核在收到 TCP 的SYN包时,会激活所有的休眠worker子进程,此时只有最先开始执行accept的子进程可以成功建立新连接,而其他worker子进程都会accept失败。这些accept失败的子进程被内核唤醒是不必要的,它们被唤醒后的执行很可能也是多余的,那么这一时刻它们占用了本不需要占用的系统资源,引发了不必要的进程上下文切换,增加了系统开销。
有些服务器本身就已经解决了「惊群」的问题,但 Nginx 作为可移植性极高的 Web 服务器,还是在自身的应用层面上较好地解决了这一问题。
Nginx 通过打开抢占锁(accept_mutex 锁 )的方式来解决了「惊群」问题,也就是它规定了同一时刻只能有唯一一个 worker 子进程监听 web 端口,新连接事件只能唤醒唯一正在监听的 worker 进程。
Nginx 为了减少锁的持有时间,采用了双队列来进行了优化,它们分为别处理连接事件的队列以及处理普通事件的队列。也就是在新连接事件全部放到处理连接事件的队列中,普通的事件则可以放到处理普通事件的队列中。那么 Nginx 会先处理该队列的事件,处理完之后,就可以释放锁了,接着再处理普通事件队列中的事件。
进程负载均衡
Nginx 通过一个全局变量(ngx_accept_disabled)来作为负载均衡机制实现的阈值,本质上是一个整型数据。在 Nginx 启动时,该阈值的初始值是一个负数,占总链接数的 7/8,当它为正数时,表示空闲连接数已经不足总数的 1/8 了,说明该 worker 十分繁忙。于是,它在本次事件循环时放弃争抢 accept_mutex 锁,专注处理已有的连接,同时将自己的ngx_accept_disabled 减一,下次事件循环时继续判断是否进入抢锁环节。
通过负载均衡的处理,很好地避免了当某个 worker 进程由于连接池耗尽而拒绝服务,同时,在其他 worker 进程上处理的连接还远未达到上限的问题。
CPU 亲和性
通常,在生产环境中配置 Nginx 的 worker 进程数量等于CPU核心数,同时会通过 worker_cpu_affinity 将Worker 进程绑定到固定的核上,让每个 worker 进程独享一个 CPU 核心,这样既能有效避免 CPU 频繁地上下文切换,也能大幅提高 CPU 缓存命中率。
进程间通信
既然使用了多进程设计,那么一定会涉及到进程间的通信,Nginx 的进程间通信主要依赖「共享内存」以及「信号」机制。
进程是计算机系统资源分配的最小单位。每个进程都有自己的资源,彼此隔离。内存是进程的私有资源,进程的内存是虚拟内存,在使用时由操作系统分配物理内存,并将虚拟内存映射到物理内存上。之后进程就可以使用这块物理内存。
共享内存是让多个进程将自己的某块虚拟内存映射到同一块物理内存,这样多个都可以读/写这块内存,实现进程间的通信。
通过共享内存,解决了不同进程的数据不共享的问题,但涉及到 Nginx 与 Linux 系统之间的通信,就需要依赖「信号」。
我们平时常用的 Nginx -s reload 以及 Nginx 平滑升级的处理本质上都是通过信号来实现的。
Nginx 事件驱动模型
事件驱动架构
Nginx 服务器响应和处理 Web 请求的过程,就是基于事件驱动模型的,它也包含「事件收集器」、「事件发送器」和「事件处理器」等三部分基本单元。
Nginx 主要处理的事件来自网络和磁盘,包括 TCP 连接的建立与断开、接收和发送网络数据包、磁盘文件的 I/O 操作等。事件分发器则负责将收集到的事件分发到目标对象中。
Nginx 通过 Event 模块实现了读/写事件的管理和分发。事件处理器作为消费者,负责接收分发过来的各种事件并处理。通常,Nginx 中每个模块都有可能成为事件消费者。当模块处理完业务逻辑之后立刻将控制权交还给 Event 模块,进行下一个事件的调度与分发。由于消费事件的主体是各 HTTP 模块,事件处理函数在一个进程中完成,因此只要各 HTTP 模块不让进程进入休眠状态,整个请求的处理过程是非常迅速的。
Nginx 不会使用进程或线程来作为事件消费者,所谓的事件消费者只能是某个模块(在这里没有进程的概念)。只有事件收集、分发器才有资格占用进程资源,它们会在分发某个事件时调用事件消费模块使用当前占用的进程资源。
Nginx 中的 epoll 模型
Nginx 采用异步非阻塞的方式来实现「事件处理器」,现在在 Linux 服务器上基本都是使用 epoll 模型。epoll 把描述符列表的管理交由内核负责,一旦有某种事件发生,内核把发生事件的描述符列表通知给进程,这样就避免了轮询整个描述符列表。
epoll 的关键 API
epoll_create- 函数原型:
int epoll_create(int size) - 作用:创建
epoll句柄 - 参数说明:参数
size用来告诉 Linux 内核监听的socket数目
- 函数原型:
epoll_ctl- 函数原型:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) - 作用:事件注册函数,可以向
epoll中添加、修改或者删除某socket对应的监听事件 - 参数说明
epfd:是epoll_create函数返回的文件描述符op:表示执行的动作,可选值EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DELfd:需要监听的socket描述符event:用于告知 Linux 内核需要监听该socket的什么事件,如可读、可写事件
- 函数原型:
epoll_wait- 函数原型:
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout) - 作用:获取
epoll中 I/O 准备就绪的socket,也就是说监听的事件已触发的socket。
- 函数原型:
epoll 支持百万级并发
在 epoll 创建阶段(epoll_create),它会在内核建立红黑树,用于存储着所有添加到 epoll 中的事件,也就是 epoll 监听的事件。同时,它也会创建一个双向链表,用于存储将要通过 epoll_wait 返回给用户的、满足条件的事件。
当 epoll_ctl 被调用时,需要监听的事件存放到红黑树中,这些事件都会与设备驱动程序建立回调关系,也就是说,相应事件发生时会调用这里的回调方法,。这个回调方法在内核中叫做ep_poll_callback,它会把这样的事件放到双向链表中。
当 epoll_wait 被调用时,它只会检查双向链表是否存在事件,如果存在,那么就把这里的事件复制到用户态内存中。
epoll 利用了红黑树来提升管理事件的性能,是非常高效的,可以轻易处理百万级别的并发连接
事件多阶段异步处理
Nginx 通过多阶段来处理请求,就是把一个请求的处理过程按照事件的触发方式划分为多个阶段,每个阶段都可以由事件收集、分发器来触发。
当一个事件被分发到事件消费者中进行处理时,事件消费者处理完这个事件相当于处理完1个请求的某个阶段。当下一次事件出现时,epoll 等事件分发器将会收到通知,再继续调用事件消费者处理请求。
每个阶段中的事件消费者都不清楚本次完整操作究竟什么时候完成,只能异步被动地等待下一次事件的通知。
这种设计配合事件驱动架构,将会极大地提高网络性能,同时使得每个进程都能全力运转,不会或者尽量少地出现进程休眠状况。因为一旦出现进程休眠,必然减少并发处理事件的数目,一定会降低网络性能,同时会增加请求处理时间的平均时延!这时,如果网络性能无法满足业务需求将只能增加进程数目,进程数目过多就会增加操作系统内核的额外操作:进程间切换,可是频繁地进行进程间切换仍会消耗 CPU 等资源,从而降低网络性能。同时,休眠的进程会使进程占用的内存得不到有效释放,这最终必然导致系统可用内存的下降,从而影响系统能够处理的最大并发连接数。
池化技术
连接
作为 Web 服务器,每一个用户请求至少对应着一个 TCP 连接,为了及时处理这个连接,至少需要一个读事件和一个写事件,使得 epoll 可以有效地根据触发的事件调度相应模块读取请求或者发送响应。因此,Nginx 中定义了基本的数据结构 ngx_connection_t 来表示连接。
连接池
Nginx 在接受客户端的连接时,所使用的 ngx_connection_t 结构体都是在启动阶段就预分配好的,使用时从连接池中获取即可。Nginx 连接池分别存放了「对下游客户端的连接」、「对上游服务器的连接」。
提前初始化连接池有助于提升连接处理的性能,因为新请求到来后无须再次申请内存;对连接数进行管控,因为系统吞吐有限,一次处理的请求数也有限,提前初始化连接池可实现通过连接池的大小来管控并行处理的连接数。
事件池
Nginx 认为每一个连接一定至少需要一个读事件和一个写事件,有多少连接就分配多少个读、写事件。由于读事件、写事件、连接池是由3个大小相同的数组组成,所以根据数组序号就可将每一个连接、读事件、写事件对应起来,这个对应关系在 ngx_event_core_module 模块的初始化过程中就已经决定了。
内存池
Nginx 创建内存池就是为了降低程序员犯错几率的:模块开发者只需要关心内存的分配,而释放则交由内存池来负责。
内存池的设计上还考虑到了小块内存的频繁分配在效率上有提升空间,以及内存碎片还可以再减少些,因此引进了「小块内存」、「大块内存」。
大、小块内存的分界点是由创建内存池时的参数以及系统页大小决定的。对于小块内存,其在用户申请后并不需要释放,而是等到释放内存池时再释放。对于大块内存,用户可以调用相关接口进行释放,也可以等内存池释放时再释放。Nginx 内存池支持增加回调函数,当内存池释放时,自动调用回调函数以释放用户申请的其他资源。值得一提的是,回调函数允许增加多个,通过链表进行链接,在内存池释放时被逐一调用。
最后
本次分享只涉及了 Nginx 常见的一些原理,上面的笔记基本都是来源于了《Nginx 底层设计与源码分析》以及《深入理解 Nginx:模块开发与架构解析》这两本书,想要深入全面的去了解的可以去阅读这两本书,我所分享的仅仅只是希望给你一个大概的了解,希望对你有所帮助。
参考
- 《Nginx 底层设计与源码分析》
- 《深入理解 Nginx:模块开发与架构解析》
- 《极客时间 - Nginx 核心知识 150 讲》