PG内存管理篇一

网上关于PG的内存管理博客讲得迷迷糊糊,上来给一大堆数据结构或者有图也是没有突出重点,本着学习的态度,我自己总结了一下。

基本思想

上下文

PG内存管理以上下文方式进行,何为上下文?就是一个内存管理器既有上文,又有下文,在逻辑组织上就是一颗树(有parent也有children)。

内存上下文机制本质上就是对内存进行分类和分层

比如说我们需要为用户发来的命令,例如 "select * from t",开辟一个内存空间并存储它,同时在对命令进行语法解析后生成的语法解析树也需要内存保存,因此 PostgreSQL 使用 MessageContext 来存储。

对于不经常改变的 Catalog Relation 可以放入缓存中,不必每次都从磁盘中读取,那么 Cache 所需的内存就可以由 CacheMemoryContext 进行管理。

当执行一个事务时,一定会伴随着内存分配,比如元组的扫描、索引的扫描或者元组的排序等等,这些内存可能需要在事务结束后才释放,因此可由 CurTransactionContextExecutorStatePortalHeapMemory 等内存上下文来管理。

可以看到,在数据库运行过程中,会不断地申请各种各样的内存,PostgreSQL 将其分门别类整理好,在内存释放时就将更加从容和方便。即系统中的内存分配操作在各种语义的内存上下文中进行,所有在内存上下文中分配的内存空间都通过内存上下文进行记录。因此可以很轻松地通过释放内存上下文来释放其中所有的内存,而不用费心地去释放其中的每一块内存。

也就是说,在相同上下文上的内存有着相似的语义(作用),那么可以共同管理,而不是每一块内存都去管理。

链式内存

有了上下文之后,那么具体的内存是怎么管理的呢?基本思想是数据库自己向操作系统OS(malloc)要一块大内存,然后分为若干大小的块状链条(freelist)数组管理。当数据库需要内存时,优先从freelist里找到匹配大小的块状内存使用,如果没有适合大小的链条结点,那么再向OS申请新的内存。

虽然思想简单,但在实现上有几个更加细致的点:

  • 新申请的内存存储在一个块blocks中,而不是直接分配给freelist
  • freelist管理的是chunk链表,而chunk则存在于blocks中
  • 用完的blocks以链表的方式串在一起,头部则是新申请的blocks

Region-Based Memory Management

reference: https://smartkeyerror.com/PostgreSQL-MemoryContext

下图为 VC6 编译器在进行 malloc 调用时返回的结果的内存布局,其中 Debug Header 只有在 Debug 模式下才会出现,但是所分配内存区域的首、尾两端的 Cookie 却必不可少,因为它记录了一次 malloc 所分配的总内存,总计占用 8 Bytes。

Alt text

也就是说,我们每次使用 malloc() 申请 24 Bytes 的内存,系统最少消耗 32 Bytes 的内存,那么对于应用程序来说,内存的实际使用率为 24/32 = 0.75。如果我们有 100 万个 malloc 调用,那么将会有非常多的内存用于 Cookie 中,如此一来内存使用效率将会非常之低。

PostgreSQL 使用了一种名为 Region-Based Memory Management 的内存管理方式,原理其实非常简单: 使用 malloc 申请较大的内存块,然后将该内存块切割成一个一个的小的内存片,将内存片返回给调用方。当调用方使用完毕返还时,并不会直接返回给操作系统,而是添加至 Free List 这一空闲链表的指定区域内,以用于下一次的内存分配。

也就是说,我们向操作系统申请24B的内存,会有8B附加信息。同样,由PG实现的内存管理中,我们申请的24B内存,也会有一些附加信息。

实际上,调用palloc返回的地址的前面,还有一片Header信息,用来记录这片内存大小等信息。

数据结构

OK,有了以上核心思想的铺垫以后,再看数据结构就很清楚明白了。

MemoryContextData负责记录和管理内存树的信息,AllocSetContext负责真正的分配和管理内存,PG向操作系统申请的内存以AllocBlock为单位。应用向PG要内存则从Block中切割出Chunk分配。应用使用内存完毕后,优先将Chunk归还到freelist(Chunk地址未变,freelist多了一个指向)。

MemoryContextData

1
2
3
4
5
6
7
8
9
10
11
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;

parent、firstchild和nextchild字段用于标识树,这其实构成的是一颗多叉树,树的每一层都是一个链表。

methods字段是一个函数表,这是C实现面向对象多态的一种手段,不懂的查询相关资料,这里不再赘述。实际上进行内存管理时,调用的是methods里的函数。methods里的函数其实是一个声明,可以有不同的具体实现,后续会绑定函数地址。

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct MemoryContextMethods{
void *(*alloc) (MemoryContext context, Size size); // 内存分配
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);
} MemoryContextMethods;

以上两个数据结构只是定义了树的关系,真正的内存在哪里?答案是另外一个大结构体AllocSetContext

AllocSetContext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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将MemoryContextData作为结构体的第一个数据成员,当我们拿到MemoryContex时(也就是拿到了AllocSet),那么可以通过类型的强制转换拿到AllocSetContext(后续在blocks也可看到这个技巧的使用)。

1
2
3
4
typedef AllocSetContext* AllocSet;
typedef struct MemoryContextData* MemoryContext;

AllocSet set = (AllocSet) context; // MemoryContext context;

AllocBlockData

AllocBlockData 是PG管理内存的基本单位,每次向OS申请都是Block的单位。

1
2
3
4
5
6
7
8
typedef struct AllocBlockData* AllocBlock;
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;

等等,不是说好内存会存在blocks中,怎么没看到一点内存的影子?实际上,这里也运用了申请的内存以AllocBlockData开头这一技巧,AllocBlockData的大小是固定的,而freeptr和endptr指向了可用空间的起始和结束地址。

1
2
3
4
5
6
#define ALLOC_CHUNKHDRSZ MAXALIGN(sizeof(AllocChunkData))
#define ALLOC_BLOCKHDRSZ MAXALIGN(sizeof(AllocBlockData))
// 除开申请的内存片以外,还需要为 AllocBlockData 和 AllocChunkData 预留空间
blksize = chunk_size + ALLOC_BLOCKHDRSZ + ALLOC_CHUNKHDRSZ;
// 向 OS 申请内存,这里使用的是 malloc
block = (AllocBlock) malloc(blksize);

AllocChunkData

前面我们已经知道了一个内存块(Block)中会被切割成一个或者多个内存片(Chunk),这个chunk的结构如下所示,同样,它也没有带有内存空间,只不过作为内存空间的头部(元信息)存在,标识着内存空间的大小。

1
2
3
4
5
6
#define ALLOC_CHUNKHDRSZ MAXALIGN(sizeof(AllocChunkData))
typedef struct AllocChunkData{
void *aset; // 该指针有两个作用,使用时指向 AllocSet,空闲时作为 next 指针链接其空闲链表
Size size; // 内存片的实际大小,以 2 的幂为大小进行向上取整
Size requested_size; // debug 使用
} AllocChunkData;

freelist

freelist管理了零碎的内存片,数组的大小默认为 11,能够保存 11 种不同大小的空闲内存片,对于数组的第 K 个元素,其保存的内存片大小为 2^(K+2) 字节。K 从 1 开始取值,也就是说,freelist 数组中最小的内存片大小为 8 Bytes,最大的内存片为 8192 bytes(默认情况下),相同大小的内存片由链表链接。

当我们使用完内存空间后,chunk块不会马上调用free返回给操作系统,而是挂载到freelist上,方便下次查找合适的内存块使用。

内存分配

在 PostgreSQL 中,所有内存的申请、释放和重置都是在内存上下文中进行的,因此不会直接使用 malloc()realloc()free() 系统调用函数,而是使用 palloc()repalloc()pfree() 来实现内存的分配、重分配和释放。

palloc

1
2
3
4
5
6
7
8
9
10
11
12
13
void * palloc(Size size){
void *ret;
// 在当前内存上下文中进行内存分配
MemoryContext context = CurrentMemoryContext;
// 将 isReset 标志位设置为 false,那么在释放内存上下文时就需要清理其内存
context->isReset = false;
// 此处为多态实现,目前只有 AllocSetAlloc() 这一个实现
ret = context->methods->alloc(context, size);
if (unlikely(ret == NULL)){
// 此处将打印 OOM 错误信息
}
return ret;
}

palloc() 方法中,内存分配实际上会调用 AllocSetAlloc() 方法,根据申请的size有不同的处理方法:

  1. size大于allocChunkLimit,说明size太大了,超过现在freelist和chunk的最大大小,需要重新申请block
  2. freelist中查找合适大小的空间,如果没有,进入到第3步
  3. 申请新的block,从block中切割chunk
1
// 补充代码

上述的逻辑还比较粗糙,实际上还有一些有意思的小点:

  • 当申请的内存大于allocChunkLimit时,会申请新内存块,该块只存放一个内存片,并且这个内存片释放后不会被freelist管理,直接释放free

  • 当freelist空间不够,block空间也不够时,旧的block会被切割成chunk扔到freelist中(物尽其用)

pfree

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
* pfree
* Release an allocated chunk.
*/
void pfree(void* pointer){
MemoryContext context = NULL;

/*
* Try to detect bogus pointers handed to us, poorly though we can.
* Presumably, a pointer that isn't MAXALIGNED isn't pointing at an
* allocated chunk.
*/
Assert(pointer != NULL);
Assert(pointer == (void*)MAXALIGN(pointer));

context = ((StandardChunkHeader*)((char*)pointer - STANDARDCHUNKHEADERSIZE))->context;
AssertArg(MemoryContextIsValid(context));

(*context->methods->free_p)(context, pointer);
}

首先,从注释中我们可以get到很重要一点就是:内存分配是以chunk为单位的,那么我们归还的时候也要检查它是否是一个chunk。

context = ((StandardChunkHeader*)((char*)pointer - STANDARDCHUNKHEADERSIZE))->context; 这句话计算真正的chunk 头地址,然后通chunk头知道当前属于哪个内存上下文,从而正确释放内存。

1
2
3
4
5
6
7
8
9
10
11
typedef struct StandardChunkHeader {
MemoryContext context; /* owning context */
Size size; /* size of data space allocated in chunk */
Size requested_size;
} StandardChunkHeader;

typedef struct AllocChunkData{
void *aset; // 该指针有两个作用,使用时指向 AllocSet,空闲时作为 next 指针链接其空闲链表
Size size; // 内存片的实际大小,以 2 的幂为大小进行向上取整
Size requested_size; // debug 使用
} AllocChunkData;

在看内存上下文的free_p函数:

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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
template <bool enable_memoryprotect, bool is_shared, bool is_tracked>
void GenericMemoryAllocator::AllocSetFree(MemoryContext context, void* pointer)
{
AllocSet set = (AllocSet)context;
AllocChunk chunk = AllocPointerGetChunk(pointer);
Size tempSize = 0;
MemoryProtectFuncDef* func = NULL;

AssertArg(AllocSetIsValid(set));

/*
* If this is a shared context, make it thread safe by acquiring
* appropriate lock
*/
if (is_shared) {
MemoryContextLock(context);
func = &SharedFunctions;
} else {
CHECK_CONTEXT_OWNER(context);
if (context->session_id > 0)
func = &SessionFunctions;
else
func = &GenericFunctions;
}

AllocFreeInfo(set, chunk);

#ifdef MEMORY_CONTEXT_CHECKING
/* Test for someone scribbling on unused space in chunk */
if (chunk->requested_size != (Size)MAXALIGN(chunk->requested_size) && chunk->requested_size < chunk->size)
if (!sentinel_ok(pointer, chunk->requested_size - ALLOC_MAGICHDRSZ)) {
if (is_shared) {
MemoryContextUnlock(context);
}
ereport(PANIC, (errmsg("detected write past chunk end in %s", set->header.name)));
}
AllocMagicData* magic =
(AllocMagicData*)(((char*)chunk) + ALLOC_CHUNKHDRSZ + MAXALIGN(chunk->requested_size) - ALLOC_MAGICHDRSZ);
Assert(magic->aset == set && magic->size == chunk->size && magic->posnum == PosmagicNum);
#endif

#ifndef ENABLE_MEMORY_CHECK
if (chunk->size > set->allocChunkLimit) {
#endif
/*
* Big chunks are certain to have been allocated as single-chunk
* blocks. Find the containing block and return it to malloc().
*/
AllocBlock block = (AllocBlock)(((char*)chunk) - ALLOC_BLOCKHDRSZ);

check_pointer_valid(block, is_shared, context, chunk);

/* OK, remove block from aset's list and free it */
if (block->prev)
block->prev->next = block->next;
else
set->blocks = block->next;

if (block->next)
block->next->prev = block->prev;

tempSize = block->allocSize;

set->totalSpace -= block->allocSize;

/* clean the structure of block */
block->aset = NULL;
block->prev = NULL;
block->next = NULL;
block->freeptr = NULL;
block->endptr = NULL;
block->allocSize = 0;

if (is_tracked)
MemoryTrackingFreeInfo(context, tempSize);

if (GS_MP_INITED)
(*func->free)(block, tempSize);
else
gs_free(block, tempSize);
#ifndef ENABLE_MEMORY_CHECK
} else {
/* Normal case, put the chunk into appropriate freelist */
int fidx = AllocSetFreeIndex(chunk->size);

chunk->aset = (void*)set->freelist[fidx];
set->freeSpace += chunk->size + ALLOC_CHUNKHDRSZ;

#ifdef MEMORY_CONTEXT_TRACK
chunk->file = NULL;
chunk->line = 0;
#endif
#ifdef MEMORY_CONTEXT_CHECKING
/* Reset requested_size to 0 in chunks that are on freelist */
chunk->requested_size = 0;
chunk->prenum = 0;
AllocMagicData* magic =
(AllocMagicData*)(((char*)chunk) + ALLOC_CHUNKHDRSZ + MAXALIGN(chunk->requested_size) - ALLOC_MAGICHDRSZ);
magic->aset = NULL;
magic->size = 0;
magic->posnum = 0;
#endif
set->freelist[fidx] = chunk;
Assert(chunk->aset != set);
}
#endif
if (is_shared)
MemoryContextUnlock(context);
}

其中主要逻辑就是:

  1. 归还的内存大于allocChunkLimit时,直接释放。
  2. 归还的内存小于allocChunkLimit时,挂到freelist上。

*func->free 目前可以直接理解为调用操作系统的free函数,实际上结合is_sharedcontext->session_id参数引出了openGauss的内存保护机制,这里避开不谈。


最后是一张内存关系图,展示了经过一段时间后的内存情况:

总结

  1. 内存上下文以block单位向OS拿内存,以chunk的单位分配内存。
  2. palloc和pfree的语义是从上下文拿内存和归还内存,内存申请和释放的单位都是chunk。

最后,非常感谢这两篇文章,里面的图很好用直接拿来用了,希望大家也去看一看这两篇文章:

作者

Desirer

发布于

2024-08-03

更新于

2024-11-15

许可协议