NGINX共享内存实现原理及源码分析
930 次浏览
发表于 2020-12-09 15:28

概述

NGINX是一个多进程的架构模型,其中类似流量统计,流量控制,负载均衡等诸多功能需要在不同的worker进程之间共享数据,相互配合完成任务。这些功能都是通过共享内存的帮助来得以实现的。

NGINX共享内存的实现依赖于slab内存分配机制。为了更高效地使用内存空间,slab内存分配机制采用了经典的时间换空间的思想。而且在增加的计算时间中,绝大部分都采用了位移操作,尽可能地在提高内存使用率的同时节省CPU时间。

本文试着去从源代码层面去分析NGINX共享内存的分配使用的slab机制是如何实现的。

共享内存原理

共享内存是众多进程间通信方式之一,通过把相同的一块物理内存映射到不同进程的虚拟地址空间,这样不同的进程之间可以相互看到某一个进程对内存的修改。采用这种方式,进程可以直接读写内存,不需要额外的数据copy,所以它是最快的进程间通信方式。

由于多个进程同享同一块内存区域,所以,需要某种同步机制比如NGINX使用的互斥锁进行同步。

Linux内核支持多种共享内存方式,如基于IO映射的mmap()系统调用,Posix标准系列API,以及系统Vshm系列API方式。

基于IO映射的mmap()系统调用,在调用mmap()时,会把文件内容映射到进程自身的一段虚拟内存空间中,更详细一点是处于进程的栈和堆之间的一片连续的虚拟地址空间。通过对这段区间内存的读写来实现对文件的访问。对这段内存的读写不需要采用系统调用如read,write来操作,只需要像操作内存一样就可以。

函数mmap的接口定义如下:

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

addr映射到进程空间的虚拟地址的起始地址。当被设置为NULL时,系统自动在进程地址空间选择一块合适的内存区域。

length指定内存段的长度。

prot是用来指定内存段的读,写,访问和执行权限。

flags参数控制内存段内容被修改以后程序的行为。NGINXmmap共享内存实现方式使用了MAP_SHAREDMAP_ANNOYMOUS两个flags。其中MAP_SHARED指明这块内存是各个进程之间共享的。MAP_ANNOYMOUS表明这片内存不是从文件中映射的,而且内存全部被初始化为0

NGINX在使用共享内存时,在Master进程中通过mmap函数使用MAP_SHAREDMAP_ANNOYMOUS两个flags来创建一片内存区域,然后再通过fork生成子进程后,子进程就自动继承这一片内存地址空间。这样就可以保证共享内存对应的虚拟地址在各个进程中也是一致的。

共享内存实现的原理涉及到很多Linux kernel关于地址空间,内存管理,缺页中断处理等等很多深入的知识,我们在此只做简单的介绍

NGINX中共享内存接口

NGINX提供了一系列的共享内存使用接口。我们在开发新的模块时,使用现有的接口可以很容易地实现和使用共享内存提供的便利。

我们以ngx_http_limit_conn_module模块为例来分析NGINX提供的这一系列共享内存的接口是如何使用的。这一模块是用来限制某一IP的并发连接数,防止恶意的流量攻击。它通过limit_conn_zone指令定义了一片共享内存区域用来存储会话状态,当前连接数等等信息以供多个worker进程共享使用。

此模块共享内存使用的流程如下:

a.     NGINX启动时,Master进程开始解析所有的配置指令。在解析到指令limit_conn_zone时,函数ngx_http_limit_conn_zone被调用。

b.    函数ngx_http_limit_conn_zone解析配置中的namesize参数,并且生成一个ngx_shm_zone_t结构。然后调用函数ngx_shared_memory_add把这共享内存的创建申请加入到全局数据结构ngx_cycle_t中的shared_memory链表中。

c.     函数ngx_shared_memory_add通过ngx_shm_zone_t结构中的name,sizetag属性判断是否有重复的结构已经存在,或者相同名字和size的结构已经被用等有效性检查。如果找到原来已经定义对的相同的结构,则直接返回。否则把生成的结构挂到全局结构ctx中的shared_memory列表成员中。至此为止的动作只是把共享内存申请的需求加入到shared_memory列表中,并没有真正的进行共享内存的分配。

d.    Master进程在NGINX解析完配置文件以后,调用函数ngx_init_cycle进行初始化。函数ngx_init_cycle会对所有注册的共享内存调用ngx_shm_alloc来申请共享内存空间。函数ngx_shm_alloc根据系统配置调用mmap或者shmget来申请共享内存虚拟地址空间。

e.     生成共享内存虚拟地址空间以后,调用函数ngx_init_zone_pool来申请和初始化共享内存空间。这个初始化函数是把申请到的内存空间初始化为ngx_slab_pool_t结构。然后分别调用ngx_shmtx_createngx_slab_init来初始化使用此共享内存的互斥锁以及初始化生成内存 slab结构。通过这个结构可以方便地管理和使用分配到的内存。

f.      最后调用ngx_shm_zone_t结构中的init函数指针,来初始化各个模块需要的数据结构。我们举例的ngx_http_limit_conn_module模块对应的初始化函数就是ngx_http_limit_conn_init_zone

以上操作只发生在master进程中。当master进程初始化完毕,调用fork生成子进程,所有的子进程就会自动继承父进程通过mmap生成的虚拟地址空间,从各个worker子进程之间可以共享上述生成的内存。

NGINX slab内存分配实现原理

上述我们分析了NGINX提供的共享内存使用接口,在初始化阶段会调用ngx_slab_init函数把共享内存区域初始化为slab结构。NGINX最终依赖这个slab结构来提供共享内存申请和释放。

NGINXslab内存分配,提供了基于页(page和基于块(CHUNK的内存分配基于页(page)的内存分配,由page页内存管理单元来进行管理,其实现相对简单,因为整个内存池的基本结构就是以页为单位进行划分的。基于块(CHUNK)的内存分配,将一页划分为若干块,实现相对复杂,除了page页内存管理单元外还引入了分级内存管理单元(slot数组)来共同管理。

在基于CHUNK的内存分配中,NGINX按照分配内存块size的大小又把内存分配分成了NGX_SLAB_SMALLNGX_SLAB_EXACTNGX_SLAB_BIG三种。为了提高内存使用率,slab分配器需要借助bitmap来标记一页中各个内存块的使用情况 ,如果想用一个uintptr_t类型的值来完全表示一页中的所有块,那么一页能划分的块数为:8 * sizeof(uintptr_t),每块的大小就等于pagesize / (8 * sizeof(uintptr_t)),在64位环境下,就是 4096 / 64 = 64,这个值就是上面的ngx_slab_exact_size。而ngx_slab_exact_shift就是ngx_slab_exact_size2个多少次方。在上述例子中,ngx_slab_exact_shift6。这种大小的内存块就是NGX_SLAB_EXACT类型。

当划分的块大于exact_size时就是所谓的NGX_SLAB_BIG类型,此种类型的CHUNK,最多使用slab变量一半的bit数目就可以完全表示一个页中所有块的分配情况。这样就可以把slab这个uintptr_t类型值分为两部分,其中高一半的bit位用做bitmap,标记块的分配情况。而低一半的bit位则用于记录对应的shift移位数,也就是对应blocksize

当划分的块小于exact_size时就是所谓的NGX_SLAB_SMALL类型,ngx_slab_page_t中的slab成员的的bit数不足以表示整个Page中的block的数量,所以,在这种情况下,需要在page开始的头部根据CHUNK的大小生成几个uintptr_t类型的数组,用其中的每一个bit来表示CHUNK的使用情况。而结构ngx_slab_page_t中的slab成员用来表示对应page中每一个blocksize大小

内存布局

master进程启动完成以后,通过ngx_slab_init函数来初始化后共享内存区域的整个空间的布局如下:

a.     最开始地方的头部被初始化为ngx_slab_pool_t结构。此结构里存放整个slab里面的管理信息。包含SLAB管理的汇总信息,如最小分配单元(min_size)、最小分配单元对应的位移(min_shift)、页管理数组地址(pages)、空闲页链表(free)、可分配空间的起始地址(start)、内存块结束地址(end)等等信息。在内存的管理过程中,内存的分配、回收、定位等等操作都依赖于这些数据。其中min_shift在最新1.19.4版本中被固定设置为3对应的min_size也就是3<<2=8

空闲页链表free是所有可用page的链表头,在基于page的分配算法中,就是通过free查找当前所有可以使用pages

b.    SLOT数组负责管理基于CHUNK固定大小的内存块的分配和回收。每一个数组成员也是ngx_slab_page_t结构。根据系统的不同情况,比如支持的最小CHUNK 大小,系统中每一个page的大小等因素,SLOT数组可以有不同的大小。按照常用的系统配置,比如支持的最小分配CHUNK大小是8,系统的page 大小4K, 那么SLOT数组就可以有9个成员。SLOT[0]~SLOT[8]分别负责区间在[1~8][9~16][17~32][33~64][65~128][129~256][257~512][513~1024]、[1025-2048]字节大小内存的分配。举个例子:假如应用进程请求申请5个字节的空间,因5处在[1~8]的区间内,因此由SLOT[0]负责该内存的分配,但区间[1~8]的上限为8,因此即使申请5个字节,却依然分配8字节给应用进程。

c.     Stats数组用来存放各种统计信息。

d.    Pages数组中每一个成员存放的是可的每一个page的管理信息。每一个元素的结构如下所示ngx_slab_page_t。数组中的每一个page信息和pages区域中的某一个page对应。

e.     我们可以看到,SLOT数组和Pages数组中的元素都是ngx_slab_page_t结构。

这个结构中slab成员是较为复杂的一个字段,其中的slab字段在不同的场景下表示不同的含义:

1.     在基于页的内存管理流程中,它可以表示连续的空闲页数目。

2.     在基于块的内存管理流程中:

1)当CHUNK_size == exact_size时,slab字段用作bitmap,表示一页中各个内存块的使用情况。

2)当CHUNK_size < exact_size时,slab字段用于存储与块大小相对应的移位数(CHUNK_shift)。

3)当CHUNK_size > exact_size时,slab字段的高半部分的bits用作bitmap,而低半部分bits则用于存储与块大小对应的移位数。

prev:通常是组合使用在存储前一个位置及标记page的类型。

f.      Pages是整个共享内存区域可以使用的内存空间,按照每一个页面4K(或者根据系统设置为别的数值)大小进行分割。

每一个page,如果全部被使用,则它不属于任何的list (slot或者free)。

如果有空闲的CHUNK可以使用,则它属于某一个slotlist。

如果是完全空闲,则它属于free链表。

g.    橙黄色区域是内存对齐需要的额外开销。pages中每一个大小为4Kpage的开始地址需要从4K地址开始。也就是每一个page的起始地址后12位必须是0。这样在pages第一个页面开始和最后一个页面结束的地方就有一些多余的内存空间被浪费。

但是,这样可以方便进行计算,减少内存管理的开销,达到时间(通过计算)换空间的效果。比如我们在释放一个page某一个slab地址时,通过把slab的地址和4K往下对齐,就很容易得到slab所在page的地址。然后根据这个page的地址,和pages的开始地址,很容易得到这个pagepages中的位置。从而很容易得到对应page的管理结构ngx_slab_page_tpages数组中的位置。

上述连续内存区域在调用ngx_slab_init函数初始化以后,各个数据结构之间的相互关系如下图所示:

a.  ngx_slab_pool_t结构中的free成员的next指针指向了pages数组的第一项。而且pages数组第一项中的slab被赋值为N,表示所有的Npages都是空白可用的。

b.  ngx_slab_pool_t结构中的startend分别指向了可用page的第一个page的起始地址和最后一个page的结束地址。

c.  slot数组中的所有的成员的next指针指向了成员本身。这个表示如果要从这个slot中申请特定大小的CHUNK,需要先申请page然后对page进行切分在从切分的page中申请某一个CHUNK

初始化完成以后,我们就可以通过NGINX提供的slab申请释放接口来使用slab中的memory

内存申请释放原理

申请接口:

void *ngx_slab_alloc(ngx_slab_pool_t *pool, size_t size);

void *ngx_slab_alloc_locked(ngx_slab_pool_t *pool, size_t size);

void *ngx_slab_calloc(ngx_slab_pool_t *pool, size_t size);

void *ngx_slab_calloc_locked(ngx_slab_pool_t *pool, size_t size);

释放接口:

void ngx_slab_free(ngx_slab_pool_t *pool, void *p);

void ngx_slab_free_locked(ngx_slab_pool_t *pool, void *p);

alloccalloc的区别在于calloc会主动把申请到的内存清零。带_locked的函数表示同步锁操作已经在函数外完成了,在函数中不需要额外再加锁。有关内存分配本身的主要逻辑都存在于函数ngx_slab_alloc_lockedngx_slab_free_locked中。

下面我们通过ngx_slab_alloc_lockedngx_slab_free_locked函数来分析slab的内存申请和释放流程。

内存申请

在函数ngx_slab_alloc_locked中,根据申请CHUNK的大小是否超过page大小的一半分别采用页面或者采用固定CHUNK大小方式进行分配。

页面分配方式是通过函数ngx_slab_alloc_pages进行的,其逻辑是:

1.     通过page_slab_pool_t结构中的free链表中查找一个对应结构中的slab数量大于要申请页数的节点。按照上述分析我们知道,当初始化完毕以后,freenext指向了pages数组中的第一个元素pages[0],而且pages[0]中的slab被初始化为slab所有可用的页面数量。

2.     如果free链表中所有的元素的slab对应的数量都小于要申请page的数目,则申请失败。

3.     找到一个slab数量大于要申请page数量的节点以后,把申请的pages从空闲列表中移除。而且,如果对应节点还有page剩余,则把剩余的page节点再挂接到free空闲列表中去。

4.     修改申请得到的pages的标志。申请得到的第一个pageslab成员变量设置为申请的page的数量和NGX_SLAB_PAGE_START标志的或值。其next成员设置为NULL,并且prev成员设置为NGX_SLAB_PAGE。如果申请的page数量大于1,则对于除第一个page以外的所有的page的成员做如下初始化。成员slab设置为NGX_SLAB_PAGE_BUSYnext设置为NULL, prev成员设置为NGX_SLAB_PAGE。所做的这些标志都是为了释放时可以正确释放。

我们可以通过下图理解slab初始化以后一共有Npages,然后再申请mpages以后整个相关数据结构的变化:


基于CHUNK小块内存分配逻辑:

1.     根据申请内存块的size找到对应的size的级数shift以及对应slot数组级数下标i

2.     根据slot的级数i找到分级slot数组元素的链表头,也就是slot[i]next指针。如果此指针指向slot[i]本身,表明需要重新申请一个完整page重新进行分割CHUNK进行分配。

3.     如果对应的next指针不指向slot[i]本身,则从slot[i]next指针指向的page进行分配。首先根据根据1中计算出的shift得到pageCHUNK的类型是NGX_SLAB_SMALLNGX_SLAB_EXACTNGX_SLAB_BIG中的哪一个。这三种类型CHUNK内存布局如下图所示:

选择对应类型的page中的bitmap中为0slot,然后计算返回分配的CHUNK的地址。根据slot在所有bitmap中的位置和此page的起始地址返回分配的CHUNK的地址。

4.     如果分配完当前的CHUNK后,本页所有的CHUNK都已经被分配使用,则把本page从对应的slot分配链表中删除。

整个内存分配的流程图如下:

我们可以看到三种CHUNK分配的内存page中,只有第一种page中有额外的管理开销用来记录pageCHUNK的状态。剩下两种page中所有的空间都可以被利用。

而且,由于采用了内存对齐等机制,NGINX slab中的内存分配可以很好地实现“自我管理”,在给出block地址的情况下能够方便快捷地正确推算出相关管理结构的位置和内容,而无需额外的存储结构。这是很好的设计其中包含了经典的时间换空间的思想。

内存释放

函数ngx_slab_free_locked用来释放申请得到的内存块。主要逻辑是:

1.     判断异常情况,得到释放空间位于的pageslot分级管理结构的位置及page的位置。

2.     根据1中得到的page的类型,如果是NGX_SLAB_PAGE类型则调用ngx_slab_free_pages对内存空间进行释放。

3.     如果不是NGX_SLAB_PAGE类型则进行如下步骤。

4.     计算出CHUNK的大小,以及要释放空间相对于page中的CHUNK的偏移。

5.     计算出对应CHUNKbitmap中的位置。(不同类型的page bitmap位置也不一样)

6.     对此CHUNK对应的bitmapbit进行有效性检查。如果已经释放直接返回。

7.     释放此CHUNK然后把对应的page重新连接到分级数组的链表头部,这样下一次有申请可以直接使用。

8.     如果对应page所有的CHUNK都已经被释放,则调用ngx_slab_free_pagespage进行回收,把它连接到poolfree链表的头部。函数ngx_slab_free_pages在释放page时,会尽量去合并后面连续的pages,形成尽量大的内存空间

结语

本来想学习NGINX中是如何实现并发和速率控制的,但是在学习的过程中却发现共享内存是现实这一功能的重要基础。于是就转而先学习一下NGINX对共享内存的使用。在此过程中越发感觉到NGINX设计的精妙之处,尤其是这个基于共享内存的SLAB管理机制,作为整个NGINX的一个重要部件也被设计的非常精巧。


发表评论
发表者

皮皮鲁

暂无个人介绍

  • 27

    文章

  • 19

    关注

  • 22

    粉丝

活动推荐
版权所有©F5 Networks,Inc.保留所有权利。京ICP备16013763号-5