被FFI做局了(3) —— 定位内存泄露#
内存泄露在Rust中被不被认为是违反内存安全的。
因为泄露的内存在正常途径中,是不会被访问到的,并且最终还是会释放的,也不会出现use-after-free,所以最终还是安全的(长期运行的服务有话说)
TL;DR#
unsafe传播裸指针后,&mut self被错误的在并发条件下进行修改,从而引起容器内存泄露。
通过以下方式编译Rust静态库,
RUSTFLAGS="-Zsanitizer=address -Zexternal-clangrt -C force-frame-pointers=yes" \
cargo build -Zbuild-std --target x86_64-unknown-linux-gnu
将rust lib链接到C++代码时,使用-static-asan,即可借助asan完全展开两种语言代码的调用栈,定位内存泄露。
背景#
C++业务需要提供一个SDK,于是通过Rust代码包装主要组件,通过FFI将Rust接口暴露出去,并提供一个C++头文件,最终打包成静态库供业务使用。下称SDK代表这个static lib所负责的代码逻辑。
然后被告知测试过程中观测到内存缓慢增长,怀疑内存泄露。C++业务自查代码没发现异常。
好嘛,Rust的神话破灭力~
工具#
熟悉C++的对内存泄露可太熟悉了,查起来都是信手拈来,可能用gdb看看coredump就能找到问题。 但是Rust基本不会出现内存泄露,反而一旦出问题还得先二分代码,更别说C++链接一个嵌入了C++代码的Rust lib,找都难找。 不过工具还是相通的。
- sanitizer算是比较常用的工具组合,Rust支持也比较多,可以很方便的在编译时就链接进去。
- valgrind也可以进行分析,但是还得安装个二进制,相对没那么方便。
- miri是Rust官方开发的一个工具,用于运行和检查 Rust 代码的中间层表示MIR,功能更为强大。特别是检测unsafe代码中存在的问题。
- jemalloc_pprof可以协助使用了jemalloc内存分配器的rust程序打内存火焰图,因为默认内存分配器有太多毛病,一般都会换成jemalloc或者mimalloc,所以这点修改还是相对方便的。
使用asan进行定位#
因为C++侧先斩后奏用asan定位过了,发现是Rust侧产生了内存泄露(屋檐了,伟大的Rust竟然有内存泄露),并且自查代码后,并没有发现在FFI边界使用的CStr::as_ptr()之类的方法leak了或者没有drop。
这就令人头大了,但是更令人头大的还在后面。
神秘rust符号#
Direct leak of 4 byte(s) in 1 object(s) allocated from:
#0
0xabcde in malloc (libasan.so.4+0xabcde)
#1
0xabcde in alloc::alloc::alloc::hf5e9d1f96004a7c7/rustc/8fcd4dd08e2ba3e922d917d819ba0be066bdb005/library/alloc/src/alloc.rs:100
#2
0xabcde in C++业务代码.cc:456
...
alloc.rs:100泄露?#
怎么#1的下一跳直接回到了SDK的调用方,甚至不是SDK的C++代码本身?
也就是说整个SDK只能追踪到最后一处调用,即申请内存的那一行。
于是询问了一下编译和链接过程,cargo build…
难不成Rust打包静态库也要启用asan?
RUSTFLAGS=-Zsanitizer=address \
cargo build -Zbuild-std --target x86_64-unknown-linux-gnu
我连std都给你加上了asan重编,总该没问题了吧?编译是能编译了,结果来了个更神秘的链接错误…
libsdk.a(xxx.o) In function `asan.module_ctor':
8hayt6gtqu5sbw3clkx4qs5no:(.text.asan.module_ctor[asan.module_ctor]+0x21): undefined reference to `__asan_register_elf_globals'
什么叫没有找到asan函数的定义?酷酷找了半天资料,终于在一个犄角旮旯#114127发现了问题所在:rustc偷偷把自己的librustc-nightly_rt.asan.a静态链接进去了,而C++业务代码链接的是libasan.so,我勒个豆啊,纯坑b。在非常远的半年以后,他们终于提出了解决方案#121207。
于是我们使用解决方案重新编译SDK
RUSTFLAGS="-Zsanitizer=address -Zexternal-clangrt" \
cargo build -Zbuild-std --target x86_64-unknown-linux-gnu
并且C++侧也使用静态链接的方式使用libasan,终于成功了运行了!
Direct leak of 4 byte(s) in 1 object(s) allocated from:
#0 0xabcde in malloc (/debug+0xabcde)
#1 0xabcde in std::sys::pal::unix::alloc::_$LT$impl$u20$core..alloc..global..pal/unix/alloc.rs:14
#2 0xabcde in _rdl_alloc src/alloc.rs:402
#3 0xabcde in alloc::alloc::Global::alloc_impl::h93f3448be7b573f1/root/.rustbrary/alloc/src/alloc.rs:183
#4 0xabcde in _$LT$alloc..alloc..Global$u20$as$u20$core..alloc..Allocator$GT$unknown-linux-gnu/lib/rustlib/src/rust/Library/alloc/src/alloc.rs:243
#5 0xabcde in main (/debug+0xabcde)
#6 0xabcde in _libc_start_call_main (/lib64/Libc.so.6+0xabcde)
靠北诶,你这是啥玩意啊,还在alloc.rs起飞啊,这次怎么连c++都不见了? 检查构建模式,确实为debug,并且也没有strip
[profile.dev]
debug = true
opt-level= 0
酷酷搜寻资料,一无所获,自暴自弃找gpt帮忙,4.1悠悠道:
RUSTFLAGS="-Zsanitizer=address -Zexternal-clangrt -C force-frame-pointers=yes" \
cargo build -Zbuild-std --target x86_64-unknown-linux-gnu
这种混合编译的问题,必须强制把栈帧打开-C force-frame-pointers=yes,不然就会像现在这样出问题。
事后测试,纯Rust的代码只需要
RUSTFLAGS=-Zsanitizer=address就够了
最终成功拿下泄露点~
Direct leak of 4 byte(s) in 1 object(s) allocated from:
#0 0xabced in malloc (/debug+0xabced)
... 一些std调用栈(包括alloc.rs:100)
#7 泄露点的函数调用
#8 0xabcde in 泄露点 src/lib.rs:101
#9 0xabdce in SDKC++侧.cc:123
#10 0xabcde in C++业务代码.cc:456
...
哦咩跌多!哦咩跌多!
额外的信息#
其实可以更早定位出来问题是HashMap导致的,因为在上asan之前,出现过了线程卡死的问题,并且后续已经gdb定位到了使用HashMap的上下文,甚至具体到了行号,且多个线程均卡死在同一个地方。后面把asan用起来才反应过来这里有问题。
Thread 0xabcde***** (LWP ***) 0xabcde********* in __memcmp_sse4_1 () from /lib64/libc.so.6
因为线程不安全的容器在多线程环境下读写,大概率会重复写同一个槽,这样卡死在memcmp也不奇怪了
反思#
那我问你,为什么你在并发条件下不用DashMap而是用HashMap,脑子有坑?
非也,其实是被rust-analyzer蒙蔽了双眼
思维惯性#
平时写代码会先写个HashMap用着,因为不确定后面该结构体是需要整体上锁,或者是其他用途(吃了不先设计的亏),或者压根就是单独有线程/协程直接持有所有权,并没有线程安全问题。况且,借用检查器也会帮我检查是否存在多处持有&mut T,或是试图修改不可变的&T。
于是我写了这么一个函数,没有任何问题。
struct S {
map: HashMap<u64,u64>,
}
impl S {
// 这是可变引用
fn foo(&mut self, k: u64) {
let v = self.map.entry(k).or_insert(1);
i_can_do_what_the_fuck_ever_i_want_with_mut(v);
}
}
🦀神跌下神坛?#
但是问题就在于我想把S通过FFI给出去,所以我写出了这样一段代码
// 是的,我把S实例的裸指针给出去了
// 甚至图方便,没有把
// Box::into_raw(Box::new(s))
// 得到的 *mut S 转换为 *const S 再传递
#[no_mangle]
unsafe extern "C" fn bar(s: *mut S) {
if s.is_null() {
do_sth();
} else {
unsafe { (*s).foo() }; // 于是蟹神降下了神罚,这真的not safe
}
}
因为unsafe传出了裸指针之后,借用检查器已经不能对这部分代码负责了。 火速把HashMap换成DashMap之后,问题解决。
真的泄露了吗?#
像一些static变量,rust是不会回收内存的,而是等待OS直接在进程exit时收回。
valgrind可能会把这部分内存报告为leak,容易影响判断。所以有时候确实是sanitizer方便一些,加编译参数即可快速使用。
