数据库内核月报

数据库内核月报 - 2024 / 08

PostgreSQL MemoryContext 标准实现解读

Author: mohen

背景

PostgreSQL 是多进程架构,它的内存架构可以划分为两大类:共享内存(shared memory area)和本地内存(local memory area)。除了动态申请的共享内存外,绝大多数共享内存是在 Postmaster 启动时分配,具有固定大小,因此内存管理相对简单,不易发生内存泄漏。而每个后端进程还必须管理自己的本地内存来完成请求的处理,这部分的内存管理比较棘手。这是因为 PostgreSQL 内核主要采用 C 语言编写,程序必须显式释放所有动态分配的内存,并且 PostgreSQL 中需要处理大量的以指针传值的逻辑,很容易产生难以排查的内存泄漏问题。内存泄漏对于多进程架构的影响是致命的,所以更好地管理本地内存是 PostgreSQL 实现中的一个重要环节。

从 7.1 版本开始,PostgreSQL 引入了内存上下文(MemoryContext)机制来管理本地内存。这个机制很好地解决了内存泄漏问题;同时也提高了内存分配效率,避免了内存碎片化的产生;也让内存管理有了生命周期。

MemoryContext 简介

MemoryContext 本质上是对内存进行分层和分类。一个 MemoryContext 实际上是一个内存池,代表一类内存。不同的 MemoryContext 组成了一个树状结构,代表了不同种类内存之间的联系。在运行过程中,开发者可以根据自己的需要创建和删除 MemoryContext。删除 MemoryContext,会让 MemoryContext(包括子 MemoryContext)中申请的内存都被释放,而不必去关心每一块内存的释放。

PostgreSQL 中有几个被熟知的 MemoryContext,它们之间的关系如下图所示:

<img src="/monthly/pic/202408/imgs/postgresql_aset_memorycontext_1.png" width = 80%/>

它们的作用为:

MemoryContext 的基本操作包括:创建 context、删除 context(释放所有获得的内存块)、重置 context(仅保留创建时申请的内存块)、在 context 中申请/释放内存片(palloc()/pfree())以及查询 context 中内存分配的情况等。

为了降低管理负担,PostgreSQL 中提供了 CurrentMemoryContext 全局变量表示当前的 MemoryContext。palloc() 会隐式地从这个 context 中分配空间。MemoryContextSwitchTo() 操作可以选择一个新的当前 context,并返回之前的 context,以便调用者最后恢复之前的 context。需要注意,pfree() 和 repalloc() 在处理内存块时,不依赖 CurrentMemoryContext,会直接调用内存块所属的 context 进行操作。 PostgreSQL 9.5 之后,MemoryContext 中引入了 reset 回调函数机制。注册的函数能够在 context 被删除或者重置时调用,调用的顺序和注册的顺序相反。回调函数用于释放与这个 context 相关的资源,比如关闭某些操作打开的文件或者释长生命对象被 context 的引用计数等。 MemoryContext 实际上是一个抽象类型,它的底层可以有多种实现方式,但是能让用户只使用同一套接口来管理不同的内存分配机制,只是内存申请释放由原来的 malloc/free 变为了 palloc/pfree 。为了实现这个目的:

  1. 一个 MemoryContext 用一个 MemoryContextData 结构表示 ,这个结构体标识了 context 的具体类型,并包含了不同类型的 MemoryContext 之间的共同信息,如父 context、子 context 和 context 的名称;
  2. 每个 MemoryContext 的内存操作方法由 MemoryContextMethods 中虚函数指针指向的方法决定,不同类型的 context 会使用派生的结构体,这些结构体必须把 MemoryContextData 作为它们的第一个字段。

接下来看下 MemoryContext 具体的数据结构(代码主要来自 PostgreSQL 14.11 src/include/nodes/memnodes.h):

typedef struct MemoryContextData *MemoryContext;

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		(*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,
						  MemoryStatsPrintFunc printfunc, void *passthru,
						  MemoryContextCounters *totals,
						  bool print_to_stderr);
#ifdef MEMORY_CONTEXT_CHECKING
	void		(*check) (MemoryContext context);
#endif
} MemoryContextMethods;

typedef struct MemoryContextData
{
	NodeTag		type;			/* identifies exact kind of context */
	/* these two fields are placed here to minimize alignment wastage: */
	bool		isReset;		/* T = no space alloced since last reset */
	bool		allowInCritSection; /* allow palloc in critical section */
	Size		mem_allocated;	/* track memory allocated for this context */
	const MemoryContextMethods *methods;	/* virtual function table */
	MemoryContext parent;		/* NULL if no parent (toplevel context) */
	MemoryContext firstchild;	/* head of linked list of children */
	MemoryContext prevchild;	/* previous child of same parent */
	MemoryContext nextchild;	/* next child of same parent */
	const char *name;			/* context name (just for debugging) */
	const char *ident;			/* context ID if any (just for debugging) */
	MemoryContextCallback *reset_cbs;	/* list of reset/delete callbacks */
} MemoryContextData;

可以看到 MemoryContext 实际上是指向 MemoryContextData 的指针。MemeoryContext 之间的联系是一个树状结构:

<img src="/monthly/pic/202408/imgs/postgresql_aset_memorycontext_2.png" width = 100%/>

parent 指向父 context,firstchild 指向第一个子 context,而 prevchild 和 nextchild 则是指向同层级的 context。

AllocSet 实现详解

MemoryContext 底层有多种实现方式(aset.c/slab.c/generation.c),其中 slab.c/generation.c 会将释放的内存直接还给操作系统,而 aset.c 则会保留一部分已释放内存在一个 freelist 中,只有在 context 被删除或者重置时归还给操作系统。aset.c(AllocSet)是 MemoryContext 的标准实现(参考 src/backend/utils/mmgr/aset.c) ,接下来介绍它的详细实现。

数据结构

首先看下 AllocSet 的数据结构组织关系,如下图所示:

<img src="/monthly/pic/202408/imgs/postgresql_aset_memorycontext_3.png" width = 80%/>

AllocSetContext

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;			/* keep this block over resets */
	/* freelist this context could be put in, or -1 if not a candidate: */
	int			freeListIndex;	/* index in context_freelists[], or -1 */
} AllocSetContext;

AllocSetContext 是 AllocSet 的核心管理模块, 所以单独介绍下其中每个成员的作用:

AllocBlockData

typedef struct AllocBlockData
{
	AllocSet	aset;			/* aset that owns this block */
	AllocBlock	prev;			/* prev block in aset's blocks list, if any */
	AllocBlock	next;			/* next block in aset's blocks list, if any */
	char	   *freeptr;		/* start of free space in this block */
	char	   *endptr;			/* end of space in this block */
}			AllocBlockData;

AllocSet 从 malloc() 中获取一块连续内存的单位是 AllocBlock。而用户调用 palloc() 获取一块连续内存的单位是 AllocChunk。一个 AllocBlock 包含 1 个或者多个 AllocChunk。用户 pfree() 释放 AllocChunk 后,可能不会直接返还操作系统,如果匹配了 freelist 中的大小,将会由对应的 freelist 管理。 AllocBlockData 是一个 AllocBlock 的 header 数据,可分配的空间是从下一个内存对齐边界开始。

AllocChunkData

typedef struct AllocChunkData
{
	/* 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;

#define ALLOCCHUNK_RAWSIZE  (SIZEOF_SIZE_T * 2 + SIZEOF_VOID_P)
#else
#define ALLOCCHUNK_RAWSIZE  (SIZEOF_SIZE_T + SIZEOF_VOID_P)
#endif							/* MEMORY_CONTEXT_CHECKING */

	/* ensure proper alignment by adding padding if needed */
#if (ALLOCCHUNK_RAWSIZE % MAXIMUM_ALIGNOF) != 0
	char		padding[MAXIMUM_ALIGNOF - ALLOCCHUNK_RAWSIZE % MAXIMUM_ALIGNOF];
#endif

	/* aset is the owning aset if allocated, or the freelist link if free */
	void	   *aset;
	/* there must not be any padding to reach a MAXALIGN boundary here! */
}			AllocChunkData;

AllocChunkData 是一个 AllocChunk 的 header 数据,可使用空间也是从下一个内存对齐边界开始。这里的 size 是表示可以使用的空间大小(不包括 metadata 的空间)。

AllocSetFreeList

typedef struct AllocSetFreeList
{
	int			num_free;		/* current list length */
	AllocSetContext *first_free;	/* list header */
} AllocSetFreeList;

/* context_freelists[0] is for default params, [1] for small params */
static AllocSetFreeList context_freelists[2] =
{
	{
		0, NULL
	},
	{
		0, NULL
	}
};

为了避免频繁地创建和删除 AllocSet,PG 中同样使用了 AllocSetFreeList 来管理部分被释放的 AllocSet,便于减少再次申请 AllocSet 的工作量。每一类 AllocSetFreeList 的候选集中的 AllocSet 必须要有 相同 的 minContextSize 和 initBlockSize,而 maxBlockSize 则不相关,因为不影响最开始分配块(initial AllocBlock)的大小。放入 AllocSetFreeList 之前,AllocSet 都会被进行 Reset 操作,只会让 keeper 指向 initial AllocBlock,然后删除其余的 block。

PG 提供了两类 AllocSetFreeList,一类是 ALLOCSET_DEFAULT_SIZES,另一类 ALLOCSET_SMALL_SIZES(ALLOCSET_START_SMALL_SIZES 也可以使用这里的候选集,因为只有 maxBlockSize 与 ALLOCSET_SMALL_SIZES 不同)。

AllocSetFreeList 中 AllocSet 的取用方式是 LIFO,但是每个 AllocSetFreeList 的个数有一个上限(MAX_FREE_CONTEXTS),超过这个上限,PG 会倾向删除最近创建的 AllocSet(即末尾的节点),为了保持进程的 MMAP 紧凑。

PG 在实现时,采用的方案是 一旦发现 AllocSetFreeList 个数溢出,就直接删除 AllocSetFreeList 所有 节点,来近似达到上面的想法。而这个方案是基于 一个会分配很多 MemoryContext 的查询,或多或少的可能会以申请相反的顺序释放 MemoryContext。实际实现时简化为,一旦发现某个 AllocSetFreeList 中数量超过 100,则会将这些 AllocSet 全部释放。在 AllocSetFreeList 中,每个 AllocSet 的 nextchild 指向下一个 AllocSet。

函数方法

AllocSetContextCreateInternal

MemoryContext
AllocSetContextCreateInternal(MemoryContext parent,
							  const char *name,
							  Size minContextSize,
							  Size initBlockSize,
							  Size maxBlockSize)

这个函数创建一个 AllocSet,会分配一个 initial AllocBlock,而 minContextSize / initBlockSize / maxBlockSize 会影响 initial AllocBlock 的大小,以及再次分配 block 的大小。主要的流程:

<img src="/monthly/pic/202408/imgs/postgresql_aset_memorycontext_4.png" width = 50%/>

确定 firstBlockSize 时,需要至少包含头部的开销,然后再去和 minContextSize/initBlockSize 比较:

    /* Determine size of initial block */
	firstBlockSize = MAXALIGN(sizeof(AllocSetContext)) +
		ALLOC_BLOCKHDRSZ + ALLOC_CHUNKHDRSZ;
	if (minContextSize != 0)
		firstBlockSize = Max(firstBlockSize, minContextSize);
	else
		firstBlockSize = Max(firstBlockSize, initBlockSize);

确定 allocChunkLimit 时,会有一个默认值 ALLOC_CHUNK_LIMIT(8 KB),但是不能超过 maxBlockSize 的 1/4,否则 allocChunkLimit 就要循环缩减一倍,直到满足要求:

    set->allocChunkLimit = ALLOC_CHUNK_LIMIT;
	while ((Size) (set->allocChunkLimit + ALLOC_CHUNKHDRSZ) >
		   (Size) ((maxBlockSize - ALLOC_BLOCKHDRSZ) / ALLOC_CHUNK_FRACTION))
		set->allocChunkLimit >>= 1;

从上面的分析可以看到,minContextSize/initBlockSize/maxBlockSize 会直接影响 AllocSet 的内存申请规则,而了解具体的规则是比较有挑战的。为方便调用,PG 定义了三个分配 size 的宏,同时也定义了一个调用函数的宏:

#ifdef HAVE__BUILTIN_CONSTANT_P
#define AllocSetContextCreate(parent, name, ...) \
	(StaticAssertExpr(__builtin_constant_p(name), \
					  "memory context names must be constant strings"), \
	 AllocSetContextCreateInternal(parent, name, __VA_ARGS__))
#else
#define AllocSetContextCreate \
	AllocSetContextCreateInternal
#endif


#define ALLOCSET_DEFAULT_SIZES \
	ALLOCSET_DEFAULT_MINSIZE, ALLOCSET_DEFAULT_INITSIZE, ALLOCSET_DEFAULT_MAXSIZE

#define ALLOCSET_SMALL_SIZES \
	ALLOCSET_SMALL_MINSIZE, ALLOCSET_SMALL_INITSIZE, ALLOCSET_SMALL_MAXSIZE

#define ALLOCSET_START_SMALL_SIZES \
	ALLOCSET_SMALL_MINSIZE, ALLOCSET_SMALL_INITSIZE, ALLOCSET_DEFAULT_MAXSIZE

因此,常见的调用方式就是:

source_context = AllocSetContextCreate(CurrentMemoryContext,
										   "CachedPlanSource",
										   ALLOCSET_START_SMALL_SIZES);

AllocSetMethods

文章开头介绍 Memorycontext 时提到了作为抽象类的两个条件,我们已经看到 MemoryContextData 确实是 AllocSetContext 第一个成员,而 MemoryContextMethods 指向具体的 method 实现是在 AllocSetContextCreateInternal 函数中完成的:

MemoryContextCreate((MemoryContext) set,
								T_AllocSetContext,
								&AllocSetMethods,
								parent,
								name);

AllocSetMethods 包含的具体方法为:

static const MemoryContextMethods AllocSetMethods = {
	AllocSetAlloc,
	AllocSetFree,
	AllocSetRealloc,
	AllocSetReset,
	AllocSetDelete,
	AllocSetGetChunkSpace,
	AllocSetIsEmpty,
	AllocSetStats
#ifdef MEMORY_CONTEXT_CHECKING
	,AllocSetCheck
#endif
};

AllocSetAlloc

static void *
AllocSetAlloc(MemoryContext context, Size size)

这个函数是 palloc() 的底层调用,具体的逻辑流程为:

<img src="/monthly/pic/202408/imgs/postgresql_aset_memorycontext_5.png" width = 50%/>

当请求的 size 超过了 allocChunkLimit,就会直接向 malloc() 申请一个 AllocBclok,并且整个 block 作为一个 AllocChunk 返回给用户。而在 freelist 请求固定大小的 chunk 时,对应 freelist 没有可用 chunk,并且 block 剩余空间也不足,会先将剩余的空间分配到 合适大小的 freelist 中,然后再向 malloc() 申请一个 nextBlockSize 的 block 用于 chunk 分配。

AllocSetFree

static void
AllocSetFree(MemoryContext context, void *pointer)

这个函数是 pfree() 的底层调用,具体的逻辑流程为:

<img src="/monthly/pic/202408/imgs/postgresql_aset_memorycontext_6.png" width = 50%/>

执行的逻辑和 AllocSetAlloc 相关,超过 allocChunkLimit 的内存直接归还给操作系统,不超过的内存,交给合适的 freelist 管理。

AllocSetRealloc

static void *
AllocSetRealloc(MemoryContext context, void *pointer, Size size)

这个函数是 repalloc() 的底层调用,具体的逻辑流程为:

<img src="/monthly/pic/202408/imgs/postgresql_aset_memorycontext_7.png" width = 50%/>

当原来分配的内存超过 allocChunkLimit,会调整新需求的 size 至少超过 allocChunkLimit,然后调用 realloc()。原来分配 chunksize 能满足新需求 size,则只调整 chunk 的可用空间即可。否则就会调用 AllocSetAlloc 分配新的需求,AllocSetFree 释放老的空间。

AllocSetReset

只保留 AllocSet->keeper 的中 block(initial block),其余的 block 都调用 free() 释放掉。

AllocSetDelete

删除一个 AllocSet,将所有资源都 free(),如果是可以放入 AllocSetFreeList 中,则会进行 MemoryContextResetOnly 后再放入。

AllocSetGetChunkSpace

对分配的一个 chunk,返回包括 AllockChunk header 在内的占用空间。

AllocSetIsEmpty

简单判断 context->isReset 是否被置位。

AllocSetStats

MemoryContextStatsInternal 的底层调用,统计 AllocSet 的 totalspace,freespace 和 block/freechunks 个数。

AllocSetCheck

debug 调试使用,主要是根据分配时规则,做一些检查。

参考文档

https://blog.csdn.net/jackgo73/article/details/89432427