前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >女朋友:一个 bug 查了两天,再解决不了,和你的代码过去吧!

女朋友:一个 bug 查了两天,再解决不了,和你的代码过去吧!

作者头像
范蠡
发布2022-08-26 12:51:35
6130
发布2022-08-26 12:51:35
举报

1.背景

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

2.服务结构

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

EventLoop 的基本结构(伪代码)如下:

代码语言:javascript
复制
void EventLoop::run()
{
     while (退出条件)
     {
          // 1.处理定时器事件
          processTimers();
  
          // 2. 利用IO复用函数检测一组socket的读写事件
          epollPollSelectDectector();
  
          // 3. 分发读写事件
         processReadAndWriteEvents(); 
     }
}

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

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

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

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

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

3.尝试一

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

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

4.尝试二

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

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

5.尝试三

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

有了方向之后,接下来找到问题就容易了。

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

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

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

代码语言:javascript
复制
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<HttpCssion>(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 类析构一次,然后崩溃。

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

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

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

6. 解决问题

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

代码语言:javascript
复制
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++ 智能指针详解》。

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

代码语言:javascript
复制
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 的拷贝构造函数定义如下:

代码语言:javascript
复制
<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 的第一个参数修改成右值引用:

代码语言:javascript
复制
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 函数中传递这个右值:

代码语言:javascript
复制
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 一下,修改后的代码如下:

代码语言:javascript
复制
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 构造函数中,pConnectionstd::move 之后就剩下一个空壳子了,其“肉体”已经转移给了 m_spConnection,所以不能在 HttpSession 构造函数中使用 pConnection 调用 getIPgetPort 方法了,应该改用 m_spConnection 来调用这两个方法,修改后代码如下:

代码语言:javascript
复制
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 问题完美解决。

7.总结

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

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

8.思考题

最后留给大家一个思考题:

代码语言:javascript
复制
#include <memory>

class A {

};

void f1(std::unique_ptr<A>&& a1) {
    
}

void f2(std::unique_ptr<A>&& a2) {
    //这里编译有错误,如何修改?
    f1(a2);
}

int main()
{
    std::unique_ptr<A> spA(new A());
    //这里编译有错误,如何修改?
    f2(spA);

    return 0;
}

这个问题搞了两天,周末都花在排查这个问题上面了,女朋友很生气,不知道今晚需不需要继续睡沙发......

本文是《女朋友要去 XXX 系列》第三篇,本系列:

篇一《女朋友要去面试 C++,我建议她这么做

篇二 《女朋友问我:什么时候用 C 而不用 C++?

关注我,更多有趣实用的编程知识~

原创不易,点个赞呗

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2022-08-01,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 高性能服务器开发 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1.背景
  • 2.服务结构
  • 3.尝试一
  • 4.尝试二
  • 5.尝试三
  • 6. 解决问题
  • 7.总结
  • 8.思考题
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档