Оновіть і візуалізуйте в окремих потоках


12

Я створюю простий 2D ігровий движок, і хочу оновити спрацьовувати спрайти в різних потоках, щоб дізнатися, як це робиться.

Мені потрібно синхронізувати оновлення та рендер. В даний час я використовую два атомних прапора. Робочий процес виглядає приблизно так:

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

У цій установці я обмежую FPS потоку візуалізації лише FPS потоку оновлення. Крім того, я використовую sleep()для обмеження FPS рендеринга та оновлення потоку до 60, тому дві функції очікування не чекатимуть багато часу.

Проблема полягає в наступному:

Середнє використання процесора становить близько 0,1%. Іноді це досягає 25% (у чотирьохядерному ПК). Це означає, що потік чекає іншого, оскільки функція очікування - це цикл часу з функцією тестування та встановлення, а цикл "час" використовуватиме всі ваші ресурси процесора.

Перше моє запитання: чи є інший спосіб синхронізації двох потоків? Я помітив, що std::mutex::lockне використовуйте процесор, поки він чекає, щоб заблокувати ресурс, тому це не цикл часу. Як це працює? Я не можу використовувати, std::mutexтому що мені потрібно буде зафіксувати їх в одній нитці та розблокувати в іншій потоці.

Інше питання: оскільки програма працює завжди зі швидкістю 60 кадрів в секунду, чому іноді її використання процесора підскакує до 25%, це означає, що один з двох очікувань багато чекає? (обидва потоки обмежені до 60 кадрів в секунду, тому в ідеалі їм не знадобиться багато синхронізації).

Редагувати: Дякую за всі відповіді. Спершу хочу сказати, що я не запускаю новий потік для кожного кадру для візуалізації. Я запускаю і оновлення, і цикл візуалізації на початку. Я думаю, що багатопотоковість може заощадити деякий час: у мене є такі функції: FastAlg () та Alg (). Alg () - це і мій obj Update, і rend obj. В одну нитку:

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

Двома потоками:

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

Тож, можливо, багатопоточність може заощадити той же час. (насправді в простому математичному додатку це робиться, де alg - це довгий алгоритм, а amd fastalg - швидший)

Я знаю, що сон не є хорошою ідеєю, хоча у мене ніколи не виникає проблем. Чи буде це краще?

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

Але це буде нескінченний цикл у той час, як буде використовуватися весь процесор. Чи можу я використовувати сон (число <15), щоб обмежити використання? Таким чином він працюватиме, наприклад, зі 100 кадрів в секунду, а функція оновлення буде викликатися всього 60 разів за секунду.

Для синхронізації двох потоків я буду використовувати waitforsingleobject з createSemaphore, щоб я міг блокувати та розблокувати в різних потоках (без використання циклу в той час), чи не так?


5
"Не кажіть, що моя багатопоточність в цьому випадку марна, я просто хочу навчитися це робити" - у такому випадку ви повинні навчитися правильно, тобто (а) не використовуйте сон () для управління кадром рідко , ніколи ніколи , і (б) уникати потоків за конструкції компонентів і уникнути робіт, а не жорстко регламентованому роздільну роботи в задачах і завданнях управління з черги робіт.
Деймон

1
@Damon (a) сон () може використовуватися як механізм частоти кадрів і насправді є досить популярним, хоча я маю згоду з тим, що є набагато кращі варіанти. (b) Користувач хоче розділити як оновлення, так і візуалізацію у двох різних потоках. Це нормальне розділення в ігровому двигуні і не настільки "потік на компонент". Це дає чіткі переваги, але може призвести до проблем, якщо зробити їх неправильно.
Олександр Дезбієн

@AlphSpirit: Те, що щось є "загальним", не означає, що це не помиляється . Навіть не вдаючись до розбіжних таймерів, просто деталізація сну принаймні на одній популярній настільній операційній системі є достатньою причиною, якщо не її ненадійністю за дизайном для кожної існуючої споживчої системи. Пояснення, чому розділяти оновлення та візуалізувати на два потоки, як описано, є нерозумним та спричиняє більше проблем, ніж це варто, зайняло б занадто багато часу. Мета оперативної програми заявляється як дізнатися, як це робиться , а також слід навчитися правильно робити це . Безліч статей про сучасний дизайн двигунів MT.
Деймон

@Damon Коли я сказав, що це популярність чи поширеність, я не хотів сказати, що це правильно. Я просто мав на увазі, що його використовували багато людей. "... хоча я повинен погодитися, що є набагато кращі варіанти" означав, що це дійсно не дуже вдалий спосіб синхронізувати час. Вибачте за непорозуміння.
Олександр Дезбієн

@AlphSpirit: Не хвилюйтесь :-) Світ наповнений речами, які багато людей роблять (і не завжди з поважної причини), але коли хтось починає вчитися, варто все ж намагатися уникати найбільш очевидно неправильних.
Деймон

Відповіді:


25

Для простого 2D двигуна з спрайтами цілком підходить однонитковий підхід. Але оскільки ви хочете навчитися робити багатопотокові, ви повинні навчитися робити це правильно.

Не

  • Використовуйте 2 потоки, які виконують більш-менш крок блокування, реалізуючи однопоточну поведінку з декількома потоками. Це має той же рівень паралелізму (нуль), але додає накладні витрати на контекстні комутатори та синхронізацію. Плюс до того, що логіку важче підхопити.
  • Використовуйте sleepдля управління частотою кадрів. Ніколи. Якщо хтось вам скаже, ударить їх.
    По-перше, не всі монітори працюють на 60 Гц. По-друге, два таймери, що тикають з однаковою швидкістю, бігаючи поруч, завжди з часом вийдуть із синхронізації (киньте дві кульки з пінгпонг на стіл з однакової висоти та слухайте). По-третє, за конструкцією не sleepє ні точним, ні надійним. Гранульованість може бути такою ж поганою, як 15,6 мс (насправді за замовчуванням для Windows [1] ), а кадр становить лише 16,6 мс при 60 кадрів в секунду, що залишає лише 1 мс для всього іншого. Крім того, важко отримати 16.6, щоб бути кратним 15.6 ... Крім того, дозволено (і іноді буде!) Повертатися лише через 30, 50 або 100 мс, або навіть довший час.
    sleep
  • Використовуйте std::mutexдля повідомлення іншого потоку. Це не для чого.
  • Припустимо, що TaskManager добре допомагає вам розповідати, що відбувається, особливо судячи з числа типу "25% процесора", яке можна витратити у вашому коді, або в драйвері користувальницького модуля, або деінде.
  • Майте одну нитку на компонент високого рівня (звичайно, є винятки).
  • Створіть теми в "випадковий час", ad hoc, за завдання. Створення ниток може бути напрочуд дорогим, і вони можуть зайняти напрочуд довгий час, перш ніж вони акутно роблять те, що ви їм сказали (особливо якщо у вас завантажено багато DLL!).

Зробіть

  • Використовуйте багатопотоковість, щоб роботи працювали асинхронно настільки, наскільки ви можете. Швидкість - це не головна ідея нитки, але робити паралельно (навіть якщо вони взагалі тривають довше, сума всіх все одно менша).
  • Використовуйте вертикальну синхронізацію для обмеження частоти кадрів. Це єдиний правильний (і невдалий) спосіб зробити це. Якщо користувач переорієнтує вас на панелі управління драйвера дисплея ("вимкніть"), то так і буде. Адже це його комп’ютер, а не ваш.
  • Якщо вам потрібно щось «відзначити» через рівні проміжки часу, використовуйте таймер . Таймери мають перевагу в тому, що вони мають набагато кращу точність та надійність порівняно з sleep[2] . Крім того, таймер, що повторюється, враховує час правильно (включаючи час, що проходить між ними), тоді як час сну протягом 16,6 мс (або 16,6 мс мінус вимірюваний_час_елапс) не дає.
  • Запустіть фізичні симуляції, які включають числову інтеграцію за фіксований крок часу (або ваші рівняння вибухнуть!), Інтерполюйте графіку між кроками (це може бути приводом для окремого потоку компонентів, але це також можна зробити без).
  • Використовуйте std::mutexлише один потік доступу до ресурсу за один раз ("взаємно виключайте") та дотримуйтесь дивної семантики std::condition_variable.
  • Уникайте, щоб нитки конкурували за ресурси. Блокуйте якомога менше (але не менше!) І тримайте замки лише до тих пір, скільки це абсолютно необхідно.
  • Діліться даними лише для читання між потоками (жодних проблем із кешем та блокування не потрібно), але не одночасно змінюйте дані (потребує синхронізації та вбиває кеш). Це включає зміну даних, які знаходяться поблизу місця, яке може прочитати хтось інший.
  • Використовуйте std::condition_variableдля блокування іншої нитки, поки деяка умова не відповідає дійсності. Семантика std::condition_variableцього додаткового мютексу, очевидно, досить дивна і вита (в основному з історичних причин, успадкованих від потоків POSIX), але змінна умова є правильним примітивом, який потрібно використовувати для того, що ви хочете.
    У випадку, якщо вам здається, що це std::condition_variableзанадто дивно, щоб вам було зручніше, ви можете просто використовувати подію Windows (трохи повільніше) замість цього, або, якщо ви сміливі, створити власну просту подію навколо NtKeyedEvents (передбачає страшні речі низького рівня). Оскільки ви використовуєте DirectX, ви все одно пов'язані з Windows, так що втрата портативності не має бути великим.
  • Розбийте роботу на задані розміри завдань, які виконуються пулом робочих ниток фіксованого розміру (не більше одного на ядро, не рахуючи гіперточених ядер). Нехай завершальні завдання включають залежні завдання (вільну, автоматичну синхронізацію). Зробіть завдання, які мають принаймні кілька сотень нетривіальних операцій кожна (або одна тривала операція блокування, як читання диска). Віддайте перевагу кеш-доступу.
  • Створіть усі теми під час запуску програми.
  • Скористайтеся асинхронними функціями, які ОС або графічний API пропонує для кращого / додаткового паралелізму не тільки на програмному рівні, але й на апаратному забезпеченні (подумайте, що передача PCIe, паралелізм CPU-GPU, диск DMA тощо).
  • Ще 10 000 речей, які я забув згадати.


[1] Так, ви можете встановити швидкість планувальника до 1 мс, але це нахмуриться, оскільки це спричиняє набагато більше комутацій контексту та споживає набагато більше енергії (у світі, де все більше пристроїв є мобільними пристроями). Це також не є рішенням, оскільки він досі не робить сон більш надійним.
[2] Таймер підвищить пріоритет потоку, що дозволить йому перервати інший середній квант нитки з рівнем пріоритету і буде заплановано першим, що є квазі-RT поведінкою. Це, звичайно, не справжній RT, але він підходить дуже близько. Прокидання зі сну просто означає, що нитка стає готовою до планування на якийсь час, коли б це не було.


Чи можете ви поясніть, чому ви не повинні мати "один потік на компонент високого рівня"? Ви маєте на увазі, що фізичне та звукове змішування у двох окремих нитках не повинно бути? Я не бачу причин для цього не робити.
Elviss Strazdins

3

Я не впевнений, чого ви хочете досягти, обмеживши обороти обох версій оновлення та візуалізації до 60. Якщо ви обмежите їх одним і тим же значенням, ви могли б просто помістити їх у ту саму нитку.

Мета при розділенні оновлення та візуалізації в різних потоках полягає в тому, щоб вони були «майже» незалежними одна від одної, щоб графічний процесор міг відтворювати 500 FPS, а логіка оновлення все ще триває 60 FPS. Ви не досягаєте дуже високого підвищення продуктивності, роблячи це.

Але ви сказали, що просто хочете дізнатися, як це працює, і це добре. У C ++ mutex - це спеціальний об'єкт, який використовується для блокування доступу до певних ресурсів для інших потоків. Іншими словами, ви використовуєте мьютекс, щоб зробити доступні розсудливі дані лише одним потоком одночасно. Зробити це досить просто:

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

Джерело: http://en.cppreference.com/w/cpp/thread/mutex

РЕДАКТУВАННЯ : Переконайтеся, що ваш mutex є класовим або файловим, як у наведеному посиланні, інакше кожен потік створить свій власний mutex і ви нічого не досягнете.

Перший потік для блокування mutex матиме доступ до коду всередині. Якщо другий потік намагається викликати функцію lock (), він блокується, поки перша нитка не розблокує її. Отже, мютекс - це здуваюча функція, на відміну від циклу в той час. Функції блокування не будуть навантажувати процесор.


І як працює блок?
Люка

Коли другий потік викличе lock (), він буде терпляче чекати, коли перша нитка розблокує мютекс, і продовжить наступний рядок після (у цьому прикладі розважливого матеріалу). EDIT: Другий потік потім заблокує мютекс для себе.
Олександр Дезбієн


1
Використовувати std::lock_guardабо подібне, не .lock()/ .unlock(). RAII не лише для управління пам'яттю!
bcrist
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.