【问题标题】:libuv allocated memory buffers re-use techniqueslibuv 分配的内存缓冲区重用技术
【发布时间】:2015-04-15 04:47:47
【问题描述】:

我正在为我的广泛网络交互应用程序使用libuv,我担心哪些技术可以在 libuv 回调延迟执行的情况下同时有效和​​安全地重用分配的内存。

在非常基本的层,暴露给 libuv 用户,需要指定缓冲区分配回调以及设置句柄读取器:

UV_EXTERN int uv_read_start(uv_stream_t*, uv_alloc_cb alloc_cb, uv_read_cb read_cb);

uv_alloc_cb 在哪里

typedef void (*uv_alloc_cb)(uv_handle_t* handle, size_t suggested_size, uv_buf_t* buf);

但问题是:每次新消息通过句柄(例如,接收到来自 uv_udp_t 句柄的每个 UDP 数据报)时都会调用此内存分配回调,并为每个传入的 UDP 直接分配新缓冲区数据报似乎非常不明智。

所以我要求一种常见的 C 技术(可能在 libuv 回调系统引入的延迟执行上下文中)尽可能重用相同的分配内存。

另外,如果可能的话,我想保持 Windows 便携。

注意事项:

  • 我知道这个问题:Does libuv provide any facilities to attach a buffer to a connection and re use it;除了说明静态分配的缓冲区是不可行的事实之外,它已被接受的答案并没有回答如何使用 libuv 实际进行内存分配。特别是,它没有涵盖附加到句柄的缓冲区(通过包装结构或句柄->数据上下文)。
  • 阅读http://nikhilm.github.io/uvbook/filesystem.html ,我注意到了uvtee/main.c - Write to pipe 剪辑下面的以下短语:

    我们制作了一个副本,这样我们就可以从两个相互独立的 write_data 调用中释放两个缓冲区。虽然这样的演示程序可以接受,但您可能需要更智能的内存管理,例如引用计数缓冲区或任何主要应用程序中的缓冲区池。

    但我找不到任何涉及 libuv 缓冲区引用计数的解决方案(如何正确执行?)或 libuv 环境中缓冲区池的显式示例(是否有任何库?)。

【问题讨论】:

    标签: c memory memory-management libuv


    【解决方案1】:

    我想分享我自己解决这个问题的经验。我能感受到您的痛苦和困惑,但实际上,如果您知道自己在做什么,考虑到您有大量的选择,实施一个可行的解决方案并不太难。

    目标

    1. 实现一个能够执行两种操作的缓冲区池 - acquirerelease

    2. 基本池化策略:

      • acquire 从池中提取缓冲区,有效地将可用缓冲区数量减少 1;
      • 如果没有可用的缓冲区,则会出现两个选项:
        • 增长池并返回一个新创建的缓冲区;或
        • 创建并返回一个虚拟缓冲区(解释如下)。
      • release 将缓冲区返回到池中。
    3. 池可以是固定大小或可变大小。 “可变” 表示最初有 M 个预分配缓冲区(例如零),并且池可以按需增长到 N。“固定” 表示所有缓冲区在创建池时预先分配 (M = N)。

    4. 实现获取 libuv 缓冲区的回调。

    5. 除了内存不足的情况外,在任何情况下都不允许无限池增长仍然使池正常工作。

    实施

    现在,让我们更详细地了解这一切。

    池结构:

    #define BUFPOOL_CAPACITY 100
    
    typedef struct bufpool_s bufpool_t;
    
    struct bufpool_s {
        void *bufs[BUFPOOL_CAPACITY];
        int size;
    };
    

    size 是当前池大小。

    缓冲区本身就是一个内存块,前缀为以下结构:

    #define bufbase(ptr) ((bufbase_t *)((char *)(ptr) - sizeof(bufbase_t)))
    #define buflen(ptr) (bufbase(ptr)->len)
    
    typedef struct bufbase_s bufbase_t;
    
    struct bufbase_s {
        bufpool_t *pool;
        int len;
    };
    

    len 是缓冲区的长度,以字节为单位。

    新缓冲区的分配如下所示:

    void *bufpool_alloc(bufpool_t *pool, int len) {
        bufbase_t *base = malloc(sizeof(bufbase_t) + len);
        if (!base) return 0;
        base->pool = pool;
        base->len = len;
        return (char *)base + sizeof(bufbase_t);
    }
    

    注意返回的指针指向头部之后的下一个字节——数据区。这允许拥有缓冲区指针,就好像它们是通过对 malloc 的标准调用分配的一样。

    释放是相反的:

    void bufpool_free(void *ptr) {
        if (!ptr) return;
        free(bufbase(ptr));
    }
    

    libuv 的分配回调如下所示:

    void alloc_cb(uv_handle_t *handle, size_t size, uv_buf_t *buf) {
        int len;
        void *ptr = bufpool_acquire(handle->loop->data, &len);
        *buf = uv_buf_init(ptr, len);
    }
    

    您可以在这里看到alloc_cb 从循环上的用户数据指针中获取缓冲池的指针。这意味着缓冲池应在使用之前附加到事件循环。换句话说,您应该在创建循环时初始化一个池并将其指针分配给data 字段。如果您已经在该字段中保存了其他用户数据,只需扩展您的结构即可。

    虚拟缓冲区 是一个假缓冲区,这意味着它并非源自池,但仍然可以正常工作。虚拟缓冲区的目的是让整个事情在池饥饿的罕见情况下工作,即当所有缓冲区都被获取并且需要另一个缓冲区时。根据我的研究,在所有现代操作系统上分配大约 8Kb 的小块内存都非常快 - 这非常适合虚拟缓冲区的大小。

    #define DUMMY_BUF_SIZE 8000
    
    void *bufpool_dummy() {
        return bufpool_alloc(0, DUMMY_BUF_SIZE);
    }
    

    获取操作:

    void *bufpool_acquire(bufpool_t *pool, int *len) {
        void *buf = bufpool_dequeue(pool);
        if (!buf) buf = bufpool_dummy();
        *len = buf ? buflen(buf) : 0;
        return buf;
    }
    

    释放操作:

    void bufpool_release(void *ptr) {
        bufbase_t *base;
        if (!ptr) return;
        base = bufbase(ptr);
        if (base->pool) bufpool_enqueue(base->pool, ptr);
        else free(base);
    }
    

    这里有两个函数 - bufpool_enqueuebufpool_dequeue。基本上,他们执行池的所有工作。

    在我的例子中,在上面所说的上面有一个 O(1) 的缓冲区索引队列,这使我能够更有效地跟踪池的状态,非常快速地获取缓冲区的索引。没有必要像我那样走极端,因为池的最大大小是有限的,因此任何数组搜索也将在时间上保持不变。

    在最简单的情况下,您可以在bufpool_s 结构中的bufs 数组中将这些函数实现为纯线性搜索器。例如,如果获取了缓冲区,则搜索第一个非 NULL 点,保存指针并将 NULL 放入该点。下次释放缓冲区时,您搜索第一个 NULL 点并将其指针保存在那里。

    池内部如下:

    #define BUF_SIZE 64000
    
    void *bufpool_grow(bufpool_t *pool) {
        int idx = pool->size;
        void *buf;
        if (idx == BUFPOOL_CAPACITY) return 0;
        buf = bufpool_alloc(pool, BUF_SIZE);
        if (!buf) return 0;
        pool->bufs[idx] = 0;
        pool->size = idx + 1;
        return buf;
    }
    
    void bufpool_enqueue(bufpool_t *pool, void *ptr) {
        int idx;
        for (idx = 0; idx < pool->size; ++idx) {
            if (!pool->bufs[idx]) break;
        }
        assert(idx < pool->size);
        pool->bufs[idx] = ptr;
    }
    
    void *bufpool_dequeue(bufpool_t *pool) {
        int idx;
        void *ptr;
        for (idx = 0; idx < pool->size; ++idx) {
            ptr = pool->bufs[idx];
            if (ptr) {
                pool->bufs[idx] = 0;
                return ptr;
            }
        }
        return bufpool_grow(pool);
    }
    

    正常的缓冲区大小是 64000 字节,因为我希望它能够舒适地放入带有标头的 64Kb 块中。

    最后,初始化和反初始化例程:

    void bufpool_init(bufpool_t *pool) {
        pool->size = 0;
    }
    
    void bufpool_done(bufpool_t *pool) {
        int idx;
        for (idx = 0; idx < pool->size; ++idx) bufpool_free(pool->bufs[idx]);
    }
    

    请注意,出于说明目的,此实现已进行了简化。这里没有减少池的策略,而在现实世界的场景中,很可能需要它。

    用法

    你现在应该可以编写你的 libuv 回调了:

    void read_cb(uv_stream_t *stream, ssize_t nread, const uv_buf_t *buf) {
        /* ... */
        bufpool_release(buf->base); /* Release the buffer */
    }
    

    循环初始化:

    uv_loop_t *loop = malloc(sizeof(*loop));
    bufpool_t *pool = malloc(sizeof(*pool));
    uv_loop_init(loop);
    bufpool_init(pool);
    loop->data = pool;
    

    操作:

    uv_tcp_t *tcp = malloc(sizeof(*tcp));
    uv_tcp_init(tcp);
    /* ... */
    uv_read_start((uv_handle_t *)tcp, alloc_cb, read_cb);
    

    更新(2016 年 8 月 2 日)

    根据请求的大小获取缓冲区时使用自适应策略也是一个好主意,并且仅在请求大量数据时才返回池化缓冲区(例如所有读取和长写入) .对于其他情况(例如大多数写入),返回虚拟缓冲区。这将有助于避免浪费池化缓冲区,同时保持可接受的分配速度。例如:

    void alloc_cb(uv_handle_t *handle, size_t size, uv_buf_t *buf) {
        int len = size; /* Requested buffer size */
        void *ptr = bufpool_acquire(handle->loop->data, &len);
        *buf = uv_buf_init(ptr, len);
    }
    
    void *bufpool_acquire(bufpool_t *pool, int *len) {
        int size = *len;
        if (size > DUMMY_BUF_SIZE) {
            buf = bufpool_dequeue(pool);
            if (buf) {
                if (size > BUF_SIZE) *len = BUF_SIZE;
                return buf;
            }
            size = DUMMY_BUF_SIZE;
        }
        buf = bufpool_alloc(0, size);
        *len = buf ? size : 0;
        return buf;
    }
    

    附:有了这个 sn-p,就不需要 buflenbufpool_dummy

    【讨论】:

    • 您愿意使用 MIT/BSD 或 LGPL v3 许可证发布此代码吗?
    • 许可噩梦再次降临... =) 我相信 SO 上的所有内容都默认遵守知识共享许可的条款。我不知道如何在 MIT/BSD 许可下进一步发布我的这个巧妙的作品,但我不介意。
    • 是的,这个许可问题是一场噩梦,再加上我的偏执,只会让事情变得更糟。 :) 谢谢。
    • bufpool_enqueue 和 bufpool_dequeue 可能会非常慢,因为循环扫描空闲槽。使用堆栈或链表会是更好的选择,并且有助于重用最近使用的缓冲区来帮助缓存内存。
    • @edwinc 它们不会“非常慢”,因为它们的容量有限。它们本质上是 O(1)。此外,文章中对此有特别说明。
    【解决方案2】:

    如果您使用的是 Linux,那么您很幸运。 Linux 内核通常默认使用所谓的SLAB Allocator。这个分配器的优点是它通过维护可回收块池来减少实际的内存分配。这对您意味着,只要您始终分配相同大小的缓冲区(理想情况下为 PAGE_SIZE 的 pow2 大小),您就可以在 Linux 上使用 malloc()

    如果您不在 Linux(或 FreeBSD 或 Solaris)上,或者如果您开发跨平台应用程序,您可以考虑使用 glib 及其 Memory Slices,它们是 SLAB 分配器的跨平台实现。它在支持它的平台上使用本机实现,因此在 Linux 上使用它不会带来任何好处(我自己进行了一些测试)。我敢肯定还有其他库可以做到这一点,或者您可以自己实现它。

    【讨论】:

    • 如果您在 Linux 上测量默认 malloc() 对 16Kb 以上的大块的性能,您会发现它与较小的大小相比有多慢。另一方面,libuv 默认需要大小为 64Kb 的缓冲区。因此,如果您担心细粒度的性能,使用默认的malloc() 分配这么大的缓冲区并不过分。
    • @neoxic 我需要用更大的块大小运行一些测试,但一般的想法是,如果你用相同的块大小再次执行malloc() 然后free() 然后malloc(),它第二次应该会快得多。
    • 请做。这正是我所做的并且非常失望。然后我发现小号和大号是按照标准malloc()分开处理/存储的。
    猜你喜欢
    • 1970-01-01
    • 2017-05-29
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2017-04-17
    相关资源
    最近更新 更多