Чи робить async (запуск :: 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 Моя думка щодо ниткових деструкторів полягала в тому, що руйнування на виході з потоку не зовсім коректно при використанні пулів потоків. Коли завдання виконується асинхронно, воно виконується "як би за новою ниткою", відповідно до специфікації, що означає, що кожна задача асинхронізації отримує власні об'єкти_потоки. Реалізація на основі пулу потоків повинна бути особливо обережною, щоб завдання, що обмінюються одним і тим самим резервним потоком, все ще поводилися так, ніби вони мають свої власні об'єкти_потоків. Розгляньте цю програму: pastebin.com/9nWUT40h
bames53

2
@ bames53 Використання "як би на новій нитці" у специфікації було величезною помилкою на мою думку. std::asyncце могло бути прекрасною справою для продуктивності - це могла бути стандартна система виконання коротких завдань, природно підкріплена пулом потоків. Наразі, це просто std::threadз деяким дерьмом, задіяним, щоб функція потоку змогла повернути значення. О, і вони додали зайвий "відкладений" функціонал, який повністю перекриває роботу std::function.
doug65536

Відповіді:


54

Питання 1 :

Я змінив це з оригіналу, тому що оригінал був неправильним. У мене було враження, що створення ниток Linux було дуже дешевим, і після тестування я визначив, що накладні виклики функцій у новому потоці порівняно з нормальним - величезні. Накладні витрати для створення потоку для обробки функціонального виклику - це щось на зразок 10000 і більше разів повільніше, ніж звичайний виклик функції. Отже, якщо ви здійснюєте багато невеликих викликів функцій, пул потоків може бути хорошою ідеєю.

Цілком очевидно, що у стандартній бібліотеці C ++, яка постачається з g ++, немає пулів потоків. Але я точно можу побачити для них випадок. Навіть маючи накладні витрати, коли потрібно просунути виклик через якусь міжпотокову чергу, це, швидше за все, буде дешевше, ніж запускати новий потік. І стандарт це дозволяє.

IMHO, люди з ядром Linux повинні працювати над тим, щоб створити нитки дешевше, ніж це є зараз. Але стандартна бібліотека C ++ також повинна розглянути можливість використання пулу для реалізації launch::async | launch::deferred.

І ОП правильно, використовуючи ::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,90 ГГц) Apple LLVM version 10.0.0 (clang-1000.10.44.4)під ОСX 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 в VM, і приблизно на 8 в рідній ОС X.

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


3
Я погоджуюся з моделлю черги на роботу, однак для цього потрібна модель "трубопроводу", яка може не застосовуватися для кожного використання паралельного доступу.
Матьє М.

1
Мені здається, що шаблони виразів (для операторів) можуть бути використані для складання результатів, для викликів функцій вам потрібен метод виклику, я думаю, але через перевантаження це може бути дещо складніше.
Матьє М.

3
"дуже дешево" відносно вашого досвіду. Я вважаю, що створення потоків Linux над головою є істотним для мого використання.
Джефф

1
@Jeff - Я думав, що це набагато дешевше, ніж є. Я оновив свою відповідь деякий час тому, щоб відобразити тест, який я зробив, щоб виявити фактичну вартість.
Омніфаріоз

4
У першій частині ви дещо недооцінюєте, скільки потрібно зробити, щоб створити загрозу, і як мало потрібно зробити, щоб викликати функцію. Виклик та повернення функції - це кілька інструкцій процесора, які маніпулюють кількома байтами у верхній частині стека. Створення загрози означає: 1. виділення стека, 2. виконання системного виклику, 3. створення структур даних у ядрі та зв’язування їх, обхоплення замків по дорозі, 4. очікування планувальника для виконання потоку, 5. перемикання контекст до потоку. Кожен з цих кроків сам по собі займає набагато більше часу, ніж викликає найскладніша функція.
cmaster - відновити моніку
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.