[翻译]内存 - 第四部分:Intersec定制分配器

原文地址:https://techtalk.intersec.com/2013/10/memory-part-4-intersecs-custom-allocators/ 算法

# malloc()不是适用全部场景的分配器

malloc()因为其通用性而很是易于使用。它没有对分配和释放的上下文作任何的假设。 这样的分配器能够连续使用,也能够在一整个执行任务先后分开使用。它们能够用在同一个线程,也能够在多个线程。由于它很通用,每一次分配都是不一样的,这意味着生存周期长的分配内存和生存周期短的分配内存都在一个内存池里。 数组

这样致使malloc()的实现比较复杂。由于内存能够在多个线程之间共享,内存池也必须是共享的,而且必需要上锁。因为现代硬件有愈来愈多的物理线程(注,应该是指CPU的多核),每次分配都对内存池上锁会对性能有灾难性的影响。所以,现代的malloc()实现采用线程本地缓存,只有当本地缓存太大或过小的时候才会锁主内存池。这也致使一些内存驻留在线程本地缓存中,不容易被其余线程使用。 缓存

既然内存块驻留在不一样的位置(线程本地缓存,全局内存池,或进程简单分配),堆会变得碎片化。未使用的内存则很难被释放回内核。而且有很大的可能,两次连续的分配,获得的内存离得很远。致使在堆上作随机访问。正如咱们在前面的文章看到的,对内存随机访问的方式离最优方案还差的很远。 安全

所以,有时候,行为可预测的特殊分配器是颇有必要的。在Intersec,咱们有好几个这样的分配器,分别用在不一样场景下。在一些特定的用例中,咱们能够提高几个数量级的性能。 网络

# 性能测试

为了提供一些可对比的点,咱们跑了一些本身写的性能测试。咱们测试了malloc()和free()在两个场景的性能。第一个是简单场景:咱们分配100万个指针,而后释放它们。咱们使用单线程环境和小块的分配来测试原始的分配器。 多线程


#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>
#include <sys/time.h>
 
struct list {
    struct list *next;
};
 
static int64_t timeval_diffmsec(const struct timeval *tv2,
                                const struct timeval *tv1)
{
    int64_t delta = tv2->tv_sec - tv1->tv_sec;
 
    return delta * 1000 + (tv2->tv_usec - tv1->tv_usec) / 1000;
}
 
int main(int argc, char *argv[])
{
    for (int k = 0; k < 3; k++) {
        struct timeval start;
        struct timeval end;
        struct list *head = NULL;
        struct list *tail = NULL;
 
        /* Allocation */
        gettimeofday(&start, NULL);
        head = tail = malloc(sizeof(struct list));
        for (int i = 0; i < 100000000; i++) {
            tail->next = malloc(sizeof(struct list));
            tail = tail->next;
        }
        tail->next = NULL;
        gettimeofday(&end, NULL);
 
        printf("100,000,000 allocations in %ldms (%ld/s)\n",
               timeval_diffmsec(&end, &start),
               100000000UL * 1000 / timeval_diffmsec(&end, &start));
 
        /* Deallocation */
        gettimeofday(&start, NULL);
        while (head) {
            struct list *cur = head;
 
            head = head->next;
            free(cur);
        }
        gettimeofday(&end, NULL);
        printf("100,000,000 deallocations in %ldms (%ld/s)\n",
               timeval_diffmsec(&end, &start),
               100000000UL * 1000 / timeval_diffmsec(&end, &start));
    }
 
    return 0;
}



第二个场景增长了多线程:给咱们的指针都分配完内存之后,咱们开始在另外一个线程释放它们,同时在主线程分配另一批指针。这样,分配和释放分别在两个线程同时进行,分配池产生了竞争。 函数

性能测试执行了三次:一次用ptmmalloc()(glibc的实现),另外一次是用tcmalloc()(Google的实现),最后是用jemalloc()(FreeBSD实如今Linux上的移植版)。 性能

结果的确依赖于malloc()的不一样实现。没有竞争的状况下,ptmalloc()比tcmalloc()性能稍微好一点(可是花费了大得多的内存空间)。tcmalloc()在多线程环境表现好得多。 测试

一批8字节指针包含100M个时,意味着要分配800MB(762MiB。注,原文这里用词比较注意,MB应该指以1000为计算单位,MiB则是以1024为计算单位)。所以在单线程的用例里,实际数据就是762MiB大小。咱们能够看到tcmalloc在内存占用上优化的更好。不过奇怪的是,tcmalloc释放被分配更慢:释放速度没法和分配速度匹配,致使咱们在多线程测试中增长线程数的时候,内存占用一直在增长。 优化

性能测试用例是人为造的,只是用极其特别的用例对小块内存的分配进行压力测试。所以这不能做为一个绝对的证据证实多线程环境tcmalloc更快,而单线程环境ptmalloc更快。可是,测试说明了没有完美的malloc()实现,为你的用例选择正确的实现可能对整体性能有巨大的影响。

最后,但不是最不重要,测试告诉你,每秒只能作一两百万的内存分配/释放。这个数字看起来很大,可是若是你每秒要处理几十万的事件,每一个事件要触发1个或多个分配,malloc()将会成为瓶颈。

# 栈分配器

Intersec,第一个(固然也是用得最多的)自定义分配器是栈分配器。这是一个后进先出(LIFO)分配器,意思是分配和释放的顺序相反。它模仿了程序栈的行为,由于分配老是以帧为一组,释放也都是一次一帧。

## 内部实现

栈分配器是大舞台式的分配器。它分配很是巨大的块(block),而后分割为小的块(chunk)。

对于每一块(block),它追踪两个关键信息:


  • 栈底
  • 帧的分割

当一个分配被执行时,栈底增长请求的大小(加上对齐和溢出校验值canaries)。若是当前block不能分配请求的大小,那么就会分配另一个block:分配器不会尝试去填充前一个block的空隙。

当一个帧被建立,前一个帧的开始位置被压入栈底。分配器老是知道当前帧的开始位置。这样,帧的移除很是快:分配器设置栈底为当前帧的开始位置,而后从新加载前一个帧的位置,并置为当前帧。另外,分配器会列出全部彻底空闲的block,并释放它们。

释放一个帧是分期返还的常量时间,它不依赖于帧上分配的chunk数量,可是依赖于帧包含的block数量。通常block的大小能够放下好几个典型的帧,这样在大部分状况下,释放一个帧不须要释听任何block。

一般分配和释放是一个严格的顺序,两次连续的分配会返回连续的内存chunk(除非新的分配请求须要一个新的区块)。这会提高程序内存的局部性访问。更进一步,多亏了顺序分配,栈分配器里几乎没有碎片。所以,当实际分配的内存都这样作时,栈分配器的压力会降低。

## t_stack

咱们的确有一个特殊的栈分配器:t_stack。这是一个线程本地单实例的栈分配器。它被用做普通程序栈的补充。t_stack的主要优点是能够动态高效的分配临时内存。物理何时,咱们想在函数内分配内存,在函数结束时释放它们,咱们都使用基于t_stack分配。

t_stack上帧的建立和释放是绑定在函数范围的。一个特别的宏定义t_scope用在词法范围的开头。这个宏利用GNU的cleanup属性在C中模拟C++的RAII行为:它建立了某个帧,而且而且增长一个清除句柄,这样不管何时退出其定义所在的词法范围,该帧都护被销毁。


static inline void t_scope_cleanup(const void **frame_ptr)
{
    if (unlikely(*unused != mem_stack_pop(&t_pool_g))) {
          e_panic("unbalanced t_stack");
    }
}
 
#define t_scope__(n)  \
    const void *t_scope_##n __attribute__((unused,cleanup(t_scope_cleanup))) \
          = mem_stack_push(&t_pool_g)
 
#define t_scope_(n)  t_scope__(n)
#define t_scope      t_scope_(__LINE__)



既然帧的分配和释放是开发者控制的,t_stack比普通的栈更灵活。有些行为在栈上是危险的,或者也作不到。好比在一个循环内作分配,或者返回一个t_stack分配的内存。可是用t_stack就很安全。另外,因为没有大小限制(除了实际可用物理内存大小),t_stack能够用于通常性的分配目的,只要内存的生存周期和帧的分配计划相一致。

用t_stack分配内存而不带t_scope声明很明显是跟普通程序栈的行为相悖的。对普通程序栈来讲,函数不能给栈带来反作用:当函数退出时,它要把栈恢复到函数开始调用的时候。为了减少混乱,咱们使用一个编码约定:当一个函数会对t_stack有反作用时(也就是它能够在它的调用者建立的帧上进行分配),它的名称必须带一个“t_”前缀。这样就比较容易的检测到缺失的t_scope:若是一个函数使用了t_stack提供的函数,可是没有包含t_scope,那么要么它的名称有“t_”前缀,要么就是疏忽了声明t_scope。

t_stack另一个优势是,跟堆分配器相比,它常常(但不老是)让错误管理更简单。因为释放是在t_scope的结尾处自动进行的,在出错的时候也不须要加额外的处理。


/* Error handling with heap-allocated memory.
 */
int process_data_heap(int len)
{
    /* We need a variable to remember the result. */
    int ret;
    /* We will need to deallocate memory, so we have to keep the
     * pointer in a variable.
     */
    byte *data = (byte *)malloc(len * sizeof(byte));
 
    ret = do_something(data, len);
    free(data);
    return ret;
}
 
/* Error handling with t_stack-allocated memory.
 */
int process_data_t_stack(int len)
{
    /* Associate the function scope to a t_stack frame.
     * That way all `t_stack`-allocated memory within the
     * function will be released at exit
     */
    t_scope;
 
    return do_something(t_new(byte, len), len);
}



t_stack的另外一个做用是,不少堆上的短时间分配能够在t_stack上分配了。这减小了堆的碎片。并且t_stack是线程本地的,不须要处理竞争。

t_stack依赖于一些非标准的C扩展,对于Intersec的一些新人来讲有些难以想象。可是在Intersec它的确是对语言提供的标准库以外一个很好的补充。

## 性能测试

咱们对栈分配器作了性能测试:

正如你看到的,这个分配器很快:它比ptmalloc和tcmalloc的最优状况更好。多亏帧机制,释放彻底不依赖于分配(测试代码能够改善一下,测算帧的建立和销毁性能)。

栈分配器的当前实现用__BIGGEST_ALIGNMENT__来作最小分配对齐。这是一个平台相关的常量,表示CPU的最大对齐要求。在x86_64上,这个常量是16字节,由于一些操做16字节数组的指令(例如SSE指令)要求按16字节对齐。这解释了为何内存占用是最优状况的两倍。

# 先进先出分配器(FIFO)

## 先进先出问题

另外一个常常用到的内存用法是先进先出(FIFO)管道:内存的释放顺序跟分配顺序(近似的)一致。一个典型的用例是在网络协议的实现中缓冲请求的上下文:当一个请求被发出并释放,以及收到响应时,每个请求都要关联到一个已分配的上下文。大部分状况下,请求被以发出的顺序处理(这不老是正确的,可是即便有个别处理时间很长的请求,对于这类处理来讲,也只是很短的时间)。

当先进先出数据在堆上直接分配时,它会放大碎片问题,由于下一个释放的chunk极可能不在堆的末端,这样就会产生一个空洞(而且因为堆上不只有先进先出数据,还有其余分配可能夹杂在两个先进先出的分配之间,状况会变得更糟)。

## 解决方案

由于这些缘由,咱们以为把这种使用模式跟其余的分配方式独立开来。咱们使用自定义的分配器来取代堆分配器。

这个分配器基本上跟栈分配器工做方式相同:它内部存放被线性使用的巨大的block(新的block只有在当前block没法知足下一次分配时才会被建立)。没有使用帧的模型,先进先出分配器使用计算每一个block大小的机制。每个block维护它内部分配的内存。当block内全部的数据都被释放时,它本身也被释放。block使用mmap来分配,以避免干扰到堆(所以也不会产生碎片)。

由于先进先出分配器使用跟栈分配器同样的分配模式(但不是同样的释放模式),它也有一些同样的属性。其中之一是,连续的分配获得的地址也是连续的。可是因为先进先出分配器的使用模式,这里局部性带来的好处并不重要:大部分时间,它被用来分配独立的元素,这些元素几乎不会一块儿使用。

先进先出分配器被设计为在单线程环境中使用,所以不须要处理竞争的问题。

## 性能测试

咱们使用先进先出分配器来运行咱们的无竞争版性能测试,这样能够和malloc来比较性能:

跟栈分配器同样,先进先出分配器的性能比malloc实现更好。可是比栈分配器慢一点点,这是由于它要单独追踪每一个分配的信息,所以没有想栈分配器同样优化。

## 循环分配器

循环分配器是栈分配器和先进先出分配器的混合。它使用帧来给内存分配分组,能够在常量时间释放一大批的分配,尽管它大部分是先进先出的使用模式。循环分配器中的帧不是堆叠的,每一个堆是独立且自包含的。

为了在循环分配器上分配内存,首先要建立一个新的帧。这要求以前的帧已经封闭。当一个帧在分配器中被打开,就能够进行分配,而且自动成为活动帧的一部分。当全部的分配都完成,帧必须被封起来。一个封闭的帧仍然是活跃的,这意味着它包含的分配仍然是能够访问的,只是帧不能再进行新的分配。当帧再也不被须要时,必须被释放。

在帧分配器中释放内存是线程安全的。这使得这个分配器在工做线程中构造用于传输的消息很是有用。在那个上下文中,它几乎能够在那些须要处理多线程的代码中直接替换t_stack来工做。


/* Single threaded version, use t_stack.
 */
void do_job(void)
{
    t_scope;
    job_ctx_t *ctx = t_new(job_ctx_t, 1);
 
    run_job(ctx);
}
 
/* Multi-threaded version, using ring allocation.
 * Note that it uses Apple's block extension.
 */
void do_job(void)
{
    const void *frame = r_newframe();
    job_ctx_t *ctx = r_new(job_ctx_t, 1);
 
    r_seal();
    thr_schedule(^{
        run_job(ctx);
        r_release(frame);
    });
}



这个用例里,帧基本上是按照先进先出的方式使用。


咱们使用这个分配器再次运行基准测试。因为环形分配器是线程安全的,基准测试覆盖了有竞争和无竞争的用例:

## 其余自定义分配器

本文介绍了Intersec使用的三种自定义分配器。这三个分配器不适用于通常的用途:它们是为了特别的使用模式而优化的。幸运的是,在大部分状况下,这三个分配器的组合使用足够让咱们避开咱们碰到的跟malloc有关的问题,好比缺少局部性,分配时的锁竞争和堆碎片。

然而,有些时候没有办法,咱们没有其余选择,必须实现一个自定义通用内存分配器来知足咱们的性能要求。所以,咱们也有一个基于TLSF (Two Level Segregate Fit)分配器。TLSF是被设计来作实时处理的分配算法,它保证操做是常数时间(分配和释放都是)。

还有更多有趣的,咱们也有页分配器和持久化分配器。最后一个咱们可能会在之后的文章讲到。

相关文章
相关标签/搜索