Потоки и многопоточность

QSemaphore vs QMutex: разница и типичные сценарии использования

7 просмотров

Введение

QMutex и QSemaphore — два базовых примитива синхронизации в Qt, и начинающие разработчики иногда путают, когда применять каждый из них. Если упростить: мьютекс защищает критическую секцию от одновременного доступа более чем одного потока (бинарная блокировка), а семафор управляет доступом к ограниченному числу одинаковых ресурсов (счётный механизм). В статье разберём разницу через практические примеры.

Концепция

QMutex имеет два состояния — заблокирован/разблокирован — и предназначен для защиты данных, к которым в любой момент должен иметь доступ ровно один поток. QSemaphore хранит внутренний счётчик доступных «разрешений» (permits): acquire() уменьшает счётчик (блокируясь, если он равен нулю), release() увеличивает его. Это делает семафор удобным инструментом для ограничения количества одновременно работающих потоков с одним и тем же ресурсом (например, не более N одновременных подключений к базе данных) или для классической задачи «производитель-потребитель», где семафор отслеживает количество доступных элементов в буфере.

Важное отличие: семафор не привязан к конкретному потоку — release() может быть вызван из другого потока, чем тот, что делал acquire() (в отличие от рекурсивного мьютекса, который ожидает разблокировки тем же потоком, который заблокировал).

Пример кода

#include <QCoreApplication>
#include <QThread>
#include <QSemaphore>
#include <QMutex>
#include <QDebug>
#include <QVector>

// Ограничение числа одновременных "подключений" к ресурсу через семафор
class ConnectionPool
{
public:
    explicit ConnectionPool(int maxConnections) : m_semaphore(maxConnections) {}

    void useConnection(int workerId)
    {
        m_semaphore.acquire(); // ждём, если все слоты заняты
        qDebug() << "Воркер" << workerId << "получил подключение";
        QThread::msleep(500); // имитация работы с ресурсом
        qDebug() << "Воркер" << workerId << "освобождает подключение";
        m_semaphore.release();
    }

private:
    QSemaphore m_semaphore;
};

// Защита общего счётчика мьютексом
class SharedCounter
{
public:
    void increment()
    {
        QMutexLocker locker(&m_mutex); // RAII-блокировка
        m_value++;
    }

    int value() const
    {
        QMutexLocker locker(&m_mutex);
        return m_value;
    }

private:
    mutable QMutex m_mutex;
    int m_value = 0;
};

int main(int argc, char *argv[])
{
    QCoreApplication app(argc, argv);

    ConnectionPool pool(2); // максимум 2 одновременных подключения
    SharedCounter counter;

    QVector<QThread *> threads;
    for (int i = 0; i < 5; ++i) {
        auto *thread = QThread::create([&pool, &counter, i]() {
            pool.useConnection(i);
            counter.increment();
        });
        threads.append(thread);
        thread->start();
    }

    for (auto *thread : threads) {
        thread->wait();
        delete thread;
    }

    qDebug() << "Итоговое значение счётчика:" << counter.value();
    return 0;
}

Пояснения к коду

ConnectionPool использует QSemaphore, инициализированный значением 2 — это означает, что не более двух воркеров одновременно могут пройти acquire() без блокировки; остальные будут ждать, пока кто-то не вызовет release(). Это типичная реализация пула ограниченных ресурсов без явной очереди.

SharedCounter использует QMutex через QMutexLocker — RAII-обёртку, которая блокирует мьютекс в конструкторе и автоматически разблокирует в деструкторе, даже при выходе из функции через исключение. Это защищает простое инкрементирование общей переменной m_value от гонки данных при одновременном вызове increment() из нескольких потоков.

Подводные камни

  • Использование мьютекса там, где нужен семафор, приводит к тому, что только один поток может работать с ресурсом за раз, даже если ресурс допускает параллельный доступ нескольким потокам (например, пул из нескольких подключений к БД) — это искусственно снижает параллелизм.
  • Дисбаланс acquire()/release(). Если поток приобрёл разрешение семафора, но завершился с ошибкой до вызова release() (например, из-за необработанного исключения), счётчик семафора «протекает», и со временем все разрешения окажутся захвачены без возможности освобождения — нужно оборачивать release() в try/finally-подобную конструкцию или RAII-обёртку (аналог QMutexLocker, но для семафора нужно писать вручную или использовать QSemaphoreReleaser).
  • Прямой вызов lock()/unlock() мьютекса вместо QMutexLocker. Без RAII-обёртки забытый unlock() при раннем выходе из функции (включая исключения) приводит к дедлоку для всех последующих обращений к этому мьютексу.
  • Рекурсивный захват обычного QMutex тем же потоком приводит к дедлоку. Если требуется повторный захват мьютекса в рекурсивных вызовах одним и тем же потоком, нужно использовать QRecursiveMutex, а не обычный QMutex.