初探nginx架构

nginx架构介绍

众所周知,nginx的性能很高,而nginx的高性能离不开它的架构。那么nginx是什么样的呢?本节我们先来了解一下 nginx 框架。

nginx启动后会和unix系统一样在后台运行。后台进程包括一个进程和多个进程。我们也可以手动关闭后台模式,让nginx运行在前台,配置nginx取消进程,让nginx可以单进程模式运行。显然,我们在生产环境中肯定不会这样做,所以关闭后台模式一般用于调试。在接下来的章节中,我们将详细解释如何调试 nginx。因此,我们可以看到 nginx 在多进程模式下工作。当然nginx也支持多线程模式,但是我们的主流模式还是多进程模式,这也是nginx的默认模式。 nginx使用多进程有很多优点,所以我主要讲解一下nginx的多进程模式。

前面说过,nginx启动后,会有一个进程和多个进程。进程主要用于对进程进行管理,包括:接收外界的信号、向各个进程发送信号、监控进程的运行状态、进程退出时(异常)自动重启新进程。在此过程中处理基本的网络事件。多个进程是对等的,它们平等地竞争来自客户端的请求,并且每个进程彼此独立。一个请求只能在一个进程中处理,一个进程不能处理其他进程的请求。可以设置进程数。一般我们会设置成和机器的cpu核数一致。之所以会这样,与nginx的进程模型和事件处理模型是分不开的。 nginx的进程模型可以用下图表示:

nginx启动后,如果我们要操作nginx,应该怎么做呢?从上面我们可以看出,要管理进程,所以我们只需要和进程进行通信即可。进程会接收外界的信号,然后根据信号做不同的事情。所以我们要控制nginx,只需通过kill向进程发送信号即可。例如, kill -HUP pid 告诉 nginx 优雅地重启 nginx。我们一般使用这个信号来重启nginx,或者重新加载配置。因为它优雅地重新启动,所以服务不会中断。进程收到HUP信号后做什么?首先,当进程收到信号时,会重新加载配置文件,然后启动新进程,并向所有旧进程发送信号,告诉它们可以光荣退休。新的启动后开始接收新的请求,而旧的在收到来自它的信号后停止接收新的请求,并在当前进程中所有未处理的请求处理完后退出。 当然,直接向进程发送信号是一种旧的操作方法。 nginx版本0.8之后,引入了一系列命令行参数,方便我们管理。比如./nginx -s就是重启nginx,./nginx -s stop就是停止nginx的运行。怎么做?举个例子,我们看到在执行命令的时候,我们启动了一个新的nginx进程,并且新的nginx进程解析完参数后,就知道我们的目的是控制nginx重新加载配置文件,这是一个信号将被发送到进程,然后下一步的动作与我们直接向进程发送信号时相同。

现在,我们知道在运行nginx的时候,nginx内部发生了什么,那么进程是如何处理请求的呢?正如我们前面提到的,进程是平等的,每个进程都有相同的机会处理请求。当我们在 80 端口上提供 http 服务时,一个连接请求来了,每个进程都可能处理这个连接。怎么做?首先,每个进程都来自进程fork。进程中先建立(),再fork多个进程。当新连接到达时,所有进程都将变得可读。为了确保只有一个进程处理连接,所有进程都会在注册读取事件之前获取读取事件。获取互斥锁的进程注册读事件,并调用读事件接受连接。当一个进程连接后,开始读取请求,解析请求,处理请求,生成数据,返回给客户端,最后断开连接,这样一个完整的请求是这样的。我们可以看到,一个请求完全由进程处理,而且只在一个进程中处理。

那么,nginx采用这种进程模型有什么好处呢?当然,肯定会有很多好处。首先,对于每个进程来说,独立的进程是不需要加锁的,这样就节省了加锁带来的开销,同时也方便了编程和查找问题。其次,使用独立的进程可以防止相互影响。一个进程退出后,其他进程仍在工作,服务不会中断,该进程会快速启动一个新进程。当然,进程的异常退出肯定是程序的bug造成的。异常退出会导致当前所有请求失败,但不会影响所有请求,因此风险降低。当然还有很多其他的好处,大家可以慢慢体验。

关于nginx的进程模型已经说了很多。接下来我们看看nginx是如何处理事件的。

可能有人要问,nginx使用多种方式处理请求,每一种只有一个主线程,能处理的并发数非常有限,能处理多少并发,多少高并发呢不,这就是nginx的天才之处,nginx使用异步非阻塞的方式来处理请求,也就是说nginx可以同时处理上千个请求。想想常见的工作方式(也有异步非阻塞的版本,但是不常用,因为和自己的一些模块冲突),每个请求都会有一个独占的工作线程,当并发数达到几千个,就会有几千个线程在处理请求。这对操作系统来说是一个很大的挑战。线程造成的内存占用非常大,线程上下文切换造成的CPU开销非常大,自然无法提升性能,这些开销完全没有意义。 .

为什么nginx可以用异步非阻塞的方式来处理,或者究竟什么是异步非阻塞?让我们回到原点,看看一个请求的完整过程。先是请求来,建立连接,然后接收数据,接收到数据后,发送数据。具体到系统底层,就是读写事件,当读写事件没有准备好时,肯定是不能操作的。如果不使用非阻塞方法调用,则必须阻塞调用。如果事件没有准备好,你只能等待。好吧,当活动准备好后,您可以继续。阻塞调用会进入内核等待,cpu会被别人占用。对于单线程应用,显然不适合。当网络事件较多时,大家都在等待。 cpu空闲的时候,没人用,cpu利用率自然上不去,更别说高并发了。嗯,你说增加进程数,这和线程模型有什么区别,注意,不要增加不必要的上下文切换。所以在nginx中,最忌讳的就是阻塞系统调用。不要阻塞,它是非阻塞的。非阻塞的意思是如果事件没有准备好,立即返回,告诉你事件没有准备好,你为什么要恐慌,稍后再回来。嗯,你可以过一会再去查看事件,直到事件准备好,在此期间你可以先做其他的事情,然后再检查事件是否正常。虽然没有被阻塞,但要时不时检查一下事件的状态,可以做的事情更多,但是开销不小。

因此存在异步非阻塞事件处理机制,具体的系统调用就是像/poll/epoll/这样的系统调用。它们提供了一种机制,允许您同时监视多个事件。调用它们是阻塞的,但可以设置超时。在超时时间内,如果一个事件准备好了,它将返回。这个机制正好解决了我们上面的两个问题,以 epoll 为例(在下面的例子中,我们以 epoll 为例来表示这种类型的函数),当事件没有准备好时,放到 epoll 中,事件就准备好了,我们去读写,当读写返回时,我们再次添加到epoll中。这样,只要有一个事件准备好了,我们就会处理它,只有在所有事件都没有准备好的时候,才在 epoll 中等待。这样,我们就可以同时处理大量的并发。当然,这里的并发请求是指未处理的请求。只有一个线程,所以当然只能同时处理一个请求,但它只是在请求之间不断的切换。就是这样,开关也因为异步事件没有准备好而主动放弃。在这里切换是免费的。你可以把它理解为一个循环来处理多个准备好的事件,实际上就是这样。与多线程相比,这种事件处理方式有很大的优势。它不需要创建线程,每个请求占用很少的内存。没有上下文切换,事件处理非常轻量级。再多的并发也不会导致不必要的资源浪费(上下文切换)。更多的并发只会占用更多的内存。之前测试过连接数,在一台24G内存的机器上,处理的并发请求数达到了200万。现在的web服务器基本都是用这种方式,这也是nginx性能高的主要原因。

我们之前说过,推荐的数字是cpu核心数,这里很容易理解。更多的数字只会导致进程竞争cpu资源,从而带来不便。必要的上下文切换。而且,为了更好地利用多核特性,nginx 提供了 cpu 亲和性的绑定选项。我们可以将某个进程绑定到某个核上,这样缓存就不会因为进程切换而失效。像这样的小优化在 Nginx 中很常见,也说明了 Nginx 作者的心血。比如nginx在做4字节字符串比较时,会将4个字符转换成int类型,然后比较,减少CPU指令数等等。

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

图片[1]-初探nginx架构-唐朝资源网

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

另外,让我们看看计时器。由于调用等待函数的时候可以设置超时时间,所以 nginx 使用这个超时时间来实现定时器。 nginx 中的定时器事件被放置在维护定时器的红黑树中。每次进入前,从红黑树中获取所有定时器事件的最小时间,计算超时时间后进入。因此,当没有事件产生,也没有中断信号时,就会超时,即定时器事件已经到来。这时,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 
            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);
}

© 版权声明
THE END
喜欢就支持一下吧
点赞118赞赏 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容