在上一篇文章《女朋友:一个 bug 查了两天,再解决不了,和你的代码过去吧!》,我们介绍了使用智能指针的几个注意事项,如果不注意这些细节,使用智能指针不仅不能帮你管理内存,而且还会造成难以排查的崩溃。
这不,今天是七夕,原本打算和女朋友吃饭、看电影......一气呵成的,结果我的 HttpServer 又崩溃了。
1. 背景
在上篇文章中我们介绍了我的 HttpServer 有 HttpSessionManager
、HttpSession
和 HttpConnection
三个类,这三个类都是用于框架内部的,这个 HttpServer 的目标要设计成一个可独立使用的 Http 模块,所以在最外层我又建立了一个 HttpServer
类,这个类负责与外部使用方交互,外部使用这个 http 库的时候只要初始化一个 HttpServer
类就可以了,举个例子,HttpServer
类提供框架初始化接口和 http 路由注册接口。
简化后的代码如下:
代码语言:javascript复制typedef std::function<bool(const Request& req, Response& resp)> Handler;
class HttpServer final {
public:
HttpServer(const char* ip, short port, EventLoop* pEventLoop) : m_ip(ip), m_port(port), m_pEventLoop(pEventLoop) {
m_spSessionManager = std::make_shared<HttpSessionManager>(pEventLoop);
}
~HttpServer() = default;
void start() {
m_pEventLoop->start();
}
//用于用户注册路由
bool registerRoute(HttpMethod method, const char* path, Handler handler);
private:
std::string m_ip;
short m_port;
EventLoop* m_pEventLoop;
std::shared_ptr<HttpSessionManager> m_spSessionManager;
};
上一篇文章中我们介绍了这个 http 模块依赖 base
模块,base
模块提供 EventLoop
类,所以我们需要先创建一个 EventLoop
对象来初始化 HttpServer,像下面这样:
int main()
{
EventLoop* eventLoop = base::createEventLoop();
HttpServer httpServer("0.0.0.0", 8888, eventLoop);
httpSever.registerRoute(HttpMethod::GET, "/somepath", [](const Request& req, Response& resp)->bool {
resp.content = "{"code": 0, "msg": "success"}";
resp.contentLength = resp.content.length();
resp.contentType = "application/json";
});
httpServer->start();
return 0;
}
在写完接受连接的逻辑之后,我开始写断开连接的逻辑,我们再来看一下接受连接的逻辑:
代码语言:javascript复制class HttpSessionManager {
public:
void onAccept(int fd) {
auto spConnection = std::make_unique<HttpConnection>(fd, m_pEventLoop);
auto spSession = std::make_shared<HttpSession>(spConnection.get(), this);
spSession->registerReadEvent();
auto clientID = spSession->getClientID();
{
std::lock_guard<std::mutex> scopedLock(m_sessionMutex);
m_mapSessions.emplace(clientID, spSession);
}
}
private:
std::map<std::string, std::shared_ptr<HttpSession>> m_mapSessions;
std::mutex m_sessionMutex;
EventLoop* m_pEventLoop;
};
由于 HttpConnection
对象中需要向 EventLoop 注册读写事件,我们在创建 HttpConnection
对象时把 HttpSessionManager
对象的 m_pEventLoop
指针通过 HttpConnection
构造函数传递过来,前者是在 HttpServer
对象中构造 HttpSessionManager
时传入,也就是说这里的 m_pEventLoop
即外部构造的 EventLoop
对象。
对于只有一个 EventLoop 的情况下,所有的客户端 socket 的读写事件都是在这个 EventLoop 中完成的,正常的收取数据的调用路径如下:
代码语言:javascript复制// 1. EventLoop检测到某个socket上有读事件,
// 2. EventLoop调用HttpConnection::onRead方法进行数据收取
// 3. HttpConnection::onRead方法收到数据并解包
// 4. 解包后,HttpConnection::onRead调用上层HttpSession::onRoute方法
// 5. HttpSession::onRoute方法调用HttpSessionManager::onRoute方法进行路由匹配并将自己的this指针一起传过去(HttpSessionManager是实际路由记录的地方)
// 6. HttpSessionManager::onRoute匹配某个路由后,执行用户自定义路由(例如上面main函数中的lamda表达式即自定义路由)
// 7. 在用户自定义路由中用户设置好想返回的数据内容和格式后,通过上面带来的HttpSession指针调用HttpSession::send方法发送数据
// 8. HttpSession::send将数据交给HttpConnection对象,后者组装后Http协议格式后利用自身拥有的socket句柄将数据发出去
此时逻辑运行的很 ok,分层也比较清楚,接下来看断开连接的逻辑,和上面收取数据的逻辑差不多,但略有不同:
代码语言:javascript复制// 1. EventLoop检测到某个socket上有读事件,
// 2. EventLoop调用HttpConnection::onRead方法进行数据收取
// 3. HttpConnection::onRead方法调用socket的recv函数收取数据时,返回值为0,表明对端断开了连接
// 4. HttpConnection::onRead方法调用HttpSession::onClose方法
// 5. HttpSession::onClose方法调用HttpSessionManager::onClose方法记录需要清理的HttpSession对象
这个在关闭连接时,有个特殊的地方需要注意,由于当前 HttpSession
和 HttpConnection
对象正在使用,所以不能直接 delete 这两个对象,所以先在 HttpSessionManager
对象中记录一下要删除的 HttpSession
对象,由于 HttpSession
对象管理着 HttpConnection
对象的生命周期,所以当 HttpSession
对象析构时会一并析构 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:
//HttpSession通过一个unique_ptr指针管理着HttpConnection的生命周期
std::unique_ptr<HttpConnection> m_spConnection;
HttpSessionManager* m_sessionManager;
std::string m_clientID;
};
那 HttpSessionManager
如何记录要被删除的 HttpSession
对象呢?我为 HttpSessionManager
对象定义了一个 std::set
容器 m_pendingDeleteSessions
,当某个 HttpSession
对象需要删除时,先在这个容器中记录下要删除的 HttpSession
的 clientID 并从 EventLoop 的 IO 复用函数上卸载对应的 socket 句柄,然后向 EventLoop 注册删除 HttpSession
任务,注册删除任务是 EventLoop 提供的能力,EventLoop 自带一个 wakeupFd
,注册任务被执行就是向这个 wakeupFd
写入一个字节,由于该 wakeupFd
也被挂载在 EventLoop 的 IO 复用函数上,所以下一轮循环时,IO 复用函数检测到 wakeupFd
有读事件,在 wakeupFd
读事件处理函数中执行我们注册的任务(这一技巧广泛地用于各种开源网络库和商业 C 产品,建议小伙伴们掌握,如果不清楚,可以阅读《C 服务器开发精髓》一书第 7.5.3 节《唤醒机制的实现》)。
我们注册任务即删除 m_pendingDeleteSessions
中记录的 HttpSession
,由于此时这个 HttpSession
已经不再使用了,所以可以安全删除了。登记要删除的 HttpSession 逻辑如下,位于 HttpSessionManager::onClose
中:
class HttpSessionManager {
public:
void onClose(std::make_shared<HttpSession>& spSession) {
auto clientID = spSession->getClientID();
m_pendingDeleteSession.emplace(clientID);
// 内部调用HttpConnection::unregisterAllEvents从EventLoop卸载该对应的fd
spSession->unregisterAllEvents();
}
private:
std::map<std::string, std::shared_ptr<HttpSession>> m_mapSessions;
std::mutex m_sessionMutex;
EventLoop* m_pEventLoop;
std::set<std::string> m_pendingDeleteSession;
};
在下一轮循环中,wakeupFd
读事件处理函数中会调用到 HttpSessionManager::clearPendingSeesions()
,在这个函数中真正销毁上一次记录的 HttpSession
:
class HttpSessionManager {
public:
void onAccept(int fd) {
auto spConnection = std::make_unique<HttpConnection>(fd, m_pEventLoop);
auto spSession = std::make_shared<HttpSession>(spConnection.get(), this);
spSession->registerReadEvent();
auto clientID = spSession->getClientID();
{
std::lock_guard<std::mutex> scopedLock(m_sessionMutex);
m_mapSessions.emplace(clientID, spSession);
}
}
void onClose(std::make_shared<HttpSession>& spSession) {
auto clientID = spSession->getClientID();
m_pendingDeleteSession.emplace(clientID);
//内部调用HttpConnection::unregisterAllEvents从EventLoop卸载该对应的fd
spSession->unregisterAllEvents();
}
void clearPendingSessions() {
auto pClientID = m_pendingDeleteSession.begin();
while (iter != m_pendingDeleteSession.end()) {
m_mapSessions.erase(*pClientID);
pClientID ;
}
m_pendingDeleteSession.clear();
}
private:
std::map<std::string, std::shared_ptr<HttpSession>> m_mapSessions;
std::mutex m_sessionMutex;
EventLoop* m_pEventLoop;
std::set<std::string> m_pendingDeleteSession;
};
这样断开连接的问题也完美解决了。
2. 产生 crash
可以实际运行的时候发现,服务在第一次断开连接后,有新连接来的时候就 crash 了,具体位置是 HttpSessionManager::onAccept
调用 spSession->registerReadEvent()
处,这个函数内部调用了 HttpConnection::registerReadEvent()
,实现如下:
class HttpConnection {
public:
HttpConnection(int fd, EventLoop* pEventLoop) : m_fd(fd), m_spEventLoop(pEventLoop) {
}
~HttpConnection() {
}
bool registerReadEvent() {
// 程序在这一行崩溃
m_spEventLoop->registerEvent(m_fd, EventType::Read);
}
private:
std::shared_ptr<EventLoop> m_spEventLoop;
int m_fd;
};
crash 问题必现,当我把 HttpSessionManager::clearPendingSessions()
逻辑注释掉,接受新连接就不会崩溃。
那么到底哪里有问题呢?我们应该如何排查这样的错误。
3. 分析、定位并解决问题
我们在上一篇文章中说过,C 程序崩溃大多数是内存问题,执行 HttpSessionManager::clearPendingSessions()
调用程序崩溃,不执行程序不崩溃,所以问题应该是这个函数中的逻辑引起的,这个函数中的逻辑是从 map 中移除 HttpSession
对象,导致 HttpSession
对象析构,既然出现了崩溃现象,那么肯定是这个对象的析构引起了某处内存问题,上一篇文章中我们讲了内存重复释放会引起崩溃,其实还存在另外一种内存崩溃的情形:某块内存被释放了,但是我们还接着使用它,也会导致 crash。我按照这个思路,先检查了 HttpSession
及其成员变量析构后,是否会有内存重复释放问题,这很容易做到,挨个检查 HttpSession
对象的成员变量和析构函数中的逻辑,如果成员变量类型是复杂类型,再递归检查下一级的成员变量即可,一直到结束,例如 HttpSession
的成员 m_spConnection
,其类型是 std::unique_ptr<HttpConnection>
,这是一个 unique_ptr
,所以其析构时会导致其管理的 HttpConnection
对象析构,再接着检查 HttpConnection
对象的析构,一直到结束。我们发现并无内存被重复释放。
那再看看第二种情况,既然是在 m_spEventLoop->registerEvent(m_fd, EventType::Read);
这一行崩溃的,那么我们检查一下 m_spEventLoop
是否有效。持有这个 EventLoop 对象的类有:
class HttpServer final {
private:
std::string m_ip;
short m_port;
EventLoop* m_pEventLoop;
std::shared_ptr<HttpSessionManager> m_spSessionManager;
};
class HttpSessionManager {
public:
void onAccept(int fd) {
auto spConnection = std::make_unique<HttpConnection>(fd, m_pEventLoop);
auto spSession = std::make_shared<HttpSession>(spConnection.get(), this);
spSession->registerReadEvent();
auto clientID = pSession->getClientID();
{
std::lock_guard<std::mutex> scopedLock(m_sessionMutex);
m_mapSessions.emplace(clientID, spSession);
}
}
private:
std::map<std::string, std::shared_ptr<HttpSession>> m_mapSessions;
std::mutex m_sessionMutex;
EventLoop* m_pEventLoop;
std::set<std::string> m_pendingDeleteSession;
};
class HttpConnection {
bool registerReadEvent() {
// 程序直接崩溃点
m_spEventLoop->registerEvent(m_fd, EventType::Read);
}
private:
std::shared_ptr<EventLoop> m_spEventLoop;
int m_fd;
};
我们发现所有类都是用 EventLoop 的原始指针管理这个 EventLoop 的,唯独 HttpConnection
使用了一个 std::shared_ptr
管理 EventLoop,这会不会有问题?有问题,假设创建一个 HttpConnection
后(接受连接),释放 HttpConnection
(断开连接),由于 HttpConnection
使用了 std::shared_ptr
管理 EventLoop,那释放 HttpConnection
会导致 EventLoop 也会被释放。这样下一次建立连接后,再次想使用这个 EventLoop 已经不存在了。第一次使用到这个 EventLoop 的地方正好是 spSession->registerReadEvent()
引起的 HttpConnection::registerReadEvent()
调用中的 m_spEventLoop->registerEvent(m_fd, EventType::Read);
,此时 m_spEventLoop
虽然是一个智能指针,但是在 HttpConnection
构造时传入的 pEventLoop 因为指向对象被释放,pEventLoop 已经是野指针了,所以调用 m_spEventLoop->registerEvent
引起了崩溃。
class HttpSessionManager {
public:
void onAccept(int fd) {
auto spConnection = std::make_unique<HttpConnection>(fd, m_pEventLoop); // 第二次调用,m_pEventLoop已经是野指针
auto spSession = std::make_shared<HttpSession>(spConnection.get(), this);
spSession->registerReadEvent();
auto clientID = pSession->getClientID();
{
std::lock_guard<std::mutex> scopedLock(m_sessionMutex);
m_mapSessions.emplace(clientID, spSession);
}
}
private:
std::map<std::string, std::shared_ptr<HttpSession>> m_mapSessions;
std::mutex m_sessionMutex;
EventLoop* m_pEventLoop;
std::set<std::string> m_pendingDeleteSession;
};
class HttpConnection {
public:
// pEventLoop是野指针
HttpConnection(int fd, EventLoop* pEventLoop) : m_fd(fd), m_spEventLoop(pEventLoop) {
}
bool registerReadEvent() {
// 程序在这一行崩溃
m_spEventLoop->registerEvent(m_fd, EventType::Read);
}
private:
std::shared_ptr<EventLoop> m_spEventLoop;
};
我们将 m_spEvent
类型换成普通指针,问题解决。这里不将引用 EventLoop 的变量全部换成智能指针的原因是 EventLoop
是外部创建的资源,C 有个原则是:哪个模块分配资源就要负责释放资源,尽量不要在一个模块分配资源,另外一个模块释放资源。
4. 总结
上述问题有没有办法规避呢?有的,这就引出了智能指针使用的另外一条经验规则:如果打算使用智能指针管理一个堆对象,建议从 new 出来的那一刻就让智能指针接管,不要出现一些地方使用智能指针,另外一些地方使用原始指针。本文的 EventLoop 对象开始的设计就是这样,因而出现了问题。
我之所以详细地介绍了 HttpServer 的各个模块和设计思路其实也是想和你分享一下一款通用的网络框架如何设计以及需要考虑的问题。
最后,如果遇到崩溃问题,千万不要慌,冷静分析。
排查完 bug,一个晚上又过去了,女朋友带着幽怨的眼神去睡觉了
。
本文是《女朋友要去 XXX 系列》第四篇,本系列:
篇一《女朋友要去面试 C ,我建议她这么做》
篇二 《女朋友问我:什么时候用 C 而不用 C ?》
篇三 《女朋友:一个 bug 查了两天,再解决不了,和你的代码过去吧!》
相关阅读
- 主线程与工作线程的分工
- Reactor 模式
- 实例:一个服务器程序的架构介绍
- 如何编写高性能日志
- 开源一款即时通讯软件的源码
- 高性能服务器架构设计总结1
- 高性能服务器架构设计总结2
- 高性能服务器架构设计总结3
- 高性能服务器架构设计总结4
- 从零实现一个 http 服务器
- 服务器开发中网络数据分析与故障排查经验漫谈
- 服务器开发通信协议设计介绍
- one thread one loop 思想
- 业务数据处理一定要单独开线程吗
- 网络通信中收发数据的正确姿势
- 日志系统的设计
- C 高性能服务器网络框架设计细节
- 一个 WebSocket 服务器是如何开发出来的?
- 如何设计断线自动重连机制
- 心跳包机制设计详解
- Modern C 智能指针详解
关注我,更多有趣实用的编程知识~
原创不易,点个赞呗