Nginx底层设计与源码分析
上QQ阅读APP看书,第一时间看更新

3.3 Nginx共享内存

进程是计算机系统资源分配的最小单位。每个进程都有自己的资源,彼此隔离。内存是进程的私有资源,进程的内存是虚拟内存,在使用时由操作系统分配物理内存,并将虚拟内存映射到物理内存上。之后进程就可以使用这块物理内存。正常情况下,各个进程的内存相互隔离。共享内存就是让多个进程将自己的某块虚拟内存映射到同一块物理内存,这样多个进程都可以读/写这块内存,实现进程间的通信。共享内存的示意图如图3-3所示。

图3-3 共享内存示意图

那么,进程该如何创建以及销毁共享内存呢?正常情况下,我们在C代码中通过malloc函数申请的内存都是进程的私有内存,不会在进程间共享。Linux提供了几个系统调用函数来创建共享内存或者释放共享内存,例如mmap、munmap等。

前面讲到,Nginx使用共享内存实现进程间通信。也就是说,Nginx除了管理单个进程内的内存分配,还需要管理进程间的共享内存。例如,统计用户请求总数需要开辟共享内存,并在每个进程处理请求时更新这块内存。很明显,共享内存会被多个进程共享,除了使用原子操作外,有时需要通过锁来保证每次只有一个进程访问。通常,Nginx共享内存由主进程负责创建,主进程记录共享内存的地址。派生(Fork)子进程时,子进程可以继承父进程记录共享内存地址的变量,进而访问共享内存。本节首先介绍Nginx如何开辟共享内存块,之后介绍如何在共享内存块中创建锁,然后讲解Nginx共享内存管理,最后举例说明Nginx现有模块是如何使用共享内存的。

3.3.1 共享内存的创建及销毁

Linux系统下创建共享内存可以使用mmap或者shmget方法。Nginx基于这两个系统调用方法封装了ngx_shm_alloc接口以及ngx_shm_free接口。下面先看一下共享内存相关的结构体以及API。

typedef struct {
    u_char      *addr;   // 指向申请的共享内存块首地址
    size_t       size;   // 内存块大小
    ngx_str_t    name;   // 内存块名称
    ngx_log_t   *log;    // 记录日志
    ngx_uint_t   exists; // 标识是否已经存在
} ngx_shm_t;
// 创建共享内存块
ngx_int_t ngx_shm_alloc(ngx_shm_t *shm);
// 释放共享内存块
void ngx_shm_free(ngx_shm_t *shm);

Nginx根据预定义的宏,采用不同的方法(mmap或者shmget)创建共享内存。此处,我们仅介绍mmap方法。

// 申请共享内存
ngx_int_t ngx_shm_alloc(ngx_shm_t *shm){
    shm->addr = (u_char *) mmap(NULL, shm->size,
                           PROT_READ|PROT_WRITE,
                           MAP_ANON|MAP_SHARED, -1, 0);
    if (shm->addr == MAP_FAILED) {
        // Log
        return NGX_ERROR;
    }
    return NGX_OK;
}
// 释放共享内存
void ngx_shm_free(ngx_shm_t *shm){
    if (munmap((void *) shm->addr, shm->size) == -1) {
        // 这里进行日志记录,限于篇幅,我们就不再展示这部分代码
    }
}

总体上看,Nginx创建共享内存主要依赖系统调用,调用过程也比较简单,本文就不再详细介绍。

3.3.2 互斥锁

Nginx互斥锁用于保障进程间同步,防止多个进程同时写共享内存块(同时读也可以)。Nginx互斥锁结构图如图3-4所示,共享内存块通过一个原子变量标识这个锁(图中共享内存中的lock),每个进程在访问变量v时,都要先获取这个锁,然后才能访问,这样就保证了在每个时间点,只有一个进程在操作变量v。每个进程内都有一个ngx_shmtx_t结构体。通过封装,进程内可以很容易地通过ngx_shmtx_t结构体进行加锁、释放锁等操作。

图3-4 Nginx互斥锁

下面介绍Nginx互斥锁的实现。总体而言,如果系统支持原子操作,Nginx可通过原子操作实现互斥锁;如果系统不支持原子操作,Nginx则通过文件锁实现互斥锁。本节主要介绍通过原子操作实现互斥锁。值得一提的是,如果系统支持信号量,则会通过信号量唤醒正在等待锁的进程(限于篇幅,我们假定系统不支持信号量,这并不影响学习互斥锁的主要逻辑)。对于通过文件锁实现互斥锁,感兴趣的读者可以自行研究。

关于互斥锁,Nginx提供的主要结构体以及API如下:

// 该结构体存储在共享内存块中
typedef struct {
    ngx_atomic_t   lock;
}ngx_shmtx_sh_t;
// 每个进程使用该结构体进行加锁、释放锁等操作
typedef struct {
    ngx_atomic_t  *lock;  // 指向ngx_shmtx_sh_t结构体lock字段
    ngx_uint_t     spin;  // 控制自旋次数
} ngx_shmtx_t;
// 创建锁
ngx_int_t ngx_shmtx_create(ngx_shmtx_t *mtx, ngx_shmtx_sh_t *addr,
                           u_char *name);
// 销毁锁
void ngx_shmtx_destroy(ngx_shmtx_t *mtx);
// 尝试加锁,失败直接返回
ngx_uint_t ngx_shmtx_trylock(ngx_shmtx_t *mtx);
// 获取锁,直到成功获取锁后才返回
void ngx_shmtx_lock(ngx_shmtx_t *mtx);
// 释放锁
void ngx_shmtx_unlock(ngx_shmtx_t *mtx);

我们先介绍锁的创建以及销毁。锁的创建和销毁比较简单,创建锁只需要将共享内存块中锁变量的地址赋值到进程锁变量的地址即可,销毁锁则不需要进行任何操作(如果系统支持信号量,则需要销毁信号量)。

// mtx是进程创建的、用于存储锁的变量,addr是共享内存块中用于标识锁的变量
ngx_int_t ngx_shmtx_create(ngx_shmtx_t *mtx,ngx_shmtx_sh_t *addr,u_char *name){
    mtx->lock = &addr->lock;
    if (mtx->spin == (ngx_uint_t) -1) return NGX_OK;
    mtx->spin = 2048;
    return NGX_OK;
}

尝试加锁的操作比较简单,通过原子变量的比较交换即可(将共享内存块中原子变量的值改为当前进程pid),如果交换成功,则意味着成功加锁,否则没有获取到锁。对于加锁操作,Nginx首先尝试获取锁,尝试一定次数后(尝试次数由ngx_shmtx_t结构体spin字段决定),则让出CPU,然后继续尝试加锁。如果系统支持信号量,则可以通过信号量优化这个过程。释放锁的逻辑比较简单,只需要通过原子操作,将共享内存块中的原子变量的值改为0即可。这部分代码比较简单,此处就不再详细介绍。

3.3.3 共享内存管理

通过前面几节的学习,我们了解了Nginx如何创建共享内存、如何创建共享内存互斥锁。在使用时,如果我们只是统计一些简单的信息,这些接口已经足够。然而,有时候我们想要更好地使用共享内存块,比如想要在共享内存块中创建复杂的结构体,例如链表、红黑树等。对于这种场景,如果我们自行维护共享内存的申请以及释放,内存管理的效率可能很低。我们想要Nginx提供一个类似于内存池的结构,帮助管理共享内存。我们可通过某个API创建一个共享内存块,然后通过一些API从这个共享内存块中申请或者释放内存。在Nginx中,管理共享内存块的结构体是ngx_slab_pool_t。类似于Nginx进程内的内存池,ngx_slab_pool_t就相当于共享内存的内存池。

在介绍ngx_slab_pool_t之前,我们先介绍几个重要的数据结构体——ngx_slab_page_t用于管理内存页,记录内存页使用的各项信息;ngx_slab_stat_t则用于统计信息。

typedef struct ngx_slab_page_s  ngx_slab_page_t;
struct ngx_slab_page_s {
    uintptr_t         slab;
    ngx_slab_page_t  *next;
    uintptr_t         prev;
};
typedef struct {
    ngx_uint_t        total;
    ngx_uint_t        used;
    ngx_uint_t        reqs;
    ngx_uint_t        fails;
} ngx_slab_stat_t;

ngx_slab_pool_t是共享内存管理的核心结构体。使用者可以通过这个结构体,从共享内存块中分配内存或者释放内存。

typedef struct {
    ngx_shmtx_sh_t    lock;
    size_t    min_size;      // 可以分配的最小内存
    size_t    min_shift;     // 最小内存的对应的偏移值(3代表min_size为2^3)
    ngx_slab_page_t  *pages; // 指向第一页的管理结构
    ngx_slab_page_t  *last;  // 指向最后一页的管理结构
    ngx_slab_page_t   free;  // 用于管理空闲页面
    ngx_slab_stat_t  *stats; // 记录每种规格内存统计信息(小块内存)
    ngx_uint_t        pfree; // 空闲页数
    u_char           *start;
    u_char           *end;
    ngx_shmtx_t       mutex;
    u_char           *log_ctx;
    u_char            zero;
    unsigned          log_nomem:1;
    void             *data;
    void             *addr;
} ngx_slab_pool_t;

下面我们介绍Nginx共享内存管理的几个核心API。

// pool指向某个共享内存块的首地址,该函数完成共享内存块初始化
void ngx_slab_init(ngx_slab_pool_t *pool);
// 从pool指向的共享内存块中申请大小为size的内存
void *ngx_slab_alloc(ngx_slab_pool_t *pool, size_t size);
// 释放pool分配的某个内存
void ngx_slab_free(ngx_slab_pool_t *pool, void *p);

Nginx共享内存管理较为复杂,限于篇幅,我们主要介绍其核心思想,不再一一列举其源码实现。假定我们的系统是64位系统,系统页大小为4KB,Nginx默认页大小与系统页大小一致,也为4KB。共享内存块初始化后的结构如图3-5所示。

图3-5 共享内存初始化后的结构

一块共享内存初始化后,总体上由以下几个部分构成。

1)ngx_slab_pool_t结构体用于管理整块共享内存,位于共享内存块的头部。

2)ngx_slab_page_t结构体有9个规格种类,也就是说有9种规格内存块,此处仅仅使用next字段组成链表。

3)ngx_slab_stat_t结构体有9个,用于配合上面的9个ngx_slab_page_t结构体,用于统计每种规格内存的分配情况。

4)页管理结构体ngx_slab_page_t的个数为pages值。pages的值是剩余内存可以分配的页数(除去上面介绍的几种结构体)。

5)对于Nginx而言,内存页的首地址默认对齐(内存块的首地址后12bit为0),所以此处需要对齐内存块,这块内存并不使用。

6)物理页的大小为4096Byte。

对于Nginx而言,当申请的内存小于等于半页内存(2048Byte)时,先申请一页内存,之后将这页内存划分为特定规格的内存块(整页内存都会划分成这种规格的内存块),使用时从这些内存块中分配一个即可。当申请的内存大于2048Byte时,首先计算需要使用的页数,然后分配整数页内存(这些页是连续的)。对于小于等于半页的内存而言,Nginx使用9种规格进行划分,分别为8Byte、16Byte、32Byte、64Byte、128Byte、256Byte、512Byte、1024Byte、2048Byte(其实就是2的整数次幂,最小是8Byte,最大是2048Byte)。Nginx将这9种内存规格划分为3类,如果加上整页内存,可以分为4类。

1)小块内存:8Byte、16Byte、32Byte;

2)精确内存:64Byte;

3)大块内存:128Byte、256Byte、512Byte、1024Byte、2048Byte;

4)页内存:4096Byte。

为什么小块内存需要分为3种规格?精确内存的大小又是如何计算?对于小于等于半页的内存而言,申请一整页内存后,我们可以将其划分成多个特定大小的内存,例如1页内存可以划分为512个8Byte内存块、256个16Byte内存块、64个64Byte内存块。强调一点,1页内存只能划分为1种规格的内存。很明显,我们需要知道1页内哪些内存块正在使用,哪些还没有使用。Nginx通过bitmap解决这个问题。对于小块内存,我们需要首先在内存页首地址开辟一段空间,用于存储bitmap,比如1页内存如果分配8Byte大小的内存块,可以分配512个,其中前面8个内存块用于存储bitmap,后面的内存块才可以使用;对于精确内存,其需要64bit,刚才我们介绍过ngx_slab_page_s结构体(这里指的是每页的页管理结构,也就是图3-5中pages个ngx_slab_page_t结构体),该结构体的slab字段正好是64bit。也就是说,我们可以使用这个字段存储bitmap。换句话说,精确内存块的大小其实是ngx_slab_page_s结构体的slab字段充当bitmap时对应的内存块大小。对于大块内存,使用slab字段的前32bit存储bitmap。

当用户申请的内存小于等于半页时,Nginx会首先申请一个内存页(如果之前申请的内存页没有使用完,则继续使用),之后将这个内存页划分为特定规格(这个规格可以满足用户要求并且也是2的整数次幂),划分完成后分给用户其中的一个内存块即可。除此之外,Nginx使用bitmap记录这个内存块的使用情况。很明显,我们下次请求同样规格的内存块时,能够使用上次还没有使用完的内存页。另外,Nginx对每种规格的内存块,都建立一个链表进行链接。这个链表的头部节点就是ngx_slab_pool_t中的ngx_slab_page_s结构体。对于空闲内存页,ngx_slab_pool_t存储了一个ngx_slab_page_s类型的free字段,该字段用于将所有空闲的页链接起来。当需要整页内存时,Nginx可以直接遍历这个链表。共享内存管理结构如图3-6所示。

图3-6 共享内存管理结构

通过将每种规格的内存页都链接到一起,就可以很容易地实现分配。但是在释放内存时,我们仅仅知道整个共享内存块的首地址(ngx_slab_free接口)以及待释放内存的地址。该如何找到这块内存所属的页,进而释放内存呢?首先计算该内存所属的内存页的地址及大小,ngx_slab_pool_t记录了可以分配的内存页的首地址以及每个页的大小。计算完成后,找到这个页对应的页管理结构。很明显,为了释放内存,Nginx需要在每个内存页的页管理结构中记录一些信息。下面我们看一下Nginx是如何使用ngx_slab_page_s结构体的。

1)对于小块内存,slab字段记录其内存块大小的偏移量(例如,3代表内存块的大小偏移量是23);对于精确内存,slab字段记录其bitmap;对于大块内存,slab字段的前32bit记录其bitmap,后32bit记录内存块大小的二进制偏移量(7代表内存块大小偏移量是27)。

2)next字段构成链表。

3)prev字段以及next字段一起构成双向链表。prev字段的后2bit用于记录页面类型,例如,00代表整页,01代表大块内存页,10代表精确内存页,11代表小块内存页。

通过ngx_slab_page_s结构体,我们可以知道当前内存页使用的情况。如果这个内存页已经分配完,释放一个内存块后,可以将其挂载到对应规格的内存管理链表中(1页内存全部使用后会将其从链表中移除,这样就不用再进行分配);如果这个内存页全部释放,还可以将其挂载到空闲内存页链表中。

前面我们重点讲解了如何申请以及如何释放小于等于半页的内存。如果申请的内存大于半页内存,我们需要按照整数页内存进行申请。申请时,我们只需要从空闲页链表中,找到符合要求的连续内存页即可。但是释放内存页时,我们需要尽可能地将其前后几个内存页连接到一起,形成一个连续的空闲内存块。下面我们看一下Nginx是如何管理连续的内存页的。

1)对于申请多个整页的情况,Nginx需要提供连续的内存页以供使用,这些页对应的页管理结构也是连续的。对于第一个内存页,其页管理结构ngx_slab_page_s的slab字段的第一位设置为1,后31bit记录连续页的个数,next、prev字段置为0。后续页面的slab字段置为0xFFFFFFFF,next字段以及prev字段置为0。

2)对于空闲的整页,我们需要将连续的空闲页整合到一起,这样才可以分配大块内存。此时,首个内存页管理结构ngx_slab_page_s的slab字段记录连续内存页的页数,next以及prev字段与其他空闲页构成双向链表。最后一个内存页的页管理结构的slab字段为0,next字段为0,prev字段指向内存块第一个内存页的页管理结构。中间内存页的页管理结构的字段都为0。

通过上面的介绍,当Nginx释放内存页时,我们找到这个页前面页的页管理结构,判断其是否空闲,如果空闲并且其前面也有很多空闲页,可以通过其页面管理结构的prev字段,找到整个空闲内存块,进而与待释放的内存页链接到一起。对于这个内存页后面的内存页也是如此。下面给出ngx_slab_free_pages结构释放内存页时,与其前面的空闲内存页链接的核心代码:

    if (page > pool->pages) {
        // join是释放内存页前面一页的页管理结构,page是待释放页面的页管理结构
        join = page - 1;
        // 判断是否是整页类型的内存
        if (ngx_slab_page_type(join) == NGX_SLAB_PAGE) {
            // 如果这个内存页是前面多个空闲页的最后一页,找到第一页
            if (join->slab == NGX_SLAB_PAGE_FREE) {
                join = ngx_slab_page_prev(join);
            }
            // next不为空,表明这个页在空闲页链表中
            if (join->next != NULL) {
                // 将这两个页合并成一个大的空闲页内存块
                pages += join->slab;
                join->slab += page->slab;
                ...
            }
        }
    }

综上所述,Nginx通过ngx_slab_pool_t结构体实现了共享内存块的管理,可以快速地为用户申请内存、回收内存。

3.3.4 共享内存使用

通过前面几节的介绍,我们已经知道Nginx如何创建共享内存块、如何创建共享内存块的锁以及如何通过共享内存池ngx_slab_pool_t分配内存。总体而言,Nginx使用共享内存有两种方案。

1)第一种方案是直接调用ngx_shm_alloc创建共享内存块,自行创建需要的锁,自行管理共享内存空间。这种方式主要用于简单场景,例如请求计数,这部分内容可以参考ngx_event_module_init函数。

2)第二种方案是使用Nginx提供的ngx_shared_memory_add函数创建共享内存块,使用ngx_slab_pool_t进行共享内存管理。例如:ngx_stream_limit_conn_module限制同一客户端的并发请求数,每当新的客户端发起请求时,需要从共享内存块中分配特定大小的内存,以便记录该客户端的请求数。

由于第一种场景比较简单,本节重点介绍第二种共享内存分配方式。下面看一下这种方式相关的结构体以及API。

typedef struct ngx_shm_zone_s  ngx_shm_zone_t;
struct ngx_shm_zone_s {
void                        *data;
    ngx_shm_t                shm;  // 用于记录共享内存块的相关信息
    ngx_shm_zone_init_pt     init; // 共享内存初始化后的回调
    void                    *tag;
    void                    *sync;
    ngx_uint_t               noreuse;
};
// 用于新增共享内存块
ngx_shm_zone_t * ngx_shared_memory_add(ngx_conf_t *cf, ngx_str_t *name, size_t size, void *tag)

Nginx在配置解析阶段,各个模块根据需要调用ngx_shared_memory_add函数,增加一个共享内存块,并且设置初始化回调函数。在ngx_init_cycle处理后期,统一创建所有的共享内存块,并且调用各个回调函数。Nginx派生子进程时,子进程会自动继承父进程的ngx_cycle_t结构体。子进程处理请求时会调用各个模块的请求处理回调函数,此时各个模块可以从ngx_cycle_t结构体中获取本模块共享内存块的相关信息,进而使用共享内存块。Nginx共享内存管理结构如图3-7所示。

图3-7 Nginx共享内存管理结构