和我一起,来一场Nginx支持高并发的探索之旅

Posted DreamMakers

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了和我一起,来一场Nginx支持高并发的探索之旅相关的知识,希望对你有一定的参考价值。

深度分析nginx的高并发原理

序言

在日常工作中,尤其是搞后台服务的,相信Nginx大家都用过,也都知道Nginx是能够支持高并发的。但是,是什么支持了它能够支持高并发呢?今天,我从我自己理解的角度来梳理和描述下底层的原理,如果有理解错误的,欢迎指正。

(1)从Nginx入手,聊聊实现原理

为什么要从Nginx入手聊,因为我不想一开始就大谈什么底层原理,我要按照一个正常人的思路去分析Nginx。

Nginx为什么能支持高并发?按照正常的理解,那一般都是Nginx的后台有很多进程或者线程在处理Client请求,不然咋能支持高并发,用单进程单线程去实现一个东西咋能支持高并发!!!。

我们都知道有单进程多线程模型和多进程单线程模型的概念,其中前者的代表作是memcached,而后者的代表作就是Nginx。

那么什么是多进程单线程模型呢?按照我的简单理解,就是在应用服务中存在多个并行的进程,而在每个进程中又都只有一个运行的线程(以避免线程切换而带来的开销)。从Nginx的实现原理来看,它就是多进程单线程的典型。

在Nginx内部分为两种进程,一种是master进程(有且只有一个),另一种是worker进程(根据配置可能存在多个),其中master进程用于向worker进程发送命令和监控所有worker进程的运行情况,在worker进程因为一些原因退出时master进程会根据配置重新创建worker进程。如下所示,这里有1个master process和2个worker process。

下面是Nginx实现的大概原理图。每个worker进程用于接收外界的Client客户端连接,并为Client提供服务。

从图中我们可以看到一个worker进程可以同时和多个Client进行连接,但是在前面我已经说过,每个worker内部只有一个线程来为这些Client进行服务,那么这种情况下又怎么能保证高并发情况下的性能的呢?我的理解是下面两个原因。

(1)每个Client请求在被Nginx接收处理的过程中完全由同一个线程处理,整个过程既不存在进程切换也不存在线程切换而带来的开销问题,为单个请求的处理性能提供了保证。

(2)针对多个Client共享同一个worker进程中的唯一线程,可能很多人觉得这会严重影响性能,但是我觉得这个问题要具体场景具体分析。单线程的模型在一定程度上的确会降低每个Client请求的处理耗时,毕竟相互之间都在相互等待,但是这是在所有Client请求连接都是活跃且同时有数据传输的情况下还会这样。针对我们使用Nginx的场景,Nginx后台后面常挂有后台服务器,在请求再被转发到后台服务器和接收后台服务器返回的时候,一般要经过网络,这个网络的耗时已经后台服务器的处理耗时都是不一样的,这也就导致了对于Client的请求来说绝对多数场景不可能做到同时都处于活跃且有数据要处理的情况。上面的分析从一个方面解释了为什么一个worker进程在单线程的情况下同时处理多个Client请求时为啥没有明显的性能问题,当然另一个重要的原因是因为Nginx底层用的是Linux epoll来处理这些Client请求。这在下一小节进行说明。

(2)深入epoll实现原理

从上一小节我们收到,对于Client请求,Nginx使用epoll从而确保了在高并发情况下的请求处理。和epoll相比,还有两种方式select/poll,为啥会选择epoll而不是select/poll,在了解完epoll之后,我们再来分析这三者之间的区别。

epoll是一种I/O事件通知机制,是linux 内核实现IO多路复用的一个实现。
IO多路复用是指,在一个操作里同时监听多个输入输出源,在其中一个或多个输入输出源可用的时候返回,然后对其的进行读写操作。

假设,有百万级别用户同时与Nginx进程保持着TCP连接(当然一个单纯的Nginx肯定没法处理上几十万上百万的并发的,这种级别的并发后台一般都要有一个集群来支持服务的请求,一般一个Nginx也就最高支持几万级别的并发),而任一时刻只有几十个或几百个连接是处于活跃的状态(接收TCP包),那么,如何才能高效的处理这种场景呢?

在最早期的linux版本中,系统采用的是select/poll方式,会将所有连接都告诉操作系统,然后轮询所有的链接,让操作系统自己找出活跃的连接然后进行处理。由于前者在存在大量连接的情况下,轮询的效率比较低,所以后来引入了I/O事件通知机制-epoll。

(2.1)epoll相关的API和数据结构

epoll相关的API接口包含三个,如下所示:

int epoll_create(int size);  
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);

  1. epoll_create方法:
    内核会产生一个epoll 实例数据结构并返回一个文件描述符,这个特殊的描述符就是epoll实例的句柄,后面的两个接口都以它为中心(即epfd形参)。size参数在现在的Linux版本中已经被弃用。

  2. epoll_ctl方法:
    将被监听的描述符添加到红黑树中或从红黑树中删除或者对监听事件进行修改。对于需要监视的文件描述符集合,epoll_ctl对红黑树进行管理,确保了在大量节点的情况下的增删改查的性能。

  3. epoll_wait方法:
    在红黑树数据结构中,当某个节点的连接因为对应的监听事件发生后,会自动从红黑树中复制到就绪列表中。当外界调用epoll_wait方法时,会直接返回就绪队列中的连接,而不需要像select/poll那样进行全量的遍历,从而提升了性能。
    events和maxevents两个参数描述一个由用户分配的struct epoll event数组,调用返回时,内核将ready list复制到这个数组中,并将实际复制的个数作为返回值。注意,如果ready list比maxevents长,则只能复制前maxevents个成员;反之,则能够完全复制ready list。另外,struct epoll event结构中的events域在这里的解释是:在被监测的文件描述符上实际发生的事件。
    参数timeout描述在函数调用中阻塞时间上限,单位是ms:
    timeout = -1表示调用将一直阻塞,直到有文件描述符进入ready状态或者捕获到信号才返回;
    timeout = 0用于非阻塞检测是否有描述符处于ready状态,不管结果怎么样,调用都立即返回;
    timeout > 0表示调用将最多持续timeout时间,如果期间有检测对象变为ready状态或者捕获到信号则返回,否则直到超时。

(2.2)epoll的两种触发方式

epoll支持边缘触发(edge trigger,ET)或水平触发(level trigger,LT)两种触发方式。通过epoll_wait等待I/O事件,如果当前没有可用的事件则阻塞调用线程或者直接返回。epoll的默认的工作模式是LT模式。select和poll只支持LT工作模式。

水平触发

触发时机:
对于读操作,只要缓冲内容不为空,LT模式返回读就绪。
对于写操作,只要缓冲区还不满,LT模式会返回写就绪。

当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你。如果系统中有大量你不需要读写的就绪文件描述符,而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率。

边缘触发

触发时机:
对于读操作
当缓冲区由不可读变为可读的时候,即缓冲区由空变为不空的时候。
当有新数据到达时,即缓冲区中的待读数据变多的时候。
当缓冲区有数据可读,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLIN事件时。
对于写操作
当缓冲区由不可写变为可写时。
当有旧数据被发送走,即缓冲区中的内容变少的时候。
当缓冲区有空间可写,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLOUT事件时。
当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。

(2.3)epoll和select/poll的对比

select和poll的动作基本一致,只是poll采用链表来进行文件描述符的存储,而select采用fd标注位来存放,所以select会受到最大连接数的限制,而poll不会。

select和poll并不会明确指出是哪些文件描述符就绪,而epoll会。造成的区别就是,系统调用返回后,调用select和poll的程序需要遍历监听的整个文件描述符找到是谁处于就绪,而epoll则直接处理即可。

select、poll采用轮询的方式来检查文件描述符是否处于就绪态,而epoll采用回调机制,造成的结果就是,随着fd的增加,select和poll的效率会线性降低,而epoll不会受到太大影响,除非活跃的socket很多。

(3)Nginx对惊群效应的处理

在讲述Nginx如何处理惊群效应的这个问题之前,先要来了解下什么是惊群效应。

什么是惊群效应?

惊群效应(thundering herd)是指多进程(多线程)在同时阻塞等待同一个事件的时候,如果等待的这个事件发生,那么他就会唤醒等待的所有进程(或者线程),但是最终却只能有一个进程(线程)获得这个时间的“控制权”,对该事件进行处理,而其他进程(线程)获取“控制权”失败,只能重新进入休眠状态,这种现象和性能浪费就叫做惊群效应。

形象点的描述:当你往一群鸽子中间扔一块食物,虽然最终只有一个鸽子抢到食物,但所有鸽子都会被惊动来争夺,没有抢到食物的鸽子只好回去继续睡觉, 等待下一块食物到来。这样,每扔一块食物,都会惊动所有的鸽子,即为惊群。

惊群效应带来什么影响?

惊群效应会导致内核对用户进程(线程)频繁地做无效的调度、上下文切换等使系统性能大打折扣。上下文切换(context switch)过高会导致 CPU 花费大量的时间在进程(线程)切换上,而不是在真正工作的进程(线程)上面。带来的直接消耗包括 CPU 寄存器要保存和加载(例如程序计数器)、系统调度器的代码需要执行。间接的消耗在于多核 cache 之间的共享数据。为了确保只有一个进程(线程)得到资源,需要对资源操作进行加锁保护,加大了系统的开销。

目前一些常见的服务器软件有的是通过锁机制解决的,比如 Nginx(它的锁机制是默认开启的,可以关闭);还有些应用软件认为惊群对系统性能影响不大,没有去做专门的处理,比如 Lighttpd。

Nginx中对惊群效应的解决方案

Nginx提供了一个accept_mutex,这是一个加在accept()上的一把互斥锁。即每个worker进程在执行accept()之前都需要先获取锁,accept()成功之后再解锁。有了这把锁,同一时刻,只会有一个进程执行accpet(),对于没有获取到锁的进程,则不用再调用accept()方法了,这样就不会有惊群问题了。accept_mutex是一个可控选项,默认是打开的。

// 如果使用了 master worker,并且 worker 个数大于 1,并且配置文件里面有设置使用 accept_mutex. 的话,设置ngx_use_accept_mutex  
 if (ccf->master && ccf->worker_processes > 1 && ecf->accept_mutex) 
   
        ngx_use_accept_mutex = 1;   
        ngx_accept_mutex_held = 0;  
        ngx_accept_mutex_delay = ecf->accept_mutex_delay;  
  else   
        ngx_use_accept_mutex = 0;  
 

如果有ngx_use_accept_mutex >0,说明 Nginx 有必要使用 accept 互斥体,这个变量的初始化在 ngx_event_process_init 中。
ngx_accept_mutex_held 表示当前是否已经持有锁。
ngx_accept_mutex_delay 表示当获得锁失败后,再次去请求锁的间隔时间,这个时间可以在配置文件中设置的。

下面看看加锁的逻辑判断:

// 如果有使用accept_mutex,则才会进行处理。  
if (ngx_use_accept_mutex) 
  
    // 如果大于0,则跳过下面的锁的处理,并减一。  
    if (ngx_accept_disabled > 0)   
        ngx_accept_disabled--; 
     else   
        // 试着获得锁,如果出错则返回。  
        if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR)   
            return;  
          
        // 如果ngx_accept_mutex_held为1,则说明已经获得锁,此时设置flag,这个flag后面会解释。
        if (ngx_accept_mutex_held)   
            flags |= NGX_POST_EVENTS;  
         else   
            // 否则,设置timer,也就是定时器。接下来会解释这段。  
            if (timer == NGX_TIMER_INFINITE  
                 || timer > ngx_accept_mutex_delay)   
                timer = ngx_accept_mutex_delay;  
              
          
      

首先有个变量ngx_accept_disabled的判断,这个变量从哪来的呢?如下所示。ngx_accept_disabled,这个变量是一个阈值,如果大于 0,说明当前的进程处理的连接过多。

ngx_accept_disabled = ngx_cycle->connection_n / 8 - ngx_cycle->free_connection_n;

下面继续说说上面的加锁逻辑。

if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR)   
   return;  
  

首先尝试的去获取锁,如果获取锁出现了错误,则直接返回。

NGX_POST_EVENTS 标记,设置了这个标记就说明当 socket 有数据被唤醒时,我们并不会马上 accept 或者说读取,而是将这个事件保存起来,然后当我们释放锁之后,才会进行 accept 或者读取这个句柄。如果没有设置 NGX_POST_EVENTS 标记的话,Nginx 会立即 Accept 或者读取句柄。

 if (timer == NGX_TIMER_INFINITE  || timer > ngx_accept_mutex_delay)   
      timer = ngx_accept_mutex_delay;  
    

这里如果 Nginx 没有获得锁,并不会马上再去获得锁,而是设置定时器,然后在 epoll 休眠(如果没有其他的东西唤醒)。此时如果有连接到达,当前休眠进程会被提前唤醒,然后立即 accept。否则休眠 ngx_accept_mutex_delay时间,然后继续 tryLock。

下面看看上面逻辑中ngx_trylock_accept_mutex(cycle) 这个方法的实现。

ngx_int_t ngx_trylock_accept_mutex(ngx_cycle_t *cycle)  
  
    // 尝试获得锁  
    if (ngx_shmtx_trylock(&ngx_accept_mutex))   
        // 如果本来已经获得锁,则直接返回Ok  
        if (ngx_accept_mutex_held  && ngx_accept_events == 0  
            && !(ngx_event_flags & NGX_USE_RTSIG_EVENT))  
          
            return NGX_OK;  
          

        // 到达这里,说明重新获得锁成功,因此需要打开被关闭的listening句柄。  
        if (ngx_enable_accept_events(cycle) == NGX_ERROR)   
            ngx_shmtx_unlock(&ngx_accept_mutex);  
            return NGX_ERROR;  
          

        ngx_accept_events = 0;  
        // 设置获得锁的标记。  
        ngx_accept_mutex_held = 1;  

        return NGX_OK;  
      

    // 如果我们前面已经获得了锁,然后这次获得锁失败
    // 则说明当前的listen句柄已经被其他的进程锁监听
    // 因此此时需要从epoll中移出已经注册的listen句柄
    // 这样就很好的控制了子进程的负载均衡  
    if (ngx_accept_mutex_held)   
        if (ngx_disable_accept_events(cycle) == NGX_ERROR)   
            return NGX_ERROR;  
          
        // 设置锁的持有为0.  
        ngx_accept_mutex_held = 0;  
      

    return NGX_OK;  

如上代码,当一个连接来的时候,此时每个进程的 epoll 事件列表里面都是有该 fd 的。抢到该连接的进程先释放锁,在 accept。没有抢到的进程把该 fd 从事件列表里面移除,不必再调用 accept,造成资源浪费。

(4)结语

本文以研究Nginx为何支持高并发为切入点,梳理了下底层的实现原理,对nginx和epoll的实现原理进行的大致的总结说明,希望对大家有所帮助。
这里总结一下:
(1)nginx里采用了主动的方法去把监听描述符放到epoll中或从epoll移出,这是Nginx的设计精髓,也支持高并发和高并发下保持性能的原因。
(2)nginx中用采互斥锁去解决谁来accept问题,保证了同一时刻,只有一个worker接收新连接,从而解决高并发情况下的惊群效应问题。

参考链接:
什么是惊群,如何有效避免惊群?
彻底搞懂epoll高效运行的原理

以上是关于和我一起,来一场Nginx支持高并发的探索之旅的主要内容,如果未能解决你的问题,请参考以下文章

不知道怎么学高并发系统设计?和我一起做好这7点,你也能玩转高并发!

点进来,与白洞一起体验一场沉浸式智慧轨道之旅

Nginx为什么可以支持高并发

大道至简 - 基于Docker的Serverless探索之旅

IDE原创集·生活创想家集合令 | 与2018(第二届)义乌设计周来一场邂逅

从 Nginx 核心架构设计,揭秘其为何能支持高并发?