Делает ли async (launch :: async) в C ++ 11 устаревшими пулы потоков, чтобы избежать дорогостоящего создания потоков?

117

Это слабо связано с вопросом: объединены ли std :: thread в C ++ 11? , Хотя вопрос отличается, намерение остается тем же:

Вопрос 1. Есть ли смысл использовать собственные пулы потоков (или сторонние библиотеки), чтобы избежать дорогостоящего создания потоков?

Вывод в другом вопросе заключался в том, что нельзя полагаться на то, что вас std::threadобъединят (это может быть, а может и нет). Однако, std::async(launch::async)похоже, у него гораздо больше шансов на объединение.

Не думаю, что это вызвано стандартом, но, IMHO, я ожидал бы, что все хорошие реализации C ++ 11 будут использовать пул потоков, если создание потоков происходит медленно. Я ожидал, что только на платформах, где создание нового потока обходится недорого, они всегда порождают новый поток.

Вопрос 2: Я так думаю, но у меня нет фактов, подтверждающих это. Я вполне могу ошибаться. Это обоснованное предположение?

Наконец, здесь я привел пример кода, который сначала показывает, как, по моему мнению, можно выразить создание потока async(launch::async):

Пример 1:

 thread t([]{ f(); });
 // ...
 t.join();

становится

 auto future = async(launch::async, []{ f(); });
 // ...
 future.wait();

Пример 2: запустить и забыть поток

 thread([]{ f(); }).detach();

становится

 // a bit clumsy...
 auto dummy = async(launch::async, []{ f(); });

 // ... but I hope soon it can be simplified to
 async(launch::async, []{ f(); });

Вопрос 3: Вы бы предпочли asyncверсии threadверсиям?


Остальное уже не часть вопроса, а только для уточнения:

Почему возвращаемое значение должно быть присвоено фиктивной переменной?

К сожалению, текущий стандарт C ++ 11 вынуждает вас фиксировать возвращаемое значение std::async, иначе выполняется деструктор, который блокируется до завершения действия. Некоторые считают это ошибкой в ​​стандарте (например, Херб Саттер).

Этот пример с cppreference.com прекрасно это иллюстрирует:

{
  std::async(std::launch::async, []{ f(); });
  std::async(std::launch::async, []{ g(); });  // does not run until f() completes
}

Еще одно уточнение:

Я знаю, что пулы потоков могут иметь и другое законное использование, но в этом вопросе меня интересует только аспект избежания дорогостоящих затрат на создание потоков .

Я думаю, что все еще бывают ситуации, когда пулы потоков очень полезны, особенно если вам нужен больший контроль над ресурсами. Например, сервер может решить обрабатывать только фиксированное количество запросов одновременно, чтобы гарантировать быстрое время отклика и повысить предсказуемость использования памяти. Пулы потоков здесь должны быть в порядке.

Локальные переменные потока также могут быть аргументом для ваших собственных пулов потоков, но я не уверен, актуально ли это на практике:

  • Создание нового потока с std::threadзапусками без инициализированных локальных переменных потока. Может быть, это не то, что вам нужно.
  • В потоках, созданных с помощью async, для меня это несколько непонятно, потому что поток можно было использовать повторно. Насколько я понимаю, сброс локальных переменных потока не гарантируется, но я могу ошибаться.
  • С другой стороны, использование собственных пулов потоков (фиксированного размера) дает вам полный контроль, если он вам действительно нужен.
Филипп Классен
источник
8
«Однако, std::async(launch::async)похоже, у них гораздо больше шансов быть объединенными». Нет, я считаю, std::async(launch::async | launch::deferred)что это можно объединить. С помощью всего лишь launch::asyncзадача , как предполагается , будет запущен в новом потоке , независимо от того, что другие задачи выполняются. С политикой launch::async | launch::deferredреализация может выбрать, какая политика, но, что более важно, откладывает выбор какой политики. То есть он может дождаться, пока поток в пуле потоков не станет доступным, а затем выбрать политику асинхронности.
bames53
2
Насколько мне известно, только VC ++ использует пул потоков с расширением std::async(). Мне все еще любопытно посмотреть, как они поддерживают нетривиальные деструкторы thread_local в пуле потоков.
bames53
2
@ bames53 Я прошел через libstdc ++, который поставляется с gcc 4.7.2, и обнаружил, что если политика запуска не совсем точная ,launch::async то он обрабатывает ее так, как если бы она была единственной, launch::deferredи никогда не выполняет ее асинхронно - по сути, эта версия libstdc ++ "выбирает" всегда использовать отложенный, если не указано иное.
doug65536
3
@ doug65536 Мое мнение о деструкторах thread_local заключалось в том, что разрушение при выходе из потока не совсем корректно при использовании пулов потоков. Когда задача запускается асинхронно, она запускается «как будто в новом потоке», согласно спецификации, что означает, что каждая асинхронная задача получает свои собственные объекты thread_local. Реализация на основе пула потоков должна уделять особое внимание тому, чтобы задачи, использующие один и тот же резервный поток, по-прежнему вели себя так, как будто у них есть свои собственные объекты thread_local. Рассмотрим эту программу: pastebin.com/9nWUT40h
bames53
2
@ bames53 Использование в спецификации фразы «как будто в новой ветке», на мой взгляд, было большой ошибкой. std::asyncмогла бы быть прекрасной вещью для производительности - это могла бы быть стандартная система выполнения краткосрочных задач, естественно поддерживаемая пулом потоков. Прямо сейчас это просто какая- std::threadто чушь, чтобы заставить функцию потока возвращать значение. Да, и они добавили избыточную «отложенную» функциональность, которая полностью перекрывает работу std::function.
doug65536

Ответы:

55

Вопрос 1 :

Я изменил это с оригинала, потому что оригинал был неправильным. У меня создалось впечатление, что создание потока Linux было очень дешевым, и после тестирования я определил, что накладные расходы на вызов функции в новом потоке по сравнению с обычным огромны. Накладные расходы на создание потока для обработки вызова функции примерно в 10000 или более раз медленнее, чем простой вызов функции. Итак, если вы выполняете много небольших вызовов функций, пул потоков может быть хорошей идеей.

Совершенно очевидно, что стандартная библиотека C ++, поставляемая с g ++, не имеет пулов потоков. Но я определенно вижу для них дело. Даже с накладными расходами, связанными с проталкиванием вызова через какую-то межпотоковую очередь, это, вероятно, будет дешевле, чем запуск нового потока. И стандарт это позволяет.

IMHO, люди, работающие с ядром Linux, должны работать над тем, чтобы сделать создание потоков дешевле, чем сейчас. Но стандартная библиотека C ++ также должна рассмотреть возможность использования пула для реализации launch::async | launch::deferred.

И OP правильный, использование ::std::threadдля запуска потока, конечно, заставляет создание нового потока вместо использования одного из пула. Так ::std::async(::std::launch::async, ...)что предпочтительнее.

Вопрос 2 :

Да, в основном это «неявно» запускает поток. Но на самом деле, все еще совершенно очевидно, что происходит. Так что я не думаю, что это слово неявно является особенно хорошим словом.

Я также не уверен, что заставлять вас ждать возврата до уничтожения обязательно является ошибкой. Я не знаю, следует ли использовать этот asyncвызов для создания потоков «демонов», которые не должны возвращаться. И если ожидается, что они вернутся, игнорировать исключения нельзя.

Вопрос 3 :

Лично мне нравится явный запуск потоков. Я очень ценю острова, где вы можете гарантировать последовательный доступ. В противном случае вы получите изменяемое состояние, когда вам всегда нужно где-то оборачивать мьютекс и не забывать его использовать.

Мне понравилась модель очереди работы намного больше, чем модель «будущего», потому что есть «островки серийного номера», так что вы можете более эффективно обрабатывать изменяемое состояние.

Но на самом деле это зависит от того, что именно вы делаете.

Тест производительности

Итак, я протестировал производительность различных методов вызова вещей и получил эти числа на 8-ядерной (AMD Ryzen 7 2700X) системе с Fedora 29, скомпилированной с clang версии 7.0.1 и libc ++ (не libstdc ++):

   Do nothing calls per second:   35365257                                      
        Empty calls per second:   35210682                                      
   New thread calls per second:      62356                                      
 Async launch calls per second:      68869                                      
Worker thread calls per second:     970415                                      

И родной, на моем MacBook Pro 15 дюймов (Intel (R) Core (TM) i7-7820HQ CPU @ 2.90GHz) с Apple LLVM version 10.0.0 (clang-1000.10.44.4)OSX 10.13.6, я получаю следующее:

   Do nothing calls per second:   22078079
        Empty calls per second:   21847547
   New thread calls per second:      43326
 Async launch calls per second:      58684
Worker thread calls per second:    2053775

Для рабочего потока я запустил поток, затем использовал очередь без блокировки для отправки запросов другому потоку, а затем дождался ответа «Готово», который будет отправлен обратно.

«Ничего не делать» - это просто проверка верхней части тестового ремня.

Понятно, что накладные расходы на запуск потока огромны. И даже рабочий поток с межпотоковой очередью замедляет работу примерно в 20 раз на Fedora 25 на виртуальной машине и примерно в 8 раз на собственной OS X.

Я создал проект Bitbucket, содержащий код, который я использовал для теста производительности. Его можно найти здесь: https://bitbucket.org/omnifarious/launch_thread_performance

всевозможный
источник
3
Я согласен с моделью рабочей очереди, однако для этого требуется модель «конвейера», которая не может быть применима для всех видов использования одновременного доступа.
Matthieu M.
1
Мне кажется, что шаблоны выражений (для операторов) можно использовать для составления результатов, для вызовов функций вам понадобится метод вызова, я думаю, но из-за перегрузок это может быть немного сложнее.
Matthieu M.
3
"очень дешево" относится к вашему опыту. Я считаю, что накладные расходы на создание потоков Linux являются существенными для моего использования.
Джефф
1
@ Джефф - Я думал, это намного дешевле, чем есть на самом деле. Я обновил свой ответ некоторое время назад, чтобы отразить тест, который я провел, чтобы определить фактическую стоимость.
Omnifarious
4
В первой части вы несколько недооцениваете, сколько нужно сделать, чтобы создать угрозу, и как мало нужно сделать для вызова функции. Вызов и возврат функции - это несколько инструкций ЦП, которые управляют несколькими байтами в верхней части стека. Создание угрозы означает: 1. выделение стека, 2. выполнение системного вызова, 3. создание структур данных в ядре и их связывание, захват блокировок на этом пути, 4. ожидание выполнения планировщиком потока, 5. переключение. контекст для потока. Каждый из этих шагов сам по себе занимает гораздо больше времени, чем вызов наиболее сложных функций.
cmaster - восстановить монику на работе 09