🍆 问题发现
在项目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
并调试项目。
-
当断点第一次断下时,在QtCreator的Expressions中添加表达式
d->size
,并记录其值。
-
F5继续执行到下一个断点,记录Expressions表达式的值。
3. F10单步执行,记录Expressions表达式的值。
比较三次执行的值,可得结论。
当代码执行到步骤2时,调用QVector<T>::size()方法会得到一个错误的值。而代码执行到步骤3时,则又会得到正常的数值。
QList的问题与QVector 类似,有兴趣的朋友可以跟下源码,此处不再累述。
🚂 解决方案
解决方案主要有如下两种:
- 加锁,对append方法和size方法加锁。避免多线程的不安全问题。但加锁后,性能必定直线下降,若对性能要求比较高,这种方法是不推荐的。
- 预分配足够的内存。这和具体业务相关了。如果需要的内存是确定的(比如QPerf项目的这个请求业务),那么预先分配足够内存则不会引起内存重新分配,因此不会进入预先分配的方法。QVector和QList都提供了预分配的构造方法(int size)。也提供了reserve()、resize()等方法实现预分配内存。
在QPerf项目中,我使用的reserve方法预分配内存,在测试启动之前,先分配足够的内存,详细可见单元测试:
providetest.h、
providetest.cpp
🌽 踩坑总结
这次事故中吸取到一个教训:
使用多线程共享的数据访问之前,最好先弄清楚用到的相关变量是否线程安全。(多线程总是充满惊吓)