问题现象

之前公司内使用的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使用量,这吞内存的效率也不算低了。必须解决掉。

memory rising

问题分析

排除自己的问题

我们当然得排除是我们自己代码的问题,首先就用官方的example代码来测试。

Modbus master example 这是个存在QT里的官方示例。我们只需要加一个QTimer循环触发按钮函数,就基本相当于没有改动过官方的代码。

持续运行之后同样观测到内存不断增长的问题。

使用Visual Studio内存快照

class view 很怪异的一个现象是内存增长发生在信号Connection,我们的代码中仅有QModbusReply需要监听finished信号,所以使用信号槽来异步处理读取的数据。且在所有分支我们都对QModbusReply使用了deleteLater()方法,因此每次进入下一次QT事件循环后sender或reciver对象被删除时,会在QObject的析构函数中断开并删除所有已创建的QMetaObject::Connection对象,所以如果正常执行的话,当在handler里读取完数据之后,便会delete reply对象,在此时会断开信号槽并清理内存。

如果问题发生在Qt系统不能通过析构函数正确处理QModbusReply的Connection的话,那可算问题大条了。我们得手动管理Connection。

继续通过函数调用堆栈分析泄漏情况 stack view

发现未清理的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.hqmodbusclient.hqmodbusclient.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 lifetime sequence

简单画了一个QueueElement的生命周期的时序图,从这图中似乎可以看出QueueElement最终都能被正常销毁,但是现实情况是其成员对象QSharedPointer所拥有的Connection并没有被释放。

对比5.15.2和5.13.2的代码这部分改动的内容并不多,我也没办法直接通过修改Qt的源码来进行调试。我也难以推测出导致Connection没有被销毁的正确原因。

解决问题

寻求场外帮助

遇到这种问题那当然并不能很简单的通过修改QT源代码来处理,只能搜索看是不是有人有像是的遭遇。

虽然内容不多,但是确实有内容明确的指出QModbusTcpClient在Qt 5.14和5.15下会导致内存泄漏

  1. QTBUG-92072 QModbusTcpClient has a memory leak
  2. QModbusTcpClient use RAM more and more.

已被报告为Critical Bug但是似乎并没有很好的解决方案。

头疼医头脚疼医脚

此解决方案有一定的局限性,并不能作为永久有效的解决方案。最终解决这个问题还是要Qt团队来解决。

既然问题是来自Connection不能被正确disconnect,那么我们手动disconnect不就可以了。

client.disconnect(SIGNAL(timeoutChanged(int)),0,0);

确实Connection被正常释放,内存并没有异常增长。

但是使用时需要注意:

  • 此动作会释放掉QModbusTcpClient内部关联的所有超时检测的QTimer(不过不修改Interval的话似乎并没有实质性影响)