1. Nginx的请求处理流程
--------------------------------------------
| 非阻塞的事件驱动处理引擎 |<---> HTTP,Mail及
| | stream(TCP)代理
WEB,EMAIL -> | 传输层状态机 HTTP状态机 MAIL状态机 | 及TCP流量 | | | 使用线程池处理磁盘阻塞调用 |<—> FastCGI,uWSGI,SCGI, ——————————————– memcached代理 ^ ^ V V 静态资源 Access访问日志 磁盘缓存 Error错误日志
我们从这张图的最左边来看,WEB,EMAIL及TCP这三种流量进入nginx之后,nginx内部会有三个大的状态机,处理TCP、UDP四层的传输层状态机,和处理应用层的HTTP状态机,已经处理邮件的状态机。那么为什么我们叫它状态机呢?
因为nginx核心的部分是使用非阻塞的事件驱动处理引擎,也就是使用我们熟知的epoll,一旦我们使用这种异步处理引擎之后,那都是需要状态机来对请求进行正确识别和处理的。
基于这样的一个状态处理机呢,我们在解析出请求需要访问静态资源的时候,它就会走左下方的箭头,找到静态资源;如果我们去做反向代理的时候呢,那么对反向代理的内容,我可以做磁盘缓存,缓存到磁盘上。
但是我们在处理静态资源的时候,会有一个问题,就是当整个内存,已经不足以完全缓存所有的文件信息的时候,像sendfile,aio这样的调用会退化成阻塞的磁盘调用,所以我们需要有一个线程池来处理。
对于每个完成的请求,我们需要记录Access日志和Error日志,而这些也是存储在磁盘中的。
2. Nginx的进程结构
Nginx其实有两种进程结构,一种是单进程结构,一种是多进程结构。单进程结构实际上并不适用于生产环境,只适合我们做开发和调试用,因为在生产环境中我们必须保持nginx足够健壮,以及利用cpu多核的特性。所以默认配置中,都是打开多进程nginx模式。
接下来我们看一下多进程结构究竟是怎样的? Master Process V Child Processes V Shared memory is used for cache,session persistence,rate limits,session log
CM(Cache Manager) CL(Cache Loader) W W W W Worker processes handle HTTP and other network traffic
当nginx使用多线程结构的时候,因为线程之间是共享同一个地址空间的,所以当某一个第三方模块引发一个地址空间段错误时,地址越界出现时,会导致整个nginx进程全部挂掉。而采用多进程这样的nginx进程模型时,往往就不会出现这样的问题。
从我刚才说的这点来看,可以发现nginx在做它的进程设计时,同样遵循了高可用高可靠的这样的一个目的,比如说,在master进程中通常第三方模块是不会在这里加入自己的功能代码的,虽然nginx设计时,允许第三方模块在master进程中添加自己独有的自定义的一些方法,但是通常没有第三方模块会这么做,那么master进程它被设计出来的目的是做worker进程的管理的,也就是说,所有的worker进程是处理真正的请求的,而master进程负责监控每个worker进程是不是正在工作,需不需要做重新载入配置文件,需不需要做热部署。
而我们说到cache,也就是说缓存的时候,那么缓存实际上要在多个worker进程间共享的,而且缓存不光要被worker进程使用,还要被CM(cache manager)和CL(cache loader)进程使用,CM和CL也是为反向代理时,后端发来的动态请求做缓存所使用的,那么CL只用来做缓存的载入,CM用来做缓存的管理,实际上每一个请求出来时使用的缓存还是由worker进程来进行的,那么这些进程间的通讯是使用共享内存来解决的。
那么有一个问题?为什么worker进程要很多,我们在这张图中看到,CM和CL各有一个进程,master进程由于是父进程,所以肯定只有一个进程,那么worker进程为什么会有很多呢?这个是因为nginx采用了事件驱动的模型以后,它希望每一个worker进程从头到尾占有一个cpu核心,往往我们不仅要把worker进程的数量配置成和cpu的核心数一致以外,还需要把每一个worker进程与某一颗cpu核绑定到一起,这样可以更好地使用每个cpu核上面的cpu缓存,来减少缓存失效的命中率。
3. nginx的事件驱动模型
从TCP编程上看HTTP请求处理:
客户端 服务器
(S1)创建新的套接字(socket)
(S2)将套接字绑定到端口80上去(bind)
(S3)允许套接字进行连接(listen)
(S4)等待连接(accept)
(C1)获取IP地址和端口号
(C2)创建新的套接字(socket)
(C3)连接到服务器IP:port上去(connet)
(S5)通知应用程序有连接到来
(S6)开始读取请求(read)
(C4)连接成功
(C5)发送HTTP请求(write)
(C6)等待HTTP响应(read)
(S7)处理HTTP请求报文
(S8)回送HTTP响应(write)
(S9)关闭连接(close)
(C7)处理HTTP响应
(C8)关闭连接(close)
nginx是如何处理事件的? Nginx事件循环如下:
NGINX EVENT LOOP
WAIT FOR EVENTS
--->ON CONNECTIONS ---------------------->|
| V |KERNEL
| RECEIVE A QUEUE |
| OF NEW EVENTS <-----事件队 列---------|
| V ^
| PROCESS THE EVENTS |
|--> QUEUE IN CYCLE ------|
THE EVENTS QUEUE PROCESSING CYCLE
BEGIN
yes V no
END<-------is events queue empty? ------------>dequeue an event
^ V
| process the event
|________________________________|
当我们nginx刚刚启动时,实际上是在这一块,“WAIT FOR EVENTS ON CONNECTIONS”,也就是 我们打开了80或者443端口,这个时候我们在等待新的事件过来,什么样的事件呢?比如说新 的客户端,连上了我们的nginx,它向我们发起了连接,我们在等这样的事件,那么这样一步 呢,往往对应着我们epoll中的叫epoll-wait这样的方法,这个时候我们的nginx其实是出于 sleep这样的一个进程状态。
当操作系统收到了一个建立TCP连接握手报文,并且处理完握手 流程以后,操作系统就会通知我们的epoll-wait这样的阻塞方法,告诉它你可以往下走了。 同时唤醒我们nginx worker进程
那么往下走完以后呢,我们就去找操作系统要事件。这里 的kernel就是操作系统内核,操作系统会把它准备好的事件放在这个事件队列中,从这个事件 队列中,我们可以获取到我们需要处理的事件,比如建立连接,比如我们收到一个tcp的请求 报文,我们都可以从这里取出来。取出来以后呢,我们就会处理这么一个事件。
处理事件的循环,就是我们发现这个队列中不为空,我们就把事件取出来,开始处理这个事件。
那么在处理事件的过程中,我可能会生成一些新的事件,比如说我发现一个连接新建立了,那 么我可能要添加一个操作时间,比如默认是60s,也就是说60s之内如果浏览器不向我发送请求 的话,我就会把这个连接关掉。又比如说,当我发现我收完了完整的HTTP请求以后,我已经可 以生成http响应了,那么这个新生成的响应呢,是需要我可以向操作系统的写缓存区里面去把 响应写进去,要求操作系统尽快的把这样一段写的响应内容发到浏览器中去。
当我们所有的事件都处理完成以后,它就会返回到WAIT FOR EVENTS ON CONNECTIONS中去。
上面的事件循环中,关键是nginx如何快速的从kernel中获取到待处理的事件 现在的nginx主要使用epoll来实现的:
{ | } <---> { | } <---> { | } <---> { | } |eventpoll |
^ rdllist基于epitem结 构的rdllink成员 ------------
| |+lock |
^ |+mtx |
| |+wq |
| |+poll_wait|
+--------<------------------<--------------------<|+rdllist |struct list_head
-------------<--------------------<|+rbr |struct rb_root_cached
/ |+ovflist |
/ |+user |
V
{}
/ \ |epitem |
{} {} ------------
/ \ / \ |+rbn |struct rb_node
{} {}{} {} ready |+rdllink |struct list_head
rbr指向一个红黑树 |+next |
每一个成员都是epitem结构体 |+ffd |
使用epitem.rbn来构建平衡二叉树 |+nwait |
|pwqlist |
|+ep |
|+fllink |
|+event |
前提:高并发连接中,每次处理的活跃连接数量占比很小。
nginx每次都从rdllist链表中获取活跃连接。每次读取完就要将这个事件, 从链表中删除,当操作系统接收到网卡中的一个报文的时候,这个链表就会 新增一个元素。
对于80端口建立连接的请求,会添加一个读事件,并放在rbr红黑树中。
4. Nginx的请求切换
前面我们介绍了nginx如何使用epoll来运行自己的事件驱动处理框架的。 那么这样的事件驱动框架,到底能给我们带来怎样的好处呢? 我们来看一下在请求切换这样的一个场景中,这种事件驱动框架对我们带来的意义。
传统的一进程仅处理一连接:
- 不做连接切换
- 依赖OS的进程调度实现并发
nginx的一线程同时处理多连接
- 用户态代码完成连接切换
- 尽量减少OS进程切换
操作系统的进程调度仅仅适用于很少量的数百上千这样的进程间做切换,相对来说进程间切换 的时间成本还能够接受,再多上几万几十万的情况下,就会完全无法接受了。 (进程间切换 的时间成本是随着进程数呈指数增长的。)
nginx是如何处理的呢?当一个请求的处理事件不满足的情况下,它在用户态直接就切到了另一个请求,这样的话我们就没有了中间这样的一个进程间切换的一个成本,因为网络事件不满足。 除非是我们nginx worker所使用的时间片已经到了,而时间片的长度呢,其实一般是5ms到800ms 所以我们在nginx worker的配置上往往会把它的优先级加到最高,比如通常我们可能会加到-19。 这样的话,往往会分配更多的时间片,保证cpu少做进程间的切换。
5. 同步&异步、阻塞&非阻塞之间的区别
阻塞和非阻塞主要是指操作系统,或者底层的c库提供的方法,或者一个系统调用,也就是我们在调用这个方法的时候,这个方法可能会导致我的进程进入sleep状态,为什么会进去sleep状态呢,就是当前的条件不满足的情况下,操作系统主动把我的进程切换为另一个进程了,再使用当前的CPU了。这样就是一个阻塞的方法。而非阻塞的方法呢,就是我们在调用该方法时,永远不会因为当我们时间片未用完时,把我们的进程主动切换掉。
而同步和异步呢,则是从我们调用的方式而言。就是我们编码中,写我们的业务逻辑,这样的一个角度。
我们可以从nginx历史的发展趋势上看出这一点。那么nginx目前除了官方在提供的javascript利用这样的同步这样写代码的方式来实现非阻塞的效果,以及openresty基于lua语言用同步写代码的效果实现非阻塞高并发的效果。
在阻塞调用中,我们以accept为例,因为绝大多数程序在调用accept方法的时候,它都是使用阻塞socket的,使用阻塞socket的时候,当我们调accept方法的时候,如果说我们监听的端口所对应的accept队列,就是操作系统已经为我们做好了几个三次握手建立成功的socket,那么阻塞socket的阻塞方法可能立刻就会得到返回,而不会被阻塞。但是如果accept队列是空的,那么操作系统就会等待三次握手的连接到达我们的内核中,我们才会去唤醒accept调用。这个时间往往是可控的,我们可以设置操作系统最长的超时时间,如果没有达到的话,也可以唤醒我们这样的一个调用。所以,这里的流程中,就是会导致进程间的切换。而前面我们谈过nginx是不能容忍这样的进程间切换的。
非阻塞的调用有什么差别呢?如果我们使用非阻塞套接字,使用accept调用去执行的时候呢,如果accept队列为空,它是不等待并立刻返回的,它会返回一个EAGAIN的错误码,所以这个时候呢,我们的代码会受到一个错误码,但是这个错误码是一个特殊的错误码,需要我们的代码去处理它。 如果我们再次调动accept,是非阻塞的,那么如果accept队列不为空呢,则把成功的一个socket建立好的套接字返回给我们的代码。 所以这里有个问题,就是由我们的代码来决定当我们的代码收到一个EAGAIN这样的一个错误码时,我们究竟是应该等一会儿继续处理这个连接,比如sleep以下,还是先切换到其他的任务,再处理。
我这里只是举了一个非常简单的accept例子,如果涉及到我们的业务特性,会使得我们的业务代码非常复杂,因此非阻塞调用是我们底层实现,如果我们用异步的方法去实现非阻塞的调用是非常自然而然的。
非阻塞调用下的同步和异步:
|
|
可以这样简单理解,异步调用就是回调,回调就是函数的最后一个参数是一个函数
6. Nginx的内存
6.1 Nginx如何通过连接池来处理网络请求
每个worker进程都有一个ngx_cycle_t
的数据结构,该数据结构包含3个数组:
- connections
数组的大小和
worker_connections
有关 read_events
write_events
每个连接自动对应一个读事件和一个写事件,所以,这三个数组的大小是相同的。 而这三个数组的下标,是相互对应的,小标相同,表示是同一个连接。
每个连接占用多大的内存呢?
连接所对应的数据结构是ngx_connections
,该结构体在64位操作系统中,大约占用232字节。
而每个连接会对应1个读事件和1个写事件,而每个事件的大小是96字节,所以一个连接总共占用(232 + 2*96)=424
事件的数据结构是:
6.2 内存池对性能的影响
在上面介绍的ngx_conection_s
这个数据结构中有个成员ngx_pool_t *pool
,它的大小可以通过connection_pool_size
这个配置来设置。
分为连接内存池(使用connection_pool_size
来设置)和请求内存池(使用request_pool_size
来设置)。
6.3 所有worker进程协同工作的关键:共享内存
nginx中进程间的通讯方式,主要是两种:信号,共享内存。
管理nginx的进程时,使用的就是信号。
实现数据的同步,只能使用共享内存。
使用共享内存会出现两个问题:锁,手动内存管理非常繁琐(需要使用Slab内存管理器)
nginx早期含有基于信号量的锁,信号量是linux中比较久远的进程同步方式, 它会导致进程进入休眠状态,也就是发生了主动切换。而现在nginx使用的锁, 都是自旋锁,而不会基于信号量。
自旋锁:当这个锁的条件没有满足,比如说这块内存,现在被1号worker进程使用。 那么2号worker进程需要去获取自旋锁的时候,只要1号进程没有释放锁,2号进程会 一直去请求这把锁。
如果是基于信号量锁,假设这把锁锁住了一扇门,如果worker进程1已经拿到这把锁,进到屋里,那么worker进程2试图去拿锁,敲门发现里面已经有人了,那么worker进程2就会就地休息。等待worker进程1出来后通知它。
而自旋锁不同,当worker进程2发现门里已经有worker进程1了,它就一直在持续的敲门。
所以使用自旋锁,要求所有的worker进程必须快速的使用共享内存,也就是快速取得锁以后快速释放锁。
- OpenResty使用共享内存的示例:
http {
lua_shared_dict dogs 10m;
server {
location /set {
content_by_lua_block {
local dogs = ngx.shared.dogs
dogs:set("Jim", 8)
ngx.say("STORED")
}
}
location /get {
content_by_lua_block {
local dogs = ngx.shared.dogs
ngx.say(dogs.get("Jim"))
}
}
}
}
- Slab管理器
使用
ngx_slab_stat
来监控slab管理器:
重新编译nginx:
|
|
然后修改nginx.conf文件:
http {
server {
location = /slab_stat {
slab_stat;
}
}
}
本文发表于 0001-01-01,最后修改于 0001-01-01。
本站永久域名「 jiavvc.top 」,也可搜索「 后浪笔记一零二四 」找到我。