Xbox 360 和 Windows 上的多核编码

多年来,处理器的性能一直在稳步提高,游戏和其他程序已经收获了这种不断增长的功能的好处,而无需做任何特别的事情。

规则已更改。 单处理器核心的性能现在增长非常缓慢(如果有的话)。 但是,典型计算机或控制台中可用的计算能力会继续增长。 区别在于,现在大部分性能提升都来自于在一台计算机中(通常是在单个芯片中)具有多个处理器核心。 Xbox 360 CPU 在一个芯片上具有三个处理器核心,2006 年销售的大约 70% 的电脑处理器是多核的。

可用处理能力的增加与过去一样显著,但现在开发人员必须编写多线程代码才能使用此功能。 多线程编程带来了新的设计和编程挑战。 本主题提供有关如何开始使用多线程编程的一些建议。

良好设计的重要性

良好的多线程程序设计至关重要,但可能非常困难。 如果你随意将主要游戏系统移动到不同的线程上,你可能会发现每个线程大部分时间都在等待其他线程。 这种类型的设计会增加复杂性和大量的调试工作量,几乎没有性能提升。

每次线程必须同步或共享数据时,都可能存在数据损坏、同步开销、死锁和复杂性。 因此,多线程设计需要清楚地记录每个同步和通信点,并且应尽可能减少此类点。 如果线程需要通信,编码工作量将增加,如果它影响过多的源代码,这可能会降低工作效率。

多线程处理的最简单设计目标是将代码分解为大型独立部分。 然后,如果将这些部分限制为每帧仅通信几次,则多线程处理将显著加快,且没有过分的复杂性。

典型的线程任务

一些类型的任务已证明可以放在单独的线程上。 以下列表并非详尽无遗,但应该提供一些想法。

渲染

呈现通常占 CPU 时间的 50% 或更多,其中可能包括走动场景图,或者仅调用 D3D 函数。 因此,将渲染移动到另一个线程可能具有显著优势。 更新线程可以填充某种呈现说明缓冲区,呈现线程随后可以处理这些缓冲区。

游戏更新线程始终比呈现线程提前一帧,这意味着它需要两帧才能在屏幕上显示用户操作。 尽管这种增加的延迟可能是一个问题,但拆分工作负载增加的帧速率通常会使总延迟可接受。

在大多数情况下,所有呈现仍在单个线程上完成,但它与游戏更新不同。

D3DCREATE_MULTITHREADED标志有时用于允许在一个线程上呈现,并在其他线程上创建资源;此标志在 Xbox 360 上被忽略,应避免在 Windows 上使用。 在 Windows 上,指定此标志会强制 D3D 花费大量时间进行同步,从而减慢呈现线程的速度。

文件解压缩

加载时间总是太长,在不影响帧速率的情况下将数据流式传输到内存中可能很困难。 如果所有数据都积极压缩在光盘上,则硬盘驱动器或光盘的数据传输速度不太可能成为限制因素。 在单线程处理器上,通常没有足够的处理器时间可用于压缩来帮助加载时间。 但是,在多处理器系统上,文件解压缩使用 CPU 周期,否则会浪费;它改进了加载时间和流式处理;这样可以节省光盘上的空间。

请勿将文件解压缩用作在生产期间应完成的处理的替代方法。 例如,如果在级别加载期间使用额外的线程来分析 XML 数据,则不会使用多线程来改善玩家的体验。

使用文件解压缩线程时,仍应使用异步文件 I/O 和大型读取,以便最大程度地提高数据读取效率。

图形绒毛

有许多图形精美的功能可以改善游戏的外观,但严格来说并不需要。 其中包括程序生成的云动画、布料和头发模拟、过程波、程序植被、更多粒子或非游戏物理。

由于这些效果不会影响游戏玩法,因此它们不会导致棘手的同步问题-它们每帧可以与其他线程同步一次或更少频率。 此外,在适用于 Windows 的游戏中,这些效果可以为具有多核 CPU 的玩家增加价值,同时在单核计算机上以静默方式省略,从而提供了一种跨各种功能进行缩放的简单方法。

物理

物理通常不能放在单独的线程上以与游戏更新并行运行,因为游戏更新通常需要立即获得物理计算的结果。 多线程物理的替代方法是在多个处理器上运行它。 尽管可以执行此操作,但这是一项复杂的任务,需要频繁访问共享数据结构。 如果可将物理工作负荷保持在足够低,以适应main线程,则作业将更简单。

支持在多个线程上运行物理的库可用。 但是,这可能会导致一个问题:当你的游戏运行物理时,它使用许多线程,但其余时间它使用很少。 在多个线程上运行物理场需要解决此问题,以便工作负载在帧上均匀分布。 如果编写多线程物理引擎,则必须仔细注意所有数据结构、同步点和负载均衡。

多线程设计示例

Windows 游戏需要在具有不同 CPU 核心数的计算机上运行。 尽管双核计算机的数量正在迅速增长,但大多数游戏机仍然只有一个核心。 适用于 Windows 的典型游戏可能会将其工作负载分解为一个线程进行更新和呈现,使用可选工作线程添加额外功能。 此外,可能会使用一些后台线程来执行文件 I/O 和网络。 图 1 显示了线程以及main数据传输点。

图 1. Windows 游戏中的线程设计

Windows 游戏中的线程设计

典型的 Xbox 360 游戏可以使用其他 CPU 密集型软件线程,因此它可能会将其工作负载分解为更新线程、呈现线程和三个工作线程,如图 2 所示。

图 2. Xbox 360 游戏中的线程设计

Xbox 360 游戏中的线程设计

除了文件 I/O 和网络之外,这些任务都有可能占用足够多的 CPU,从而受益于使用自己的硬件线程。 这些任务还具有足够独立的潜力,因此无需通信即可在整个帧中运行。

游戏更新线程管理控制器输入、AI 和物理,并为其他四个线程准备说明。 这些指令放置在游戏更新线程拥有的缓冲区中,因此在生成指令时不需要同步。

在帧结束时,游戏更新线程将指令缓冲区交给其他四个线程,然后开始处理下一帧,填充另一组指令缓冲区。

由于更新和呈现线程相互锁定工作,因此它们的通信缓冲区只是双重缓冲:在任何给定时间,更新线程都会填充一个缓冲区,而呈现线程正在从另一个缓冲区进行读取。

其他工作线程不一定与帧速率相关联。 解压缩一段数据所需的时间可能比一个帧少得多,或者可能需要多个帧。 即使布料和头发模拟也不需要完全以帧速率运行,因为不太频繁的更新可能是可以接受的。 因此,这三个线程需要不同的数据结构才能与更新线程和呈现线程通信。 每个线程都需要一个可以保存工作请求的输入队列,呈现线程需要一个可以保存线程生成结果的数据队列。 在每个帧结束时,更新线程将向工作线程的队列添加一个工作请求块。 每帧仅向列表添加一次可确保更新线程最大程度地减少同步开销。 每个工作线程都使用如下所示的循环,尽可能快地从工作队列中拉取分配:

for(;;)
{
    while( WorkQueueNotEmpty() )
    {
        RemoveWorkItemFromWorkQueue();
        ProcessWorkItem();
        PutResultInDataQueue();
    }
    WaitForSingleObject( hWorkSemaphore ); 
}

由于数据从更新线程到工作线程再到呈现线程,因此在某些操作进入屏幕之前,可能会有三个或更多帧的延迟。 但是,如果将延迟容错任务分配给工作线程,则这应该不是问题。

另一种设计是让多个工作线程都从同一个工作队列中绘制。 这会提供自动负载均衡,并使所有工作线程更有可能保持繁忙状态。

游戏更新线程必须注意不要给工作线程太多工作,否则工作队列可能会持续增加。 更新线程对此的管理方式取决于工作线程正在执行的任务类型。

同时进行多线程处理和线程数

创建的所有线程不相等。 两个硬件线程可能位于单独的芯片上、同一芯片上,甚至位于同一核心上。 游戏程序员需要注意的最重要配置是一个内核上的两个硬件线程 - 同时多线程 (SMT) 或 Hyper-Threading 技术 (HT 技术) 。

SMT 或 HT 技术线程共享 CPU 核心的资源。 由于它们共享执行单元,因此运行两个线程(而不是一个线程)的最大加速通常为 10% 到 20%,而不是从两个独立的硬件线程中可以达到的 100%。

更重要的是,SMT 或 HT 技术线程共享 L1 指令和数据缓存。 如果内存访问模式不兼容,则最终可能会争用缓存并导致许多缓存未命中。 在最坏的情况下,当运行第二个线程时,CPU 核心的总性能实际上可能会降低。 在 Xbox 360 上,这是一个相当简单的问题。 Xbox 360 的配置是已知的(每个有两个硬件线程的三个 CPU 核心),开发人员将其软件线程分配给特定的 CPU 线程,并且可以测量以查看其线程设计是否为他们提供了额外的性能。

在 Windows 上,情况更为复杂。 线程数及其配置因计算机而异,确定配置很复杂。 函数 GetLogicalProcessorInformation 提供有关不同硬件线程之间的关系的信息,此函数在 Windows Vista、Windows 7 和 Windows XP SP3 上可用。 因此,目前必须使用 CPUID 指令以及 Intel 和 AMD 提供的算法来确定可用的“真实”线程数。 有关详细信息,请参阅参考。

DirectX SDK 中的 CoreDetection 示例包含使用 GetLogicalProcessorInformation 函数或 CPUID 指令返回 CPU 核心拓扑的示例代码。 如果当前平台上不支持 GetLogicalProcessorInformation ,则使用 CPUID 指令。 可在以下位置找到 CoreDetection:

源:

DirectX SDK root\Samples\C++\Misc\CoreDetection

可执行:

DirectX SDK root\Samples\C++\Misc\Bin\CoreDetection.exe

最安全的假设是每个 CPU 核心不超过一个 CPU 密集型线程。 拥有比 CPU 核心更多的 CPU 密集型线程会带来很少或根本没有好处,并且会带来额外的开销和额外的线程的复杂性。

创建线程

创建线程是一个相当简单的操作,但存在许多潜在的错误。 下面的代码显示了创建线程、等待线程终止,然后清理的正确方法。

const int stackSize = 65536;
HANDLE hThread = (HANDLE)_beginthreadex( 0, stackSize,
            ThreadFunction, 0, 0, 0 );
// Do work on main thread here.
// Wait for child thread to complete
WaitForSingleObject( hThread, INFINITE );
CloseHandle( hThread );

...

unsigned __stdcall ThreadFunction( void* data )
{
#if _XBOX_VER >= 200
    // On Xbox 360 you must explicitly assign
    // software threads to hardware threads.
    XSetThreadProcessor( GetCurrentThread(), 2 );
#endif
    // Do child thread work here.
    return 0;
}

创建线程时,可以选择指定子线程的堆栈大小,或指定零,在这种情况下,子线程将继承父线程的堆栈大小。 在 Xbox 360 上,如果线程启动时堆栈已完全提交,则指定零可能会浪费大量内存,因为许多子线程不需要像父线程那样多的堆栈。 在 Xbox 360 上,堆栈大小必须是 64 KB 的倍数也很重要。

如果使用 CreateThread 函数创建线程,则 C/C++ 运行时 (CRT) 将不会在 Windows 上正确初始化。 建议改用 CRT _beginthreadex 函数。

CreateThread_beginthreadex 的返回值是线程句柄。 此线程可用于等待子线程终止,这比在检查线程状态的循环中旋转要简单得多且效率高得多。 若要等待线程终止,只需使用线程句柄调用 WaitForSingleObject

在线程终止且线程句柄关闭之前,不会释放线程的资源。 因此,在使用完线程句柄后,使用 CloseHandle 关闭线程句柄非常重要。 如果要等待使用 WaitForSingleObject 终止线程,请确保在等待完成后才关闭句柄。

在 Xbox 360 上,必须使用 XSetThreadProcessor 将软件线程显式分配给特定硬件线程。 否则,所有子线程将保留在与父线程相同的硬件线程上。 在 Windows 上,可以使用 SetThreadAffinityMask 向操作系统强烈建议线程应在哪个硬件线程上运行。 通常应在 Windows 上避免使用此方法,因为你不知道系统中可能正在运行哪些其他进程。 通常最好让 Windows 计划程序将你的线程分配给空闲的硬件线程。

创建线程是一项成本高昂的操作。 应很少创建和销毁线程。 如果你发现自己想要频繁创建和销毁线程,请改用等待工作的线程池。

同步线程

要使多个线程协同工作,必须能够同步线程、传递消息和请求对资源的独占访问权限。 Windows 和 Xbox 360 附带一组丰富的同步基元。 有关这些同步基元的完整详细信息,请参阅平台文档。

独占访问

通常需要获得对资源、数据结构或代码路径的独占访问权限。 获取独占访问权限的一个选项是互斥体,其典型用法如下所示。

// Initialize
HANDLE mutex = CreateMutex( 0, FALSE, 0 );

// Use
void ManipulateSharedData()
{
    WaitForSingleObject( mutex, INFINITE );
    // Manipulate stuff...
    ReleaseMutex( mutex );
}

// Destroy
CloseHandle( mutex );
The kernel guarantees that, for a particular mutex, only one thread at a time can 
acquire it.
The main disadvantage to mutexes is that they are relatively expensive to acquire 
and release. A faster alternative is a critical section.
// Initialize
CRITICAL_SECTION cs;
InitializeCriticalSection( &cs );

// Use
void ManipulateSharedData()
{
    EnterCriticalSection( &cs );
    // Manipulate stuff...
    LeaveCriticalSection( &cs );
}

// Destroy
DeleteCriticalSection( &cs );

关键部分的语义与互斥体类似,但它们只能用于在进程内同步,而不能在进程之间同步。 他们的main优势是执行速度比互斥体快大约二十倍。

事件

如果两个线程(可能是一个更新线程和一个呈现线程)轮流使用一对呈现描述缓冲区,则它们需要一种方法来指示何时使用其特定缓冲区。 为此,可以将 使用 CreateEvent) 分配的事件 (与每个缓冲区相关联。 当线程使用缓冲区完成时,它可以使用 SetEvent 发出此信号,然后可以对另一个缓冲区的事件调用 WaitForSingleObject 。 此方法可以轻松地推断资源三重缓冲。

信号灯

信号灯用于控制可以运行的线程数,通常用于实现工作队列。 一个线程将工作添加到队列,并在向队列添加新项时使用 ReleaseSemaphore 。 这允许从等待线程池中释放一个工作线程。 工作线程只调用 WaitForSingleObject,当它返回时,它们知道队列中有一个工作项。 此外,必须使用关键部分或其他同步技术,以保证对共享工作队列的安全访问。

避免 SuspendThread

有时,当希望线程停止它正在执行的操作时,使用 SuspendThread 而不是正确的同步基元会很诱人。 这总是一个坏主意,很容易导致死锁和其他问题。 SuspendThread 还与 Visual Studio 调试器交互不力。 避免 SuspendThread。 请改用 WaitForSingleObject

WaitForSingleObject 和 WaitForMultipleObjects

WaitForSingleObject 函数是最常用的同步函数。 但是,有时你希望线程等待多个条件同时满足,或直到满足一组条件之一。 在这种情况下,应使用 WaitForMultipleObjects

互锁函数和无锁编程

有一系列函数用于在不使用锁的情况下执行简单的线程安全操作。 这些是 Interlocked 系列函数,例如 InterlockedIncrement。 这些函数以及使用仔细设置标志的其他技术一起称为无锁编程。 正确执行无锁编程可能非常棘手,在 Xbox 360 上比在 Windows 上更困难。

有关无锁编程的详细信息,请参阅 Xbox 360 和 Microsoft Windows 的无锁编程注意事项

最小化同步

某些同步方法比其他方法快。 但是,与其选择尽可能快的同步技术来优化代码,不如减少同步频率。 这比过于频繁的同步要快,并且它使代码更简单,更易于调试。

某些操作(如内存分配)可能必须使用同步基元才能正常工作。 因此,从默认共享堆执行频繁的分配将导致频繁的同步,从而浪费一些性能。 如果使用 HeapCreate) ,则避免频繁分配或使用每线程堆 (使用 HEAP_NO_SERIALIZE可以避免这种隐藏同步。

隐藏同步的另一个原因是D3DCREATE_MULTITHREADED,这会导致 Windows 上的 D3D 对许多操作使用同步。 (Xbox 360.) 上忽略标志

每线程数据(也称为线程本地存储)可能是避免同步的重要方法。 Visual C++ 允许使用 __declspec (线程) 语法将全局变量声明为每个线程。

__declspec( thread ) int tls_i = 1;

这为进程中的每个线程提供自己的tls_i副本,无需同步即可安全高效地引用这些副本。

__declspec (线程) 技术不适用于动态加载的 DLL。 如果使用动态加载的 DLL,则需要使用 TLSAlloc 系列函数来实现线程本地存储。

销毁线程

销毁线程的唯一安全方法是让线程本身退出,方法是从main线程函数返回,或者让线程调用 ExitThread_endthreadex。 如果使用 _beginthreadex 创建线程,则它应使用_endthreadex或从main线程函数返回,因为使用 ExitThread 不会正确释放 CRT 资源。 永远不要调用 TerminateThread 函数,因为线程不会被正确清理。 线程应始终自杀, 他们不应该被谋杀。

OpenMP

OpenMP 是一种语言扩展,用于使用杂注指导编译器并行化循环,从而将多线程处理添加到程序。 Windows 和 Xbox 360 上的 Visual C++ 2005 支持 OpenMP,可与手动线程管理结合使用。 OpenMP 可以是代码的多线程部分的便捷方法,但不太可能是理想的解决方案,尤其是对于游戏。 OpenMP 可能更适用于运行时间较长的生产任务,例如处理艺术和其他资源。 有关详细信息,请参阅 Visual C++ 文档或转到 OpenMP 网站

分析

多线程分析非常重要。 在线程相互等待的情况下,很容易导致长时间停止。 这些停滞可能很难找到和诊断。 为了帮助识别它们,请考虑向同步调用添加检测。 采样探查器还可以帮助识别这些问题,因为它可以记录计时信息,而无需对其进行重大更改。

定时

rdtsc 指令是在 Windows 上获取准确计时信息的一种方法。 遗憾的是,rdtsc 有多个问题,使得它成为发货标题的糟糕选择。 rdtsc 计数器不一定在 CPU 之间同步,因此,当线程在硬件线程之间移动时,可能会获得较大的正负差异。 根据电源管理设置,rdtsc 计数器增量的频率也可能随游戏运行而更改。 若要避免这些困难,应首选 QueryPerformanceCounterQueryPerformanceFrequency ,以便在发货游戏中实现高精度计时。 有关计时的详细信息,请参阅 游戏计时和多核处理器

调试

Visual Studio 完全支持 Windows 和 Xbox 360 的多线程调试。 Visual Studio 线程窗口允许在线程之间切换,以查看不同的调用堆栈和局部变量。 线程窗口还允许冻结和解冻特定线程。

在 Xbox 360 上,可以使用watch窗口中的@hwthread元变量来显示当前所选软件线程正在运行的硬件线程。

如果对线程进行有意义的命名,则线程窗口更易于使用。 Visual Studio 和其他 Microsoft 调试器允许命名线程。 实现以下 SetThreadName 函数,并在启动时从每个线程调用它。

typedef struct tagTHREADNAME_INFO
{
    DWORD dwType;     // must be 0x1000
    LPCSTR szName;    // pointer to name (in user address space)
    DWORD dwThreadID; // thread ID (-1 = caller thread)
    DWORD dwFlags;    // reserved for future use, must be zero
} THREADNAME_INFO;

void SetThreadName( DWORD dwThreadID, LPCSTR szThreadName )
{
    THREADNAME_INFO info;
    info.dwType = 0x1000;
    info.szName = szThreadName;
    info.dwThreadID = dwThreadID;
    info.dwFlags = 0;

    __try
    {
        RaiseException( 0x406D1388, 0,
                    sizeof(info) / sizeof(DWORD),
            (DWORD*)&info );
    }
    __except( EXCEPTION_CONTINUE_EXECUTION ) {
    }
}

// Example usage:
SetThreadName(-1, "Main thread");

(KD) 和 WinDBG 的内核调试器也支持多线程调试。

测试

多线程编程可能很棘手,一些多线程 bug 很少出现,因此很难找到和修复它们。 刷新它们的最佳方式之一是在各种计算机上进行测试,尤其是那些具有四个或更多处理器的计算机。 在单线程计算机上完美工作的多线程代码可能会在四处理器计算机上立即失败。 AMD 和 Intel CPU 的性能和计时特征可能有很大差异,因此请务必根据两家供应商的 CPU 在多处理器计算机上进行测试。

Windows Vista 和 Windows 7 改进

对于面向较新版本 Windows 的游戏,有许多 API 可以简化可缩放的多线程应用程序的创建。 对于新的 ThreadPool API 和一些附加的 syncrhonziation 基元 (条件变量、精简读/写器锁和一次性初始化) 尤其如此。 可以在以下 MSDN 杂志文章中找到这些技术的概述:

在这些操作系统上使用 Direct3D 11 功能 的应用程序还可以利用并发对象创建的新设计以及延迟的上下文命令列表,以提高多线程呈现的可伸缩性。

总结

通过精心设计来最大程度地减少线程之间的交互,可以从多线程编程中获得显著的性能提升,而不会给代码增加过多的复杂性。 这将让你的游戏代码经历下一波处理器改进,并提供更具吸引力的游戏体验。

参考