:::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 等库是一样的, 例如:
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
其余一些工具函数, 如 memdup2.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()
函数:
#include <wsutil/wmem/wmem.h>
wmem_allocator_t *myPool;
myPool = wmem_allocator_new(WMEM_ALLOCATOR_SIMPLE);
从此之后, 不用再关心使用的分配器类型(如这里的 WMEM_ALLOCATOR_SIMPLE
), 在后续函数调用中只需要传入 myPool 就行了.
3.3 销毁内存池
不论分配器类型是什么, 都可调用 wmem_destroy_allocator()
函数来销毁内存池:
#include <wsutil/wmem/wmem.h>
wmem_allocator_t *myPool;
myPool = wmem_allocator_new(WMEM_ALLOCATOR_SIMPLE);
/* Allocate some memory in myPool ... */
wmem_destroy_allocator(myPool);
3.4 重用内存池
可释放内存池的所有内存, 但不销毁它本身, 以便之后重用. 根据分配器类型不同, 这么做(调用wmem_free_all()
)可能比完全销毁再重新创建内存池高效很多. 在循环中调用时这种方法尤为推荐:
#include <wsutil/wmem/wmem.h>
wmem_allocator_t *myPool;
myPool = wmem_allocator_new(WMEM_ALLOCATOR_SIMPLE);
for (...) {
/* Allocate some memory in myPool ... */
/* Free the memory, faster than destroying and recreating
the pool each time through the loop. */
wmem_free_all(myPool);
}
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 个成员.
/* See section "4. Internal Design" of doc/README.wmem for details on this structure */
struct _wmem_allocator_t {
/* 第1组, 消费者函数 Consumer functions */
void *(*walloc)(void *private_data, const size_t size);
void (*wfree)(void *private_data, void *ptr);
void *(*wrealloc)(void *private_data, void *ptr, const size_t size);
/* 第2组, 生产者函数 Producer/Manager functions */
void (*free_all)(void *private_data);
void (*gc)(void *private_data);
void (*cleanup)(void *private_data);
/* 第3组 回调函数链表 Callback List */
struct _wmem_user_cb_container_t *callbacks;
/* Implementation details */
void *private_data;
enum _wmem_allocator_type_t type;
gboolean in_scope;
};
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