Если previous_end_value
не существует, то это первый блок, поэтому достаточно обновить end_value
для следующего блока (опять же, если таковой существует, — может случиться, что блок всего один) (8).
Наконец, если какая-то операция возбуждает исключение, мы перехватываем его (9)и сохраняем в объекте-обещании (10), чтобы оно было распространено в следующий блок, когда он попытается получить последнее значение из предыдущего блока (4). В результате все исключения доходят до последнего блока, где и возбуждаются повторно (11), поскольку в этой точке мы гарантированно работаем в главном потоке.
Из-за необходимости синхронизации между потоками этот код не получится переписать под std::async
. Каждая задача ждет результатов, которые становятся доступны по ходу выполнения других задач, поэтому все задачи должны работать параллельно.
Разобравшись с решением, основанным на распространении результатов обработки предыдущих блоков, посмотрим на второй из описанных алгоритмов вычисления частичных сумм.
Реализация прогрессивно-попарного алгоритма вычисления частичных сумм
Второй способ вычисления частичных сумм, основанный на сложении элементов, расположенных на все большем расстоянии друг от друга, работает лучше всего, когда процессоры могут выполнять операции сложения синхронно. В таком случае никакой дополнительной синхронизации не требуется, потому что все промежуточные результаты можно сразу распространить на следующий нуждающийся в них процессор. Но на практике такие системы встречаются редко — разве что в случаях, когда один процессор может выполнять одну и ту же команду над несколькими элементами данных одновременно с помощью так называемых SIMD-команд (Single-Instruction/Multiple-Data — одиночный поток команд, множественный поток данных). Поэтому мы должны проектировать программу в расчете на общий случай и производить явную синхронизацию на каждом шаге.
Сделать это можно, например, с помощью барьера — механизма синхронизации, который заставляет потоки ждать, пока указанное количество потоков достигнет барьера. Когда все потоки подошли к барьеру, они разблокируются и могут продолжать выполнение. В библиотеке C++11 Thread Library готовая реализация барьера отсутствует, так что нам придется написать свою собственную.
Представьте себе американские горки в парке аттракционов. Если желающих покататься достаточно, то смотритель не запустит состав, пока не будут заняты все места. Барьер работает точно так же: вы заранее задаете число «мест», и потоки будут ждать, пока все «места» заполнятся. Когда ожидающих потоков собирается достаточно, они все получают возможность продолжить выполнение; барьер при этом сбрасывается и начинает ждать следующую партию потоков. Часто такая конструкция встречается в цикле, где на каждой итерации у барьера собираются одни и те же потоки. Идея в том, чтобы все потоки «шли в ногу» — никто не должен забегать вперед. В рассматриваемом алгоритме такой «поток-торопыга» недопустим, потому что он мог бы модифицировать данные, которые еще используются другими потоками, или, наоборот, сам попытался бы использовать еще не модифицированные должным образом данные.
В следующем листинге приведена простая реализация барьера.
Листинг 8.12.Простой класс барьера
class barrier {
unsigned const count;
std::atomic spaces;
std::atomic generation;
public:
explicit barrier (unsigned count_) : ←
(1)
count(count_), spaces(count), generation(0) {}
void wait() {
unsigned const my_generation = generation; ←
(2)
if (!--spaces) {←
(3)
spaces = count;←
(4)
++generation; ←
(5)
} else {
while (generation == my_generation) ←
(6)
std::this_thread::yield(); ←
(7)
}
}
};
Здесь при конструировании барьера мы указываем количество «мест» (1), которое сохраняется в переменной count. В начале количество мест у барьера spaces равно count
. Когда очередной поток выполняет функцию wait()
, значение spaces
уменьшается на единицу (3). Как только оно обращается в нуль, количество мест возвращается к исходному значению count
(4), а переменная generation
увеличивается, что служит для других потоков сигналом к продолжению работы (5). Если число свободных мест больше нуля, то поток должен ждать. В этой реализации используется простой спинлок (6), который сравнивает текущее значение generation
с тем, что было запомнено в начале wait()
(2). Поскольку generation
изменяется только после того, как все потоки подошли к барьеру (5), мы можем во время ожидания уступить процессор с помощью yield()
(7), чтобы ожидающий поток не потреблял процессорное время.
Читать дальше