Обновление и рендеринг в отдельных темах


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% (в четырехъядерном ПК). Это означает, что поток ожидает другого, потому что функция wait является циклом while с функцией test и set, а цикл while будет использовать все ресурсы вашего ЦП.

Мой первый вопрос: есть ли другой способ синхронизации двух потоков? Я заметил, что std::mutex::lockне используйте процессор, пока он ожидает блокировки ресурса, поэтому он не является циклом while. Как это работает? Я не могу использовать, std::mutexпотому что мне нужно будет заблокировать их в одном потоке и разблокировать в другом потоке.

Другой вопрос: Поскольку программа работает всегда со скоростью 60 FPS, почему иногда загрузка процессора увеличивается до 25%, а это означает, что один из двух ожиданий ждет много? (оба потока ограничены 60 кадрами в секунду, поэтому в идеале им не нужно много синхронизации).

Редактировать: Спасибо за все ответы. Сначала я хочу сказать, что я не запускаю новый поток каждый кадр для рендера. Я запускаю цикл обновления и рендеринга в начале. Я думаю, что многопоточность может сэкономить время: у меня есть следующие функции: FastAlg () и Alg (). Alg () - это мой объект обновления obj и render obj, а Fastalg () - это моя очередь отправки рендера для renderer. В одной теме:

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

В два потока:

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

Так что, возможно, многопоточность может сэкономить время. (на самом деле это происходит в простом математическом приложении, где alg - длинный алгоритм, а быстрый - более быстрый)

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

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

Но это будет бесконечный цикл while, который будет использовать весь процессор. Могу ли я использовать сон (число <15), чтобы ограничить использование? Таким образом, он будет работать, например, со скоростью 100 кадров в секунду, а функция обновления будет вызываться всего 60 раз в секунду.

Для синхронизации двух потоков я буду использовать объект waitforsingle с createSemaphore, чтобы иметь возможность блокировать и разблокировать в другом потоке (без использования цикла while), не так ли?


5
«Не говорите, что моя многопоточность бесполезна в этом случае, я просто хочу узнать, как это сделать» - в этом случае вы должны изучать вещи правильно, то есть (а) не использовать sleep () для управления кадром редко , никогда , и (б) избегать проектирования потоков для каждого компонента и избегать выполнения lockstep, вместо этого делить работу на задачи и обрабатывать задачи из рабочей очереди.
Деймон

1
@Damon (a) sleep () может использоваться как механизм частоты кадров и на самом деле довольно популярен, хотя я должен согласиться с тем, что есть гораздо лучшие варианты. (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% CPU», которое можно потратить в вашем коде, или в драйвере пользовательского режима, или где-то еще.
  • Иметь один поток на компонент высокого уровня (конечно, есть некоторые исключения).
  • Создавайте темы в «случайное время», 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 и т. Д.).
  • 10000 других вещей, которые я забыл упомянуть.


[1] Да, вы можете установить скорость планировщика до 1 мс, но это не одобряется, поскольку вызывает намного больше переключений контекста и потребляет намного больше энергии (в мире, где все больше и больше устройств являются мобильными устройствами). Это также не решение проблемы, поскольку оно все еще не делает сон более надежным.
[2] Таймер повысит приоритет потока, что позволит ему прерывать другой поток с равным приоритетом в середине кванта и планироваться первым, что является квази-RT поведением. Это, конечно, не правда RT, но это очень близко. Пробуждение из спящего режима означает лишь то, что поток готов к планированию на какое-то время, когда бы это ни было.


Не могли бы вы объяснить, почему вы не должны иметь «Один поток на компонент высокого уровня»? Вы имеете в виду, что физика и аудио не должны смешиваться в двух отдельных потоках? Я не вижу причин не делать этого.
Элвисс Страздинс,

3

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

Цель разделения Update и Render в разных потоках состоит в том, чтобы оба «почти» не зависели друг от друга, чтобы графический процессор мог отображать 500 FPS, а логика обновления по-прежнему работала со скоростью 60 FPS. При этом вы не добьетесь очень высокого прироста производительности.

Но ты сказал, что просто хотел знать, как это работает, и это нормально. В C ++ мьютекс - это специальный объект, который используется для блокировки доступа к определенным ресурсам для других потоков. Другими словами, вы используете мьютекс, чтобы сделать разумные данные доступными только одному потоку одновременно. Для этого достаточно просто:

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

Источник: http://en.cppreference.com/w/cpp/thread/mutex

РЕДАКТИРОВАТЬ : Убедитесь, что ваш мьютекс является классом или файлом, как в приведенной ссылке, иначе каждый поток создаст свой собственный мьютекс, и вы ничего не добьетесь.

Первый поток, блокирующий мьютекс, будет иметь доступ к коду внутри. Если второй поток попытается вызвать функцию lock (), он будет блокироваться, пока первый поток не разблокирует ее. Так что мьютекс - это блокирующая функция, в отличие от цикла while. Функции блокировки не будут оказывать нагрузку на процессор.


А как работает блок?
Люка

Когда второй поток вызовет lock (), он будет терпеливо ждать, пока первый поток разблокирует мьютекс, и продолжит на следующей строке после (в этом примере, разумные вещи). РЕДАКТИРОВАТЬ: Второй поток затем заблокирует мьютекс для себя.
Александр Дезбиенс


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