nginx内存池实现原理

1. 主要特点

        nginx的内存池ngx_pool_t的主要特点如下:

  • 由于nginx处理请求的过程中,主要是频繁的申请小块的内存,因而ngx_pool_t会提前申请好供小块内存使用的内存块;
  • 在c语言开发过程中,程序员经常会忘记释放内存块,因而nginx框架本身则对内存池的释放工作进行了管理,当我们需要自定义模块开发的时候,只需要从ngx_pool_t中申请内存即可;
  • 由于不同的内存的使用周期不同,比如用于nginx运行所需的内存空间,其生命周期必须与nginx整个生命周期相同,而用于处理请求的内存空间,则在一次请求处理完成之后就需要释放。因而nginx主要预定义了三种生命周期的内存块:请求级别的内存池、连接级别的内存池和模块级别的内存池。当我们需要自定义模块时,只需要根据自身的需求,选择合适的内存池进行内存的申请即可;

2. 实现原理

        在介绍ngx_pool_t的整体结构之前,我们首先需要对其实现的基本数据结构进行讲解,如下是ngx_pool_t的数据结构:

struct ngx_pool_s {
    // 描述小块内存池,当分配小块内存时,剩余的预分配空间不足时,会再分配1个ngx_pool_t,
    // 它们会通过d中的next成员构成单链表
    ngx_pool_data_t d;
    // 评估申请内存属于小块还是大块的标准
    size_t max;
    // 多个小块内存池构成链表时,current指向分配内存时遍历的第一个小块内存池
    ngx_pool_t *current;
    // 用于ngx_output_chain,与内存池关系不大,略过
    ngx_chain_t *chain;
    // 大块内存都直接从进程的堆中分配,为了能够在销毁内存池时同时释放大块内存,就把每一次分配的大块内存通过
    // ngx_pool_large_t组成单链表挂在large成员上
    ngx_pool_large_t *large;
    // 所有待清理资源(例如需要关闭或者删除的文件)以ngx_pool_cleanup_t对象构成单链表,
    // 挂在cleanup成员上
    ngx_pool_cleanup_t *cleanup;
    // 内存池执行中输出日志的对象
    ngx_log_t *log;
};

        关于上面的ngx_pool_t的各个属性,我们需要说明几点:

  • ngx_pool_data_t属性中存储了当前内存池的内存池的使用情况,比如已使用内存与未使用内存的分界点地址,当前内存块的结束地址等,而且ngx_pool_data_t中海油一个属性next指向了下一个ngx_pool_t结构体,这主要是如果当前内存池差不多使用饱和时,就会新申请一个内存池,通过这里的next属性形成一个链表;
  • 在调用内存池的方法进行内存块的申请时,需要传入将要申请的内存大小,此时就会将其与这里的max属性进行比较,如果要申请的内存块比max小,说明申请的是小块内存,此时就会从上面的ngx_pool_data_t中进行申请,否则申请的就是大块内存,此时就会从下面的ngx_pool_large_t属性中申请;
  • current属性的主要作用是指向当前可以申请的内存块,初始情况下,其指向的是当前内存池。在ngx_pool_data_t中还有一个属性failed,该属性标志了从当前内存池中申请小块内存失败的次数,如果超过4次都申请失败了,则表示当前内存池可用内存不足,后面再申请内存时就不会从当前内存池中申请,此时就会将current指向下一个内存池,可以看到,通过不断地更改current的指向,就可以避免每次申请内存时对前面已经消耗殆尽的内存池的遍历工作;
  • ngx_pool_large_t存储的主要是大块内存,其本身是一个单链表,链表每个节点都表示一个大块内存,而每次申请大块内存时都会直接从系统内存中进行申请;
  • ngx_pool_cleanup_t的主要作用是保存了当前内存池被销毁时的回调函数,从而进行一些清理工作,其内部也是以单链表的形式组织的;

        下面我们来看一下上面介绍的ngx_pool_data_t的数据结构:

typedef struct {
    // 指向未分配的空闲内存的首地址
    u_char *last;
    // 指向当前小块内存池的尾部
    u_char *end;
    // 同属于一个pool的多个小块内存池间,通过next相连
    ngx_pool_t *next;
    // 每当剩余空间不足以分配出小块内存时,failed成员就会加1.failed成员大于4后,
  	// ngx_pool_t的current将移向下一个小块内存池
    ngx_uint_t failed;
} ngx_pool_data_t;

        这里的ngx_pool_data_t的各个属性其实是比较好理解的,本质上,其主要是对未使用内存块和已使用内存块进行管理的。下面我们来看一下ngx_pool_large_t的数据结构:

struct ngx_pool_large_s {
    // 所有大块内存通过next指针联在一起
    ngx_pool_large_t *next;
    // alloc指向ngx_alloc分配除的大块内存。调用ngx_pfree()后alloc可能是NULL
    void *alloc;
};

        可以看到,这里的ngx_pool_large_t是一个典型的单链表形式的数据结构,其alloc属性指向了当前申请的大块内存。如下是ngx_pool_cleanup_t的数据结构:

struct ngx_pool_cleanup_s {
    // handler初始为空,需要设置为清理方法
    ngx_pool_cleanup_pt handler;
    // ngx_pool_cleanup_add()方法的size>0时data不为null,此时可改写data指向的内存,
    // 用于为handler指向的方法传递必要的参数
    void *data;
    // 由ngx_pool_cleanup_add()方法设置next成员,用于将当前ngx_pool_cleanup_t
  	// 添加到ngx_pool_t的cleanup链表中
    ngx_pool_cleanup_t *next;
};

        这里的ngx_pool_cleanup_s本质上也是一个典型的单链表,其handler属性指向了需要回调的方法,而data属性则指定了回调该方法时需要传入的参数。

        通过对上面的数据结构的介绍,我们基本上大致理解了nginx是如何实现其内存池的,下面我们就以一个结构图的形式展示nginx是如何通过组织上面的数据结构来实现内存池的:

        上图是由两个ngx_pool_t以单链表的形式组织的,可以看到,第一个ngx_pool_t的current指针指向了下一个ngx_pool_t,这是由于第一个ngx_pool_t的申请失败次数已经超过4了。另外还需要说明的一点是,图中第一个ngx_pool_t的large指针指向的ngx_pool_large_t单链表是由三个节点组成的,但是其第二个节点的alloc属性指向的是NULL,这是由于在释放ngx_pool_t的内存空间时,只会释放所申请的内存块,而不会释放存储该内存块的ngx_pool_large_t结构体,这样在下次申请ngx_pool_large_t结构体时,可以先检查该单链表中是否有可用的空闲的ngx_pool_large_t结构体进行复用。

3. 源码讲解

        上面我们已经对ngx_pool_t的基本组织结构进行了讲解,接下来则主要会对其各个操作方法进行讲解。如下是ngx_pool_t的主要方法:

方法名 作用
ngx_create_pool(size_t size, ngx_log_t *log) 创建内存池,注意它的size参数并不等同于可分配空间,它同时还包含了管理结构的大小,这意味着:size决不能小于sizeof(ngx_pool_t),否则就会有内存越界错误。通常可以设size为NGX_DEFAULT_POOL_SIZE,该宏目前为16KB,不用担心16KB会不够用,当这第一个16KB用完时,会自动再分配16KB内存的。
ngx_destroy_pool(ngx_pool_t *pool) 销毁内存池,它同事会把通过该pool分配出的内存释放,而且,还会执行通过ngx_pool_cleanup_add()方法添加的各类资源清理方法
ngx_reset_pool(ngx_pool_t *pool) 重置内存池,即将内存池中的原有内存释放后继续使用。这个方法的实现是,会把大块内存释放给操作系统,而小块内存则在不释放的情况下复用
ngx_palloc(ngx_pool_t *pool, size_t size) 分配地址对齐的内存。按总线长度(例如sizeof(unsigned long))对齐地址后,可以减少CPU读取内存的次数,当然代价是有一些内存浪费
ngx_pnalloc(ngx_pool_t *pool, size_t size) 分配内存时不进行地址对齐
ngx_pcalloc(ngx_pool_t *pool, size_t size) 分配出地址对齐的内存后,再调用memset()将这些内存全部清0
ngx_pmemalign(ngx_pool_t *pool, size_t size, size_t alignment) 按参数alignment进行地址对齐来分配内存。注意,这样分配出的内存不管申请的size有多小,都是不会使用小块内存池的,它会从进程的堆中分配内存,并挂在大块内存组成的large单链表中
ngx_pfree(ngx_pool_t *pool, void *p) 提前释放大块内存。它的效率不高,其实现是遍历large链表,寻找ngx_pool_large_t的alloc成员等于待释放地址,找到后释放内存给操作系统,将ngx_pool_large_t移除链表并删除
ngx_pool_cleanup_t * ngx_pool_cleanup_add(ngx_pool_t *p, size_t size) 添加一个需要在内存池释放时同步释放的资源。该方法会返回一个ngx_pool_cleanup_t结构体,而我们得到后需要设置ngx_pool_cleanup_t的handler成员为释放资源时执行的方法。ngx_pool_cleanup_add有一个参数size,当它不为0时,分分配size大小的内存,并将ngx_pool_cleanup_t的data成员指向该内存,这样可以利用这段内存传递参数,供四方资源的方法使用。当size为0时,data将为NULL
ngx_pool_run_cleanup_file(ngx_pool_t *p, ngx_fd_t fd) 在内存池释放前,如果需要提前关闭文件(当然是调用过ngx_pool_cleanup_add()添加的文件,同时ngx_pool_cleanup_t的handler成员被设为ngx_pool_cleanup_file),则调用该方法
ngx_pool_cleanup_file(void *data) 以关闭文件来释放资源的方法,可以设置到ngx_pool_cleanup_t的handler成员
ngx_pool_delete_file(void *data) 以删除文件来释放资源的方法,可以设置到ngx_pool_cleanup_t的handler成员

        上面的方法的实现原理本质上还比较简单,我们这里主要对内存池的创建、内存申请和内存的释放方法进行讲解,其余的方法读者朋友可以自行阅读其源码。如下是ngx_create_pool()方法的源码:

ngx_pool_t *ngx_create_pool(size_t size, ngx_log_t *log) {
  ngx_pool_t *p;

  // 创建一个ngx_pool_t结构体,初始化相关内存,并且将空间大小按照16的倍数进行对齐
  p = ngx_memalign(NGX_POOL_ALIGNMENT, size, log);
  if (p == NULL) {
    return NULL;
  }

  // 申请的内存空间包括了ngx_pool_t结构体需要占用的空间,因而last初始的位置处于sizeof(ngx_pool_t)之后
  p->d.last = (u_char *) p + sizeof(ngx_pool_t);
  // 将end初始化为内存块的结尾位置
  p->d.end = (u_char *) p + size;
  // next指向的是下一个ngx_pool_t,这里是创建的方法,因而将其置为NULL
  p->d.next = NULL;
  // 初始时failed为0,表示申请内存失败的次数为0
  p->d.failed = 0;

  // 实际可使用的内存块大小需要扣除ngx_pool_t需要使用的部分
  size = size - sizeof(ngx_pool_t);
  // 如果size小于NGX_MAX_ALLOC_FROM_POOL所指定的数值,则使用size,否则ngx_pool_t最多可用
  // 内存为NGX_MAX_ALLOC_FROM_POOL
  p->max = (size < NGX_MAX_ALLOC_FROM_POOL) ? size : NGX_MAX_ALLOC_FROM_POOL;

  // current指向了当前正在使用的ngx_pool_t,只有在申请失败次数failed超过4次的时候,
  // 才会将current指针指向下一个ngx_pool_t
  p->current = p;
  p->chain = NULL;
  // 存储大块内存的链表初始时为NULL
  p->large = NULL;
  // 用于进行清理工作的cleanup链表初始时为NULL
  p->cleanup = NULL;
  p->log = log;

  return p;
}

        这里创建内存池的过程主要可以分为三步:

  • ngx_pool_t申请内存块,从代码中可以看出,用户传入的size大小的内存块中有一部分是需要用来存储ngx_pool_t结构体本身所需要占用的内存的,因而用户申请的内存块的大小是一定要比sizeof(ngx_pool_t)要大的;
  • 根据申请的内存块,初始化ngx_pool_data_t结构体的各个属性值;
  • ngx_pool_t中其余各个属性赋予初始值;

        下面我们来看一下ngx_pool_t是如何申请内存块的:

void * ngx_palloc(ngx_pool_t *pool, size_t size) {
#if !(NGX_DEBUG_PALLOC)
  if (size <= pool->max) {
    // 如果申请的内存大小比一个pool可以申请的最大内存要小,则尝试在某个pool中申请内存
    return ngx_palloc_small(pool, size, 1);
  }
#endif

  // 这里说明要申请的内存块超过了一个pool能够申请的最大内存块,此时在large链表中申请
  return ngx_palloc_large(pool, size);
}

        这里在申请内存块之前,会判断要申请的内存大小是否比pool->max要小,是则调用ngx_palloc_small()方法进行小块内存的申请,否则调用ngx_palloc_large()申请大块内存。我们首先看看ngx_palloc_small()方法的源码:

static ngx_inline void * ngx_palloc_small(ngx_pool_t *pool, size_t size, ngx_uint_t align) {
  u_char *m;
  ngx_pool_t *p;

  p = pool->current;

  do {
    m = p->d.last;

    // 这里如果需要对齐,则首先对齐last索引指向的位置
    if (align) {
      m = ngx_align_ptr(m, NGX_ALIGNMENT);
    }

    // 如果剩余的空间比要申请的空间大,则更新last索引,并且返回当前申请的内存的首地址
    if ((size_t) (p->d.end - m) >= size) {
      p->d.last = m + size;

      return m;
    }

    // 如果剩余的空间不足,则检查下一个ngx_pool_t
    p = p->d.next;

    // 如果下一个ngx_pool_t结构体不为空,则继续循环
  } while (p);

  // 走到这里说明没有一个ngx_pool_t结构体有足够的空间,此时就会创建一个新的ngx_pool_t结构体
  return ngx_palloc_block(pool, size);
}

        上面的流程,本质上就是遍历ngx_pool_t链表,以检查当前节点的可用内存块是否足以申请目标内存块的大小。如果遍历到最后,还是没有可用的ngx_pool_t有足够的内存,则会调用ngx_palloc_block()方法新申请一个ngx_pool_t结构体以申请内存。如下是ngx_palloc_block()方法的源码:

static void * ngx_palloc_block(ngx_pool_t *pool, size_t size) {
  u_char *m;
  size_t psize;
  ngx_pool_t *p, *new;

  // 计算第一个ngx_pool_t所申请的内存空间大小
  psize = (size_t) (pool->d.end - (u_char *) pool);

  // 对psize进行对齐,并且申请相应的内存空间
  m = ngx_memalign(NGX_POOL_ALIGNMENT, psize, pool->log);
  if (m == NULL) {
    return NULL;
  }

  new = (ngx_pool_t *) m;

  // 初始化新的ngx_pool_t结构体的相关属性
  new->d.end = m + psize;
  new->d.next = NULL;
  new->d.failed = 0;

  m += sizeof(ngx_pool_data_t);
  m = ngx_align_ptr(m, NGX_ALIGNMENT);
  // 在新的ngx_pool_t结构体中申请内存块
  new->d.last = m + size;

  // 这里主要是将前面的pool链表中的failed加一,并且如果其值大于4,则将其current指针指向下一个pool。
  // 这里nginx是通过判断当前pool的failed次数是否大于4来判断其大概率可用内存已经比较小了,再继续分配也可能
  // 是没有内存空间的,因而直接将其current指针指向下一个pool
  for (p = pool->current; p->d.next; p = p->d.next) {
    if (p->d.failed++ > 4) {
      pool->current = p->d.next;
    }
  }

  // 走到这里,p指向的是最后一个pool,因而将其next指针指向新的pool
  p->d.next = new;

  return m;
}

        这里主要是新申请了一个ngx_pool_t结构体,并且将其添加到原ngx_pool_t的单链表中,另外,这里还会对先前遍历的单链表中的各个节点的failed属性进行更新,如果更新前的值比4要大,则将其current指针指向下一个pool。下面我们来看看ngx_pool_t是如何申请大块内存的:

static void * ngx_palloc_large(ngx_pool_t *pool, size_t size) {
  void *p;
  ngx_uint_t n;
  ngx_pool_large_t *large;

  // 从系统中申请目标大小的内存块
  p = ngx_alloc(size, pool->log);
  if (p == NULL) {
    return NULL;
  }

  n = 0;

  // 遍历large链表,找到一个ngx_pool_large_t结构体的alloc指针为空的节点,将其作为存储新申请的内存块的
  // 结构体。存在这样的ngx_pool_large_t结构体是因为在释放内存块时,只会释放alloc指针指向的内存空间,
  // 而会保留ngx_pool_large_t结构体
  for (large = pool->large; large; large = large->next) {
    if (large->alloc == NULL) {
      large->alloc = p;
      return p;
    }

    // 如果遍历超过了3个节点还是未找到alloc为空的节点,则不继续查找了,而是申请一个新的
    // ngx_pool_large_t结构体
    if (n++ > 3) {
      break;
    }
  }

  // 申请一个新的ngx_pool_large_t结构体
  large = ngx_palloc_small(pool, sizeof(ngx_pool_large_t), 1);
  if (large == NULL) {
    ngx_free(p);
    return NULL;
  }

  // 将新申请的large->alloc指针指向前面申请的内存块
  large->alloc = p;
  // 将新申请的large节点插入到pool->large链表的首节点位置
  large->next = pool->large;
  pool->large = large;

  return p;
}

        可以看到,这里在申请大块内存时,首先会在系统内存中直接申请目标大小的内存块,然后遍历ngx_pool_large_t单链表,找到一个可用的空闲的节点(最多找4个节点),将新申请的内存块放到该节点中,如果找不到,则新申请一个ngx_pool_large_t节点。下面我们来看一下ngx_pool_t是如何释放内存的。需要注意的是,ngx_pool_t提供的是释放大块内存的方法,对于小块内存的释放,其是直接根据内存池的生命周期,在其生命周期结束的时候一次性释放的,如下是ngx_pfree()方法的源码:

ngx_int_t ngx_pfree(ngx_pool_t *pool, void *p) {
  ngx_pool_large_t *l;

  // 遍历pool->large链表,查找与指定指针地址相等的节点,找到了则释放该节点申请的内存块
  for (l = pool->large; l; l = l->next) {
    if (p == l->alloc) {
      ngx_log_debug1(NGX_LOG_DEBUG_ALLOC, pool->log, 0,
                     "free: %p", l->alloc);
      ngx_free(l->alloc);
      l->alloc = NULL;

      return NGX_OK;
    }
  }

  return NGX_DECLINED;
}

        从上面的代码可以看出,ngx_pool_t释放大块内存时,是直接遍历ngx_pool_large_t单链表,找到当前要释放的节点,对其内存块进行释放的。

4. 小结

        本文首先对nginx的内存池的主要特点进行了介绍,然后介绍了其实现的基本数据结构和nginx内存池是如何组织这些数据结构的,最后从源码的角度对nginx内存池的主要实现方法进行了介绍。