LightDB Enterprise Postgres 内存池技术

C语言内存管理

在C语言程序中,内存管理是一个非常重要的事情,内存管理不好会导致内存访问异常,内存泄漏等问题,
基本都会造成系统崩溃等严重后果。

在程序开发中,分配内存是很简单的,最大困难点在于内存的释放。
内存释放核心原则很简单: 1. 使用完毕必须要释放;2. 释放后就不能再访问。
但是在真实的复杂程序流中,实现起来会很困难。
例如下面代码中,必须要确保每个返回流程前都要free,否则就会有内存泄漏。

void fun() {
    void *mem = malloc(10);

    if(aa) {
      free(mem);
      return;
    }

    if(bb) {
      free(mem);
      return; 
    }

    free(mem);
    return;
}

我们在函数接口设计时,内存谁来分配谁来释放,也是一个麻烦的事情。

char *myFunc() 
{
    /**
     * 我想返回一个内部分配的字符串,但是这个内存是怎么管理呢?
     */
}

像这种函数,一般三种处理方法:

  1. 调用者负责分配内存,如sprintf,strcpy等,缺点是调用者在调用前并不知道需要多大内存。
  2. 函数内部分配内存,如strdup函数,缺点是调用者需要记得释放内存。
  3. 静态内存方式,如localtime函数,缺点是线程不安全,大小编译时就固定了。

再比如我们调用第三方提供的接口时,必须要清楚确认其内存分配机制,
以下代码中,指针p是否要释放呢?怎么释放?这些问题通过C语言本身是无法确认的,必须通过
接口函数开发者提供的源码内部,注释,文档等提供。

// p怎么释放?
void *p = thirdparty_function();

内存管理给C程序员造成巨大的负担,
在C语言之后设计的语言中,都或多或少地提供和更好的内存管理机制。

内存池

在C语言中有一个比较好的内存管理方案是内存池(MemoryContext)。
其核心思想是:
程序不直接从操作系统分配内存,而是从MemoryContext对象中分配内存,所有分配的内存在MemoryContext对象都有记录,
在释放MemoryContext对象时,会释放从这个MemoryContext对象中分配的所有内存。

在实际应用中,可以把MemoryContext对象和某个业务实例绑定,在业务流程开始时,分配业务实例的同时分配一个与之关联的MemoryContext对象。
在处理这个业务实例过程中所需要的内存都从它关联的MemoryContext对象中分配;在这个业务流程结束时,把MemoryContext对象释放,
这样就可以释放这个业务实例处理过程中分配的所有内存。
这样在业务流程中间的模块只要根据需要分配内存即可,不用考虑释放,可以大幅减轻业务处理模块的内存管理负担。

例如考虑一个简化版的数据库逻辑,数据库服务器接收到一个sql执行请求后,我们为此sql创建一个MemoryContext对象,
在之后的语法解析,计划器,执行器等模块处理这个sql请求时,都从这个sql请求对应的MemoryContext对象分配内存。
在把执行结果返回给客户端后,释放这个MemoryContext对象。
LightDB Enterprise Postgres 内存池技术_第1张图片

有了MemoryContext,我们不仅可以减轻中间环节的内存管理负担,也可以简化某些繁琐的代码,
例如,我们可以基于内存池实现如下的字符串处理函数,这会比C标准库中的字符串函数更安全好用。

MemoryContext ctx = createMemoryContext();
// 字符串连接
char *str1 = strcat(ctx, "a", "b");
// 字符串构造
// snprintf(str2, sizeof(str2)-1, %s:%d", "hello", 123)
char *str2 = sprintf(ctx, "%s:%d", "hello", 123);

destoryMemoryContex(ctx);

基于MemoryContext的软件,在内存在使用完毕后,不会立即释放,所有内存都要等到等到整个业务流处理完后释放,
所以它会增加软件系统对内存的需求。
考虑上面简化版数据库的例子,如果每个环节都立即释放内存,则处理一个请求最多只需要500字节内存,而使用内存池则最多需要800字节。

内存池的这个延迟释放特性在很多场景下是不可接受的。例如,在数据库中,事务作为最小执行单元,我们计划
在事务开始时,为这个事务创建一个内存池,在事务结束时释放这个内存池。
LightDB Enterprise Postgres 内存池技术_第2张图片

但问题在于,一个事务中包含的语句数量是不确定的,执行时长也是不确定的。
在语句数量很多,语句执行需要较大内存,事务时间执行时间很长等情形下,
内存池会长时间占有较多内存不能释放,导致内存资源无法充分利用。

层级内存池

这个问题有效的解决办法就是让内存池支持层级结构,其结构如下所示:

LightDB Enterprise Postgres 内存池技术_第3张图片

你可以用下面的伪代码结合上面的示意图协助理解。

// 事务开始
MemoryContext tctx = createMemoryContext();

foreach(sql in sqlList) 
{
    // 为每个SQL创建一个事务的下级内存池,用于执行SQL
    MemoryContext sqlCtx = createMemoryContext(tctx);
    execute(sql);
    destoryMemoryContex(sqlCtx);
}
// 事务结束
destoryMemoryContex(tctx);

上下级的内存池的关系需遵循两点要求:

  1. 释放上级时自动释放其所有的下级
  2. 上级的生命周期比下级的生命周期长

第1点释放上级时自动释放其所有下级,这为业务流的异常处理提供了便利。
例如事务在执行过程中被意外终止,则终止逻辑只需要释放事务内存池就可以了。

在应用开发时需严格注意的是数据的生命周期,数据的生命周期是由具体业务决定的,如果数据的生命周期长于所在内存池的生命周期,
则会出现在内存的提早释放,这会引发程序异常崩溃。例如我们把某个事务级别的数据存储于SQL级别的内存池中,SQL级别的内存池释放后并不意味着
事务的结束,事务界别的数据可能还会被使用。

内存池的层级可以有很多,主要取决于业务的复杂度和内存管理精确度的需求。层级越多,内存管理越精细,编程负担越大。

要注意内存池本身的管理,如果一个下级内存池忘记释放,它就要等到上级内存释放的时候才能释放,同样会造成内存泄漏。

资源管理

从更广泛的意义上看,内存只是应用程序需要管理的一种资源,程序中普遍存在其他资源,例如常见的文件句柄,网络连接等,
这些资源在使用上和内存类似,都要先获取资源句柄,使用完毕后必须主动释放。如果获取后没有释放,则会导致资源泄漏,影响
程序的正常运行。

所以内存池可以简单地扩展,以接受这些非内存资源的管理,例如我们可以提供下面的函数:

/* 注册资源 */
void registerResource(MemoryContext ctx, void *handle, callback  free_fun);

我们把资源句柄和对应的释放函数传给内存池,在释放内存池的时候,内部就可以调用这个函数释放对应的资源。

MemoryContext ctx = createMemoryContext();

FILE *fp = fopen("xx.txt", "r");
registerResource(ctx, fp, fclose); 

fread(fp, ....); // 读写文件...

destoryMemoryContext(ctx); // 释放内存,关闭文件

这种方式有好有坏,作为一名合格的C程序员,对于资源的关闭是十分警觉的,如果他没有注意到内存池的这个特性,
维护代码时另外又加上了fclose函数调用,这就会造成重复释放。

一个折中的方法是为特定的资源提供基于内存池的构造函数,这样可以强调资源是从内存池中分配的。例如:

// 提供基于内存池的fopen函数
FILE *myFopen(MemoryContext ctx, char *, char *);

// 提供基于内存池的业务对象构造函数
Order *createOrder(MemoryContext ctx, ...);

LightDB的内存池

得益于LightDB单线程的特性,在LightDB代码中,当前内存池可以使用全局变量的引用。

LightDB使用了一个全局变量来保存当前内存池,并且提供了一个简单的内存池切换函数

/* 全局变量,指向当前内存池 */
MemoryContext CurrentMemoryContext;

// 内存池切换函数
MemoryContext MemoryContextSwitchTo(MemoryContext newCtx);

MemoryContextSwitchTo函数的实现非常简单,看实现的代码就可以,应该不需要解释了。

MemoryContext MemoryContextSwitchTo(MemoryContext context)
{
  MemoryContext old = CurrentMemoryContext;

  CurrentMemoryContext = context;
  return old;
}

使用全局变量来引用当前内存池,可以让代码简化一些,不需要频繁地把内存池作为参数到处传递。

/* 
 * 无需显式传递ctx
 * 函数内部会在CurrentMemoryContext上分配内存
 */
char *str1 = my_strcat(s1, s2);  
char *str2 = my_sprintf("%s:%d", str, num);

根据之前分析可以知道,业务处理过程中不可避免地需要切换当前内存池,以把数据放在合适生命周期的内存池上,在LightDB中途切换内存池的代码一般是这样的:

/* 1 ... */

/* 2 切换到新的内存池 */
MemoryContext oldContext = MemoryContextSwitchTo(newContext);

/* 3 在新的内存池上分配资源 */

/* 4 切换会旧的内存池*/
MemoryContextSwitchTo(oldContext);

/* 5 ... */

我们看下面这个真实的例子,这个例子来自LightDB内核代码。MessageContext可以认为是消息级别内存池,用于处理客户端发来的一条或者多条SQL语句。
但是我们在处理SQL语句的时候,不可避免地需要创建事务级别的数据,所以中途必然要切换内存池。

/* src/backend/tcop/postgres.c */
void PostgresMain(...) 
{
    for(;;) {

      /* 切换至MessageContext */
      MemoryContextSwitchTo(MessageContext);

      /* 读取网络请求消息 */
      ReadCommand(...);

      /* ... */

      /* 切换到事务上下文 */
      MemoryContextSwitchTo(CurTransactionContext);

      /* 获取事务快照 */
      analyze_requires_snapshot(parsetree);

      /* 切换到Message上下文 */
      MemoryContextSwitchTo(MessageContext);

      /* ... */
    }
}

注意切换内存池应该始终是两步配套的,如果你的函数把当前内存池切换掉了,会导致上级函数发生意料之外的错误。

void myFunc() 
{

  /*  切换到新的内存池 */
  MemoryContext oldContext = MemoryContextSwitchTo(ctx2);

  /* 忘记切换回旧的内存池*/
  // MemoryContextSwitchTo(oldContext);
}

void upperFunc() 
{
    MemoryContext oldContext = MemoryContextSwitchTo(ctx1);

    myFunc();

    // 下面的内存是在哪儿分配的???
    void *p = pmalloc(100);  

    MemoryContextSwitchTo(oldContext);
}

总结

内存池技术补充了C语言本身内存管理机制的缺失,简化编码过程中的内存管理负担,
有利于LightDB等大型C语言软件系统的稳定性。

你可能感兴趣的:(数据库,数据库开发,数据库架构,c语言)