C++ 内存崩溃问题应该如何调试?

背景

最近因为项目需要,使用 C++ 开发一个简易的 HTTP Server,基本框架写完后,实际测试了一下,却出现了一个 crash 问题,而崩溃的地方莫名其妙的,排查了差不多两天,最终解决。C/C++ 程序内存崩溃问题,不管对新手还是老手来说,都是不容易解决的问题。本文通过这个实际工作中的案例来分析一下,如果一个 C/C++ 程序崩溃,应该如何排查。

服务结构

这个 HTTP Server 依赖一个基础工程,我们叫它 base 库吧,这个基础工程来自大团队的公共组件,编译后的文件叫 libbase.so。libbase.so 基于 IO 复用函数检测 socket 读写事件,然后分发读写事件给业务模块处理,其程序结构就是一个 EventLoop,如果你还不熟悉 EventLoop,可以看这两篇《one thread one loop 思想》和 《one thread one loop 经典服务器结构》。EventLoop 的基本结构(伪代码)如下:

void EventLoop::run()
{
    while (退出条件)
    {
        // 1.处理定时器事件
        processTimers();

        // 2. 利用IO复用函数检测一组socket的读写事件
        epollPollSelectDectector();

        // 3. 分发读写事件
        processReadAndWriteEvents();    
    }
}

崩溃的地方在 epollPollSelectDectector 处,崩溃的现象是,当有新连接连上来后,可以正常走到监听 socket 的 accept 函数,之后下一轮循环走到 epollPollSelectDectector 时就崩溃了,且通过崩溃的调用堆栈最底层只能看到这个函数,epollPollSelectDectector() 内部就看不到具体的崩溃处了。

理论上说,base 模块是多个团队都在使用的基础模块,经过长时间的验证,因为代码内部逻辑问题导致的崩溃的可能性较低,但是调用堆栈却显示 libbase.lib 内部崩溃,在崩溃的地方加上断点后,每次第二次执行到这里就必然崩溃,而且不是进入任何内部函数后崩溃,这就比较奇怪了。

那么,这样的问题如何排查呢?

这里请读者记住一个经验规则,C/C++ 程序大多数崩溃都是内存问题,一般有如下几种内存问题:

  • 内存出现了覆盖,例如写一个内存区域时没控制好长度,越界了,把其他字段的值破坏了,这个时候再使用这个被破坏的字段就会出现崩溃;
  • 内存被重复释放,一块内存已经被释放了,但是因为逻辑问题,再次尝试释放这块内存,这个时候也会出现崩溃,再次尝试释放不一定是用户主动行为,可能是编译器偷偷安排的工作,例如析构函数的调用。

尝试一

既然 base 模块崩溃的可能性不大,那么是不是业务模块使用 base 模块时不当?例如初始化不当,即没有按照 base 模块的正确初始化方法初始化,导致一些数据块因为没初始化被使用,导致崩溃。

于是,我认真检查和阅读了 base 模块的相关代码,确认使用 base 模块进行了正确的初始化,所以崩溃原因不是这个。

尝试二

那会不会真的是 base 模块的 bug?我的服务叫 http 模块,这是一个可执行程序,依赖 libbase.so,由于 EventLoop 的逻辑都在这个 libbase.so 中,调试起来不方便,于是临时把我的所有源码文件拷贝到 base 工程中,然后修改 CMakeLists.txt 文件(我们使用的 CMake 管理工程),让 http 直接使用 base 的源码文件。

修改后,再次使用 gdb 启动 http 程序,测试下来还是在原来的位置崩溃,这说明崩溃和 libbase.so 内部实现应该关系不大,也排除了是因为引用了错误的 base 版本,或者调试的时候 base 的源码与二进制文件不匹配误报了错误堆栈这两个原因。

尝试三

经过前面两步基本可以确定,gdb 显示的崩溃堆栈基本不具有参考价值,错误原因一定在我们自己的 http 模块,而且是内存问题。既然是内存问题,肯定属于我们上面说的两种之一,先看第一种,认真检查了一下,我们的业务代码中并没有什么内存拷贝操作,所以进一步缩小范围,一定是对象重复释放的问题。

有了方向之后,接下来就容易的多了。

这个 http 模块并不复杂,主要有 4 个类:

  • HttpServer 类是对外暴露的接口类,提供 HTTP 框架的启动停止和路由注册功能;
  • HttpServer 类通过 HttpSessionManager 类来管理 HttpSession 类;
  • HttpSession 类负责 HTTP业务逻辑处理,例如执行用户定义的各种路由回调函数;
  • HttpSession 类往下是 HttpConnection 类,HttpConnection 与业务无关,它负责通过 TCP 收取数据,然后解析 HTTP 协议,将解析的 HTTP 请求交给上层 HttpSession 类处理,同时 HttpSession 处理好业务逻辑后将需要响应的数据往下交给 HttpConnection 类,HttpConnection 负责组装成 HTTP 协议格式的包,发送出去。

简化之后的各个类代码如下:

class HttpConnection {
public:
    HttpConnection(int fd) {

    }

    ~HttpConnection() {

    }
};

class HttpSession {
public:
    HttpSession(HttpConnection* pConnection, HttpSessionManager* pSessionManager) {
        m_spConnection.reset(pConnection);

        m_clientID = pConnection->getIP() + ":" + pConnection->getPort() + ":" + generateUniqueID();
    }

    ~HttpSession() {

    }

    std::string getClientID() const {
        return m_clientID;
    }

private:
    std::unique_ptr<HttpConnection>     m_spConnection;
    HttpSessionManager*                 m_sessionManager;
    std::string                         m_clientID;
};

class HttpSessionManager {
public:
    HttpSessionManager();
    ~HttpSessionManager();

    void onAccept(int fd) {
        auto pConnection = std::make_unique<HttpConnection>(fd);
        auto pSession = std::make_shared<HttpSession>(pConnection.get(), this);
        auto clientID = pSession->getClientID();
        {
            std::lock_guard<std::mutex> scopedLock(m_sessionMutex);
            m_mapSessions.emplace(clientID, pSession);
        }
    }

private:
    std::map<std::string, std::shared_ptr<HttpSession>>     m_mapSessions;
    std::mutex                                              m_sessionMutex;
};

既然是对象重复释放问题,那么我们在这几个自定义类的构造函数和析构函数中加上日志,并打印当前对象 this 指针观察一下,看看各个对象的构造和析构是否成对匹配。

加了日志后,我们发现当接受一个新连接时:

  • HttpSession 类构造了一次,无析构;
  • HttpConnection 类构造一次,析构一次

断开连接时:

  • HttpSession 类析构一次,然后崩溃。

到这里我们看出,程序的行为已经不符合预期了:接受连接,HttpSession 和 HttpConnection 类应该均构造一次,不会发生析构;连接断开时,HttpSession 和 HttpConnection 类应该均析构一次。

正因为 HttpConnection 对象提前析构了一次, HttpSession 之后使用这个析构的 HttpConnection 对象导致崩溃(代码中 HttpSession 有一个指向 HttpConnection 的成员变量智能指针),HttpSession 即使不使用 HttpConnection 对象,在断开连接时,HttpSession 析构会触发其成员变量 HttpConnection 对象的析构,而此时HttpConnection 对象早就不存在了,程序仍然崩溃。

那么问题来了,为啥 HttpConnection 对象会提前析构?

我们来重点看下 HttpSessionManager 对象的 onAccept 函数:

void onAccept(int fd) {
    auto pConnection = std::make_unique<HttpConnection>(fd);
    auto pSession = std::make_shared<HttpSession>(pConnection.get(), this);
    auto clientID = pSession->getClientID();
    {
        std::lock_guard<std::mutex> scopedLock(m_sessionMutex);
        m_mapSessions.emplace(clientID, pSession);
    }
}

pConnection 是一个类型为 std::unique_ptr<HttpSession> 的智能指针对象,pConnection 出了 onAccept 函数作用域之后,会自动析构,当析构该对象时,其持有的资源引用计数变为 0,导致 HttpConnection 对象析构。但是,接下来的一行,却将该 HttpConnection 对象的原始指针传给了 HttpSession 对象, HttpSession 对象内部用另外一个 std::unique_ptr 对象 m_spConnection 持有这个指针。这里违反一个使用智能指针的原则:一旦一个堆对象被智能指针管理后,就要一直用智能指针管理,尽量不要再将对象的原始指针到处传递了。因而,犯了错误,导致程序崩溃。

如果你对 C++11 智能指针不熟悉,可以看这篇文章《 Modern C++ 智能指针详解》。

问题原因找到了,我们根据上述原则,修改下代码(这里只贴出修改之处):

class HttpSession {
public:
    HttpSession(std::unique_ptr<HttpConnection>& pConnection, HttpSessionManager* pSessionManager) : m_spConnection (pConnection) {
        m_clientID = pConnection->getIP() + ":" + pConnection->getPort() + ":" + generateUniqueID();
    }

private:
    std::unique_ptr<HttpConnection>     m_spConnection;    
};

void onAccept(int fd) {
    auto pConnection = std::make_unique<HttpConnection>(fd);
    auto pSession = std::make_shared<HttpSession>(pConnection, this);
    //代码省略...
};

这样写,是没法通过编译的,因为 std::unique_ptr 的拷贝构造函数定义如下:

<template T>
class unique_ptr {
public:
    unique_ptr(const unique_ptr& rhs) = delete;
}

也就是说 std::unique_ptr 的拷贝构造函数被显式删掉了(想一想为什么?),所以无法在 HttpSession 的初始化列表中调用其拷贝构造函数赋值给 m_spConnection 对象,好在 std::unique_ptr 的移动构造函数(Move Constructor)是可以正常使用的,所以,我们将 HttpSession 的第一个参数修改成右值引用:

class HttpSession {
public:
    HttpSession(std::unique_ptr<HttpConnection>&& pConnection, HttpSessionManager* pSessionManager) : m_spConnection (pConnection) {
        m_clientID = pConnection->getIP() + ":" + pConnection->getPort() + ":" + generateUniqueID();
    }

private:
    std::unique_ptr<HttpConnection>     m_spConnection;
};

然后,在 onAccept 函数中传递这个右值:

void onAccept(int fd) {
    auto pConnection = std::make_unique<HttpConnection>(fd);
    //使用std::move将左值pConnection变成右值
    auto pSession = std::make_shared<HttpSession>(std::move(pConnection), this);
    auto clientID = pSession->getClientID();
    {
        std::lock_guard<std::mutex> scopedLock(m_sessionMutex);
        m_mapSessions.emplace(clientID, pSession);
    }
}

但是,这样的代码还是无法编译,所以现在传递给 HttpSession 的构造函数中第一个实参是右值了,但是对不起,等实际传到 HttpSession 的构造函数中又变成左值了,所以我们需要再次 std::move 一下,修改后的代码如下:

class HttpSession {
public:
    HttpSession(std::unique_ptr<HttpConnection>&& pConnection, HttpSessionManager* pSessionManager) : m_spConnection (std::move(pConnection)) {
        m_clientID = pConnection->getIP() + ":" + pConnection->getPort() + ":" + generateUniqueID();
    }

private:
    std::unique_ptr<HttpConnection>     m_spConnection;
};

程序至此可以编译通过了,但是实际一运行还是崩溃......

哦,还有个地方忘记修改了,在 HttpSession 构造函数中,pConnection 被 std::move 之后就剩下一个空壳子了,其“肉体”已经转移给了 m_spConnection,所以不能在 HttpSession 构造函数中使用 pConnection 调用 getIP 和 getPort 方法了,应该改用 m_spConnection 来调用这两个方法,修改后代码如下:

class HttpSession {
public:
    HttpSession(std::unique_ptr<HttpConnection>&& pConnection, HttpSessionManager* pSessionManager) : m_spConnection (std::move(pConnection)) {
        m_clientID = m_spConnection->getIP() + ":" + m_spConnection->getPort() + ":" + generateUniqueID();
    }

private:
    std::unique_ptr<HttpConnection>     m_spConnection;
};

再次编译代码,运行后,程序不再崩溃,至此这个 crash 问题完美解决。

总结

C++11(Modern C++)以及之后的版本提供的智能指针使用起来确实很方便,也建议你在实际的 C++ 的项目中多多使用,可以避免很多内存泄漏问题,但是前提是我们必须充分理解每一种智能指针的用法和注意事项,尤其是在和左值、右值、移动构造、std::move 、std::forward 等特性结合使用时,需要多加小心。

C++ 程序的内存崩溃问题一直是繁、难问题,出现这类问题时,不要胡乱尝试,一定要思路明确,慢慢缩小范围,本文的思路以及介绍中两种引起内存的问题,深入理解,可以帮你解决大多数内存引起的崩溃问题。

从哪里可以系统地学到本文介绍的知识?

有读者私信我,本文的这些知识在哪里可以系统地学到,本文一共提到了三大块东西,我挨个给大家推荐一些比较好的书。(链接收集于网络,喜欢请购买正版。)

如何学习 Modern C++?

现在很多 C++ 项目都开始支持 C++11 了,作为 C++ 开发者你应该拥抱 Modern C++,一旦熟悉了,相信你会喜欢 Modern C++ 带来的便捷。C++11 是需要重点学习的,C++14/17 乃至 C++20 可以简略了解一下即可。

推荐书单:

  • 《深入理解 C++11:C++11 新特性解析与应用》
  • 《深入应用 C++11:代码优化与工程级应用》
  • 《C++17 完全指南》
  • 《Cpp 17 in Detail》
链接:  https://pan.baidu.com/s/1Sq5xgLehQRA1Aqy_nbJR5w 提取码: qmrm

另外,Effective C++ 系列的作者 Scott Meyers 又写了本介绍现代 C++ 各种技巧和避坑指南的书《Effective Modern C++》,这本书也不错,推荐一下:

链接:  https://pan.baidu.com/s/10W2rnKztXyZJEoIGVTjYdw 提取码: sudh
链接:  https://pan.baidu.com/s/1tU1haj9YMPVxpVrrbNZy1A 提取码: xmhu

C++ 20 可以看《Modern C++ Programming Cookbook》这本书,虽然是英文版的,但是这个书写的比较通俗易懂,且给的例子也比较直白。

链接:  https://pan.baidu.com/s/1ak7_Yk5xo9KM5-l7XjX7Tg
提取码: g6ij

如何学习 C++ 网络编程

C++ 网络编程方面的实战书来,我推荐韩国人尹圣雨写的这本《TCP/IP 网络编程》,这本书也适合无任何 Socket API 编程经验的小白,这本书涵盖从基础的 Socket API 到高级的 IO 网络模型,有非常详细和生动的例子。

链接:  https://pan.baidu.com/s/1anUwEIgaj7zOrOnNl0cKag 提取码: kctw

等你有了一定的网络编程以后(熟练使用常见 Socket API),你可以看看游双的《Linux 高性能服务器编程》( 链接: https://pan.baidu.com/s/19l3wOaphmes4N4rx4FdLZA 提取码: b3tx ),这本书给没有基础的人或者基础不扎实的人的感觉是,尤其是书的前三章,这书怎么这么垃圾,又把网络理论书上面的东西搬过来凑字数,但是如果你有基础再按照书上的步骤在机器上实践一遍,你会发现,真是一本难得的、良心的书,桃李不言下自成蹊吧。如果你掌握了这本书上说的这些知识,你再看像 libevent 这样的开源网络库或者 Redis、Nginx 等源码的网络通信模块,你轻松很多。

学习使用 GDB 调试 C/C++ 项目

网上很多关于 gdb 的教程比较多,但是都比较零散的,不成体系,且调试的都是各种玩具型程序,看完后很多读者还是不知道如何调试大型 C/C++ 项目,因此我结合自己的工作经验,写了一套《gdb 高级调试实战教程》,这个教程有如下特点:

  • 以调试开源项目 Redis-Server 为例,项目不是玩具型的,具有实战意义;
  • 按调试流程,从 gdb 附加调试程序,到启动 gdb 调试再到使用 gdb 中断 Redis 查看各种状态,循序渐进地介绍各种 gdb 调试命令;
  • 介绍了实际工作中 gdb 的各种高级调试技巧,例如如何显示超长字符串、如何使用 gdb 调试多进程程序等等;
  • 也介绍了基于 gdb 的一些高级工具,如 cgdb、VisualGDB,这些章节是为不习惯 gdb 显示源码方式的同学量身定制。

以下是电子书目录:

《gdb 高级调试实战教程》电子书下载链接:

链接:  https://pan.baidu.com/s/1_CLLvrDRl3JXd4evJ85hWw 提取码: btbu

《gdb 高级调试实战教程》相关的配套资源:

链接:  https://pan.baidu.com/s/1OyH-rOD0MwOvCDPKJMNEHw 提取码: 6yqf

掌握 gdb 调试等于拿到学习 C/C++ 的钥匙,只要可以利用 gdb 调试,即使很复杂的项目,在不断调试和分析过程中总能搞明白的。 关于 gdb 的调试知识除了 gdb 自带的 help 手册,国外还有一本权威的书籍《Debugging with GDB: The The gnu Source-Level Debugger》,系统地介绍了 gdb 调试的方方面面:

链接:  https://pan.baidu.com/s/1xO1KrmJ_eBoYBNlFvlPS4g 提取码: gkry

原创不易,如果觉得有用,请给 

@张小方

 点个赞~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值