在 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)

共享内存初始化

对共享内存分页主要有以下两个步骤:

  1. 初始化互斥锁

现在 nginx 对 IP 的互斥锁实现由两种,首先是如果当前编译器/系统/库支持原子操作,那么就使用相应的实现实现位于共享内存里的互斥锁(即对共享区内的数据块执行 cas,并判断是否支持信号量,支持的话就wait,否则自己 cpu_pause 下不行就调度出去,下一次进来重复上述过程)

否则利用文件锁来实现 IP 互斥锁(用配置文件中的 lock_file + 共享区名称创建所需的文件)(相应的 API 为 ngx_*lock_fd)。

  1. 页表构建

在初始化好锁之后,就可以创建页表了。 nginx 的页表结构比较简单,是一个简单的链表:

  • slab – slab 标记,不同类型页有不同的作用
  • next – 下一个页
  • prev – 前一个页

在实际中,页表是按大小进行组织,小需求(< 0.5pagesize)由前若干个 page(又称为 slot ) 管理,大需求都是连续整页地分配,由第二部分的page 分配。基本如下:

Alt pic

在实际内存中,格式化结果如下:
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 字节。

  1. 当 chunk 小于 ngx_slab_exact_size时,此时一个指针长度的bitmap无法表示所有chunk状态,所以需要 m 个指针,此时,每个page 前 m 个 uintptr_t 大小的区域都是作为 bitmap 使用,剩余的部分才作为可使用的内存区域, 此时用 slab 来表示每个 chunk 的大小(低四位);
  2. 当 chunk 刚刚好是 ngx_slab_exact_size 时,这个时候就用 页头的 slab 作为 bitmap 使用;
  3. 当 chunk 大于 ngx_slab_exact_size 时,这时候,至多使用半个指针就能表示 bitmap 了, slab 就被分成两部分,高16/32位表示 bitmap, 低4位表示 log(sizeof(chunk)) (也就是说,chunk 大小不能大于 64K,64位系统的页大小不能超过4M)。
1
2
3
4
5
6
7
/* 获取大小 */
n = ngx_pagesize_shift - (page->slab & NGX_SLAB_SHIFT_MASK);
/* bitmap 中 后N个1 */
n = 1 << n;
n = ((uintptr_t) 1 << n) - 1;
/*移到高位, 此时 mask 就是bitmap中当前选择的位置之前 */
mask = n << NGX_SLAB_MAP_SHIFT;

如下图:

Alt pic

接下来,就利用 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;

Alt pic

共享内存分配与回收

页的分配:

ngx_slab_alloc_pages 负责分配页表,参数为 pages,即需要多少个页。

free 的链表保存着可分配的页,每个free 中的 page.slab 表示的是后续连续 page.slab 页都是可用的。这也相当于把链表压缩了下。

循环查找 free 链表,找到最近可用页,将对应的(连续)页取出,返回给 ngx_slab_alloc。

页的回收:

相应的, ngx_slab_free_pages 负责页的回收,在1.7.2 之前的版本有个问题,就是当程序跑足够久之后,会产生内存碎片,也就是说连续的页会越来越少,直到最后无法再分配大内存。解决办法是直接在回收页的时候合并相邻空闲页就可以了,上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
page->slab = pages--;

if (pages) {
ngx_memzero(&page[1], pages * sizeof(ngx_slab_page_t));
}

/* 下个节点存在,通常都会存在,将自己解除出来即可 */
if (page->next) {
prev =(ngx_slab_page_t *) (page->prev & ~NGX_SLAB_PAGE_MASK);
prev->next = page->next;
page->next->prev = page->prev;
}

/* 连续页最后一片的下一片 */
join = page + page->slab;

if (join < pool->last) {
type = join->prev & NGX_SLAB_PAGE_MASK;

if ( type == NGX_SLAB_PAGE) {
/* 如果连续页连着的也是空白页首页,也在free链表中,那么就可以合并起来 */
if (join->next != NULL) {
pages += join->slab;
page->slab += join->slab;

prev = (ngx_slab_page_t *) (join->prev & ~NGX_SLAB_PAGE_MASK);
prev->next = join->next;
join->next->prev = join->prev;

join->slab = NGX_SLAB_PAGE_FREE;
join->next = NULL;
join->prev = NGX_SLAB_PAGE;
}
}
}

/* 接下来是合并前面的页,被释放的非第一个页 */
if (page > pool->pages) {
join = page - 1;
type = join->prev & NGX_SLAB_PAGE_MASK;

if ( type == NGX_SLAB_PAGE) {
/* 如果前一个页是非首页,那么移到它的首页(就是prev 保存的,见后文) */
if (join->slab == NGX_SLAB_PAGE_FREE) {
join = (ngx_slab_page_t *) (join->prev & ~NGX_SLAB_PAGE_MASK);
}
/* 同理,将自己合并到前一个空白页的后面 */
if (join->next != NULL) {
pages += join->slab;
join->slab += page->slab;

prev = (ngx_slab_page_t *) (join->prev & ~NGX_SLAB_PAGE_MASK);
prev->next = join->next;
join->next->prev = join->prev;

page->slab = NGX_SLAB_PAGE_FREE;
page->next = NULL;
page->prev = NGX_SLAB_PAGE;

page = join;
}
}
}

/* 将页头保存到最后一页的prev中,以合并时快速索引 */
if (pages) {
page[pages].prev = (uintptr_t) page;
}

/* 将空白页插到free 链表最前头 */
page->prev = (uintptr_t) &pool->free;
page->next = pool->free.next;

page->next->prev = (uintptr_t) page;

pool->free.next = page;

内存分配:

根据上面的分析,内存分配就比较简单了,首先判断大小,根据大小选择不同的slot or 直接分配:

  1. 大于页的一半,直接进入页的分配,查找满足需求的最大连续页;
  2. 小于页的一半,查找对应的slot,看看slot中有没有可用的页和chunk,没有的话分配一个页,然后查找chunk,并将该页插到slot的链表前头,以方便下一次分配快速查找;当下一次chunk分配不出来了,就把页从slot链表中移除,减少空闲搜索的空间;为了充分利用空间,nginx 恨不得把每个字段掰开来用,所以这种情况又根据bitmap的大小分成三种情况,如上文所述。

内存回收:

内存回收同样在两个大同分支中进行,大内存直接把页挂回free 链表中,并且为了减少碎片进行空闲页的合并;小内存则先进性chunk 回收,发现整个页也回收完了就回收页,每回收完就把页挂到 slot 链表中。

参考文献:

  1. https://www.usenix.org/legacy/publications/library/proceedings/bos94/full_papers/bonwick.a
  2. http://man7.org/training/download/posix_shm_slides.pdf
  3. https://www.kernel.org/doc/gorman/html/understand/
  4. Memory FAQ http://landley.net/writing/memory-faq.txt
  5. nginx中slab分配器的实现 http://www.pagefault.info/?p=177