Czy async (launch :: async) w C ++ 11 sprawia, że ​​pule wątków są przestarzałe, aby uniknąć kosztownego tworzenia wątków?

117

Jest to luźno związane z tym pytaniem: czy std :: thread jest w puli w C ++ 11? . Chociaż pytanie jest inne, intencja jest taka sama:

Pytanie 1: Czy nadal ma sens używanie własnych (lub biblioteki innej firmy) pul wątków, aby uniknąć kosztownego tworzenia wątków?

Wniosek z drugiego pytania był taki, że nie można liczyć na std::threadto, że zostaniemy połączeni (może lub nie). std::async(launch::async)Wydaje się jednak, że ma znacznie większą szansę na połączenie.

Nie wydaje mi się, że jest to wymuszone przez standard, ale IMHO spodziewałbym się, że wszystkie dobre implementacje C ++ 11 będą używać puli wątków, jeśli tworzenie wątków jest powolne. Tylko na platformach, na których tworzenie nowego wątku jest niedrogie, spodziewałbym się, że zawsze tworzą nowy wątek.

Pytanie 2: Tak właśnie myślę, ale nie mam faktów, aby to udowodnić. Mogę się bardzo mylić. Czy to zgadywanie?

Na koniec przedstawiłem tutaj przykładowy kod, który najpierw pokazuje, jak myślę, że tworzenie wątków można wyrazić za pomocą async(launch::async):

Przykład 1:

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

staje się

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

Przykład 2: odpal i zapomnij wątek

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

staje się

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

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

Pytanie 3: Czy wolisz asyncwersje od threadwersji?


Reszta nie jest już częścią pytania, ale tylko dla wyjaśnienia:

Dlaczego wartość zwracana musi być przypisana do zmiennej fikcyjnej?

Niestety, obecny standard C ++ 11 wymusza przechwycenie zwracanej wartości std::async, ponieważ w przeciwnym razie wykonywany jest destruktor, który blokuje się do momentu zakończenia akcji. Niektórzy uważają to za błąd w normie (np. Przez Herba Suttera).

Ten przykład z cppreference.com ładnie to ilustruje:

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

Kolejne wyjaśnienie:

Wiem, że pule wątków mogą mieć inne uzasadnione zastosowania, ale w tym pytaniu interesuje mnie tylko aspekt unikania kosztownych kosztów tworzenia wątków .

Myślę, że nadal istnieją sytuacje, w których pule wątków są bardzo przydatne, zwłaszcza jeśli potrzebujesz większej kontroli nad zasobami. Na przykład serwer może zdecydować o obsłudze tylko określonej liczby żądań jednocześnie, aby zagwarantować szybkie czasy odpowiedzi i zwiększyć przewidywalność wykorzystania pamięci. Pule wątków powinny być w porządku.

Zmienne lokalne wątku mogą być również argumentem dla twoich własnych pul wątków, ale nie jestem pewien, czy ma to zastosowanie w praktyce:

  • Tworzenie nowego wątku ze std::threadstartami bez zainicjowanych zmiennych lokalnych wątku. Może nie tego chcesz.
  • W wątkach utworzonych przez program asyncjest dla mnie niejasny, ponieważ wątek mógł zostać ponownie użyty. Z mojego zrozumienia nie ma gwarancji, że zmienne lokalne wątku zostaną zresetowane, ale mogę się mylić.
  • Z drugiej strony korzystanie z własnych pul wątków (o stałym rozmiarze) zapewnia pełną kontrolę, jeśli naprawdę tego potrzebujesz.
Philipp Claßen
źródło
8
" std::async(launch::async)Wydaje się jednak, że ma znacznie większe szanse na połączenie." Nie, uważam, std::async(launch::async | launch::deferred)że to może być połączone. Samo launch::asynczadanie ma zostać uruchomione w nowym wątku, niezależnie od tego, jakie inne zadania są uruchomione. Dzięki polityce launch::async | launch::deferredimplementacja wybiera, którą politykę, ale co ważniejsze, może opóźnić wybór tej polityki. Oznacza to, że może poczekać, aż wątek w puli wątków stanie się dostępny, a następnie wybrać zasadę asynchronizacji.
bames53
2
O ile wiem, tylko VC ++ używa puli wątków z std::async(). Nadal jestem ciekawy, jak obsługują nietrywialne destruktory thread_local w puli wątków.
bames53
2
@ bames53 Przeszedłem przez bibliotekę libstdc ++, która jest dostarczana z gcc 4.7.2 i stwierdziłem, że jeśli polityka uruchamiania nie jest dokładna, launch::async to traktuje ją tak, jakby była tylko launch::deferredi nigdy nie wykonuje jej asynchronicznie - w efekcie ta wersja libstdc ++ „wybiera” zawsze używać odroczonego, chyba że wymuszono inaczej.
doug65536
3
@ doug65536 Moja uwaga dotycząca destruktorów thread_local dotyczyła tego, że niszczenie przy wyjściu wątku nie jest do końca poprawne podczas korzystania z pul wątków. Gdy zadanie jest uruchamiane asynchronicznie, jest uruchamiane „jak w nowym wątku”, zgodnie ze specyfikacją, co oznacza, że ​​każde zadanie asynchroniczne otrzymuje własne obiekty thread_local. Implementacja oparta na puli wątków musi zachować szczególną ostrożność, aby zapewnić, że zadania współużytkujące ten sam wątek zapasowy nadal zachowują się tak, jakby miały własne obiekty thread_local. Rozważ ten program: pastebin.com/9nWUT40h
bames53
2
@ bames53 Używanie wyrażenia „jak w nowym wątku” w specyfikacji było moim zdaniem ogromnym błędem. std::asyncmógł być piękną rzeczą dla wydajności - mógł to być standardowy system wykonywania krótkich zadań, naturalnie wspierany przez pulę wątków. W tej chwili jest to tylko std::threadz przyczepionymi bzdurami, aby funkcja wątku mogła zwrócić wartość. Aha, i dodali zbędną „odroczoną” funkcjonalność, która std::functioncałkowicie pokrywa się z zadaniem .
doug65536,

Odpowiedzi:

54

Pytanie 1 :

Zmieniłem to z oryginału, ponieważ oryginał był zły. Miałem wrażenie, że tworzenie wątków w Linuksie jest bardzo tanie i po przetestowaniu stwierdziłem, że narzut wywołania funkcji w nowym wątku w porównaniu z normalnym jest ogromny. Narzut związany z utworzeniem wątku do obsługi wywołania funkcji jest około 10000 lub więcej razy wolniejszy niż zwykłe wywołanie funkcji. Tak więc, jeśli wydajesz wiele małych wywołań funkcji, dobrym pomysłem może być pula wątków.

Jest całkiem oczywiste, że standardowa biblioteka C ++ dostarczana z g ++ nie ma pul wątków. Ale zdecydowanie widzę dla nich uzasadnienie. Nawet biorąc pod uwagę narzut związany z koniecznością przepchnięcia połączenia przez jakąś kolejkę między wątkami, prawdopodobnie byłoby to tańsze niż rozpoczęcie nowego wątku. A standard na to pozwala.

IMHO, ludzie z jądrem Linuksa powinni popracować nad tym, aby tworzenie wątków było tańsze niż obecnie. Ale standardowa biblioteka C ++ powinna również rozważyć użycie puli do implementacji launch::async | launch::deferred.

A OP jest poprawny, użycie ::std::threaddo uruchomienia wątku oczywiście wymusza utworzenie nowego wątku zamiast używania jednego z puli. Więc ::std::async(::std::launch::async, ...)jest preferowany.

Pytanie 2 :

Tak, w zasadzie to „niejawnie” uruchamia wątek. Ale tak naprawdę nadal jest całkiem oczywiste, co się dzieje. Więc nie sądzę, aby to słowo w sposób dorozumiany było szczególnie dobrym słowem.

Nie jestem też przekonany, że zmuszanie Cię do czekania na powrót przed zniszczeniem jest koniecznie błędem. Nie wiem, czy powinieneś używać asyncwywołania do tworzenia wątków „demonów”, które nie powinny powrócić. A jeśli oczekuje się, że powrócą, ignorowanie wyjątków nie jest w porządku.

Pytanie 3 :

Osobiście lubię, gdy uruchamianie wątków jest jednoznaczne. Przywiązuję dużą wagę do wysp, na których możesz zagwarantować dostęp szeregowy. W przeciwnym razie skończysz ze zmiennym stanem, że zawsze musisz gdzieś zawijać muteks i pamiętać o jego użyciu.

Model kolejki roboczej podobał mi się o wiele lepiej niż model „przyszły”, ponieważ istnieją „wyspy szeregowe”, dzięki czemu można efektywniej obsługiwać stan zmienny.

Ale tak naprawdę to zależy od tego, co dokładnie robisz.

Test wydajności

Więc przetestowałem wydajność różnych metod wywoływania rzeczy i wymyśliłem te liczby na 8-rdzeniowym (AMD Ryzen 7 2700X) systemie z Fedorą 29 skompilowanym z clang w wersji 7.0.1 i libc ++ (nie 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                                      

I natywny, na moim 15-calowym MacBooku Pro (Intel (R) Core (TM) i7-7820HQ, 2,90 GHz) z systemem Apple LLVM version 10.0.0 (clang-1000.10.44.4)OSX 10.13.6, otrzymuję to:

   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

W przypadku wątku roboczego uruchomiłem wątek, a następnie użyłem kolejki bez blokad do wysyłania żądań do innego wątku, a następnie czekałem na odesłanie odpowiedzi „Gotowe”.

„Nic nie rób” to po prostu przetestowanie napowietrznej uprzęży testowej.

Oczywiste jest, że koszt uruchomienia wątku jest ogromny. Nawet wątek roboczy z kolejką między wątkami spowalnia działanie o współczynnik około 20 w Fedorze 25 na maszynie wirtualnej i około 8 w natywnym systemie OS X.

Stworzyłem projekt Bitbucket zawierający kod, którego użyłem do testu wydajności. Można go znaleźć tutaj: https://bitbucket.org/omnifarious/launch_thread_performance

Wszelaki
źródło
3
Popieram model kolejki roboczej, jednak wymaga to posiadania modelu „potoku”, który może nie mieć zastosowania przy każdym użyciu dostępu współbieżnego.
Matthieu M.
1
Wydaje mi się, że do tworzenia wyników można użyć szablonów wyrażeń (dla operatorów), w przypadku wywołań funkcji potrzebna byłaby metoda wywołania , ale z powodu przeciążenia może to być nieco trudniejsze.
Matthieu M.
3
„bardzo tanio” zależy od Twojego doświadczenia. Uważam, że narzut tworzenia wątków w Linuksie jest istotny dla mojego użytku.
Jeff,
1
@Jeff - Myślałem, że to dużo tańsze niż jest. Jakiś czas temu zaktualizowałem odpowiedź, aby odzwierciedlić test, który przeprowadziłem, aby odkryć rzeczywisty koszt.
Omnifarious
4
W pierwszej części nieco nie doceniasz, ile trzeba zrobić, aby stworzyć zagrożenie, a ile trzeba zrobić, aby wywołać funkcję. Wywołanie i powrót funkcji to kilka instrukcji procesora, które manipulują kilkoma bajtami na szczycie stosu. Tworzenie zagrożenia oznacza: 1. przydzielenie stosu, 2. wykonanie wywołania systemowego, 3. utworzenie struktur danych w jądrze i ich łączenie, chwytanie blokad po drodze, 4. oczekiwanie na wykonanie wątku przez harmonogram, 5. przełączanie kontekst do wątku. Każdy z tych kroków sam w sobie trwa znacznie dłużej niż najbardziej złożone wywołania funkcji.
cmaster