QModbusTcpClient发生内存泄漏
问题现象
之前公司内使用的Qt版本均为5.13,后期因为这个版本不是LTS,还有更新的Chromium所以向上升级到了5.15.X(然而开源版本也不是LTS ?)。老的项目继续使用5.13,新的项目就全用5.15开发。
之后有组员向我反映在他的项目里使用QModbusTcpClient循环读取slave的情况下会导致内存不断增长。我自己的项目正好换了一种控制方式,控制反转客户端用了QModbusTcpServer,正好跳过了这个问题。
写了一个测试程序,使用QModbusTcpClient以50ms读取slave数据。大概的代码就长这样
void Client::run(){
......
if(auto* reply = client->sendReadRequest(unit)){
if(!reply->isFinished()){
connect(reply,&QModbusReply::finished,this,&Client::slot_handle_read_reply);
}else{
reply->deleteLater();
}
return true;
}
return false;
}
......
bool Client::slot_handle_read_reply()
{
QModbusReply* reply = qobject_cast<QModbusReply *>(sender());
if(!reply){
reply->deleteLater();
return false;
}
reply->deleteLater();
return true;
}
运行42s后增长45KB,增加586个对象。且放置一晚上大约就增加了150MB使用量,这吞内存的效率也不算低了。必须解决掉。
问题分析
排除自己的问题
我们当然得排除是我们自己代码的问题,首先就用官方的example代码来测试。
Modbus master example
这是个存在QT里的官方示例。我们只需要加一个QTimer循环触发按钮函数,就基本相当于没有改动过官方的代码。
持续运行之后同样观测到内存不断增长的问题。
使用Visual Studio内存快照
很怪异的一个现象是内存增长发生在信号Connection,我们的代码中仅有QModbusReply需要监听finished信号,所以使用信号槽来异步处理读取的数据。且在所有分支我们都对QModbusReply使用了deleteLater()方法,因此每次进入下一次QT事件循环后sender或reciver对象被删除时,会在QObject的析构函数中断开并删除所有已创建的QMetaObject::Connection对象,所以如果正常执行的话,当在handler里读取完数据之后,便会delete reply对象,在此时会断开信号槽并清理内存。
如果问题发生在Qt系统不能通过析构函数正确处理QModbusReply的Connection的话,那可算问题大条了。我们得手动管理Connection。
继续通过函数调用堆栈分析泄漏情况
发现未清理的Connection其实是由QModbusClient::sendReadRequest
创建在内部,与我们的代码毫无关系。
再次尝试循环调用sendReadRequest读取数据,并且最后删除QModbusTcpClient对象,此时被占用的内存被全部释放。
根据以上情况以及在5.13下的测试结果,能够确定这是在QT5.15下新出现的一个内存泄漏Bug。
QT源代码分析
根据VS的内存快照分析,我们可以看到泄漏的内存分配位于
QObject::connect<void (__cdecl QModbusClient::*)(int),void (__cdecl QTimer::*)(int)> +4,415 +653,420 5,161 763,828 Qt5SerialBusd.dll
是QModbusTcpClient::enqueueRequest函数中创建QModbusClient::SomeSignal(int)和QTimer::SomeSlot(int)的一个Connection,所以先从QT源代码中qtserialbus
组件中找到QModbusTcpClient的声明和实现。
因为Qt使用了d/p指针来隐藏具体的代码实现,能使作为API的头文件更优雅,所以QModbusTcpClient存在 qmodbusclient_p.h、 qmodbusclient.h、 qmodbusclient.cpp 这三个文件。
//qmodbusclient_p.h
QModbusReply *enqueueRequest(const QModbusRequest &request, int serverAddress,
const QModbusDataUnit &unit,
QModbusReply::ReplyType type) override
{
......
const int tId = transactionId();
// 向socket写入数据
if (!writeToSocket(tId, request, serverAddress))
return nullptr;
Q_Q(QModbusTcpClient);
// 构建QModbusReply*对象
auto reply = new QModbusReply(type, serverAddress, q);
// 创建modbus读写队列元素
const auto element = QueueElement{ reply, request, unit, m_numberOfRetries,
m_responseTimeoutDuration };
// 保持队列元素和transaction id
m_transactionStore.insert(tId, element);
// 给QModbusReply添加销毁后操作
q->connect(reply, &QObject::destroyed, q, [this, tId](QObject *) {
// 不存在此transaction id则退出
if (!m_transactionStore.contains(tId))
return;
// 取出element
const QueueElement element = m_transactionStore.take(tId);
// 关闭队列对象的超时定时器
if (element.timer)
element.timer->stop();
// 退出代码块销毁element
});
if (element.timer) {
// 存在超时定时器,当超时设定时间改变时改变超时定时器的触发时间
// 根据内存快照分析就是此处的connect导致了内存泄漏
q->connect(q, &QModbusClient::timeoutChanged,
element.timer.data(), QOverload<int>::of(&QTimer::setInterval));
// 超时处理
QObject::connect(element.timer.data(), &QTimer::timeout, q, [this, writeToSocket, tId]() {
if (!m_transactionStore.contains(tId))
return;
// 取出element
QueueElement elem = m_transactionStore.take(tId);
if (elem.reply.isNull())
return;
if (elem.numberOfRetries > 0) {
// 减少重试次数
elem.numberOfRetries--;
if (!writeToSocket(tId, elem.requestPdu, elem.reply->serverAddress()))
return;
m_transactionStore.insert(tId, elem);
// 重新开始计时
elem.timer->start();
qCDebug(QT_MODBUS) << "(TCP client) Resend request with tId:" << Qt::hex << tId;
} else {
qCDebug(QT_MODBUS) << "(TCP client) Timeout of request with tId:" <<Qt::hex << tId;
elem.reply->setError(QModbusDevice::TimeoutError,
QModbusClient::tr("Request timeout."));
}
// 退出代码块销毁element
});
......
}
顺便把QueueElement贴上来帮助理解
//qmodbusclient_p.h
struct QueueElement {
QueueElement() = default;
QueueElement(QModbusReply *r, const QModbusRequest &req, const QModbusDataUnit &u, int num,
int timeout = -1)
: reply(r), requestPdu(req), unit(u), numberOfRetries(num)
{
if (timeout >= 0) {
// always the case for TCP
timer = QSharedPointer<QTimer>::create();
timer->setSingleShot(true);
timer->setInterval(timeout);
}
}
bool operator==(const QueueElement &other) const {
return reply == other.reply;
}
QPointer<QModbusReply> reply;
QModbusRequest requestPdu;
QModbusDataUnit unit;
int numberOfRetries;
QSharedPointer<QTimer> timer;
QByteArray adu;
qint64 bytesWritten = 0;
qint32 m_timerId = INT_MIN;
};
找到了QModbusTcpClient::enqueueRequest的实现就能看出是什么导致的内存泄漏了。Qt给他们每个modbus读写request都创建了一个QTimer用来处理超时的情况,而为了能在QModbusTcpClient修改了超时时间的时候同步修改所有已经创建的reuqest,使用了信号槽来传递参数。
每个读写都会创建一个QueueElement来保存到m_transactionStore中,QueueElement中的timer就是用来超时检测的QTimer。
产生问题的Connection是在QModbusTcpClient中创建的用于连接QModbusClient::timeoutChanged(int)信号和QTimer::setInterval(int)槽。
简单画了一个QueueElement的生命周期的时序图,从这图中似乎可以看出QueueElement最终都能被正常销毁,但是现实情况是其成员对象QSharedPointer
对比5.15.2和5.13.2的代码这部分改动的内容并不多,我也没办法直接通过修改Qt的源码来进行调试。我也难以推测出导致Connection没有被销毁的正确原因。
解决问题
寻求场外帮助
遇到这种问题那当然并不能很简单的通过修改QT源代码来处理,只能搜索看是不是有人有像是的遭遇。
虽然内容不多,但是确实有内容明确的指出QModbusTcpClient在Qt 5.14和5.15下会导致内存泄漏
已被报告为Critical Bug但是似乎并没有很好的解决方案。
头疼医头脚疼医脚
此解决方案有一定的局限性,并不能作为永久有效的解决方案。最终解决这个问题还是要Qt团队来解决。
既然问题是来自Connection不能被正确disconnect,那么我们手动disconnect不就可以了。
client.disconnect(SIGNAL(timeoutChanged(int)),0,0);
确实Connection被正常释放,内存并没有异常增长。
但是使用时需要注意:
- 此动作会释放掉QModbusTcpClient内部关联的所有超时检测的QTimer(不过不修改Interval的话似乎并没有实质性影响)