:::success 我不想只是翻译这个文件的内容, 会加入自己的注解

最后基于 wireshark 3.7.0 版本文档更新 :::

1 介绍

wmem 是 Wireshark 的内存管理框架(Wireshark MEMory manager), 用于替换 Wireshark 2.0 移除的旧框架 emem.

为了便于内存管理, 以及降低内存泄漏的可能性, Wireshark 引入了自己的内存管理 API. 此 API 实现于 wsutil/wmem, 提供了内存池和函数, 其甚至可用于处理异常(很多解析器可引发异常)时的内存管理. 协议解析所用的 memory scopes 定义在源码 epan/wmem_scopes.h中. 正确使用 wmem 可提升代码性能, 并大幅降低异常情况下内存泄漏的可能性.

wmem 最初是在发往 wireshark-dev 邮件列表的邮件里构思的.

下文的生产者和消费者, 分别指 wmem 内存池的创建者和使用者.

2 消费者指南

在开发解析器, 或者其他”userspace”代码时, 使用 wmem 和使用 malloc, g_malloc 等内存分配机制很相似. 只需包含头文件(epan/wmem_scopes.h), 还可获得内存池句柄(如果想创建内存池的话, 见下面的小节”3 生产者指南“).

内存池是指向 wmem_allocator_t 的不透明指针, 它是几乎所有 wmem 函数的第一个参数. 除了此参数以外, 这些函数的用法和 glib 等库是一样的, 例如:

  1. wmem_alloc(myPool, 20);

以上代码在 myPool 指向的内存池分配了 20 字节空间.

2.1 内存池生命周期

每个内存池都有定义好的生命周期, 或作用域(scope), 在这之后内存池中的所有内存将被无条件释放. 当使用内存池分配内存时, 必须小心其生命周期:

  • 如果生命周期 < 所需, 会带来释放后又使用(use-after-free)问题;
  • 如果生命周期 > 所需, 会导致无法探查的内存泄漏

在这两种情况下, (使用 wmem 内存池的)风险都会大于收益.

如果没有内存池能匹配所需内存的生命周期, 我们有两个选择:

  • 创建新的内存池(见第3节)
  • 使用 NULL 内存池

所有带有 wmem_allocator_t 指针参数的函数, 都接受 NULL 参数, 此时内存需要手动管理(类似 malloc 或 g_malloc). 这样分配的内存必须通过 wmem_free() 手动释放以避免内存泄漏(不过这种泄露至少是可以通过 valgrind 检查到的). 注意把通过 wmem 分配的内存直接传递给 free() 或 g_free() 是不安全的, 因为手动管理的内存的后端类型是可能改变的.

2.2 Wireshark全局内存池

只要包含 wmem 头文件, 解析器就自动有以下内存池可用:

  • pinfo->pool
    用于单报文解析, 即在当前报文解析后内存就会被自动释放
  • wmem_packet_scope()

类似 pinfo->pool, 用于不方便取得 pinfo 的情况, 但最好还是使用 pinfo->pool

  • wmem_file_scope()
    文件作用域, 当前文件关闭后内存会被自动释放
  • wmem_epan_scope()

EPAN 库作用域, 直到 epan_cleanup() 调用后才会释放内存. 也可不调用, 因为程序结束后会自动释放

这三个作用域的大小关系为: epan_scope > file_scope > packet_scope.
在内存池的作用域之外使用它们会导致抛出断言错误, 见 wmem/wmem_scopes.c.

2.3 pinfo内存池

某些内存分配需要比报文解析作用域(packet scope)更长一点(如 AT_STRINGZ地址和传递给 add_new_data_source() 的东西) — 一般是到下一个报文被解析后. 这时可使用 pinfo 结构体的 pool 成员, 它是一个作用域为 pinfo 结构体生命周期的 wmem 内存池.

2.4 API

各函数的完整文档可参考源码头文件中的 Doxygen 注释, 这里仅给出应该参考的各头文件的概要信息.

2.4.1 核心API

  • wmem_core.h

基础内存管理函数 (wmem_alloc, wmem_realloc, wmem_free)

2.4.2 字符串

  • wmem_strutl.h

用于处理以 0 结尾的 C 字符串(如 strdup, strdup_printf)

  • wmem_strbuf.h

类似 C++ 里 std::string 或 Glib 里 GString 的字符串实现

2.4.3 容器数据结构

  • wmem_array.h
    自动增长数组(vector)
  • wmem_list.h
    双向链表
  • wmem_map.h
    hash map 或称 hash table
  • wmem_multimap.h
    hash multimap, 与 map 的区别是 value 的 key 可以重复. 这个容器是较新版本才实现的, 3.4.5 中还没有
  • wmem_queue.h

先进先出(FIFO)队列

  • wmem_stack.h

后进先出(LIFO)栈

  • wmem_tree.h

平衡二叉树(红黑树)

2.4.4 其他工具

  • wmem_miscutl.h
    其余一些工具函数, 如 memdup

    2.5 回调函数

    :::warning 一般不需要使用这个功能; 如果要使用, 必须确保正确理解其危险性 ::: 有时需要在内存释放之前执行额外的清理工作, 比如需要关闭某个文件句柄. 此时, 可调用 wmem_register_callback() 来注册一个回调函数, 当内存池中的内存被释放前, 会先执行所有已注册的回调函数.

注意回调函数的调用顺序是未定义的, 不能假设某个回调函数先于或后于另一个被调用.

:::warning 手动释放或移动内存(wmem_free, wmem_realloc)不会触发回调函数. 在已注册回调函数的内存上手动调用这些函数是错误的 :::

3 生产者指南

如果只编写解析器(即 wmem 的使用者), 不需要看这个小节.

旧的 emem 框架的一个问题是它只有两种分配器后端: glib 和 mmap, 这两者在 if 语句, 环境变量和 #ifdef 宏的各种组合里被混合使用. wmem 中不同分配器后端被清晰的分离, 由内存池的所有者来选择使用哪一个.

3.1 可用分配器后端

每个分配器对应 wmem_core.h 文件里 wmem_allocator_type_t 枚举中的一项, 详见代码中的 Doxygen 注释.

3.2 创建内存池

包含 wmem 头文件并调用 wmem_allocator_new() 函数:

  1. #include <wsutil/wmem/wmem.h>
  2. wmem_allocator_t *myPool;
  3. myPool = wmem_allocator_new(WMEM_ALLOCATOR_SIMPLE);

从此之后, 不用再关心使用的分配器类型(如这里的 WMEM_ALLOCATOR_SIMPLE), 在后续函数调用中只需要传入 myPool 就行了.

3.3 销毁内存池

不论分配器类型是什么, 都可调用 wmem_destroy_allocator() 函数来销毁内存池:

  1. #include <wsutil/wmem/wmem.h>
  2. wmem_allocator_t *myPool;
  3. myPool = wmem_allocator_new(WMEM_ALLOCATOR_SIMPLE);
  4. /* Allocate some memory in myPool ... */
  5. wmem_destroy_allocator(myPool);

销毁内存池将释放其中已分配的所有内存.

3.4 重用内存池

可释放内存池的所有内存, 但不销毁它本身, 以便之后重用. 根据分配器类型不同, 这么做(调用wmem_free_all())可能比完全销毁再重新创建内存池高效很多. 在循环中调用时这种方法尤为推荐:

  1. #include <wsutil/wmem/wmem.h>
  2. wmem_allocator_t *myPool;
  3. myPool = wmem_allocator_new(WMEM_ALLOCATOR_SIMPLE);
  4. for (...) {
  5. /* Allocate some memory in myPool ... */
  6. /* Free the memory, faster than destroying and recreating
  7. the pool each time through the loop. */
  8. wmem_free_all(myPool);
  9. }
  10. wmem_destroy_allocator(myPool);

4 内部设计

wmem 虽然是以 C90 C 实现的, 但却使用了面向对象设计模式. 尽管性能很重要, 但开发 wmem 的主要目标是可维护性和防止内存泄漏.

4.1 struct _wmem_allocator_t

wmem_allocator.h 头文件中的 _wmem_allocator_t 结构体是 wmem 的核心. 此结构体使用 C 函数指针来实现”接口”这种面向对象设计模式(在 C++ 中也称抽象类).

不同分配器实现需提供完全相同的接口, 并将此结构体实例的成员赋值为具体的函数. 此结构体分为 3 组, 共 10 个成员.

  1. /* See section "4. Internal Design" of doc/README.wmem for details on this structure */
  2. struct _wmem_allocator_t {
  3. /* 第1组, 消费者函数 Consumer functions */
  4. void *(*walloc)(void *private_data, const size_t size);
  5. void (*wfree)(void *private_data, void *ptr);
  6. void *(*wrealloc)(void *private_data, void *ptr, const size_t size);
  7. /* 第2组, 生产者函数 Producer/Manager functions */
  8. void (*free_all)(void *private_data);
  9. void (*gc)(void *private_data);
  10. void (*cleanup)(void *private_data);
  11. /* 第3组 回调函数链表 Callback List */
  12. struct _wmem_user_cb_container_t *callbacks;
  13. /* Implementation details */
  14. void *private_data;
  15. enum _wmem_allocator_type_t type;
  16. gboolean in_scope;
  17. };

4.1.1 实现细节

  • private_data
  • type

private_data 是一个 void 指针, 分配器实现可用它来保存内部所需的数据结构. private_data 指针会被传递到几乎所有其他函数.

type 是一个 wmem_allocator_type_t 枚举值, 它的值是由wmem_allocator_new()函数设置的, 而不是与实现相关的构造函数. 分配器实现应将此成员视为只读.

消费者函数

  • walloc()
  • wfree()
  • wrealloc()

这三个函数与标准库相应函数语义相同, 都带有分配器的private_data参数.

注意 wrealloc() 和 wfree() 一般不需要由用户代码直接调用, 它们主要是为 wmem 可能希望实现的数据结构所使用的优化(比如, 如果没有 realloc, 实现一个动态数组是不高效的).

另要注意分配器不需要处理 NULL 指针或长度为 0 的请求 — 这些检查在 wmem 上层就做了. 分配器作者可假定所有传入的指针(wrealloc 和 wfree) 都是非空的, 所有传入的长度(walloc 和 wrealloc)都是非 0 的.

生产者/管理器函数

  • free_all()
  • gc()
  • cleanup()

这几个函数仅带有一个参数, 即分配器的 private_data 指针.

free_all()函数应释放内存池中当前已分配的所有内存. 注意这不必和在所有已分配的内存块上调用 free() 一样 — free_all() 允许执行额外的清理工作, 或使用在每次释放一个内存块时无法执行的优化.

gc()函数应通过尽可能把不需要的内存还给系统, 或优化内部数据结构等手段, 以减少解析器过多的内存用量.

cleanup()函数应执行最终的清理工作并释放所有内存. 它类似于析构函数. 为便于使用, wmem 负责在调用此函数之前调用 free_all(). 但 gc() 并不保证被调用.

4.2 内存池无关(Pool-Agnostic) API

emem 的一个问题是每个作用域实现都需要封装函数. 即使已有 stack 实现, 也无法使用文件作用域内存, 除非有人花时间(为文件作用域内存)写了 se_stack_wrapper 封装函数.

在 wmem 中, 所有公共 API 以内存池做为第一个参数, 因此(如数据结构)可编写一次而使用所有内存池. 像 wmem stack 这样的数据结构, 仅在创建时使用内存池参数, 此指针会保存在内部数据结构中, 后续调用(如 push, pop) 则只需要 stack 本身而不需要内存池参数.

4.3 调试

wmem 的主要调试控制是 WIRESHARK_DEBUG_WMEM_OVERRIDE 环境变量. 如果设置此环境变量, 所有 wmem_allocator_new() 调用将返回相同类型的分配器, 无视代码中传入的类型. 该环境变量目前有 4 个值:

  • simple
    强制使用 WMEM_ALLOCATOR_SIMPLE. valgrind 脚本设置此值 , 因为它是唯一可被 valgrind 跟踪的分配器.
  • strict
    强制使用 WMEM_ALLOCATOR_STRICT. fuzz-test 脚本设置此值, 因为 fuzz-test 的目的是尽可能发现更多的问题.
  • block
    强制使用 WMEM_ALLOCATOR_BLOCK. 未被任何脚本使用, 但可用于 block 分配器压力测试.
  • block_fast
    强制使用 WMEM_ALLOCATOR_BLOCK_FAST. 未被任何脚本使用, 但可用于 fast block 分配器压力测试.

注意无论此环境变量的值是什么, 调用分配器特定的帮助函数总是安全的. 如果分配器参数的类型是错误的, 这些函数需要是无效操作(no-ops).

4.4 测试

wmem_test.c 中包含简单的 wmem 测试套件, 它会编译为 wmem_test. 它至少包含所有现有功能的基本测试. 此套件由 build-bots 通过 shell 脚本 test/test.py 自动调用, 而此脚本又调用 test/suite_unittests.py.

添加到 wmem 中的新特性(分配器, 数据结构, 工具函数等)必须向此套件中添加测试代码. 测试套件可能需要更熟悉 Glib 测试框架的人进行清理,但它确实可以完成这项工作。

5 性能说明

由于我自己的错误判断, 有一个持久的想法, 即 wmem 在一般情况下比其他分配器神奇地快. 但事实并非如此.

首先, wmem 提供多种不同的分配器后端, 因此试图笼统地比较 wmem 和其他系统的性能, 会令人困惑和误导的.

其次, 任何现代系统提供的 malloc 都有非常聪明和高效的分配器算法, 想要比 libc 的分配器更快总的来说是浪费时间, 除非有特定的分配模式可以去优化.

第三, 虽然历史上有人认为应该在内核前面放一些东西来减少上下文切换, 但现代的 libc 实现应该已经做到了. 进行动态库调用仍比调用本地定义, 链接优化的(locally-defined linker-optimized)函数开销要大, 但这些开销小到可以忽略.

综上所述, wmem 的某些分配器是可能比标准 libc malloc 要快的, 在下列情形:

  • BLOCK 和 BLOCK_FAST 分配器都提供很高效的 free_all() 操作, 它比在每个单独分配(的内存块)上调用 free() 要快很多个数量级
  • BLOCK_FAST 分配器专门为 Wireshark 报文作用域内存池优化, 它具有非常短且定义良好的生命周期, 以及非常正规的(regular)分配模式; 在这个特定的应用场景, 了解这些原理可轻松击败 libc