В заметке о потоках 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 ) ); // Если вызов снаружи класса
Alexander
Очень хороший пример. Спасибо!