IT Notes

Пример использования QtConcurrent

Введение

В заметке о потоках qt я вкратце упомянул возможность использования QtConcurrent в многопоточных приложениях. И обратил ваше внимание на то, что рассматриваемый там пример не являлся целевым для этого класса. Однако я обещал, что когда-нибудь мы дойдем до рассмотрения более подходящего примера. Вот этот момент и настал. Приступим.

Постановка задачи: обесцвечивание изображения

Предположим, что у нас есть обычное изображение QImage. И мы хотим сделать его черно-белым. К сожалению, на момент написания заметки в стандартной библиотеке Qt такой возможности еще не предусмотрено. Но не расстраивайтесь. Ее достаточно легко реализовать.

Однопоточная реализация

Начнем с простой однопоточной версии:

static QVector< QRgb > colorTable;

struct GrayscaleTaskInput {
    QImage srcImg;
    int xFrom;
    int xTo;
    int yFrom;
    int yTo;
    QImage* destImg;
};

void makeRegionGrayscale( const GrayscaleTaskInput& task ) {
    for( int y = task.yFrom; y < task.yTo; ++y ) {
        uchar* line = task.destImg->scanLine( y );
        for( int x = task.xFrom; x < task.xTo; ++x ) {
            QRgb srcImgPixel = task.srcImg.pixel( x, y );
            QRgb* grayImgPixel = reinterpret_cast< QRgb* >( line + x );
            *grayImgPixel = qGray( srcImgPixel );
        }
    }
}

QImage convertImageToGrayscaleEasy( const QImage& img ) {
    QImage grayImg( img.size(), QImage::Format_Indexed8 );
    grayImg.setColorTable( colorTable );
    makeRegionGrayscale( GrayscaleTaskInput{ img, 0, img.width(), 0, img.height(), &grayImg } );

    return grayImg;
}

int main( int argc, char* argv[] ) {
    if( colorTable.isEmpty() ) {
        for( int i = 0; i < 256; ++i ) {
            colorTable << qRgb( i, i, i );
        }
    }

    …
    QImage grayImg = convertImageToGrayscaleEasy( srcImg );
    …

    return 0;
}

Попробуем разобраться с тем, как работает этот код. Первым делом мы определяем вектор colorTable, который заполняется в начале функции main(). Он просто содержит 256 оттенков серого расположенных по порядку от черного qRgb( 0, 0, 0 ) до белого qRgb( 255, 255, 255 ). Далее следует определение структуры GrayscaleTaskInput. Признаюсь, что это заготовка для перехода к многопоточной реализации. В этой структуре содержится исходное изображение, которое мы хотим сделать черно-белым; диапазоны координат x и y, для которых требуется произвести обесцвечивание; и наконец указатель на выходное изображение, в который будем записывать черно-белые пиксели.

Объект структуры GrayscaleTaskInput ожидает получить функция makeRegionGrayscale(). Она и будет делать всю основную работу. Здесь мы в двойном цикле проходим по каждому пикселю из указанного диапазона. Чтобы получить более высокую скорость работы для доступа к пикселям, мы используем scanLine(). То есть работаем напрямую с буфером в памяти, в котором хранится изображение. Мы также могли воспользоваться функцией-членом setPixel(), но она менее эффективна и в среднем может замедлить скорость работы в два раза. Поэтому в цикле для каждой строки выходного изображения destImg мы запрашиваем указатель на буфер. Считываем значение пикселя на позиции (x; y) для исходного изображения srcImg с помощью функции-члена pixel(). Затем получаем указатель на пиксель выходного изображения на позиции x в нашем буфере. А потом просто переводим пиксель исходного изображения в градации серого с помощью qGray() и полученное значение присваиваем пикселю выходного изображения.

Однопоточный пример, который использует функцию makeRegionGrayscale(), выглядит довольно просто. В первой строке мы создаем изображение с размером, равным исходному изображению. Обратите внимание на формат Format_Indexed8, который мы передали в конструктор объекта grayImg. Он указывает на то, что на каждый пиксель будет приходиться по 1 байту, то есть 8 бит. И этот 1 байт мы индексируем с помощью нашей цветовой таблицы colorTable, которую определили немного выше. После этого нам остается лишь вызывать makeRegionGrayscale() с соответствующими параметрами и вернуть получившееся черно-белое изображение.

Пользоваться получившейся функцией очень легко. Пример ее вызова представлен в функцию main(). На вход подаем любое изображение QImage, а на выходе получаем его же, но уже черно-белое. При этом исходное изображение не изменяется.

Замер производительности

Чтобы сравнить однопоточную и многопточную (скоро мы до нее доберемся) реализации, напишем простенький бенчмарк для оценки времени работы функций:

template< typename Func, typename… Args >
void benchmark( int iterCount, Func func, Args… args ) {
    if( iterCount <= 0 ) {
        return;
    }
    QVector< int > elapsedTimes;
    for( int i = 0; i <iterCount; ++i ) {
        QTime time;
        time.start();
        func( args… );
        elapsedTimes << time.elapsed();
    }

    double max = *std::max_element( elapsedTimes.constBegin(), elapsedTimes.constEnd() ) / 1000.0;
    double min = *std::min_element( elapsedTimes.constBegin(), elapsedTimes.constEnd() ) / 1000.0;
    int sum = std::accumulate( elapsedTimes.constBegin(), elapsedTimes.constEnd(), 0 );
    double avg = sum / ( iterCount * 1000.0 );

    std::cout << "    MAX: " << max << std::endl <<
                 "    MIN: " << min << std::endl <<
                 "    AVG: " << avg << std::endl <<
                 "************************************************************" << std::endl;

Функция benchmark() использует шаблоны с переменным числом параметров, поэтому не забудьте включить режим c++11, если ваш компилятор этого требует. Про шаблоны с переменным числом параметров я достаточно подробно рассказал в заметке, посвященной паттерну Наблюдатель.

На вход benchmark() принимает 3 параметра: количество итераций iterCount, которые нужно сделать для составления статистики; функцию func(), производительность которой мы собрались замерять; аргументы args, которые нужно передать функции func(). Реализация этой шаблонной функции весьма тривиальна. В цикле мы запускаем iterCount раз функцию func() с аргументами args. Для каждого запуска мы делаем замер времени с помощью объекта QTime. Результаты каждого замера заносятся в вектор. Затем для вектора с замерами времени мы определяем минимальную статистику: находим максимальное, минимальное и среднее время выполнения func(). И выводим эти результаты на консоль.

Для convertImageToGrayscaleEasy() вызов бенчмарка имеет следующий вид:

static const int BENCHMARK_ITER_COUNT = 20;

std::cout << "Easy:" << std::endl;
benchmark( BENCHMARK_ITER_COUNT, convertImageToGrayscaleEasy, img );

В результате запуска этого кода на моем компьютере для достаточно большого входного изображения на консоль выводится следующее (у вас, вероятно, будут совсем другие цифры):

Easy:
    MAX: 0.611
    MIN: 0.563
    AVG: 0.5715
************************************************************

В среднем около 0,57 секунд. Не так уж плохо. Посмотрим, что можно выжать из моего компьютера, задействовав потоки.

Многопоточная реализация на основе QtConcurrent::map()

Посмотрим на "быстрое" решение нашей задачи:

static const int THREAD_COUNT = QThread::idealThreadCount();

QImage convertImageToGrayscaleFast( const QImage& img ) {
    QImage grayImg( img.size(), QImage::Format_Indexed8 );
    grayImg.setColorTable( colorTable );

    int dy = img.height() / THREAD_COUNT;
    int y = 0;
    QVector< GrayscaleTaskInput > tasks;
    for( ; y < img.height() - dy; y += dy ) {
        tasks << GrayscaleTaskInput { img, 0, img.width(), y, y + dy, &grayImg };
    }

    QFuture< void > future = QtConcurrent::map( tasks, makeRegionGrayscale );
    makeRegionGrayscale( GrayscaleTaskInput{ img, 0, img.width(), y, img.height(), &grayImg } );
    future.waitForFinished();

    return grayImg;
}

Начало функции ничем не отличается от того, что мы уже видели в однопоточной реализации. Создаем новое изображение, задаем его формат и таблицу цветов. А вот дальше уже начинаются некоторые отличия. Изображение мы делим на горизонтальные полосы. Количество полос будет совпадать с количеством потоков THREAD_COUNT, значение которого мы делаем по умолчанию равным QThread::idealThreadCount(). Функция idealThreadCount() просто возвращает количество ядер процессора, что является наиболее логичным вариантом при выборе числа потоков. Затем мы создаем вектор, в который будем собирать объекты GrayscaleTaskInput для каждой полосы, кроме последней. Последнюю полосу мы обрабатываем в вызывающем потоке. Но обратите внимание, что перед вызовом makeRegionGrayscale() для крайней полосы, мы сделали следующее:

QFuture< void > future = QtConcurrent::map( tasks, makeRegionGrayscale );

Функция map() для каждого элемента вектора tasks запускает функцию makeRegionGrayscale() в своем потоке. Тогда чтобы дождаться результата и вернуть законченное черно-белое изображение, мы принимаем возвращаемый объект QFuture< void >. А перед тем, как вернуть изображение, вызываем waitForFinished(), чтобы гарантировать, что каждая полоса изображения обработана.

Таким образом, основная идея использования QtConcurrent заключается в простом выполнении асинхронных действий для синхронных задач. То есть мы разбиваем сложную задачу на части и отправляем их на обработку в разные потоки. Но не возвращаем управление вызывающей подпрограмме сразу, а ждем, пока все потоки не закончат свою работу. Рассмотренный выше пример является хорошей демонстрацией этого принципа. Но что там у нас со скоростью работу? Пора добавлять бенчмарк:

std::cout << "Fast:" << std::endl;
benchmark( BENCHMARK_ITER_COUNT, convertImageToGrayscaleFast, img );

И вот что у меня выводится на консоль для того же самого изображения:

Fast:
    MAX: 0.152
    MIN: 0.134
    AVG: 0.14285
************************************************************

В среднем около 0,14 секунд. Конечно, не мгновенно, но по сравнению с простой однопоточной реализацией это и правда быстро. Мы увеличили скорость работы алгоритма более, чем в 4 раза.

Заключение

Вот мы и посмотрели на типичный пример использования QtConcurrent для реализации многопоточного кода. Такой подход имеет свои преимущества и недостатки. С одной стороны, для слишком большого объема входных данных вызов подобных функций может привести к "тормозам" в GUI. Но во многих случаях простота использования перекрывает этот недостаток, который может никогда и не всплыть. Так нам достаточно реализовать одну функцию и без забот ее использовать, не думая о внутренней многопоточной природе. Но для реализации сложного асинхронного взаимодействия потоков нам придется использовать сигналы и слоты, либо какие-то еще более заумные схемы на базе очередей, мьютексов и прочей гадости. Но все зависит от задачи, которая перед вами стоит. В каких-то ситуациях одно решение окажется идеальным, а где-то просто не подойдет.

Похожие публикации

Комментарии

Очень хороший пример. Спасибо!

Спасибо за отзыв :)

подскажите пожалуйста, если tasks и makeRegionGrayscale являются членами класса, то как правильно засунуть makeRegionGrayscale ?

QFuture< void > future = QtConcurrent::map( tasks, makeRegionGrayscale );

Если функция-член makeRegionGrayscale() статическая (рекомендуется, иначе придется думать о синхронизации потоков и терять производительность), то разницы нет:

QFuture< void > future = QtConcurrent::map( m_tasks, makeRegionGrayscale ); // Если вызов внутри класса

QFuture< void > future = QtConcurrent::map( myObj.m_tasks, MyClass::makeRegionGrayscale ); // Если вызов снаружи класса

Если функция-член является нестатической, то:

QFuture< void > future = QtConcurrent::map( m_tasks, std::bind( &MyClass::makeRegionGrayscale, this, std::placeholders::_1 ) ); // Если вызов внутри класса

QFuture< void > future = QtConcurrent::map( myObj.m_tasks, std::bind( &MyClass::makeRegionGrayscale, &myObj, std::placeholders::_1 ) ); // Если вызов снаружи класса