独立的物理线程没有锁

Separate physics thread without locks

本文关键字:线程 独立      更新时间:2023-10-16

我有一个经典的物理线程与图形线程的问题:

假设我正在运行一个用于物理更新的线程和一个用于渲染的线程。

在物理线程(伪代码)中:

while(true)
{
  foreach object in simulation
     SomeComplicatedPhysicsIntegration( &object->modelviewmatrix);
     //modelviewmatrix is a vector of 16 floats (ie. a 4x4 matrix)
}

在图形线程中:

while(true)
{
  foreach object in simulation
    RenderObject(object->modelviewmatrix);
}

现在,理论上这不需要锁,因为一个线程只向矩阵写入,另一个线程仅读取,而且我不太关心过时的数据。

更新矩阵不是一个原子操作的问题,有时图形线程只读取部分更新的矩阵(即并非所有16个浮点都已复制,仅复制了其中的一部分),这意味着矩阵的一部分来自一个物理帧,另一部分来自前一帧,这反过来意味着矩阵不是更仿射的(即基本上已损坏)。

有没有什么好的方法可以在不使用锁的情况下防止这种情况发生?我读到过一个可能使用双缓冲的实现,但我无法想象在不同步线程的情况下有什么方法可以工作。

编辑:我想我真正想使用的是某种三重缓冲,就像他们在图形显示器上使用的一样。。有人知道三重缓冲算法的一个很好的演示吗?

编辑2:事实上,使用非同步三重缓冲不是一个好主意(如以下答案所示)。物理线程可以运行多个周期,消耗大量CPU并使图形线程停滞,计算最终甚至无法渲染的帧。

我选择了一种带有单锁的简单双缓冲算法,其中物理线程在交换缓冲区之前只比图形线程提前1帧计算。类似这样的东西:

物理学:

while(true)
{
  foreach physicstimestep
   foreach object in simulation    
      SomeComplicatedPhysicsIntegration( &object->modelviewmatrix.WriteBuffer);
  LockSemaphore()
  SwapBuffers()
  UnlockSemaphore()
}

图形:

 while(true)
    {
     LockSemaphore()
      foreach object in simulation
        RenderObject(object->modelviewmatrix.ReadBuffer);
     UnlockSemaphore()
    }

听起来怎么样?

您可以在两个线程之间维护一个共享队列,并实现物理线程,使其仅在完全填充该矩阵中的所有值后向队列添加矩阵。这假设物理线程在每次迭代中分配一个新矩阵(或者更具体地说,一旦矩阵被放入队列,它们就被视为只读矩阵)。

因此,每当图形线程从队列中提取矩阵时,都可以保证它是完全填充的,并且是生成矩阵时模拟状态的有效表示。

注意,图形线程将需要能够处理队列对于一个或多个迭代是空的情况,并且,最好对每个队列条目进行全局时间戳,这样就可以在不使用任何正式同步技术的情况下保持两个线程的合理同步(例如,不允许图形线程使用任何具有未来时间戳的矩阵,并且如果下一个矩阵在过去太远,则允许它在队列中向前跳过)。还要注意,无论您使用什么队列,都必须实现,这样,如果物理线程试图在图形线程删除某些内容的同时添加某些内容,它就不会爆炸。

但我无法想象在不同步线程的情况下会有什么工作方式。

无论您使用哪种方案,同步线程在这里都是绝对必要的。如果没有同步,您将面临物理线程将远远领先于图形线程的风险,反之亦然。您的程序,通常是一个提前时间的主线程,需要控制线程操作,而不是线程机制。

双缓冲是一种让物理和图形线程并行运行的方案(例如,你有一台多CPU或多核机器)。物理线程在一个缓冲区上操作,而图形线程在另一个缓冲区时操作。请注意,这会导致图形滞后,这可能是问题,也可能不是问题。

双重缓冲背后的基本要点是复制要在屏幕上呈现的数据。

如果使用某种锁定运行,则模拟线程将始终呈现在显示线程的正前方一帧。每一个被模拟的数据都会被渲染。(同步不必太重:一个简单的条件变量可以频繁更新,并以相当低的成本唤醒渲染线程。)

如果在没有同步的情况下运行,如果渲染线程无法跟上,则模拟线程可能会模拟从未渲染过的事件。如果在数据中包含单调递增的生成号(在每个完整的模拟周期后更新),则渲染线程可以忙于等待这两个生成号(每个数据缓冲区一个)。

一旦其中一个(或两个)生成数大于最近渲染的生成数,请将最新的缓冲区复制到渲染线程中,更新最近渲染的计数器,然后开始渲染。完成后,返回忙碌的等待状态。

如果渲染线程太快,您可能会在繁忙的等待中消耗大量处理器。因此,只有当您希望定期跳过渲染某些数据,并且几乎不需要等待更多模拟时,这才有意义。

假设无锁定或接近无锁定的更新实际上是最能解决你的问题的,听起来你希望物理线程计算一个新的矩阵,然后立即更新所有这些值,所以图形线程得到什么版本的矩阵并不重要,只要(a)它最终得到了它们,(b)它永远不会得到旧的一半。

在这种情况下,听起来你想要一个物理线程,比如:

/* pseudocode */
while (true) foreach (object in simulation) {
    auto new_object = object;
    SomeComplicatedPhysicsIntegrationInPlace(new_object)
    atomic_swap(object, new_object); // In pseudocode, ignore return value since nowhere
                                     // else changes value of object. In code, use assert, etc
}

或者,您可以计算整个模拟的新状态,然后交换值。实现这一点的一个简单方法是:

/*伪码*/while(true){simulation[1-global_active_idx]=simulation[global_active_idx];foreach(模拟中的对象[global_inactive_idx]){SomeComplicatedPhysicsIntegrationInPlace(对象);}global_active_idx=1-全局_active_id;//隐含地假设这是原子的}

图形线程应不断渲染模拟[global_active_idx]。

事实上,这是行不通的。它在许多情况下都能工作,因为通常情况下,在大多数处理器上,将1写入包含0的内存位置实际上是原子的,但它不能保证工作。具体来说,其他线程可能永远不会重读该值。许多人通过声明变量volatile来混淆这一点,虽然在许多编译器上都有效,但不能保证有效。

然而,要使这两个例子都起作用,您只需要一个原子写指令,而C++直到C++0x才提供,但编译器很容易实现,因为大多数"写int"指令都是原子的,编译器只需要确保这一点。

因此,您可以在物理循环结束时使用atomic_swap函数编写代码,并根据(a)锁定、写入、解锁序列来实现这一点,因为它只会阻塞一次内存写入的时间长度,并且可能每整帧只阻塞一次,或者(b)编译器内置的原子支持,例如。http://gcc.gnu.org/onlinedocs/gcc-4.1.2/gcc/Atomic-Builtins.html

也有类似的解决方案,例如,物理线程更新信号量,图形线程将其简单地视为值为0或1的变量;例如,物理线程将完成的计算发布到队列中(以与上述类似的方式在内部实现),图形线程不断渲染队列的顶部,如果队列下溢,则重复最后一个。

然而,我不确定我是否理解你的问题。如果物理没有改变,为什么更新图形有意义?为什么有什么意义比物理更新得更快,难道它不能在每个区块中进一步推断吗?锁定更新真的有什么不同吗?

不更新物理线程中的矩阵?

取一个区块(可能是您刚刚渲染的一行),将其位置/大小/其他内容排入物理线程。反转/转置/whateverCleverMatrix将modelviewmatrix的行填充到另一个新行中。将其发布回渲染线程。在渲染中的某个适当时间复制新行。也许你不需要复制它——也许你可以把一个"旧"矢量换成新矢量,然后释放旧矢量?

这可能吗,或者矩阵的结构/操作/其他什么太复杂了吗?

这一切都取决于你的数据结构,所以这个解决方案可能不合适/不可能。

Rgds,Martin

现在,理论上这不需要锁,因为一个线程只向矩阵写入,另一个线程仅读取,而且我不太关心过时的数据。

注意:如果没有适当的同步,就无法保证读取线程会观察到写入线程的任何更改。这一方面被称为可见性,遗憾的是,它经常被忽视。

您可以使两个矩阵适合一个缓存行大小(128字节),并将其对齐到128字节。通过这种方式,矩阵被加载在高速缓存行中,并因此在一个块中向存储器写入和从存储器写入。不过,这并不是为了装样子,还需要做更多的工作。(这只解决了在更新矩阵时读取矩阵并获得非正交结果的问题。)