前言
不得不说,这类Bug真的是特别难排查找,故障点往往远离Bug点,这就导致程序的运行非常的诡异和费解
不知道我理解的对不对,本文这样定义内存泄漏和内存溢出:
- 内存泄漏
指的是,没有释放申请的堆内存,如:
如果忘记int main(){ void* p = malloc (100); // free (p); }
free(p)
,就会导致内存的泄漏,内存泄漏并不会导致任何错误,但是会浪费内存。 - 内存溢出
指的是,访问的内存空间,超过了允许的范围,如:int main(){ // 1、破坏栈空间 int a[10]; a[10] = 1; // 2、破坏堆空间 int *b = (int *) malloc(10 * sizeof(int)); b[10] = 1; // 3、访问非法地址 int *p; *p = 1; }
工具
检查内存溢出和越界,需要一个强大的工具进行错误排查,这里选择的是Clion+valgrind,比如如下代码:
#include <malloc.h>
int main() {
int *b = (int *) malloc(10 * sizeof(int));
b[10] = 1;
}
将会得到这样的数据结果:
源代码一
#include <pistache/http.h>
#include <proto/rfit.pb.h>
using namespace std;
struct FunctionRegisterEntry {
FunctionRegisterEntry(RFIT_NS::FunctionRegisterMsg msg_,
Pistache::Async::Deferred<void> deferred_) :
msg(std::move(msg_)),
deferred(std::move(deferred_)) {}
RFIT_NS::FunctionRegisterMsg msg;
Pistache::Async::Deferred<void> deferred;
};
auto handle(Pistache::Polling::Epoll &poller, Pistache::PollableQueue<FunctionRegisterEntry> &queue) {
return [&] {
for (;;) {
vector<Pistache::Polling::Event> events;
int ready_fds = poller.poll(events);
if (ready_fds == -1) return;
for (auto e : events) {
if (e.tag == queue.tag()) {
auto f = queue.popSafe();
f->deferred.resolve();
return;
}
}
}
};
}
void f(Pistache::PollableQueue<FunctionRegisterEntry> &queue) {
RFIT_NS::FunctionRegisterMsg msg;
string s = "mem leak";
msg.set_dldata(s);
auto p = Pistache::Async::Promise<void>(
[&](Pistache::Async::Deferred<void> deferred) {
FunctionRegisterEntry func(std::move(msg), std::move(deferred));
queue.push(std::move(func));
});
p.then([&] {
printf("%s", s.c_str());
}, PrintException());
}
int main() {
Pistache::Polling::Epoll poller;
Pistache::PollableQueue<FunctionRegisterEntry> queue;
queue.bind(poller);
thread t(handle(poller, queue));
f(queue);
t.join();
}
源码其实比较复杂,但是这已经是尽力简化了,这里面使用了Pistache的两个类,分别是PollableQueue类和Promise类,用于实现异步编程。此外还还使用了proto,创建了一个FunctionRegisterMsg
类,其结构如下:
message FunctionRegisterMsg{
string funcName = 1;
uint64 memSize = 2;
double coreRation = 3;
uint32 concurrency = 4;
bytes dlData = 5;
}
1、代码逻辑
-
main()
函数创建了一个Poller和一个PollableQueue队列,并将队列绑定到Poller中; - 然后启动一个线程,线程的作用是启动Poller监控队列;
- 然后
main()
函数,向队列中放入一个数据,子线程将异步的处理队列,主要就是将数据从队列中取出,不做任何处理,然后执行回调函数,打印“mem leak”
2、valgrind执行结果
内存越界
printf()
函数,说明是在调用printf()时,出现了bug。
首先简化一下f()
的实现:
void f() {
string s = "mem leak";
auto p = Pistache::Async::Promise<void>([&](Pistache::Async::Deferred<void> deferred) { ... });
p.then(
[&]{
printf("%s", s.c_str());
}, PrintException());
}
printf()
函数打印的是string s
,但是string s
在整个流程中都没有被修改,为什么会出现异常呢?
这其实是由于异步机制导致的,因为虽然f()
是由主线程调用的,但是p.then()
注册的回调函数并不一定由谁来执行,这一点在Promise类中有详细的分析。
在这里,显然,当主线程执行then()
时,异步处理还没有执行deferred.resolve()
,那么f()将回调函数注册到Promise就返回了,当子线程执行完异步过程,调用deferred.resolve()
时,会执行回调函数,也就是printf()
,但是要打印的字符串s
是引用的f()
的局部变量string s = "mem leak";
,其在f()返回后就被释放了,因此在这里等价于执行:
int main() {
auto *s = new string("mem leak");
delete s;
printf("%s", s->c_str());
return 0;
}
访问了已经被释放的内容,将会引起内存越界!
这个Bug我找了2天(),因为当时传入的是Respone对象,与一个简单的字符串相比,其引起的越界访问将产生大量错误,导致valgrind的结果非常的多,而且是一层套一层,很难定位到是哪个位置出现了错误!
3、bug修复
这个代码修复其实很容易,只要改一下then()的回调函数中的引用捕获改为值捕获即可:
p.then([&] {
printf("%s", s.c_str());
}, PrintException());
改为:
p.then([=] {
printf("%s", s.c_str());
}, PrintException());
此时,异常信息将只剩下一个
内存泄漏
源代码二
int childIndex;
struct Child {
Child() {
s = static_cast<char *>(malloc(100));
printf("Child() %d %p\n", index, s);
};
Child(Child &other) {
s = static_cast<char *>(malloc(100));
memcpy(s, other.s, 100);
printf("Child(Child &other) %d %p\n", index, s);
}
Child(Child &&other) noexcept {
// s=other.s;
// other.s= nullptr;
s = static_cast<char *>(malloc(100));
memcpy(s, other.s, 100);
printf("Child(Child &&other) %d %p\n", index, s);
}
~Child() {
free(s);
printf("~Child() %d %p\n", index, s);
}
char *s;
int index = childIndex++;
};
int main() {
Child c;
Queue<Child> q;
q.push(c);
}
1、代码逻辑
定义了一个Child类以及构造函数、复制构造函数和移动构造函数。
需要注意的是,移动构造函数的实现是存在缺陷的,合理的实现应该是如注释所写的那样,正是这个缺陷最后导致了Pistache的Queue设计出现了内存泄漏问题。
2、执行结果
这段代码的执行结果是这样的:
Child() 0 0x55f6180
Child(Child &other) 1 0x55f6730
~Queue
Child(Child &&other) 2 0x55f67e0
~Child() 2 0x55f67e0
~Child() 0 0x55f6180
从结果上我们可以发现,代码缺少了一次析构函数的执行。
查看valgrind打印的信息,发现有100字节没有释放
3、Queue源码分析
Pistache Queue是一种无锁的MPSC设计,即多生产者单消费者模型,算法并不难理解,就不展开了,我们主要来研究数据在push和pop过程中是怎么构造和释放的。
push
q.push(c);
template <typename U>
void push(U&& u)
{
Entry* entry = new Entry(std::forward<U>(u));
auto* prev = head.exchange(entry);
prev->next = entry;
}
template <class U>
explicit Entry(U&& u)
: storage()
, next(nullptr)
{
new (&storage) T(std::forward<U>(u));
}
- push函数会首先new一个Entry对象,而在Entry的构造函数中,执行了
new (&storage) T(std::forward<U>(u));
因为我们传入的是c
,而非std::move(c)
,因此将使用Child的复制构造函数创建一个新的Child对象,并将其值赋值给storage,此过程对应了输出结果中的Child(Child &other) 1 0x55f6730
;因为Entry对象对象是通过new创建的,因此,storage是在堆中的,进而我们的c1也是在堆中,c1.s指向的数据块当然也是在堆中,因为是在复制构造函数中用malloc创建的。
virtual ~Queue()
{
printf("~Queue");
while (!empty())
{
Entry* e = pop();
e->data().~T();
delete e;
}
delete tail;
}
- 然后程序结束,会调用queue的析构函数,因此会打印
~Queue
;
virtual Entry* pop()
{
auto* res = tail;
auto* next = res->next.load(std::memory_order_acquire);
if (next)
{
tail = next;
new (&res->storage) T(std::move(next->data()));
return res;
}
return nullptr;
}
- 在析构函数中,会调用pop函数,pop函数我们需要关注的仅仅是
new (&res->storage) T(std::move(next->data()));
在这里next->data()
就是我们在push阶段的storage
,在这里我们可以看到使用了移动构造函数,将数据传递给了新的entry对象,如果在这里我们使用的是合理的构造函数,那么原来的c1的数据,将全部交给c2,但是在这里并没有,因此c1.s依然存在与堆中 - 最后在
~Queue中
执行了e->data().~T(); delete e;
分别用于释放c2和entry对象,注意entry对象并不会主动的释放c2!
这个过程可能非常抽象和难以理解,这里用内存示意图来描述整个过程: