Zaktualizuj i renderuj w osobnych wątkach

12

Tworzę prosty silnik do gier 2D i chcę zaktualizować i renderować duszki w różnych wątkach, aby dowiedzieć się, jak to zrobić.

Muszę zsynchronizować wątek aktualizacji i renderowania. Obecnie używam dwóch flag atomowych. Przepływ pracy wygląda mniej więcej tak:

Thread 1 -------------------------- Thread 2
Update obj ------------------------ wait for swap
Create queue ---------------------- render the queue
Wait for render ------------------- notify render done
Swap render queues ---------------- notify swap done

W tej konfiguracji ograniczam FPS wątku renderującego do FPS wątku aktualizacji. Poza tym używam sleep()do ograniczenia zarówno renderowania, jak i aktualizacji FPS wątku do 60, więc dwie funkcje oczekiwania nie będą długo czekać.

Problemem jest:

Średnie użycie procesora wynosi około 0,1%. Czasami nawet do 25% (w czterordzeniowym komputerze PC). Oznacza to, że wątek czeka na drugi, ponieważ funkcja oczekiwania jest pętlą while z funkcją testowania i ustawiania, a pętla while zużywa wszystkie zasoby procesora.

Moje pierwsze pytanie brzmi: czy istnieje inny sposób synchronizacji dwóch wątków? Zauważyłem, że std::mutex::locknie używaj procesora, gdy czeka on na zablokowanie zasobu, więc nie jest to pętla while. Jak to działa? Nie mogę użyć, std::mutexponieważ będę musiał zablokować je w jednym wątku i odblokować w innym wątku.

Drugie pytanie brzmi; skoro program działa zawsze z prędkością 60 klatek na sekundę, dlaczego czasami jego użycie procesora skacze do 25%, co oznacza, że ​​jedno z dwóch czekań dużo czeka? (oba wątki są ograniczone do 60 klatek na sekundę, więc idealnie nie będą wymagały dużo synchronizacji).

Edycja: Dzięki za wszystkie odpowiedzi. Najpierw chcę powiedzieć, że nie zaczynam nowego wątku dla każdej ramki do renderowania. Zaczynam zarówno aktualizację, jak i renderowanie na początku. Myślę, że wielowątkowość może zaoszczędzić trochę czasu: Mam następujące funkcje: FastAlg () i Alg (). Alg () to zarówno moja aktualizacja obj, jak i obiekt render, a Fastalg () to moja „kolejka wysyłania renderowania do” renderer ”. W jednym wątku:

Alg() //update 
FastAgl() 
Alg() //render

W dwóch wątkach:

Alg() //update  while Alg() //render last frame
FastAlg() 

Więc może wielowątkowość może zaoszczędzić ten sam czas. (w rzeczywistości robi to w prostej aplikacji matematycznej, gdzie alg jest długim algorytmem, a szybszym - szybszym)

Wiem, że sen nie jest dobrym pomysłem, chociaż nigdy nie miałem problemów. Czy to będzie lepsze?

While(true) 
{
   If(timer.gettimefromlastcall() >= 1/fps)
   Do_update()
}

Ale będzie to nieskończona pętla while, która będzie wykorzystywała cały procesor. Czy mogę użyć trybu uśpienia (liczba <15), aby ograniczyć użycie? W ten sposób będzie działać na przykład przy 100 fps, a funkcja aktualizacji zostanie wywołana tylko 60 razy na sekundę.

Aby zsynchronizować dwa wątki, użyję funkcji waitforsingleobject z createSemaphore, aby móc blokować i odblokowywać różne wątki (bez użycia pętli while), prawda?

Liuka
źródło
5
„Nie mów, że moja wielowątkowość jest w tym przypadku bezużyteczna, chcę tylko nauczyć się, jak to robić” - w takim przypadku powinieneś nauczyć się rzeczy właściwie, to znaczy (a) nie używaj sleep () do kontrolowania ramki rzadko , nigdy i (b) unikaj projektowania wątków na komponenty i unikaj uruchamiania blokady, zamiast tego dziel pracę na zadania i obsługuj zadania z kolejki roboczej.
Damon,
1
@Damon (a) sleep () może być używany jako mechanizm liczby klatek na sekundę i jest w rzeczywistości dość popularny, chociaż muszę się zgodzić, że istnieją o wiele lepsze opcje. (b) Użytkownik tutaj chce oddzielić zarówno aktualizację, jak i renderować w dwóch różnych wątkach. Jest to normalna separacja w silniku gry i nie jest tak „zależna od wątku”. Daje to wyraźne korzyści, ale może powodować problemy, jeśli zostanie wykonane nieprawidłowo.
Alexandre Desbiens
@AlphSpirit: Fakt, że coś jest „powszechne”, nie oznacza, że ​​nie jest źle . Nawet bez wchodzenia w rozbieżne liczniki czasu, sama ziarnistość snu w co najmniej jednym popularnym systemie operacyjnym dla komputerów stacjonarnych jest wystarczającym powodem, jeśli nie jego zawodnością w stosunku do projektu w każdym istniejącym systemie konsumenckim. Wyjaśnienie, dlaczego rozdzielenie aktualizacji i renderowania na dwa wątki zgodnie z opisem jest nierozsądne i powoduje więcej problemów, niż byłoby to warte, trwałoby zbyt długo. Cel PO jest określony jako nauczenie się, jak to się robi , co powinno być dowodem, jak to się robi poprawnie . Wiele artykułów na temat nowoczesnej konstrukcji silnika MT.
Damon
@Damon Kiedy powiedziałem, że jest popularny lub powszechny, nie chciałem powiedzieć, że to prawda. Chodziło mi tylko o to, że używało go wiele osób. „… chociaż muszę się zgodzić, że istnieją o wiele lepsze opcje”, oznaczało, że rzeczywiście nie jest to bardzo dobry sposób synchronizacji czasu. Przepraszam za nieporozumienie.
Alexandre Desbiens,
@AlphSpirit: Nie martw się :-) Świat jest pełen rzeczy, które robi wiele osób (i nie zawsze z dobrych powodów), ale kiedy zaczynasz się uczyć, wciąż powinieneś unikać najbardziej oczywistych błędów.
Damon

Odpowiedzi:

25

W przypadku prostego silnika 2D ze spritami podejście jednowątkowe jest całkowicie dobre. Ale ponieważ chcesz nauczyć się wielowątkowości, powinieneś nauczyć się robić to poprawnie.

Nie rób

  • Użyj 2 wątków, które wykonują mniej lub bardziej blokadę, implementując zachowanie jednowątkowe z kilkoma wątkami. Ma to ten sam poziom równoległości (zero), ale dodaje narzut dla przełączników kontekstu i synchronizacji. Co więcej, logika jest trudniejsza do zrozumienia.
  • Służy sleepdo kontrolowania liczby klatek na sekundę. Nigdy. Jeśli ktoś ci każe, uderz go.
    Po pierwsze, nie wszystkie monitory pracują z częstotliwością 60 Hz. Po drugie, dwa liczniki tykające z tą samą prędkością biegną obok siebie zawsze w końcu się zsynchronizują (upuść dwie piłki pingpongowe na stół z tej samej wysokości i słuchaj). Po trzecie, z założenia nie sleepjest ani dokładne, ani niezawodne. Ziarnistość może być tak mała jak 15,6 ms (w rzeczywistości domyślna w systemie Windows [1] ), a ramka ma tylko 16,6 ms przy 60 klatkach na sekundę, co pozostawia zaledwie 1 ms na wszystko inne. Ponadto trudno jest uzyskać 16,6 wielokrotności 15,6 ... Można także (i czasem będzie) powracać dopiero po 30 lub 50 lub 100 ms, a nawet dłużej.
    sleep
  • Służy std::mutexdo powiadamiania o innym wątku. Nie po to jest.
  • Załóżmy, że TaskManager jest dobry w mówieniu o tym, co się dzieje, szczególnie na podstawie liczby takiej jak „25% CPU”, którą można wydać w kodzie, w sterowniku trybu użytkownika lub gdzie indziej.
  • Mają jeden wątek na komponent wysokiego poziomu (są oczywiście wyjątki).
  • Twórz wątki w „losowych czasach”, ad hoc, dla każdego zadania. Tworzenie wątków może być zaskakująco drogie i może zająć zaskakująco dużo czasu, zanim zaczną robić to, co im powiedziałeś (szczególnie jeśli masz załadowane wiele bibliotek DLL!).

Zrobić

  • Użyj wielowątkowości, aby wszystko działało asynchronicznie, jak tylko możesz. Szybkość nie jest główną ideą łączenia wątków, ale robienie rzeczy równolegle (więc nawet jeśli zajmują więcej razem, suma wszystkiego jest wciąż mniejsza).
  • Użyj synchronizacji pionowej, aby ograniczyć liczbę klatek na sekundę. To jedyny prawidłowy (i niezawodny) sposób na zrobienie tego. Jeśli użytkownik zastąpi cię na panelu sterowania sterownika ekranu („wymuś wyłączenie”), niech tak będzie. W końcu to jego komputer, a nie twój.
  • Jeśli chcesz „zaznaczyć” coś w regularnych odstępach czasu, użyj timera . Zaletą timerów jest o wiele lepsza dokładność i niezawodność w porównaniu z sleep[2] . Ponadto cykliczny licznik czasu prawidłowo uwzględnia czas (w tym czas, który mija między nimi), podczas gdy sen przez 16,6 ms (lub 16,6 ms minus zmierzony_czas_elastyczny) nie.
  • Przeprowadzaj symulacje fizyki, które wymagają integracji numerycznej w ustalonym czasie (lub twoje równania wybuchną!), Interpoluj grafikę między krokami ( może to być usprawiedliwienie dla osobnego wątku na komponent, ale można to również zrobić bez).
  • Użyj, std::mutexaby mieć tylko jeden wątek na raz dostęp do zasobu („wzajemnie się wykluczaj”) i aby zachować dziwną semantykę std::condition_variable.
  • Unikaj rywalizacji wątków o zasoby. Zablokuj tak mało, jak to konieczne (ale nie mniej!) I przytrzymaj blokady tak długo, jak to absolutnie konieczne.
  • Współdziel dane tylko do odczytu między wątkami (bez problemów z pamięcią podręczną i bez konieczności blokowania), ale nie modyfikuj jednocześnie danych (wymaga synchronizacji i zabija pamięć podręczną). Obejmuje to modyfikowanie danych znajdujących się w pobliżu miejsca, które ktoś może odczytać.
  • Użyj, std::condition_variableaby zablokować inny wątek, dopóki jakiś warunek nie będzie spełniony. Semantyka std::condition_variablez tym dodatkowym muteksem jest wprawdzie dość dziwna i pokręcona (głównie z powodów historycznych odziedziczonych po wątkach POSIX), ale zmienna warunkowa jest poprawną operacją podstawową do tego, czego chcesz.
    Jeśli uważasz, że jest ci std::condition_variablezbyt dziwnie, aby czuć się komfortowo, możesz po prostu użyć zdarzenia Windows (nieco wolniej) lub, jeśli jesteś odważny, zbudować własne proste wydarzenie wokół NtKeyedEvent (obejmuje przerażające rzeczy na niskim poziomie). Kiedy korzystasz z DirectX, i tak jesteś już związany z Windows, więc utrata przenośności nie powinna być dużym problemem.
  • Podziel pracę na zadania o rozsądnych rozmiarach, które są uruchamiane przez pulę wątków roboczych o stałej wielkości (nie więcej niż jeden na rdzeń, nie licząc rdzeni z hiperwątkiem). Pozwól, aby zadania końcowe kolejkowały zadania zależne (bezpłatna, automatyczna synchronizacja). Wykonuj zadania, z których każda ma co najmniej kilkaset nietrywialnych operacji (lub jedną długą operację blokującą, taką jak odczyt dysku). Preferuj ciągły dostęp do pamięci podręcznej.
  • Utwórz wszystkie wątki na początku programu.
  • Skorzystaj z funkcji asynchronicznych oferowanych przez system operacyjny lub graficzny interfejs API w celu uzyskania lepszej / dodatkowej równoległości, nie tylko na poziomie programu, ale także sprzętu (pomyśl o transferach PCIe, równoległości CPU-GPU, DMA dysku itp.).
  • 10 000 innych rzeczy, o których zapomniałem wspomnieć.


[1] Tak, możesz ustawić szybkość harmonogramu na 1ms, ale jest to niezadowolone, ponieważ powoduje o wiele więcej przełączeń kontekstu i zużywa dużo więcej energii (w świecie, w którym coraz więcej urządzeń to urządzenia mobilne). To także nie jest rozwiązanie, ponieważ nadal nie sprawia, że ​​sen jest bardziej niezawodny.
[2] Timer zwiększy priorytet wątku, co pozwoli mu przerwać kolejny wątek o równym priorytecie w połowie kwantowym i zostać zaplanowany jako pierwszy, co jest zachowaniem quasi-RT. To oczywiście nie jest prawda RT, ale jest bardzo blisko. Przebudzenie ze snu oznacza jedynie, że wątek będzie gotowy do zaplanowania w dowolnym momencie, kiedy tylko będzie to możliwe.

Damon
źródło
Czy możesz wyjaśnić, dlaczego nie powinieneś „mieć jednego wątku na komponent wysokiego poziomu”? Czy masz na myśli, że nie należy mieszać fizyki i dźwięku w dwóch osobnych wątkach? Nie widzę powodu, aby tego nie robić.
Elviss Strazdins
3

Nie jestem pewien, co chcesz osiągnąć, ograniczając liczbę klatek na sekundę aktualizacji i renderowania do 60. Jeśli ograniczysz je do tej samej wartości, możesz po prostu umieścić je w tym samym wątku.

Celem oddzielenia aktualizacji i renderowania w różnych wątkach jest to, aby oba były „prawie” niezależne od siebie, tak aby procesor graficzny mógł renderować 500 klatek na sekundę, a logika aktualizacji wciąż osiąga 60 klatek na sekundę. W ten sposób nie osiągasz bardzo wysokiego przyrostu wydajności.

Ale powiedziałeś, że chcesz wiedzieć, jak to działa, i jest w porządku. W C ++ muteks jest specjalnym obiektem służącym do blokowania dostępu do niektórych zasobów dla innych wątków. Innymi słowy, używasz muteksu, aby udostępnić sensowne dane tylko przez jeden wątek na raz. Aby to zrobić, jest to dość proste:

std::mutex mutex;
mutex.lock();
// Do sensible stuff here...
mutex.unlock();

Źródło: http://en.cppreference.com/w/cpp/thread/mutex

EDYTOWAĆ : Upewnij się, że muteks ma klasę lub plik, tak jak w podanym linku, w przeciwnym razie każdy wątek utworzy swój własny muteks i nic nie osiągniesz.

Pierwszy wątek, który zablokuje muteks, będzie miał dostęp do kodu wewnątrz. Jeśli drugi wątek spróbuje wywołać funkcję lock (), będzie się blokował, dopóki pierwszy wątek go nie odblokuje. Muteks jest więc funkcją blokowania, w przeciwieństwie do pętli while. Blokowanie funkcji nie będzie obciążało procesora.

Alexandre Desbiens
źródło
A jak działa blok?
Liuka,
Kiedy drugi wątek wywoła funkcję lock (), będzie cierpliwie czekał na odblokowanie muteksu przez pierwszy wątek i będzie kontynuowany w następnym wierszu po (w tym przykładzie sensowne rzeczy). EDYCJA: Drugi wątek zablokuje muteks dla siebie.
Alexandre Desbiens
1
Użyj std::lock_guardlub podobny, nie .lock()/ .unlock(). RAII służy nie tylko do zarządzania pamięcią!
bcrist