Введение
При переходе от однопоточного кода к многопоточному самая частая ошибка — продолжать использовать обычные контейнеры (QVector, QList, QMap, std::vector) из нескольких потоков без какой-либо синхронизации, считая их «просто данными». На самом деле ни один стандартный контейнер Qt или STL не является потокобезопасным для одновременной записи из нескольких потоков. В статье рассмотрим паттерны безопасного доступа к общим данным и частые ошибки, которые приводят к трудноуловимым крашам.
Концепция
Есть три основных подхода к безопасному доступу к данным из нескольких потоков: (1) защита обычного контейнера мьютексом на каждую операцию доступа; (2) использование сообщений вместо общих данных — каждый поток владеет своими данными, а обмен происходит через сигналы/слоты или очереди сообщений; (3) использование специализированных lock-free или atomic-структур там, где это применимо и оправдано сложностью.
Важно понимать степень безопасности самого QObject: документация Qt явно указывает, какие классы являются reentrant (можно использовать из разных потоков, но не одновременно с тем же объектом), thread-safe (можно использовать одновременно из разных потоков) или вовсе не предназначены для многопоточного использования (большинство виджетов GUI). Подавляющее большинство контейнеров Qt — reentrant, но не thread-safe.
Пример кода
#include <QCoreApplication>
#include <QThread>
#include <QMutex>
#include <QMutexLocker>
#include <QMap>
#include <QVector>
#include <QDebug>
// Потокобезопасная обёртка над QMap для накопления статистики из разных потоков
template <typename Key, typename Value>
class ThreadSafeMap
{
public:
void insert(const Key &key, const Value &value)
{
QMutexLocker locker(&m_mutex);
m_map.insert(key, value);
}
bool tryGet(const Key &key, Value &outValue) const
{
QMutexLocker locker(&m_mutex);
auto it = m_map.constFind(key);
if (it == m_map.constEnd()) {
return false;
}
outValue = it.value();
return true;
}
// Возвращаем КОПИЮ, а не ссылку — иначе вызывающий код может
// читать контейнер уже без защиты мьютекса
QMap<Key, Value> snapshot() const
{
QMutexLocker locker(&m_mutex);
return m_map;
}
private:
mutable QMutex m_mutex;
QMap<Key, Value> m_map;
};
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
ThreadSafeMap<int, double> stats;
QVector<QThread *> threads;
for (int t = 0; t < 4; ++t) {
auto *thread = QThread::create([&stats, t]() {
for (int i = 0; i < 1000; ++i) {
stats.insert(t * 1000 + i, i * 0.5);
}
});
threads.append(thread);
thread->start();
}
for (auto *thread : threads) {
thread->wait();
delete thread;
}
qDebug() << "Итоговый размер карты:" << stats.snapshot().size();
return 0;
}
Пояснения к коду
ThreadSafeMap — простая обёртка-шаблон над QMap, в которой каждая операция доступа защищена мьютексом через QMutexLocker. Ключевой момент — метод snapshot() возвращает копию всей карты, а не ссылку на внутренний контейнер: если бы метод возвращал ссылку, вызывающий код мог бы итерироваться по контейнеру уже после освобождения мьютекса, в момент, когда другой поток модифицирует карту — это классическая, скрытая гонка данных, которую легко допустить даже после «правильной» защиты отдельных операций.
Четыре потока одновременно вставляют записи с непересекающимися ключами — мьютекс гарантирует, что внутренняя структура QMap не повреждается при конкурентных вставках.
Подводные камни
- Возврат ссылки или итератора на защищённый контейнер за пределы блокировки обесценивает всю защиту мьютексом — данные могут быть изменены другим потоком в момент использования «одолженной» ссылки.
- Copy-on-write контейнеры Qt (QVector, QList, QString и другие) имеют тонкости при общем доступе. Сам механизм неявного совместного использования данных (implicit sharing) потокобезопасен на уровне счётчика ссылок, но это не делает безопасным одновременное изменение содержимого через разные копии из разных потоков без дополнительной синхронизации.
- Гранулярность блокировки слишком крупная или слишком мелкая. Один глобальный мьютекс на все операции со всеми данными приложения превращает многопоточность в фикцию (все потоки сериализуются), а слишком мелкая гранулярность (отдельный мьютекс на каждый элемент) увеличивает накладные расходы и риск дедлоков при необходимости блокировки нескольких элементов одновременно.
- Итерирование по контейнеру во время его изменения другим потоком, даже если каждая отдельная операция защищена мьютексом, может привести к неконсистентному состоянию, если итерация сама не охватывается одной блокировкой целиком — частая причина трудно воспроизводимых крашей при отладке.