深入理解Nginx:模块开发与架构解析
0 参考资料
Nginx 源码注释:https://github.com/chronolaw/annotated_nginx
2 如何编写HTTP模块
2.7 Nginx提供的高级数据结构
1 | - ngx_queue_t 双向链表 |
第3部分 深入Nginx
8 Nginx 基础架构
8.2.1 Nginx的模块化设计
ngx_module_t接口及其对核心、事件、HTTP、mail等4类模块ctx上下文成员的具体化:
官方Nginx共有五大类模块:核心模块、配置模块、事件模块、HTTP模块、mail模块。
这五类模块中,配置模块与核心模块是与Nginx框架密切相关的,是其他模块的基础。而事件模块是HTTP模块和mial模块的基础。HTTP模块和mail模块的“地位”相似,都更关注于应用层面。在事件模块中,ngx_event_core_module事件模块是其他有事件模块的基础;在HTTP模块中,ngx_module_core_module模块是其他所有HTTP模块的基础;在mail模块中,ngx_mail_core_module模块是其他所有mail模块的基础。
Nginx常用模块及其之间的关系:
8.2.2 事件驱动框架
传统的Web服务器是进程或线程做为事件的消费者,一个请求产生的事件被进程处理,直到这个请求处理结束,才会释放进程资源。
传统Web服务器事件处理模型:
nginx通过事件驱动的方式处理,事件的消费者是某个模块,没有事件只会交给对应的模块处理:
以上的模型要求每个模块不能有导致进程阻塞的行为。否则会使进程休眠,大大影响并发性能。
8.2.3 请求的多阶段异步处理
多阶段异步处理和事件驱动架构是密切相关的。以下示例,获取一个静态文件的HTTP请求可以分为如下阶段:
这7个阶段可以重复发生。每个阶段都由事件分发器触发,然后继续调用事件消费者处理请求。通过划分阶段可以避免某个处理事件过长导致进程的延迟或者阻塞。
- 划分阶段的原则:一般是找到请求处理流程中的阻塞方法,在阻塞代码段上按照下边的4种方式划分阶段:
- 讲阻塞进程的方法按照相关的触发事件分解为两个阶段
把阻塞方法改为调用非阻塞方法,调用非阻塞方法后将进程归还给事件分发器这就是第一个阶段,然后增加第二阶段处理非阻塞方法返回的结果。
示例:
例如,在使用send调用发送数据给用户时,如果使用阻塞socket句柄,那么send调用在向操作系统内核发出数据包后就必须把当前进程休眠,直到成功发出数据才能“醒来”。这时的send调用发送数据并等待结果。我们需要把send调用分解为两个阶段:发送且不等待结果阶段send结果返回阶段。因此,可以使用非阻塞socket句柄,这样调用send发送数据后,进程是不会进入休眠的,这就是发送且不等待结果阶段;再把socket句柄加入到事件收集器中就可以等待相应的事件触发下一个阶段,send发送的数据被对方收到后这个事件就会触发send结果返回阶段。这个send调用就是请求的划分阶段点。
- 将阻塞方法调用按照时间分解为多个阶段的方法调用
例如读取文件,因为nginx使用的事件模块没有打开对异步I/O的支持,所以还是需要用调用阻塞的方式读取(可以使用内核的异步I/O,但不是所有平台都提供)。可以把读取文件的操作划分成多次,比如每次读取10KB,这样减少了每次占用进程的时间,每次读取操作完之后,如果要进入下个阶段,可以考虑使用定时器出发,或者把读取到的内容发出去,然后由网络时间触发进入下一个阶段。
- 使用定时器时间触发划分阶段
- 如果无法避免要调用阻塞方法,可以考虑使用独立的进程执行,然后进程执行完后触发事件。
比如有些模块实现的不合理,使用了阻塞方法,如果要使用这些模块,在执行阻塞方法时,可以使用独立的进程调用。
11 HTTP框架的执行流程
11.1 HTTP处理流程
HTTP框架在初始化时就会将每个监听ngx_listening_t结构体的handler方法设为ngx_http_init_connection方法。
ngx_http_init_connection方法的执行流程:
第一次可读事件到来会执行ngx_http_init_request,流程如下:
为了提升性能,Nginx并不会在创建连接时就分配内存,只有在第一个可读事件到来后,开始创建ngx_http_request_t,并进行初始化。
ngx_http_request_t结构体保存了很多整个请求的信息和处理过程中的数据。
ngx_http_init_request执行到最后会调用ngx_http_process_request_line方法开始接收、解析HTTP请求行,请求行的处理至少会调用一次,根据网络分包情况,会出现多次调用,流程如下:
解析请求行后,就会把解析的一些内容保存到ngx_http_request_t结构中特定变量下。
下边开始在ngx_http_process_request_headers中接收HTTP头部:
下边调用ngx_http_process_request开始处理请求:
处理请求的过程中,会执行HTTP各阶段,并且调用阶段里要执行的各模块。
执行各阶段的模块,由ngx_http_core_run_phases调用各阶段的checker方法执行。
1 | // 启动引擎数组处理请求 |
模块的处理函数都是通过框架给的checker函数调用,不同阶段的checker函数存在差异,所以不同阶段也可以在调用模块处理时做一些操作。
一个请求多半需要Nginx事件模块多次地调度HTTP模块处理, 这时就要看在ngx_http_process_request处理请求的第2步设置的读写事件的回调方法ngx_http_request_handler的功能了。
请求在处理的时候,第一次调用的是ngx_http_process_request,后边再次处理则使用ngx_http_request_handler,处理流程如下:
通常来说, 在接收完HTTP头部后, 是无法在一次Nginx框架的调度中处理完一个请求的。 在第一次接收完HTTP头部后, HTTP框架将调度ngx_http_process_request方法开始处理请求, 这时 如果某个checker方法返回了NGX_OK, 则将会把控制权交还给Nginx框架。 当这个请求上对应的事件再次触发时, HTTP框架将不会再调度ngx_http_process_request方法处理请求, 而是由ngx_http_request_handler方法开始处理请求。
11.2 读HTTP请求状态机流程
参考文章:https://www.codedump.info/post/20190131-nginx-read-http-request/
11.3 checker方法
ngx_http_core_run_phases函数中会遍历handlers数组,handlers数组是包含所有模块的处理函数,在ngx_http_init_phase_handlers函数中初始化所有HTTP阶段的模块时填充。不同的阶段会指定checker函数,每个阶段里又包括多个模块,每个模块都有自己的handler处理方法,如果当前阶段顺序调用模块处理时,如果不想处理剩下模块,可以直接通过next,跳到下一个阶段。
下边是ngx_http_core_generic_phase的checker方法:
1 | // NGX_HTTP_POST_READ_PHASE/NGX_HTTP_PREACCESS_PHASE |
以上方法中,如果模块处理方法返回NGX_OK,则跳过本阶段剩下的模块,直接跳到下一个阶段。如果返回NGX_DECLINED,则继续在本阶段查找下一个模块,NGX_AGAIN/NGX_DONE则暂时中断ngx_http_core_run_phases方法,等待下次写事件到来后被epoll再次调用。
11.4 subrequest与post请求
Nginx使用的完全无阻塞的事件驱动框架是难以编写功能复杂的模块的, 可以想见, 一个请求在处理一个TCP连接时, 将需要处理这个连接上的可读、 可写以及定时器事件, 而可读事件中又包含连接建立成功、 连接关闭事件, 正常的可读事件在接收到HTTP的不同部分时又要做不同的处理, 这就比较复杂了。 如果一个请求同时需要与多个上游服务器打交道, 同时处理多个TCP连接, 那么它需要处理的事件就太多了, 这种复杂度会使得模块难以维护。 Nginx解决这个问题的手段就是第5章中介绍过的subrequest机制。
11.5 处理HTTP包体
在ngx_http_request_t结构体中的count引用计数标识,因为HTTP模块在处理请求时,接受包体的同时可能还需要处理其他业务,如使用upstream机制与另一台服务器通信。所以在销毁请求时需要通过这个计数判断,否则可能引发严重错误,在为一个请求添加新的事件,或者把一些已经由定时器、epoll中移除的事件重新加入其中,都需要把这个请求的引用计数加1。通过这个标识可以让HTTP框架知道,HTTP模块对于该请求有独立的异步处理机制。
调用ngx_http_read_client_request_body方法相当于启动了接收包体这个动作。
读取请求包体的重要结构为ngx_http_request_body_t
1 | // 请求体的数据结构,用于读取或丢弃请求体数据 |
此结构存放在ngx_http_request_t结构体的request_body成员中。
ngx_http_read_client_request_body方法的流程图如下:
如果下次再次触发可读事件,则变为调用ngx_http_do_read_client_request_body方法接收包体:
在接收包体时需要根据配置文件中关于client_body_timeout配置项,配置相应的操作。
- 放弃接收包体
ngx_http_discard_request_body调用ngx_http_discarded_request_body_handler,nginx放弃接收包体,是会在接收完后再丢弃。
11.6 发送HTTP响应
ngx_http_send_header:发送响应头
ngx_http_output_filter方法用于发送响应包体, 它的第2个参数就是用于存放响应包体的缓冲区。
ngx_http_write_filter中会计算发送速率和根据sendfile_max_chunk进行处理。
ngx_http_send_header和ngx_http_output_filter都会调用所有模块注册在过滤链表上的处理方法,然后链表末尾都会调用ngx_http_write_filter函数真正发送数据。
Nginx执行的时候是怎么按照次序依次来执行各个过滤模块呢?它采用了一种很隐晦的方法,即通过局部的全局变量。比如,在每个filter模块,很可能看到如下代码:
1 | static ngx_http_output_header_filter_pt ngx_http_next_header_filter; |
ngx_http_top_header_filter是一个全局变量。当编译进一个filter模块的时候,就被赋值为当前filter模块的处理函数。而ngx_http_next_header_filter是一个局部全局变量,它保存了编译前上一个filter模块的处理函数。所以整体看来,就像用全局变量组成的一条单向链表。
每个模块想执行下一个过滤函数,只要调用一下ngx_http_next_header_filter这个局部变量。而整个过滤模块链的入口,需要调用ngx_http_top_header_filter这个全局变量。ngx_http_top_body_filter的行为与header fitler类似。
响应头和响应体过滤函数的执行顺序如下所示:
这图只表示了head_filter和body_filter之间的执行顺序,在header_filter和body_filter处理函数之间,在body_filter处理函数之间,可能还有其他执行代码。
nginx在发送数据时使用单链表,单链表负载的就是ngx_buf_t
1 | // 表示一个单块的缓冲区,既可以是内存也可以是文件 |
一般buffer结构体可以表示一块内存,内存的起始和结束地址分别用start和end表示,pos和last表示实际的内容。如果内容已经处理过了,pos的位置就可以往后移动。如果读取到新的内容,last的位置就会往后移动。所以buffer可以在多次调用过程中使用。如果last等于end,就说明这块内存已经用完了。如果pos等于last,说明内存已经处理完了。下面是一个简单的示意图,说明buffer中指针的用法:
11.7 结束HTTP请求
12 upstream机制的设计与实现
12.1 upstream机制
upstream机制的场景示意图:
upstream机制中两个核心结构体ngx_http_upstream_t和ngx_http_upstream_conf_t:
1 | // ngx_http_upstream_t |
12.2 启动upstream
通过ngx_http_upstream_create方法创建ngx_http_upstream_t结构体,其中的成员还需要各个http模块自行设置。
1 | ngx_int_t ngx_http_upstream_create(ngx_http_request_t *r) |
启动upstream机制的ngx_http_upstream_init方法定义如下:
1 | // 启动upstream框架,开始与上游服务器异步交互 |
ngx_http_upstream_init方法的流程如下:
12.3 与上游服务器建立连接
为了保证建立TCP连接这个操作不会阻塞进程, Nginx使用无阻塞的套接字来连接上游服务器。
调用的ngx_http_upstream_connect方法就是用来连接上游服务器的, 由于使用了非阻塞的套接字, 当方法返回时与上游之间的TCP连接未必会成功建立, 可能还需要等待上游服务器返回TCP的SYN/ACK包。
1 | // 尝试连接后端服务器 |
流程图如下:
- 上边把此连接 ngx_connection_t的读写事件都设置为了ngx_http_upstream_handler
- 将upstream机制的write_event_handler方法设置为ngx_http_upstream_send_request_handler,此方法会多次触发,实际还是调用ngx_http_upstream_send_request方法发送。
- upstream的read_event_handler方法设置为ngx_http_upstream_process_header
- 连接建立成功后,则调用ngx_http_upstream_send_request方法
ngx_http_upstream_send_request方法的流程图:
12.4 接收上游服务器的响应头部
在上边的ngx_http_upstream_send_request方法中,当请求全部发送给上游服务器时,开始准备接收来自上游服务器的响应。由ngx_http_upstream_process_header方法处理上游服务器的响应,此方法也会多次被调用。
Nginx可以代理多种不同的协议,分为两段方式,先处理响应头,然后处理响应体。
处理包体分为3种不同的方式:
- 不转发响应:不转发包体是upstream机制最基本的功能, 特别是客户端请求派生出的子请求多半不需要转发包体。
- 转发响应时下游网速优先
- 转发响应时上游网速优先
12.5 以下游网速优先来转发响应
转发上游服务器的响应到下游客户端,必然由上游事件来驱动,下游网速优先实际上意味着需要开辟一块固定长度的内存作为缓冲区。
12.5.1 转发响应的包头
在ngx_http_upstream_send_response方法中完成的,处理流程如下
通过调用ngx_http_send_header方法向客户端发送HTTP包头,会调用header过滤链表,走一下所有模块:
1 | // 发送头,调用ngx_http_top_header_filter |
在方法中会判断配置的buffering标志,若为0,表示以下游网速优先,如果为1,则会以上游网速优先,因为上游网速一般比下游网速快很多,所有需要更大的缓冲区保存,如果达到上限,以磁盘文件的形式来缓存来不及向下游转发的响应。
12.5.2 转发响应包体
如果buffering为0,则后边转发响应包体将会由ngx_http_upstream_process_non_buffered_upstream方法处理连接上的都事件。
无论是接收上游服务器的响应, 还是向下游客户端发送响应, 最终调用的方法都是ngx_http_upstream_process_non_buffered_request, 唯一的区别是该方法的第2个参数不同, 当需要读取上游的响应时传递的是0, 当需要向下游发送响应时传递的是1。
ngx_http_upstream_process_non_buffered_request的流程图:
ngx_http_upstream_process_non_buffered_request方法中调用ngx_http_output_filter方法,走过整个body过滤链表:
1 | // 发送响应体,调用ngx_http_top_body_filter |
12.6 以上游网速优先来转发响应
如果将ngx_http_upstream_conf_t配置结构体的buffering标志位设置为1, 那么ngx_event_pipe_t结构体必须要由HTTP模块创建。
ngx_event_pipe_t结构体维护着上下游间转发的响应包体, 它相当复杂。 例如, 缓冲区链表ngx_chain_t类型的成员就定义了6个(包括free_raw_bufs、 in、 out、 free、 busy、 preread_bufs) , 为什么要用如此复杂的数据结构支撑看似简单的转发过程呢? 这是因为Nginx的宗旨就是高效率, 所以它绝不会把相同内容复制到两块内存中, 而同一块内存如果既要用于接收上游发来的响应, 又要准备向下游发送, 很可能还要准备写入临时文件中, 这就带来了很高的复杂度, ngx_event_pipe_t结构体的任务就在于解决这个问题。
- 转发响应包头
转发响应包头还是通过调用ngx_http_upstream_send_response方法,
1 | u->read_event_handler = ngx_http_upstream_process_upstream; |
方法中设置处理上游读事件回调方法为ngx_http_upstream_process_upstream。设置处理下游写事件的回调方法为ngx_http_upstream_process_downstream。
- 转发响应包头
处理上游读事件的方法是ngx_http_upstream_process_upstream, 处理下游写事件的方法是ngx_http_upstream_process_downstream, 但它们最终都是通过ngx_event_pipe方法实现缓存转发响应功能的。
12.7 结束upstream请求
结束upstream请求调用ngx_http_upstream_finalize_request方法完成
1 | // 结束请求,这时会调用finalize_request回调函数 |
14 进程间的通信机制
14.1 概述
Nginx框架使用了3种消息传递方式: 共享内存、 套接字、 信号。
14.2 共享内存
14.2.1 共享内存创建和销毁
Linux提供了mmap和shmget系统调用在内存中创建一块连续的线性地址空间,通过munmap或shmdt系统调用释放这块内存。
nginx定义了ngx_shm_t结构体描述共享内存。
1 | // 真正操作共享内存的对象 |
nginx中使用如下方式创建和释放共享内存:
1 | // 创建一块共享内存 |
以上是使用mmap创建,关于mmap的函数原型如下:
1 | void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset); |
mmap可以将磁盘文件映射到内存中, 直接操作内存时Linux内核将负责同步内存和磁盘文件中的数据, fd参数就指向需要同步的磁盘文件, 而offset则代表从文件的这个偏移量处开始共享, 当然Nginx没有使用这一特性。 当flags参数中加入MAP_ANON或者MAP_ANONYMOUS参数时表示不使用文件映射方式, 这时fd和offset参数就没有意义, 也不需要传递了, 此时的mmap方法和ngx_shm_alloc的功能几乎完全相同。length参数就是将要在内存中开辟的线性地址空间大小, 而prot参数则是操作这段共享内存的方式(如只读或者可读可写) , start参数说明希望的共享内存起始映射地址, 当然, 通常都会把start设为NULL空指针。
同样创建共享内存nginx中也提供了使用shmget:
1 | ngx_int_t |
14.2.2 共享内存使用实战–监控
ngx_http_stub_status_module模块对连接的状态监控就用到了共享内存,因为连接的状况展示的是多个worker进程的统计情况。
模块中使用共享内存保存各种统计指标,在统计过程中使用原子操作对统计指标进行修改。
1 | // 在ngx_init_cycle里调用,fork子进程之前 |
14.3 原子操作
14.3.1 原子操作方法
原子操作在不同的架构下实现方式不同,下边是nginx在x86架构下使用嵌入汇编实现。
1 | static ngx_inline ngx_atomic_uint_t |
1 | //比较lock和old的值,如果相等,则把lock设置为set |
14.3.2 自旋锁
nginx中基于原子操作实现了spinlock自旋锁,自旋锁不会导致进程睡眠,当发现锁已经被其他进程获得时,则始终保持进程在可执行状态,每当内核调度到这个进程执行时就持续检查是否可以获取到锁。
1 | // 自旋锁,尽量不让出cpu抢锁 |
14.4 Nginx频道
ngx_channel_t频道是Nginx master进程与worker进程之间通信的常用工具, 它是使用本机套接字实现的。 下面先来看看socketpair方法, 它用于创建父子进程间使用的套接字。
1 | int socketpair(int d, int type, int protocol, int sv[2]); |
这个方法可以创建一对关联的套接字sv[2]。 下面依次介绍它的4个参数: 参数d表示域, 在Linux下通常取值为AF_UNIX; type取值为SOCK_STREAM或者SOCK_DGRAM, 它表示在套接字上使用的是TCP还是UDP; protocol必须传递0; sv[2]是一个含有两个元素的整型数组,实际上就是两个套接字。 当socketpair返回0时, sv[2]这两个套接字创建成功, 否则socketpair返回–1表示失败。
nginx中的ngx_channel结构如下:
1 | typedef struct { |
在nginx中的master进程中创建worker进程前执行创建channel
1 | ngx_pid_t |
nginx中目前只是master向worker进程发送,worker进程接收,其实socketpair是双向通信,但目前nginx没有worker向master进程发送的。
操作channel的函数
1 | ngx_int_t ngx_write_channel(ngx_socket_t s, ngx_channel_t *ch, size_t size, |
nginx的master进程通过channel向worker进程发送退出、重新打开进程已经打开过的文件等信号,如果使用channel发送失败,则master进程会提供kill系统调用发送。
14.5 信号
nginx接收信号,执行不同指令,如接收到SIGUSR1信号就意味着需要重新打开文件。
定义信号的结构体如下:
1 | // 标记unix信号,handler=ngx_signal_handler |
Nginx定义了一个数组,用来定义进程将会处理的所有信号:
1 | // 命令行-s参数关联数组 |
以上的所有信号在ngx_init_signals方法中初始化:
1 | // 初始化signals数组 |
14.6 信号量
信号量与信号不同,信号用来传递消息,信号量用来保证两个或多个代码段不被并发访问,是一种共享资源有序访问的工具。
nginx中创建和销毁信号量的函数:
1 | // 初始化互斥锁 |
信号量是如何实现互斥锁功能的呢? 例如, 最初的信号量sem值为0, 调用sem_post方法将会把sem值加1, 这个操作不会有任何阻塞; 调用sem_wait方法将会把信号量sem的值减1, 如果sem值已经小于或等于0了, 则阻塞住当前进程(进程会进入睡眠状态) , 直到其他进程将信号量sem的值改变为正数后, 这时才能继续通过将sem减1而使得当前进程继续向下执行。 因此, sem_post方法可以实现解锁的功能, 而sem_wait方法可以实现加锁的功能。
在ngx_shmtx_lock中可能用到信号量中sem_wait试图获取锁。
14.7 文件锁
Linux内核提供了基于文件的互斥锁, 而Nginx框架封装了3个方法, 提供给Nginx模块使用文件互斥锁来保护共享数据。 下面首先介绍一下这种基于文件的互斥锁是如何使用的, 其实很简单, 通过fcntl方法就可以实现。
1 | int fcntl(int fd, int cmd, struct flock *lock); |
其中参数fd是打开的文件句柄, 参数cmd表示执行的锁操作, 参数lock描述了这个锁的信息。
参数fd必须是已经成功打开的文件句柄。 实际上, nginx.conf文件中的lock_file配置项指定的文件路径, 就是用于文件互斥锁的, 这个文件被打开后得到的句柄, 将会作为fd参数传递给fcntl方法, 提供一种锁机制。
这里的cmd参数在Nginx中只会有两个值: F_SETLK和F_SETLKW, 它们都表示试图获得互斥锁, 但使用F_SETLK时如果互斥锁已经被其他进程占用, fcntl方法不会等待其他进程释放锁且自己拿到锁后才返回, 而是立即返回获取互斥锁失败; 使用F_SETLKW时则不同, 锁被占用后fcntl方法会一直等待, 在其他进程没有释放锁时, 当前进程就会阻塞在fcntl方法中, 这种阻塞会导致当前进程由可执行状态转为睡眠状态。
关于文件锁的实现函数:
1 | ngx_err_t ngx_trylock_fd(ngx_fd_t fd); |
在使用文件锁是要注意是否会导致进程睡眠,根据实际情况抉择。
14.8 互斥锁
基于原子操作、信号量、文件锁,nginx在更高层次封装了一个互斥锁,许多Nginx模块也是更多直接使用它。操作方法如下:
互斥锁接口的内部实现中使用了原子操作、信号量和文件锁,可以通过参数控制使用什么逻辑。
以上接口都是通过操作ngx_shmtx_t类型的结构体来实现互斥操作:
1 | // ngx_shmtx_sh_t |
在函数中Nginx也会判断当前环境是否支持原子操作,信号量、文件锁等。然后执行对应的分支。
14.9 总结
Nginx是一个能够并发处理几十万甚至几百万个TCP连接的高性能服务器, 因此, 在进行进程间通信时, 必须充分考虑到不能过分影响正常请求的处理。 例如, 使用14.4节介绍的套接字通信时, 套接字都被设为了无阻塞模式, 防止执行时阻塞了进程导致其他请求得不到处理, 又如, Nginx封装的锁都不会直接使用信号量, 因为一旦获取信号量互斥锁失败, 进程就会进入睡眠状态, 这会导致其他请求“饿死”。
当用户开发复杂的Nginx模块时, 可能会涉及不同的worker进程间通信, 这时可以从本章介绍的进程间通信方式上进行选择, 从使用上说, ngx_shmtx_t互斥锁和共享内存应当是第三方Nginx模块最常用的进程间通信方式了, ngx_shmtx_t互斥锁在实现中充分考虑了是否引发睡眠的问题, 用户在使用时需要明确地判断出是否会引发进程睡眠。 当然, 如果不使用Nginx封装过的进程间通信方式, 则需要注意跨平台,以及是否会阻塞进程的运行等问题。
16 slab共享内存
在Nginx中多个Woker进程共享数据,如果是简单的进程间通信,可以使用以上的方式,如果需要共享不同大小的结构对象,如链表、树、图等,可以通过一段共享内存进行共享,为了高效的管理共享内存,Nginx使用了slab内存管理机制。
16.1 操作slab的方法
slab中只有下边5个接口:
1 | // 初始化新创建的共享内存 |
16.2 使用slab共享内存示例(未看)
16.3 slab内存管理实现原理
slab中把整块内存按4KB分整许多页,每一页只存固定大小的内存块,由于一页上能够分配的内存块数量是有限的,可以在页首用bitmap方式,按二进制位表示页对应位置的内存块是否在使用中。只是遍历bitmap二进制位去寻找页上的空闲内存块, 使得消耗的时间很有限, 例如bitmap占用的内存空间小导致CPU缓存命中率高, 可以按32或64位这样的总线长度去寻找空闲位以减少访问次数等。
关于对页的管理,分为空闲页、半满页和全满页,不同的页通过链表维护。
slab会把一页分成不同的内存块大小,内存块分为8,16,32,64.。。。字节。当申请的字节数大于8小于等于16时, 就会使用16字节的内存块, 以此类推。
按照不同页中含有的内存块大小分类,然后包含相同内存块大小的页组成页链表,并且页的首部放在slots数组中,slots数组也是按序排列,比如开始元素存放的地址是8字节内存块所属页的链表,依次递增。
上图包含了空闲页、半满页、全满页的链表和分别存在两个slot中。
slab中使用ngx_slab_pool_t结构管理共享内存,在常见slab后,ngx_slab_pool_t结构存储在共享内存开始位置,并且通过初始化结构中的属性管理后边的内存。结构如下:
1 | // ngx_slab_pool_t |
下边是一个页的结构ngx_slab_page_t
1 | // ngx_slab_page_t |
如果页链表中有多个连续页空闲,则可以进行合并,合并后页的数量计入slab中,然后修改页的next指针,指向后边的页。
上边有5个页,其中有连续的页,如上边slab=2,全满页会脱离链表,所以next和prev指针为0。
ngx_slab_max_size指定了最大内存块的大小。
1 | // 最大slab,是page的一半 |
分配内存流程:
通过slots数组管理包含相同类型内存块大小的页面,slots数组有序,通过线性偏移,则可以直接找到需要的内存块大小所属页面链表地址,然后在页链表中找能满足的页,如果分配的内存大于了ngx_slab_max_size,则直接分配空闲页,如果小于则看看有没有半满页能满足,在页内部包含多个内存块,通过bitmap管理,标识内存块是否可用。如果bitmap全部可用,则表示当前页为全满页,则加入全满页链表。