callable. Затем, наконец, вызовем функцию callable().get() с целью начать вычисления и дождаться возвращаемых значений, чтобы вывести на экран и их. До вызова callable() не выполняется никаких вычислений, а после этого вызова и начинается вся магия.
auto result (
pconcat(
ptwice(
pconcat(
pcreate("foo "),
pcreate("bar "))),
pconcat(
pcreate("this "),
pcreate("that "))));
cout << "Setup done. Nothing executed yet.n";
cout << result().get() << 'n';
}
11. Компиляция и запуск программы показывают, что все вызовы create выполняются одновременно, а остальные вызовы — уже после них. Кажется, будто все они были спланированы интеллектуально. Вся программа работает 16 секунд. Если бы шаги выполнялись не параллельно, то для завершения программы потребовалось бы 30 секунд. Обратите внимание: для одновременного выполнения всех вызовов create нужна система как минимум с четырьмя ядрами ЦП. Если у системы будет меньше ядер, то некоторые вызовы должны будут делить ЦП, что увеличит время выполнения программы.
$ ./chains
Setup done. Nothing executed yet.
3s CREATE "foo "
3s CREATE "bar "
3s CREATE "this "
3s CREATE "that "
5s CONCAT "this " "that "
5s CONCAT "foo " "bar "
3s TWICE "foo bar "
5s CONCAT "foo bar foo bar " "this that "
foo bar foo bar this that
Как это работает
Простая последовательная версия этой программы без вызовов async и объектов типа future выглядела бы так:
int main()
{
string result {
concat(
twice(
concat(
create("foo "),
create("bar "))),
concat(
create("this "),
create("that "))) };
cout << result << 'n';
}
В данном примере мы написали вспомогательные функции async_adapter и asynchronize, которые позволили создать новые функции на основе функций create, concat и twice. Мы назвали эти новые асинхронные функции pcreate, pconcat и ptwice. Сначала опустим сложность реализации async_adapter и asynchronize с целью увидеть, что они дают. Последовательная версия выглядит аналогично следующему коду:
string result {concat( ... )};
cout << result << 'n';
Распараллеленная версия выглядит аналогично этому фрагменту:
auto result (pconcat( ... ));
cout << result().get() << 'n';
Теперь перейдем к сложной части. Типом распараллеленного результата является не string, а вызываемый объект, возвращающий объект типа future<string>, для которого можно вызвать функцию get(). На первый взгляд это выглядит без умным.
Как и зачем мы работаем с объектами, которые возвращают значения типа future? Проблема заключается в том, что наши методы create, concat и twice слишком медленные. (Да, мы искусственно их замедлили, поскольку пытались смоделировать реальные приложения, которые потребляют много времени ЦП.) Но мы определили, что дерево зависимостей, описывающее поток данных, имеет независимые части, пригодные для параллельного выполнения. Рассмотрим два примера планов (рис. 9.5).
С левой стороны мы видим план для одного ядра. Все вызовы функций нужно выполнять один за другим, поскольку у нас есть только один ЦП. Это значит, что, поскольку вызов функции create длится 3 секунды, вызов concat — 5 секунд, а twice — 3 секунды, для получения конечного результата потребуется 30 секунд.
С правой стороны мы видим план, где задачи выполняются максимально распараллеленно. В идеальном мире, где все компьютеры имеют четыре ядра, можно создать все подстроки одновременно, а затем сконкатенировать их. Минимальное время получения результата с оптимальным параллельным планом равно 16 секундам. Мы не можем ускорить выполнение программы, если не сделать сами вызовы функций быстрее. Имея всего четыре ядра ЦП, можно добиться этого времени выполнения. Мы достигли оптимального расписания. Как оно работает?
Мы могли бы просто написать следующий код:
auto a (async(launch::async, create, "foo "));
auto b (async(launch::async, create, "bar "));
auto c (async(launch::async, create, "this "));
auto d (async(launch::async, create, "that "));
auto e (async(launch::async, concat, a.get(), b.get()));
auto f (async(launch::async, concat, c.get(), d.get()));
auto g (async(launch::async, twice, e.get()));
auto h (async(launch::async, concat, g.get(), f.get()));
Это хорошее начало для a, b, c и d, которые представляют четыре подстроки. Они создаются асинхронно в фоновом режиме. К сожалению, этот код блокируется в строке, где мы инициализируем e. Чтобы сконкатенировать a и b, нужно вызвать get() для обеих подстрок, данный код будет заблокирован до тех пор, пока данные значения не будут готовы. Очевидно, это плохая идея, поскольку распаралелливание перестает быть паралелльным после первого вызова get(). Требуется более хорошая стратегия.
Задействуем сложные вспомогательные функции, которые мы написали. Первая из них — это asynchronize:
template <typename F>
static auto asynchronize(F f)
{
return [f](auto ... xs) {
return [=] () {
return async(launch::async, f, xs...);
};
};
}
При наличии функции int f(int, int) можно сделать следующее:
auto f2 ( asynchronize(f) );
auto f3 ( f2(1, 2) );
auto f4 ( f3() );
int result { f4.get() };
Функция f2 — это наша асинхронная версия функции f. Ее можно вызвать с теми же аргументами, что и функцию f, поскольку f2 подражает ей. Затем она возвращает вызываемый объект, который мы сохраняем в f3. Функция f3 теперь захватывает f и аргументы 1, 2, но пока ничего не вызывает. Это все делается ради захвата.
Теперь при вызове функции f3() мы наконец получаем объект типа future, поскольку f3() делает вызов async(launch::async,f,1,2);! В некотором смысле семантическое значение f3 заключается в следующем: «Получить захваченную функцию и аргументы, а затем передать их в вызов std::async».
Внутреннее лямбда-выражение, которое не принимает никаких аргументов, позволяет пойти по нестандартному пути. С его помощью можно настроить работу для параллельной отправки, но не нужно вызывать никаких блокирующих функций.