好好活就是有意义的事,有意义的事就是好好活
记一次Bug 调试 —— 内存泄漏&&内存越界
记一次Bug 调试 —— 内存泄漏&&内存越界

记一次Bug 调试 —— 内存泄漏&&内存越界

前言

不得不说,这类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;
}

将会得到这样的数据结果:

Clion的valgrind输出

有关valgrind的使用,可以参考valgrind 的使用​www.jianshu.com/p/5a31d9aa1be2

源代码一

#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;
}

代码逻辑

  • main()函数创建了一个Poller和一个PollableQueue队列,并将队列绑定到Poller中;
  • 然后启动一个线程,线程的作用是启动Poller监控队列;
  • 然后main()函数,向队列中放入一个数据,子线程将异步的处理队列,主要就是将数据从队列中取出,不做任何处理,然后执行回调函数,打印“mem leak”

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的结果非常的多,而且是一层套一层,很难定位到是哪个位置出现了错误!

bug修复

这个代码修复其实很容易,只要改一下then()的回调函数中的引用捕获改为值捕获即

p.then([&] {
        printf("%s", s.c_str());
    }, PrintException());

改为:
p.then([=] {
        printf("%s", s.c_str());
    }, PrintException());

此时,异常信息将只剩下一个

内存泄漏

这个bug我同样找了两天,非常的痛苦,最终发现,这个bug是Pistache的Queue的设计缺陷导致的。但是现在这个代码,太难分析的,需要进一步简化:

源代码二

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);
}

代码逻辑

定义了一个Child类以及构造函数、复制构造函数和移动构造函数。
需要注意的是,移动构造函数的实现是存在缺陷的,合理的实现应该是如注释所写的那样,正是这个缺陷最后导致了Pistache的Queue设计出现了内存泄漏问题。

执行结果

这段代码的执行结果是这样的:

Child() 0 0x55f6180
Child(Child &other) 1 0x55f6730
~Queue
Child(Child &&other) 2 0x55f67e0
~Child() 2 0x55f67e0
~Child() 0 0x55f6180

从结果上我们可以发现,代码缺少了一次析构函数的执行。

查看valgrind打印的信息,发现有100字节没有释放

valgrind信息

正如上面说的,如果使用合理的移动构造函数,那么就不会存在这个问题。但是Queue的设计应该考虑到这种情况,因为只要确保所有的对象都被释放,那么就不会出现内存泄漏,因此锅应该还由Pistache背上。

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!
    这个过程可能非常抽象和难以理解,这里用内存示意图来描述整个过程:

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注