1. 内存上下文概述

从7.1版本开始,PostgreSQL使用新的内存管理机制——内存上下文(MemoryContext)来进行内存管理,以解决之前大量指针传递引起的内存泄漏问题。使用该机制后,数据库中的内存分配操作都是在各种语义的内存上下文中进行,所有在内存上下文中分配的内存空间都通过内存上下文进行记录。因此可以很轻松的通过释放内存上下文来释放其中的所有内容。在实际的使用过程中,每个进程都会有自己的内存上下文,并且所有的内存上下文组成一个树形结构,如下图,其根节点为TopMemoryContext。在根节点之下有多个子节点,每个子节点都用于不同的功能模块,例如CacheMemoryContext用于管理Cache,ErrorMemoryContext用于错误处理,每个子节点又可以有自己的子节点。这样当我们释放树的父节点时,其所有的孩子节点也会被释放掉,这样就不用担心内存泄漏了。

mc1

2. 数据结构

与内存上下文相关的几个数据结构如下:

typedef struct MemoryContextData
{
	NodeTag		type;			/* identifies exact kind of context */
	MemoryContextMethods *methods;		/* virtual function table */
	MemoryContext parent;		/* NULL if no parent (toplevel context) */
	MemoryContext firstchild;	/* head of linked list of children */
	MemoryContext nextchild;	/* next child of same parent */
	char	   *name;			/* context name (just for debugging) */
	bool		isReset;		/* T = no space alloced since last reset */
} MemoryContextData;

typedef struct MemoryContextData *MemoryContext;

在MemoryContext结构中,需要注意一下其methods成员,它是一个MemoryContextMethods类型的指针。我们看一下这个类型:

typedef struct MemoryContextMethods
{
	void	   *(*alloc) (MemoryContext context, Size size);
	/* call this free_p in case someone #define's free() */
	void		(*free_p) (MemoryContext context, void *pointer);
	void	   *(*realloc) (MemoryContext context, void *pointer, Size size);
	void		(*init) (MemoryContext context);
	void		(*reset) (MemoryContext context);
	void		(*delete_context) (MemoryContext context);
	Size		(*get_chunk_space) (MemoryContext context, void *pointer);
	bool		(*is_empty) (MemoryContext context);
	void		(*stats) (MemoryContext context, int level);
#ifdef MEMORY_CONTEXT_CHECKING
	void		(*check) (MemoryContext context);
#endif
} MemoryContextMethods;

可以看到,该结构是一组函数指针,每种类型对应一种内存操作。

在目前的PostgreSQL中,只是把MemoryContext当做一种抽象类型,它可以有很多的实现,但目前只有AllocSetContext一种实现,且该实现对应的MemoryContextMethods实现为全局变量AllocSetMethods变量对应的函数集。

/*
 * AllocSetContext is our standard implementation of MemoryContext.
 *
 * Note: header.isReset means there is nothing for AllocSetReset to do.
 * This is different from the aset being physically empty (empty blocks list)
 * because we may still have a keeper block.  It's also different from the set
 * being logically empty, because we don't attempt to detect pfree'ing the
 * last active chunk.
 */
typedef struct AllocSetContext
{
	MemoryContextData header;	/* Standard memory-context fields */
	/* Info about storage allocated in this context: */
	AllocBlock	blocks;			/* head of list of blocks in this set */
	AllocChunk	freelist[ALLOCSET_NUM_FREELISTS];		/* free chunk lists */
	/* Allocation parameters for this context: */
	Size		initBlockSize;	/* initial block size */
	Size		maxBlockSize;	/* maximum block size */
	Size		nextBlockSize;	/* next block size to allocate */
	Size		allocChunkLimit;	/* effective chunk size limit */
	AllocBlock	keeper;			/* if not NULL, keep this block over resets */
} AllocSetContext;

typedef AllocSetContext *AllocSet;

AllocSetContext的几个重要字段说明如下:

  • header.isReset:是否重置内存上下文。所谓重置内存上下文是指释放内存上下文中所分配的内存给操作系统。在一个内存上下文被创建时,其isReset字段置为True,表示从上一次重置到目前没有内存被分配。只要在该内存上下文中进行了分配,就需要将isReset字段置为False。有了该变量的标识,在进行重置时,我们可以检查该字段的值,如果内存上下文没有进行过内存分配,则不需要进行实际的重置工作,从而提高效率。
  • initBlockSize、maxBlockSize、nextBlockSize:initBlockSize和maxBlockSize字段在内存上下文创建时指定,且在创建是nextBlockSize会置为和initBlockSize相同的值。nextBlockSize表示下一次分配的内存块的大小,在进行内存分配时,如果需要分配一个新的内存块,则这个新的内存块的大小将采用nextBlockSize的值。在有些情况下,需要将下一次要分配的内存块的大小置为上一次的2倍,这时nextBlockSize就会变大。但最大不超过maxBlockSize指定的大小。当内存上下文重置时,nextBlockSize又会恢复到初始值,也就是initBlockSize。
  • allocChunkLimit:内存块会分成多个被称为内存片的内存单元,在分配内存片时,如果一个内存片的尺寸超过了宏ALLOC_CHUNK_LIMIT时,将会为该内存片单独分配一个独立的内存块,这样做是为了避免日后进行内存回收时造成过多的碎片。由于宏ALLOC_CHUNK_LIMIT是不能运行时更改的,因此PostgreSQL提供了allocChunkLimit用于自定义一个阈值。如果定义了该字段的值,则在进行超限检查时会使用该字段来替换宏定义进行判断。
  • keeper:在内存上下文进行重置时不会对keeper中记录的内存块进行释放,而是对齐内容进行清空。这样可以保证内存上下文重置结束后就可以包含一定的可用内存空间,而不需要使用malloc另行申请。另外也可以避免在某个内存上下文被反复重置时,反复进行m内存片alloc带来的风险。

内存块(block)和内存片(chunk):

AllocSetContext中有两个数据结构AllocBlock和AllocChunk,分别代表内存块和内存片,PostgreSQL就是使用内存块和内存片来管理具体的内存的。AllocSet所管理的内存区域包含若干个内存块(内存块用AllocBlockData结构表示),每个内存块又被分为多个内存片(用AllocChunkData结构表示)单元。我们一般认为内存块是“大内存”,而内存片是“小内存”,PostgreSQL认为大内存使用频率低,小内存使用频率高。所以申请大内存(内存块)使用的是malloc,并且使用完以后马上会释放给操作系统,而申请小内存(内存片)使用的是自己的一套接口函数,而且使用完以后并没有释放给操作系统,而是放入自己维护的一个内存片管理链表,留给后续使用。

内存块和内存片的数据结构如下:

/*
 * AllocBlock
 *		An AllocBlock is the unit of memory that is obtained by aset.c
 *		from malloc().	It contains one or more AllocChunks, which are
 *		the units requested by palloc() and freed by pfree().  AllocChunks
 *		cannot be returned to malloc() individually, instead they are put
 *		on freelists by pfree() and re-used by the next palloc() that has
 *		a matching request size.
 *
 *		AllocBlockData is the header data for a block --- the usable space
 *		within the block begins at the next alignment boundary.
 */
typedef struct AllocBlockData
{
	AllocSet	aset;			/* aset that owns this block */
	AllocBlock	next;			/* next block in aset's blocks list */
	char	   *freeptr;		/* start of free space in this block */
	char	   *endptr;			/* end of space in this block */
}	AllocBlockData;

/*
 * AllocChunk
 *		The prefix of each piece of memory in an AllocBlock
 *
 * NB: this MUST match StandardChunkHeader as defined by utils/memutils.h.
 */
typedef struct AllocChunkData
{
	/* aset is the owning aset if allocated, or the freelist link if free */
	void	   *aset;
	/* size is always the size of the usable space in the chunk */
	Size		size;
#ifdef MEMORY_CONTEXT_CHECKING
	/* when debugging memory usage, also store actual requested size */
	/* this is zero in a free chunk */
	Size		requested_size;
#endif
}	AllocChunkData;

关于AllocSet,除了之前介绍的几个重要字段外,还有一些重要信息介绍如下:

(1)头部信息header

头部信息是一个MemoryContextData结构,header是进入一个内存上下文的唯一外部接口,事实上管理内存上下文的接口函数都是通过对header的管理来实现的。

(2)内存块链表blocks

该字段是一个指向AllocBlockData结构体的指针,表示一个内存块。AllocBlockData之间通过其next成员链接成一个单向链表,而blocks则指向这个链表的头部。AllocBlockData记录在一块内存区域的起始地址处,这块地址区域通过标准的库函数malloc进行分配,称之为一个内存块。在每个内存块中进行分配时产生的内存片段称之为内存片,每个内存片包含一个头部信息和数据区域,其中头部信息包含该内存片所属的内存上下文以及该内存区的其他相关信息,内存片的数据区则紧跟在其头部信息分布之后。通过PostgreSQL中定义的palloc函数和pfree函数,我们可以自由的在内存上下文中申请和释放内存片,被释放的内存片将被加到空闲链表中以备重复使用。

(3)freelist链表

该数组用于维护在内存块中被回收的空闲内存片,这些空闲内存片用于再分配。freelist数组类型为AllocChunk,数组默认长度为11(由宏ALLOCSET_NUM_FREELISTS定义)。该数组中的每一个元素指向一个由特定大小空闲内存片组成的链表,这个大小与该元素在数组中的位置有关:freelist数组中最小的空闲内存片为23=8字节(freelist[0]指向的链表维护的空闲内存片),最大不超过213=8K字节(freelist[10]指向的链表维护的空闲内存片),即数组中第K个元素所指向链表的每个空闲数据块的大小为2k+2字节。因此,freelist数组中实际上维护了11个不同大小的空闲内存片链表,管理者11个不同大小的空闲内存片。其中的aset成员有两个作用,如果一个内存片正在使用,则它的aset字段指向其所属的AllocSet。如果内存片是空闲的,也就是说它处于一个内存空闲链表中,那么它的aset字段指向空闲链表中它之后的内存片。这样从freelist数组元素指向的链表头开始,顺着aset字段指向的下一个内存片就可以找到该空闲链表中的所有空闲的内存片。

可以看到freelist中内存片的大小都是2的指数,我们申请的特定大小内存将取正好够用的那个内存片。如果申请的内存超过了allocChunkLimit(如果未定义,则取ALLOC_CHUNK_LIMIT)字段的值,则直接分配一个内存块,这个内存块中只存放一个内存片。这种情况下,当这个内存片释放的时候,会将整个内存块释放,而不是将内存片加到freelist中。

最后用一个图(来自网络)来看一下内存结构:

mc2.png

3. 内存上下文中的内存分配

这里只讲一下内存分配。函数AllocSetAlloc负责处理具体的内存分配工作,该函数的参数为一个内存上下文节点以及需要申请的内存大小,具体的内存分配流程如下所示:

(1)判断需要申请的内存大小是否超过了当前内存上下文中允许分配的内存片的最大值(即内存上下文节点的allocChunkLimit字段)。若超过,则为其分配一个新的独立的内存块,然后在该内存块中分配指定大小的内存片。接下来将该内存块加入到内存块链表中,最后设置内存上下文的isReset字段为False并返回内存片的指针。如果申请的大小没有超过限制则执行步骤(2)。

(2)计算申请的内存大小在freelist数组中对应的位置,如果存在合适的空闲内存片,则将空闲链表的指针(freelist数组中的某个元素)指向该内存片的aset字段所指向的地址(在空闲内存片中,aset字段指向它在空闲链表中的下一个内存片)。然后将内存片的aset字段指向其所属内存上下文节点,最后返回该内存片的指针。如果空闲链表中没有满足要求的内存片则执行步骤(3)。

(3)对内存上下文的内存块链表(blocks)字段的第一个内存块进行检查,如果该内存块中的未分配空间足以满足分配的要求,则直接在该内存块中分配内存片并返回内存片的指针。这里可以看到,在内存上下文中进行内存分配时,总是在内存块链表中的第一个内存块中进行,当该内存块中空间用完之后会分配新的内存块并作为新的内存块链表首部,因此内存块链表中的第一块也称作活动内存块。如果内存块链表中第一个内存块没有足够的未分配空间则执行步骤(4)。

(4)由于现有的内存块都不能满足这一次内存分配要求,因此需要申请新的内存块,但是当前的活动内存块中还有未分配的空间,如果申请新的内存块并将之作为新的活动内存块,则当前活动内存块中未分配的空间就会被浪费。为了避免浪费,这里会先将当前活动内存块中未分配空间分解成个数尽可能少的内存片(即每个内存片尽可能大),并将他们加入到freelist数组中,然后创建一个新的内存块(其大小为前一次分配的内存块的两倍,但不超过maxBlockSize)并将之作为新的活动内存块(即加入到内存块链表的首部)。最后再活动内存块中分配一个满足申请内存大小的内存片,并返回其指针。

4. 释放内存上下文

释放内存上下文中的内存,主要有以下三种方式:

(1)释放一个内存上下文中指定的内存片——当释放一个内存上下文中指定的内存片时,调用函数AllocSetFree,该函数执行方式如下:

  • 如果指定的要释放的内存片时内存块中唯一的一个内存片,则将该内存块直接释放。
  • 否则,将指定的内存片加入到freelist中以便下次分配。

(2)重置内存上下文——重置内存上下文是由函数AllocSetReset完成的。在进行重置时,内存上下文中除了在keeper字段中指定要保留的内存块外,其他内存块全部释放,包括空闲链表中的内存。keeper中指定保留的内存块将被清空内容,它使内存上下文重置之后就立刻有一块内存可供使用。

(3)释放当前内存上下文中的全部内存块——这个工作由AllocSetDelete函数完成,该函数释放当前内存上下文中所有的内存块,包括keeper指定的内存块在内。但内存上下文节点并不释放,因为内存上下文节点实在TopMemeoryContext中申请的内存,将在进程运行结束时统一释放。

本文总结自《PostgreSQL数据库内核分析》。