В заметке о потоках qt я вкратце упомянул возможность использования QtConcurrent в многопоточных приложениях. И обратил ваше внимание на то, что рассматриваемый там пример не являлся целевым для этого класса. Однако я обещал, что когда-нибудь мы дойдем до рассмотрения более подходящего примера. Вот этот момент и настал. Приступим.
Постановка задачи: обесцвечивание изображения
Предположим, что у нас есть обычное изображение QImage. И мы хотим сделать его черно-белым. К сожалению, на момент написания заметки в стандартной библиотеке Qt такой возможности еще не предусмотрено. Но не расстраивайтесь. Ее достаточно легко реализовать.
Однопоточная реализация
Начнем с простой однопоточной версии:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
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, а на выходе получаем его же, но уже черно-белое. При этом исходное изображение не изменяется.
Замер производительности
Чтобы сравнить однопоточную и многопточную (скоро мы до нее доберемся) реализации, напишем простенький бенчмарк для оценки времени работы функций:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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() вызов бенчмарка имеет следующий вид:
1 2 3 4 |
static const int BENCHMARK_ITER_COUNT = 20; std::cout << "Easy:" << std::endl; benchmark( BENCHMARK_ITER_COUNT, convertImageToGrayscaleEasy, img ); |
В результате запуска этого кода на моем компьютере для достаточно большого входного изображения на консоль выводится следующее (у вас, вероятно, будут совсем другие цифры):
1 2 3 4 5 |
Easy: MAX: 0.611 MIN: 0.563 AVG: 0.5715 ************************************************************ |
В среднем около 0,57 секунд. Не так уж плохо. Посмотрим, что можно выжать из моего компьютера, задействовав потоки.
Многопоточная реализация на основе [crayon-66cf1250d2a1b672121928-i/]
Посмотрим на «быстрое» решение нашей задачи:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
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() для крайней полосы, мы сделали следующее:
1 |
QFuture< void > future = QtConcurrent::map( tasks, makeRegionGrayscale ); |
Функция map() для каждого элемента вектора tasks запускает функцию makeRegionGrayscale() в своем потоке. Тогда чтобы дождаться результата и вернуть законченное черно-белое изображение, мы принимаем возвращаемый объект QFuture< void >. А перед тем, как вернуть изображение, вызываем waitForFinished(), чтобы гарантировать, что каждая полоса изображения обработана.
Таким образом, основная идея использования QtConcurrent заключается в простом выполнении асинхронных действий для синхронных задач. То есть мы разбиваем сложную задачу на части и отправляем их на обработку в разные потоки. Но не возвращаем управление вызывающей подпрограмме сразу, а ждем, пока все потоки не закончат свою работу. Рассмотренный выше пример является хорошей демонстрацией этого принципа. Но что там у нас со скоростью работу? Пора добавлять бенчмарк:
1 2 |
std::cout << "Fast:" << std::endl; benchmark( BENCHMARK_ITER_COUNT, convertImageToGrayscaleFast, img ); |
И вот что у меня выводится на консоль для того же самого изображения:
1 2 3 4 5 |
Fast: MAX: 0.152 MIN: 0.134 AVG: 0.14285 ************************************************************ |
В среднем около 0,14 секунд. Конечно, не мгновенно, но по сравнению с простой однопоточной реализацией это и правда быстро. Мы увеличили скорость работы алгоритма более, чем в 4 раза.
Заключение
Вот мы и посмотрели на типичный пример использования QtConcurrent для реализации многопоточного кода. Такой подход имеет свои преимущества и недостатки. С одной стороны, для слишком большого объема входных данных вызов подобных функций может привести к «тормозам» в GUI. Но во многих случаях простота использования перекрывает этот недостаток, который может никогда и не всплыть. Так нам достаточно реализовать одну функцию и без забот ее использовать, не думая о внутренней многопоточной природе. Но для реализации сложного асинхронного взаимодействия потоков нам придется использовать сигналы и слоты, либо какие-то еще более заумные схемы на базе очередей, мьютексов и прочей гадости. Но все зависит от задачи, которая перед вами стоит. В каких-то ситуациях одно решение окажется идеальным, а где-то просто не подойдет.