在单独的线程中更新和渲染


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,因此这两个等待函数不会等待太多时间。

问题是:

平均CPU使用率约为0.1%。有时,在四核PC中,它会上升25%。这意味着一个线程正在等待另一个线程,因为wait函数是一个带有test and set函数的while循环,而while循环将使用您的所有CPU资源。

我的第一个问题是:是否有另一种方式同步两个线程?我注意到std::mutex::lock在等待锁定资源时不要使用CPU,因此它不是while循环。它是如何工作的?我无法使用,std::mutex因为我需要将它们锁定在一个线程中并在另一个线程中解锁。

另一个问题是;由于该程序始终以60 FPS的速度运行,为什么有时它的CPU使用率会跃升到25%,这意味着两个等待中的一个正在等待很多?(两个线程都限制为60fps,因此理想情况下它们不需要太多同步)。

编辑:感谢所有答复。首先,我想说的是,我不会在每个帧开始渲染新线程。我从一开始就开始了update和render循环。我认为多线程可以节省一些时间:我具有以下功能:FastAlg()和Alg()。Alg()既是我的Update obj又是render obj,而Fastalg()是我的“发送渲染队列到” renderer”“。在一个线程中:

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

在两个线程中:

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

因此,也许多线程可以节省相同的时间。(实际上,在简单的数学应用程序中,它确实是这样,其中alg是一个长算法,而fastalg是一个更快的算法)

我知道睡觉不是一个好主意,尽管我从来没有遇到过问题。这样会更好吗?

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

但这将是一个无限的while循环,它将使用所有CPU。我可以使用sleep(数字<15)来限制使用吗?这样,它将以例如100 fps的速度运行,并且更新功能每秒仅被调用60次。

为了同步两个线程,我将使用带有createSemaphore的waitforsingleobject,以便能够在不同的线程中锁定和解锁(使用while循环进行淘汰),不是吗?


5
“不要在这种情况下告诉我多线程是没有用的,我只是想学习如何做” –在这种情况下,您应该正确学习,即(a)不要使用sleep()来控制帧。 ,永远不要和(b)避免按组件进行线程设计并避免运行锁步,而是将工作拆分为任务并处理工作队列中的任务。
戴蒙2014年

1
@Damon(a)sleep()可以用作帧速率机制,并且实际上非常流行,尽管我必须同意还有更好的选择。(b)这里的用户希望将更新和渲染分为两个不同的线程。这是游戏引擎中的正常分离,并不是“每个组件线程”。它具有明显的优点,但是如果操作不正确,则会带来问题。
Alexandre Desbiens 2014年

@AlphSpirit:某些事物是“常见”的事实并不意味着它没有。甚至不必花很多时间,至少在一个流行的台式机操作系统上的睡眠粒度就足够了,即使不是每个现有消费者系统上每个设计的可靠性也不行。解释为什么将update和render分为两个线程是不明智的,并且会导致比原本值得花费的麻烦更多的时间。OP的目标陈述为学习如何完成,应该学习如何正确完成。围绕现代MT引擎设计的大量文章。
达蒙2014年

@Damon当我说它很流行或很常见时,我并不是说它是对的。我只是说它被很多人使用。“……尽管我必须同意,还有更好的选择”,这实际上并不是同步时间的一种好方法。很抱歉对于这个误会。
Alexandre Desbiens 2014年

@AlphSpirit:不用担心:-)世界上充满了许多人所做的事情(并非总是有充分的理由),但是当人们开始学习时,仍然应该避免出现最明显的错误。
戴蒙2014年

Answers:


25

对于带有Sprite的简单2D引擎,单线程方法非常好。但是,由于您想学习如何进行多线程,因此您应该学习正确地进行多线程。

不要

  • 使用2个线程或多或少地执行锁步操作,并通过多个线程实现单线程行为。这具有相同的并行度(零),但是增加了上下文切换和同步的开销。另外,逻辑很难理解。
  • 使用sleep控制帧速率。决不。如果有人告诉您,请打他们。
    首先,并非所有监视器都以60Hz运行。其次,两个计时器以相同的速率滴答作响,最终总是不同步(将两个乒乓球从相同的高度放到桌子上,然后聆听)。第三,sleep通过设计既不准确,也不可靠。粒度可能高达15.6毫秒(实际上是Windows [1]上的默认值),并且一帧在60 fps时仅为16.6毫秒,而其他一切仅剩下1毫秒。另外,很难使16.6成为15.6的倍数...
    而且,sleep仅在30或50或100 ms或更长时间之后才允许(有时会!)返回。
  • 使用std::mutex通知另一个线程。这不是它的目的。
  • 假设TaskManager擅长告诉您发生了什么事情,特别是从“ 25%CPU”之类的数字来看,可以在代码中,用户模式驱动器中或其他地方使用它。
  • 每个高级组件只有一个线程(当然也有一些例外)。
  • 根据任务在“随机时间”临时创建线程。创建线程可能会非常昂贵,而且可能要花很长时间才能真正完成您告诉他们的操作(特别是如果您加载了很多DLL!)。

  • 使用多线程可以使事情尽可能异步地运行。速度不是线程化的主要思想,而是并行处理(因此,即使它们在一起花费的时间更长,总和仍然更少)。
  • 使用垂直同步来限制帧速率。这是唯一正确(且不失败)的方法。如果用户在显示驱动程序的控制面板中覆盖了您(“强制关闭”),那就这样。毕竟是他的计算机,而不是您的计算机。
  • 如果您需要定期打勾,请使用计时器。与sleep[2]相比,计时器具有更高的准确性和可靠性。同样,一个循环计时器正确地计入了时间(包括经过的时间),而睡眠了16.6毫秒(或16.6毫秒减去measured_time_elapsed)却没有。
  • 运行涉及固定时间数值积分的物理模拟(否则您的方程式会爆炸!),在步骤之间插值图形(这可能是每个组件线程单独的借口,但也可以不这样做)。
  • 用于std::mutex一次仅使一个线程访问资源(“相互排除”),并遵守的怪异语义std::condition_variable
  • 避免让线程竞争资源。尽可能少地锁(但不要少!),仅在绝对必要时才锁。
  • 不要在线程之间共享只读数据(没有缓存问题,也不需要锁定),但不要同时修改数据(需要同步并杀死缓存)。这包括修改其他人可能会读取的位置附近的数据。
  • 使用std::condition_variable以阻止另一个线程直到某些条件为真。std::condition_variable带有那个额外的互斥体的语义被认为是很奇怪和扭曲的(主要是由于历史原因是从POSIX线程继承的),但是条件变量是用于所需对象的正确原语。
    如果您觉得std::condition_variable太奇怪了,不妨使用Windows事件(速度稍慢)来代替,或者,如果您有胆量,可以围绕NtKeyedEvents构建自己的简单事件(涉及可怕的低级内容)。在使用DirectX时,无论如何您都已经绑定到Windows,因此可移植性的丧失不是一个大问题。
  • 将工作分解为由固定大小的工作线程池运行的合理大小的任务(每个内核最多一个,不计算超线程内核)。让完成任务将依赖的任务加入队列(免费,自动同步)。使每个任务至少执行几百个非平凡的操作(或读取磁盘等一个冗长的阻塞操作)。首选缓存连续访问。
  • 在程序启动时创建所有线程。
  • 充分利用OS或图形API提供的异步功能,以获得更好/附加的并行性,不仅在程序级别,而且在硬件方面(请考虑PCIe传输,CPU-GPU并行性,磁盘DMA等)。
  • 我忘记提到的10,000件事。


[1]是的,您可以将调度程序的速率设置为1毫秒,但是这很令人讨厌,因为它会导致更多的上下文切换并消耗更多的功率(在世界上越来越多的设备是移动设备)。这也不是解决方案,因为它仍然无法使睡眠更加可靠。
[2]计时器将提高线程的优先级,这将使其可以中断另一个相等优先级的线程中量子并首先进行调度,这是准RT行为。当然,这不是真正的RT,但它非常接近。从睡眠状态唤醒仅意味着线程随时准备就绪,可以随时进行调度。


您能否解释为什么不应该“每个高级组件只有一个线程”?您是说一个人不应该在两个单独的线程中混用物理和音频吗?我认为没有任何理由不这样做。
Elviss Strazdins

3

我不确定要通过将Update和Render的FPS都限制为60来实现什么。如果将它们限制为相同的值,则可以将它们放在同一线程中。

在不同线程中分离Update和Render的目的是使两者“几乎”彼此独立,以便GPU可以渲染500 FPS,而Update逻辑仍保持60 FPS。这样做不能获得很高的性能。

但是您说您只是想知道它是如何工作的,这很好。在C ++中,互斥锁是一个特殊的对象,用于锁定其他线程对某些资源的访问。换句话说,您使用互斥锁使一次只能通过一个线程访问敏感数据。为此,这非常简单:

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

资料来源:http : //en.cppreference.com/w/cpp/thread/mutex

编辑:请确保您的互斥锁是类或文件级的,如给定的链接中所述,否则每个线程将创建自己的互斥锁,并且您将无法实现任何目的。

锁定互斥锁的第一个线程将有权访问其中的代码。如果第二个线程试图调用lock()函数,它将阻塞直到第一个线程将其解锁。因此,互斥锁是一种缓冲功能,与while循环不同。阻塞功能不会给CPU造成压力。


以及该块如何工作?
Liuka,2014年

当第二个线程调用lock()时,它将耐心等待第一个线程解锁互斥锁,并在之后的下一行继续(在此示例中为明智的东西)。编辑:第二个线程然后将锁定其互斥体。
Alexandre Desbiens 2014年


1
使用std::lock_guard或类似名称,而不用.lock()/ .unlock()。RAII不仅用于内存管理!
bcrist 2014年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.