C/C++编程:nginx服务器模型

nginx在启动后,会有一个master进程和多个worker(工作)进程

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

也就是说,master进程充当整个进程组与用户的交互接口,同时对进程进行监护。它不需要处理网络事件,不负责业务的执行,只会通过管理worker进程来实现重启服务,平滑升级、更换日志文件、配置文件实时生效等功能。我们要控制nginx,只需要通过 kill 向master进程发送信号就行了

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

nginx的进程模型,可以由下图来表示:
C/C++编程:nginx服务器模型

在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来说,

    • 执行这个命令时,master收到这个信号以后先启动一个新的Nginx进程
    • 而新的Nginx进程在解析到reload参数后,就知道是要控制Nginx来重新加载配置文件,它会向master进程发送信号
    • 然后master会重新加载配置文件,在启动新的worker进程,并向所有老的worker进程发送信号,告诉他们可以退休了
    • 新的worker启动之后就可以以新的配置文件接收新的请求了(热部署的原理。)

C/C++编程:nginx服务器模型

现在,我们知道了我们在操作nginx的时候,nginx内部做了什么事情,那么,worker进程又是如何处理请求的呢?

我们前面有提到,worker进程之间是平等的,每个进程,处理请求的机会也是一样的。

当我们提供80端口的HTTP服务时,一个连接请求过来,每个进程都有可能处理这个连接,怎么做到的呢

  • 首先,每个worker进程都是从master进程fork过来,在master进程里面,先建立好需要listen的socket(listenfd)之后,然后再fork出多个worker进程
    • 所有worker进程的listenfd会在新连接到来时变得可读(在linux2.6以前是这样,但是2.6以及以后,所有worker进程的listenfd在新连接到来时只有一个变得可读)
    • 为保证只有一个进程处理该连接,所有worker进程在册listenfd读事件前抢accept_mutex,抢到互斥锁的那个进程注册listenfd读事件,在读事件里调用accept接受该连接。
    • 当一个worker进程在accept这个连接之后,就开始读取请求,解析请求,处理请求,产生数据后,再返回给客户端,最后才断开连接,这样一个完整的请求就是这样的了。(linux已经解决了accept问题

C/C++编程:nginx服务器模型
C/C++编程:nginx服务器模型
也就是说,客户端请求到一个master之后,worker获取任务的机制不是直接分配也不是轮询,而是一种争取的机制,“抢”到任务后再执行任务,比如选择目标服务器tomcat等,然后返回结果。

C/C++编程:nginx服务器模型

惊群现象

  • master进程首先通过socket()来创建一个socket文件描述符用来监听,然后fork生成子(worker)进程,子进程将继承父进程的sockefd,之后子进程 accept() 后将创建已连接描述符(connected descriptor),然后通过已连接描述符来与客户端通信。

  • 那么,由于所有子进程都继承了父进程的sockfd,那么当连接到来时,所有子进程都将收到通知并“争着”与它建立连接,这就叫做“惊群现象”。大量的进程被激活又被挂起,只有一个进程可以accept()到这个连接,这当然会消耗系统资源

  • nginx提供了一个叫做accept的东西,这是一个加在accept上的一把共享锁。即每个worker进程在执行accept之前都需要先获取锁,获取不到就放弃执行accept()。有了这把锁之后,同一时刻,就只会有一个进程去accept(),这样就不会有惊群问题了(当accept()之后就释放锁,这个时候内核只会唤醒一个阻塞在accept_mutex上的进程,其他进程还是会休眠)。accept_mutex 是一个可控选项,我们可以显示地关掉,默认是打开的。

  • 其实在linux2.6版本以后,linux内核已经解决了accept()函数的“惊群”现象,大概的处理方式就是,当内核接收到一个客户连接后,只会唤醒等待队列上的第一个进程(线程),所以如果服务器采用accept阻塞调用方式,在最新的linux系统中已经没有“惊群效应”了,所以这个选项可以关掉

从上面我们可以看到,一个请求,完全由worker进程来处理,而且只在一个worker进程中处理。那这样有什么好处呢?

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

总的来说,使用多进程模式,不仅能够提高并发率,而且进程之间彼此独立,一个worker进程挂了也不会影响其他worker进程

当然,worker进程竞争监听客户端的连接请求也是有它的缺点的。比如说

  • 可能所有的请求都被一个worker进程给竞争获取了,导致其他进程都比较空闲,而某一个进程会处于忙碌的状态,这种状态可能还会导致无法及时响应连接而丢弃discard掉本有能力处理的请求
  • 这种不公平的现象,是需要避免的,尤其是在高可靠web服务器环境下。
  • 针对这种现象,Nginx采用了一个是否打开accept_mutex选项的值,ngx_accept_disabled标识控制一个worker进程是否需要去竞争获取accept_mutex选项,进而获取accept事件。

多进程模型每个进程/线程只能处理一路IO,那么nginx是如何处理多路IO呢?

  • 如果不使用IO多路复用,那么在一个进程中,同时只能处理一个请求,比如执行accept(),如果没有连接过来,那么程序会阻塞在这里,直到有一个连接过来,才能继续往下执行
  • 而多路复用,允许我们只在事件发生时才将控制返还给程序,而其他时候内核都挂起进程,随时待命

这也是nginx进程为什么这么快的原因:nginx采用IO多路复用模型nginx:

  • nginx会注册一个事件:“如果来自一个新客户端的连接请求到来,再通知我”,此后只有连接请求到来,服务器才会执行accept()来接收请求。
  • 又比如向上游服务器(比如 PHP-FPM)转发请求,并等待请求返回时,这个处理的worker不会在这阻塞,它会在发送完请求后,注册一个事件:“如果缓冲区接收到数据了,告诉我一声,我再将它读进来”,于是进程空闲下来等待事件发生

上面讲了很多关于nginx的进程模型,接下来,我们来看看nginx是如何处理事件的。

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

  • 非也,这就是nginx的高明之处,nginx采用了异步非阻塞的方式来处理请求,也就是说,nginx是可以同时处理成千上万的请求的

  • 想想apache的常用工作方式(apache也有异步非阻塞版本,但因其与自带某些模块冲突,所以不常用)每个请求都会独占一个工作线程,当并发数上到几千时,就同时有几千的线程在处理请求了。

  • 这对操作系统来说,是个不小的挑战,线程带来的内存占用非常大,线程的上下文切换带来的CPU开销很大,自然性能就上不去了,而这些开销完全是没有意义的

为什么nginx可以采用异步非阻塞的方式来处理呢?或者异步非阻塞到底是怎么回事呢?

我们先回到原点,看看一个请求的完整过程

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

我们之前说过,推荐设置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源码阅读笔记-Master Woker进程模型

  • 被问懵逼:谈谈 Nginx 快的原因?

上一篇:08-Nginx原理及优化参数配置


下一篇:云原生的弹性 AI 训练系列之二:PyTorch 1.9.0 弹性分布式训练的设计与实现