寻根究底,Qt容器并行访问问题

1 篇文章 0 订阅

🍆 问题发现

在项目QPerf维护过程中,发现了一个BUG
BUG的相关分析如下:

在类WorkRequestState中有一个成员变量QVector<Record *> m_records,其值初始为空,当任务启动时,会将Record实例添加到m_records。
Record类包含一个flag属性,该属性直接与当前实例在m_records中的索引绑定,比如第一个Record的flag为0,那么在m_records的索引为0.
同时,WorkRequestState对象会等待其它线程触发void WorkRequestState::onReply(const ReplyPacket &pack) 方法。
在该方法中,接收到的ReplyPacket有属性flag与m_records中加入的flag是一一对应的。方法会先验证m_records的长度。然后从其去除Record对象进行处理。
在测试中发现,取到的m_record.size() 有一定几率出现负数。

🍓 重现

如果不熟悉QPerf 的业务,上一节的内容可能会让人云里雾里,不过没关系。 在本节中,我将利用Qt自带的单元测试框架QTest重现这个问题。 定义类ProvideTest,该类提供run方法作为测试容器(QVector和QList)的调用方法,该类中,启用QThreadPool多线程调用WriteRunner和ReadRunner分别实现对容器的读写并行访问 ,测试代码如下所示。

🐛 测试代码

providetest.h

#ifndef PROVIDETEST_H
#define PROVIDETEST_H

#include <QList>
#include <QVector>


/**
 * @brief The ProvideTest class
 * qt容器的的线程安全测试。
 */
class ProvideTest
{
public:
    ProvideTest();
    void run();
private:
    QVector<int> m_list;
    QList<int> m_list2;
    friend class ReadRunner;
    friend class WriteRunner;
};

#endif // PROVIDETEST_H 

providetest.cpp

#include "providetest.h"

#include <QTest>
#include <QThreadPool>
/**
 * @brief The ReadRunner class
 * 读取线程的对象。
 */
class ReadRunner: public QRunnable{
public:
    ReadRunner(int total, ProvideTest *owner);
    // QRunnable interface
public:
    void run();
private:
    ProvideTest *m_owner;
    int m_total;
};
/**
 * @brief The WriteRunner class
 * 写入的线程对象。
 */
class WriteRunner: public QRunnable{
public:
    WriteRunner(int total, ProvideTest *owner);
    // QRunnable interface
public:
    void run();
private:
    ProvideTest *m_owner;
    int m_total;
};
ProvideTest::ProvideTest()
{

}

void ProvideTest::run()
{
    int total = 10000000; // 测试数量,越多越容易重现。
 
    ReadRunner *read = new ReadRunner(total, this);
    QThreadPool pool;
    pool.start(read);

    WriteRunner *write = new WriteRunner(total, this);
    pool.start(write);
}

void ReadRunner::run()
{
    int count = m_total;
    auto fun = [](auto &item)
    {
        int size = item.size();
        if(size < 0)
        {
            QString msg = QString("Failed size:%1;cur size:%2").arg(size).arg(item.size());
            QFAIL(msg.toUtf8().data());
        }
    };
    for(int i = 0; i < count; i++){
        fun(m_owner->m_list);
        fun(m_owner->m_list2);
    }
}

void WriteRunner::run()
{
    int count = m_total;
    for(int i = 0; i < count; i++){
        m_owner->m_list.append(i);
        m_owner->m_list2.append(i);
    }
}

ReadRunner::ReadRunner(int total, ProvideTest *owner)
{
    m_total = total;
    m_owner = owner;
}

WriteRunner::WriteRunner(int total, ProvideTest *owner)
{
    m_total = total;
    m_owner = owner;
}

🍌测试结果

将测试代码放入测试框架调用

void perf::test_qlist()
{
    ProvideTest test;
    test.run();
} 

测试结果如下:
在这里插入图片描述

通过测试结果的输出,可以判断,第一次访问取得m_list.size()的值为负,第二次却取到了一个正值。
##🍄 问题查找
审查代码,确定无堆栈溢出的可能。那么问题可能出QList(QVector)本身。花了一点时间阅读QVector源码。分析。
以下源码摘自C:\Qt\Qt5.12.9\5.12.9\mingw73_64\include\QtCore\qvector.h,中文注释为本人方便阅读添加。
d为QVector的类成员,类型为QTypedArrayData 。
size 源码很简单,直接返回d的size成员。
inline int size() const { return d->size; }

append 源码如下,也是对成员d的一堆操作。

template <typename T>
void QVector<T>::append(const T &t)
{
    const bool isTooSmall = uint(d->size + 1) > d->alloc;  // 查找加入之后是否大于预分配的大小
    if (!isDetached() || isTooSmall) {
        T copy(t);
        QArrayData::AllocationOptions opt(isTooSmall ? QArrayData::Grow : QArrayData::Default);
        reallocData(d->size, isTooSmall ? d->size + 1 : d->alloc, opt); // 重新分配

        if (QTypeInfo<T>::isComplex)
            new (d->end()) T(qMove(copy));
        else
            *d->end() = qMove(copy);

    } else {
        if (QTypeInfo<T>::isComplex)
            new (d->end()) T(t);
        else
            *d->end() = t;
    }
    ++d->size;
}

以上代码基本执行流程为:

  • 判断长度,预分配的列表不能插入新的数据项时,调用reallocaData重新分配内存。
  • 初始新的数据项分配。
  • 重置数据长度。
    主流程上似乎没有什么问题,很有可能是reallocData方法,跟进reallocData方法的源代码。

reallocData 方法源代码

template <typename T>
void QVector<T>::reallocData(const int asize, const int aalloc, QArrayData::AllocationOptions options)
{
    Q_ASSERT(asize >= 0 && asize <= aalloc);
    Data *x = d;

    const bool isShared = d->ref.isShared();

    if (aalloc != 0) {
        if (aalloc != int(d->alloc) || isShared) {
            QT_TRY {
                // allocate memory
                x = Data::allocate(aalloc, options);
                Q_CHECK_PTR(x);
                // aalloc is bigger then 0 so it is not [un]sharedEmpty
#if !defined(QT_NO_UNSHARABLE_CONTAINERS)
                Q_ASSERT(x->ref.isSharable() || options.testFlag(QArrayData::Unsharable));
#endif
                Q_ASSERT(!x->ref.isStatic());
                x->size = asize;

                T *srcBegin = d->begin();
                T *srcEnd = asize > d->size ? d->end() : d->begin() + asize;
                T *dst = x->begin();

                if (!QTypeInfoQuery<T>::isRelocatable || (isShared && QTypeInfo<T>::isComplex)) {
                    QT_TRY {
                        if (isShared || !std::is_nothrow_move_constructible<T>::value) {
                            // we can not move the data, we need to copy construct it
                            while (srcBegin != srcEnd)
                                new (dst++) T(*srcBegin++);
                        } else {
                            while (srcBegin != srcEnd)
                                new (dst++) T(std::move(*srcBegin++));
                        }
                    } QT_CATCH (...) {
                        // destruct already copied objects
                        destruct(x->begin(), dst);
                        QT_RETHROW;
                    }
                } else {
                    ::memcpy(static_cast<void *>(dst), static_cast<void *>(srcBegin), (srcEnd - srcBegin) * sizeof(T));
                    dst += srcEnd - srcBegin;

                    // destruct unused / not moved data
                    if (asize < d->size)
                        destruct(d->begin() + asize, d->end());
                }

                if (asize > d->size) {
                    // construct all new objects when growing
                    if (!QTypeInfo<T>::isComplex) {
                        ::memset(static_cast<void *>(dst), 0, (static_cast<T *>(x->end()) - dst) * sizeof(T));
                    } else {
                        QT_TRY {
                            while (dst != x->end())
                                new (dst++) T();
                        } QT_CATCH (...) {
                            // destruct already copied objects
                            destruct(x->begin(), dst);
                            QT_RETHROW;
                        }
                    }
                }
            } QT_CATCH (...) {
                Data::deallocate(x);
                QT_RETHROW;
            }
            x->capacityReserved = d->capacityReserved;
        } else {
            Q_ASSERT(int(d->alloc) == aalloc); // resize, without changing allocation size
            Q_ASSERT(isDetached());       // can be done only on detached d
            Q_ASSERT(x == d);             // in this case we do not need to allocate anything
            if (asize <= d->size) {
                destruct(x->begin() + asize, x->end()); // from future end to current end
            } else {
                defaultConstruct(x->end(), x->begin() + asize); // from current end to future end
            }
            x->size = asize;
        }
    } else {
        x = Data::sharedNull();
    }
    if (d != x) {
        if (!d->ref.deref()) {
            if (!QTypeInfoQuery<T>::isRelocatable || !aalloc || (isShared && QTypeInfo<T>::isComplex)) {
                // data was copy constructed, we need to call destructors
                // or if !alloc we did nothing to the old 'd'.
                freeData(d);
            } else {
                Data::deallocate(d); // 这里对d释放。没有任何保护。
            }
        }
        d = x;
    }

    Q_ASSERT(d->data());
    Q_ASSERT(uint(d->size) <= d->alloc);
#if !defined(QT_NO_UNSHARABLE_CONTAINERS)
    Q_ASSERT(d != Data::unsharableEmpty());
#endif
    Q_ASSERT(aalloc ? d != Data::sharedNull() : d == Data::sharedNull());
    Q_ASSERT(d->alloc >= uint(aalloc));
    Q_ASSERT(d->size == asize);
}

🍊 产生原因

此时已经猜到一大半原因了,重新分配内存后,会先释放d,再将新分配的地址赋予d,而这个过程并不是线程安全的。
也就是说QVector本身并非线程安全,其append、size方法,当发生了内存重新分配时,是不支持并发访问的。

🍒验证猜想

使用QtCreator将断点下在
C:\Qt\Qt5.12.9\5.12.9\mingw73_64\include\QtCore\qvector.h:641
C:\Qt\Qt5.12.9\5.12.9\mingw73_64\include\QtCore\qvector.h:643
并调试项目。

  1. 当断点第一次断下时,在QtCreator的Expressions中添加表达式d->size,并记录其值。
    步骤一

  2. F5继续执行到下一个断点,记录Expressions表达式的值。
    步骤二3. F10单步执行,记录Expressions表达式的值。
    步骤三

比较三次执行的值,可得结论。
当代码执行到步骤2时,调用QVector<T>::size()方法会得到一个错误的值。而代码执行到步骤3时,则又会得到正常的数值。
QList的问题与QVector 类似,有兴趣的朋友可以跟下源码,此处不再累述。

🚂 解决方案

解决方案主要有如下两种:

  1. 加锁,对append方法和size方法加锁。避免多线程的不安全问题。但加锁后,性能必定直线下降,若对性能要求比较高,这种方法是不推荐的。
  2. 预分配足够的内存。这和具体业务相关了。如果需要的内存是确定的(比如QPerf项目的这个请求业务),那么预先分配足够内存则不会引起内存重新分配,因此不会进入预先分配的方法。QVector和QList都提供了预分配的构造方法(int size)。也提供了reserve()、resize()等方法实现预分配内存。

QPerf项目中,我使用的reserve方法预分配内存,在测试启动之前,先分配足够的内存,详细可见单元测试:
providetest.h
providetest.cpp

🌽 踩坑总结

这次事故中吸取到一个教训:
使用多线程共享的数据访问之前,最好先弄清楚用到的相关变量是否线程安全。(多线程总是充满惊吓)

  • 4
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值