在 nginx 模块开发中,充分使用了 nginx 的共享内存, 以下为 nginx 共享内存机制的总结。
nginx 共享内存结构
nginx 共享内存采用 slab 形式分配,类似 Jeff Bonwick 的经典论文[1],由一个 ngx_shm_zone_t 管理。这个结构主要用于 nginx 启动时初始化过程的上下文传递。其中:
- data – 任意指针,创建成功回调方法的上下文
- shm – ngx_shm_t 对象,这个才是真正保存指向共享内存区指针的对象
- init – 初始化回调方法,在 nginx 主循环中创建好共享区之后,会调用这个方法
- tag – 任意指针,一般是指向创建的模块
- noreuse – 在重新载入时(reload,配置重载或者二进制包替换)是否禁止复用,默认是允许复用。在内置tcp/http 的 upstream zone 中为了让配置起效,会设置成禁止复用。
nginx 这么设计会因为它将配置读取和资源分配相分离,这样也能防止不同配置之间共享区配置冲突。我们重点关注下 ngx_shm_t 类型。
- ngx_shm_t 分成以下几个部分:
- addr – 分配共享区的首地址,也就是 mmap 的返回值
- size – 共享区大小
- name – 共享区名称
- log – 日志对象,用于共享内存的日志,一般就是 cycle 的日志
ngx_shm_t 的分配非常简单,当前系统是否支持匿名内存(MAP_ANONYMOUS),如果不支持且有 /dev/zero 设备,就会用 /dev/zero 来创建,否则看看是否支持System V shared memory。匿名内存:
shm->addr = (u_char*) mmap(NULL, shm->size, PROT_READ|PROT_WRITE, MAP_ANON|MAP_SHARED, -1, 0);
如果是 /dev/zero 方式:
fd = open("/dev/zero")
shm->addr = (u_char*) mmap(NULL, shm->size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
close(fd);
如果是 System V shared memory 的方式:
id = shmget(IPC_PRIVATE, shm->size, (SHM_R|SHM_W|IPC_CREAT));
shm->addr = shmat(id, NULL, 0);
shmctl(id, IPC_RMID, NULL); /* 所有进程解绑后自动销毁 */
接下来我们就需要聚焦在 addr 这个上面了。
为了方便内存管理和提高分配效率, nginx 在向系统申请到指定的内存之后,就在内存最开始的地方划分一块区域作为 meta 数据区,即 ngx_slab_pool_t。以下为 ngx_slab_pool_t 的结构:
- lock – 非指针,保存 mutex 互斥锁的数据
- min_size – 1 << min_shift,最小的大小
- min_shift – 3,最小大小的偏移
- pages – ngx_slab_page_t*, 指向页结构元数据
- last – 最后一个页
- free – 可用页,下连着可用页的链表
- start – 共享内存数据区开始地址(实际数据分配位置)
- end – 共享内存结束地址 + 1
- mutex – 用来控制内存分配竞态的互斥量,实际上指向的是lock里的数据,或者文件锁实现的话fd保存文件描述符
- log_ctx – 日志上下文,指向 &zero
- zero – 终止符
- log_nomem – flag, 是否打印无法分配内存信息
- data – 用户数据,用来传递与共享内存相关的上下文
- addr – 共享内存开始地址(就是 shm->addr)
共享内存初始化
对共享内存分页主要有以下两个步骤:
- 初始化互斥锁
现在 nginx 对 IP 的互斥锁实现由两种,首先是如果当前编译器/系统/库支持原子操作,那么就使用相应的实现实现位于共享内存里的互斥锁(即对共享区内的数据块执行 cas,并判断是否支持信号量,支持的话就wait,否则自己 cpu_pause 下不行就调度出去,下一次进来重复上述过程)
否则利用文件锁来实现 IP 互斥锁(用配置文件中的 lock_file + 共享区名称创建所需的文件)(相应的 API 为 ngx_*lock_fd)。
- 页表构建
在初始化好锁之后,就可以创建页表了。 nginx 的页表结构比较简单,是一个简单的链表:
- slab – slab 标记,不同类型页有不同的作用
- next – 下一个页
- prev – 前一个页
在实际中,页表是按大小进行组织,小需求(< 0.5pagesize)由前若干个 page(又称为 slot ) 管理,大需求都是连续整页地分配,由第二部分的page 分配。基本如下:
在实际内存中,格式化结果如下:
1 级是page,大小等于系统的 pagesize:
pages = size / (ngx_pagesize + sizeof(ngx_slab_page_t))
如果该页由slot 管理,那么 page 中又分成若干个 chunk, chunk 的大小在不同的slot是不一样的,最小的大小为 min_size 为 8,最大的chunk 为 1024。不同chunk大小的bitmap 表示方法不一样:
在初始化时,系统预先计算了一个指针长度能表示整个page 时,chunk刚刚好的大小应该是多大(即ngx_slab_exact_size)。比如在 32 位系统为 128字节, 而 64 位为 64 字节。
- 当 chunk 小于 ngx_slab_exact_size时,此时一个指针长度的bitmap无法表示所有chunk状态,所以需要 m 个指针,此时,每个page 前 m 个 uintptr_t 大小的区域都是作为 bitmap 使用,剩余的部分才作为可使用的内存区域, 此时用 slab 来表示每个 chunk 的大小(低四位);
- 当 chunk 刚刚好是 ngx_slab_exact_size 时,这个时候就用 页头的 slab 作为 bitmap 使用;
- 当 chunk 大于 ngx_slab_exact_size 时,这时候,至多使用半个指针就能表示 bitmap 了, slab 就被分成两部分,高16/32位表示 bitmap, 低4位表示 log(sizeof(chunk)) (也就是说,chunk 大小不能大于 64K,64位系统的页大小不能超过4M)。
1 | /* 获取大小 */ |
如下图:
接下来,就利用 mask 查找可用的块,要注意的是,如果当前chunk 分配完了刚好整个页都被用光了,那么需要将这个页从可用页中删除掉(就是从 slot 上解除掉,会不会像个孤页?不会,因为根据被free的地址就能反推得到这个页头,当这个页有chunk被释放时,会被挂回来)。
当大小需要超过 pagesize/2 的时候, 这时候就跨过 slots, 直接从页头区查找合适的连续空间,以page为单位。这时候大小的信息保存在 slab 中。此时 slab 的最高位表示为连续页的开始,其余部分表示为连续页的个数。如果存在连续页,后续页头的的slab所有位均为1。
另外又利用prev 指针的低2位来表示该页属于那种类型大小。
即将可分配内存分成N页,每一页的大小就是系统页大小,然后每一页都有一个ngx_slab_page_t 类型的 meta 数据描述:
|<--------------------------------size----------------------------------->|(end)
pool_t| (log(pagesize) - log(min_size)) * page| pages * page| pages * pagesize |
|<---------------index0---------------->|<-free/pages |<-start last->|.end
刚开始 free->next = pages, free->prev = NULL; pages->next = pages->prev = free;
共享内存分配与回收
页的分配:
ngx_slab_alloc_pages 负责分配页表,参数为 pages,即需要多少个页。
free 的链表保存着可分配的页,每个free 中的 page.slab 表示的是后续连续 page.slab 页都是可用的。这也相当于把链表压缩了下。
循环查找 free 链表,找到最近可用页,将对应的(连续)页取出,返回给 ngx_slab_alloc。
页的回收:
相应的, ngx_slab_free_pages 负责页的回收,在1.7.2 之前的版本有个问题,就是当程序跑足够久之后,会产生内存碎片,也就是说连续的页会越来越少,直到最后无法再分配大内存。解决办法是直接在回收页的时候合并相邻空闲页就可以了,上代码:
1 | page->slab = pages--; |
内存分配:
根据上面的分析,内存分配就比较简单了,首先判断大小,根据大小选择不同的slot or 直接分配:
- 大于页的一半,直接进入页的分配,查找满足需求的最大连续页;
- 小于页的一半,查找对应的slot,看看slot中有没有可用的页和chunk,没有的话分配一个页,然后查找chunk,并将该页插到slot的链表前头,以方便下一次分配快速查找;当下一次chunk分配不出来了,就把页从slot链表中移除,减少空闲搜索的空间;为了充分利用空间,nginx 恨不得把每个字段掰开来用,所以这种情况又根据bitmap的大小分成三种情况,如上文所述。
内存回收:
内存回收同样在两个大同分支中进行,大内存直接把页挂回free 链表中,并且为了减少碎片进行空闲页的合并;小内存则先进性chunk 回收,发现整个页也回收完了就回收页,每回收完就把页挂到 slot 链表中。
参考文献: