游戏引擎学习第122天
仓库:https://gitee.com/mrxiao_com/2d_game_3
讨论了多线程(Multithreading)
今天开始讨论的话题对大家来说不太好,因为这是一个相对棘手的技术问题,虽然它很重要。这个话题不像优化那样是通过解决一个问题并进一步精细化来变得有趣。相反,它是一层额外的复杂性,增加了我们已经面临的挑战。我们今天讨论的内容涉及多线程。
多线程是指通过多个同时执行的CPU核心或独立处理器共同处理问题。理想情况下,可以将它们分开,使得它们之间的通信非常小。然而,不幸的是,有时候无法做到这一点,取决于问题的性质,有时也取决于解决问题的创造性。
讨论了如何使用简单的方式去理解和实现多线程,尽量让它不那么复杂。虽然它很重要,且是编程中不可避免的部分,但它从来没有变得容易过。多线程不是一个新鲜的概念,它一直存在于编程世界中,但它的挑战性却依然没有减少。
接下来要讲解的是操作系统中的“线程”和“纤程”概念。线程和纤程指的是操作系统在执行过程中对不同执行粒度的逻辑分离。简单来说,操作系统通过线程和纤程来管理多个执行单元,确保它们能在一个系统中并行工作。
总体来说,多线程让问题变得更加复杂,但它同时也让性能提升成为可能。掌握多线程技术对于程序的执行效率至关重要,尤其在现代硬件和复杂任务中。
讲解了CPU状态(CPU state)。
在编写代码时,代码看起来像汇编语言指令,每个指令(如NASM指令)依次执行。在执行时,CPU需要知道当前执行的是哪条指令,这由“指令指针”来跟踪。指令执行完后,指令指针会移动到下一条指令,然后继续执行。
除此之外,CPU还需要跟踪其他重要的状态信息。首先,CPU需要知道当前执行的指令是什么,这就是所谓的指令指针,指示着CPU当前正在执行哪条指令。然后,CPU需要了解内存的布局,因为现代系统使用虚拟内存,因此它需要知道虚拟地址如何映射到物理内存芯片上的实际地址。
此外,CPU还需要存储寄存器状态。CPU的寄存器用于存储正在处理的数据,例如通过SSE(Streaming SIMD Extensions)指令集操作的寄存器。寄存器用于临时存储数据,便于快速访问和运算。所有这些信息共同构成了CPU的“状态”,确保CPU能够正确地执行每条指令并访问正确的内存区域。
这些元素共同决定了CPU能否正确执行程序,包括指令、内存布局和寄存器的状态。每一部分都至关重要,缺一不可。
介绍操作系统如何概念化这个过程。
操作系统是如何将这些内容进行拆分的呢?我们知道,在任何给定的时间,系统上运行着多个应用程序(即多个进程)。每个进程都有自己的虚拟地址空间。比如在我们的程序中,这个应用程序有一个虚拟地址空间,它的内存布局由操作系统管理。而另一个应用程序,比如Mischief,也有自己的虚拟地址空间。这两个进程虽然在同一台机器上运行,但它们的内存布局是独立的。
这种内存布局管理的粒度被称为“进程”。进程是指一个应用程序运行时的内存布局状态。当我们说一个应用程序在运行时,它的内存布局就是这样安排的。但是,进程并不是管理所有的内容,比如CPU正在执行的汇编指令、寄存器的状态等,这些并不是每个进程独立的。事实上,汇编指令和寄存器等状态是与进程无关的,它们属于低层次的执行级别。
每个进程可能会有多个并行的或独立的代码执行状态,这就是我们通常所说的“线程”。所以,进程底层其实是可以包含多个线程,这些线程负责并发或独立的执行任务。
讨论线程(Threads)。
在一个进程中,实际上会有多个线程,线程负责存储与CPU状态相关的其他信息。每个线程知道自己正在执行哪些指令,并且会保存与处理器寄存器相关的状态。所以,可以将它们看作有两个层次:一个是进程,它是内存映射和一组线程的集合;另一个是线程,它存储与处理器状态相关的附加信息,超出了内存映射本身的范畴。
以我们的程序为例,它是一个正在运行的进程,是一个可执行文件。在这个进程内部,有许多线程在运行。我们编写了其中一个线程,也就是主执行线程。然而,操作系统启动了一些我们无法控制的其他线程。例如,DirectSound会在我们的进程空间中启动一些线程,这些线程会处理与音频设备(音频输出设备)的交互,虽然我们无法直接控制这些线程的行为,但它们的存在是由于我们使用了DirectSound。
因此,在我们的程序中,已经有多个线程在运行,但我们自己编写的线程只有一个——主执行线程。
讨论纤程(Fibers)。
除了进程和线程,还有一种叫做纤程(fibers)的概念,虽然它们不是特别必要,因此在这里不详细讨论。纤程和线程非常相似,主要的区别在于,线程是操作系统的一个“第一公民”,它直接与硬件交互,硬件能够同时执行多个线程。而纤程则是线程的一个简化版本,允许你保存CPU状态,但它们不会并发执行。简单来说,纤程只是保存某个任务的状态,然后稍后再恢复执行。纤程通常用于一些旧的重叠工作场景,在这些场景下,你想让自己更轻松地管理工作流,但在现代的多线程开发中,我们通常不需要考虑纤程,因为我们的目标是利用多个CPU核心并发执行任务。
因此,尽管纤程曾在过去的某些场景中有过使用,但它们并不是我们当前关注的重点。我们现在的重点是如何通过多线程技术来利用多核处理器的优势。
讲解为什么我们关心这些。
为什么我们不能只在单个线程中编写所有内容呢?原因是,正如我们在 Windows 任务管理器中看到的那样,CPU实际上有多个逻辑处理单元。我现在使用的这台机器是 Xeon 处理器,它有很多这样的处理单元。如果你使用的是现代电脑,可能只有八个逻辑处理单元,通常是四个物理核心,每个核心有两个超线程,总共有八个线程执行槽。
我特别提到这一点,是因为每个处理单元实际上都可以同时执行一个线程,这意味着如果我们的代码只在一个线程中运行,那么无论做什么,CPU上总共有16个执行槽,而我们只能填充其中的一个。也就是说,实际上我们把15/16的性能潜力都浪费了,因为CPU只能在一个执行槽上运行我们程序的代码,其他15个槽只能等着,不做任何有用的工作,可能偶尔有操作系统或其他程序在这些槽上做些工作(比如图形驱动程序)。
因此,我们进行多线程编程的目标,主要是为了让游戏英雄可以创建更多的线程,让处理器能够利用更多的逻辑处理单元,从而提升性能。
解释“核心”(Core)。
在多线程的思考方式中,有两个重要的概念——核心和超线程。首先,核心是处理器中的一组可以执行工作的单元,核心包含指令解码单元、算术逻辑单元和内存单元等。简而言之,核心是执行计算、查找地址等操作的单位。
每个核心在一个时钟周期内可以发出多个指令,前提是有足够的指令可供发出。在过去的讨论中,我们提到过如何使用IACA等工具来分析每个时钟周期内哪些指令可以发到哪些端口,以及这些指令如何被核心处理。因此,核心的概念就是拥有多个执行端口,允许在一个时钟周期内发出多个指令并执行。
现代的x64处理器核心(比如当前机器上的处理器,或者大部分现代桌面计算机的处理器)能够同时从多个地方发出指令。一个核心可能包含多个执行端口,在一个时钟周期内,如果有空闲的端口且有指令可以匹配该端口,那么这条指令就可以被发出并开始执行。
至于超线程,超线程是一种使得单个核心能够同时处理多个指令流的技术。它通过在核心上增加额外的虚拟执行上下文,让核心可以模拟多个逻辑处理单元,从而提高资源的利用率和并行处理能力。
解释“超线程”(Hyperthread)。
超线程技术与线程的定义相似,它也包含了CPU的状态、指令流的位置以及寄存器的状态等。不过,与传统的线程不同,超线程能够在同一个核心上同时并行运行两个线程。为了更好地理解这一点,先回顾一下传统的核心和线程分配方式:
在传统的多核心处理器中,每个核心上只会运行一个线程。假设一个处理器有两个核心,那么操作系统会将每个线程分配给一个核心,核心A执行线程A,核心B执行线程B,这样两个线程就能够同时运行,互不干扰。
但在支持超线程的处理器中,一个核心上可以同时运行多个线程,具体能运行多少线程取决于该处理器支持的超线程数量。例如,一些处理器支持每个核心运行两个超线程,甚至有的支持四个超线程。超线程的实现原理是通过为每个线程分配不同的寄存器、指令状态等资源来使得每个线程看起来像是独立的。
在每个时钟周期内,处理器会检查这两个线程,看它们是否有指令可以同时发往处理器的多个端口。假设处理器可以在一个时钟周期内执行四条指令,传统方法下,如果有四条指令,但这些指令都只能发送到一个端口,那么只能执行一条指令,其余的指令就会被浪费掉。而使用超线程技术后,处理器会同时检查两个线程中的指令,如果指令能够匹配不同的端口,就可以同时执行多条指令。例如,线程A的第一条指令可以发往端口1,第二条指令发往端口0,线程B的指令也可以发往其他端口,这样就能够在一个时钟周期内执行更多的指令,提高效率。
超线程的优势在于,它能有效地减少传统多线程系统中线程切换的延迟,而无需依赖操作系统的调度,减少了上下文切换的开销。在某些情况下,两个线程的操作可以互补,比如一个线程做内存访问,另一个线程进行计算,这样就能够更高效地利用处理器资源,减少空闲周期,提高系统整体性能。
介绍操作系统如何进行线程切换。
在一个处理器中,通常有比处理器核心或超线程可用的执行槽更多的线程在运行。例如,虽然一个处理器可能有16个逻辑处理槽,但在实际使用中,操作系统管理的线程数量往往会达到数百甚至数千个。每个进程都有多个线程,这些线程需要被调度到有限的核心上。
操作系统的任务就是在多个线程之间进行调度,以便高效地利用这些有限的执行槽。即使一个处理器具有多个核心和支持超线程,仍然有大量线程无法同时执行,因此操作系统必须管理这些线程的调度。比如,当只有16个可用的执行槽时,而系统中可能有成千上万的线程等待执行,操作系统需要决定哪些线程在给定的时刻被分配到这些槽中运行。
这些执行槽通常被称为“逻辑CPU”或“逻辑核心”,它们并不等同于物理核心,而是指操作系统为每个核心或者超线程提供的虚拟处理单元。在高并发的情况下,操作系统通过线程调度,合理安排每个线程的执行时机,确保系统的运行效率和资源的最大化利用。
讨论逻辑核心(Logical cores)。
在讨论处理器的物理核心和逻辑核心时,关键点在于“逻辑核心”并不是真正的物理核心,而是指操作系统为每个物理核心分配的虚拟执行单元。比如,一个物理核心可能会支持两个线程,这样就可以创建两个逻辑核心。这意味着,虽然一个处理器可能只有8个物理核心,但它可以通过超线程技术提供16个逻辑核心。
然而,超线程的性能并不等同于物理核心的性能,因为物理核心不仅有自己的计算单元(例如算术逻辑单元,ALU)和内存单元,还拥有独立的缓存系统。而超线程技术让多个线程共享这些硬件资源,这样每个线程的执行速度可能不如物理核心独立运行时那样高效。不过,超线程可以提升系统的吞吐量,在多线程任务中能够提供一定的性能增益,尽管并不像直接增加物理核心那么显著。
对于处理器厂商来说,添加超线程的成本较低,因为它们不需要复制所有硬件资源,如乘法器和内存单元,只需扩展寄存器文件等资源,虽然这些扩展也是需要成本的。这也是为什么大多数现代处理器使用超线程技术的原因。通过增加超线程,可以有效利用核心的计算资源,尤其是在多任务和多线程的工作负载下。
通常情况下,制造商会根据核心的硬件资源利用率来决定是否添加超线程。如果一个核心的计算资源(如ALU)在常规工作负载下并未得到完全利用,那么加入更多的超线程可以提升整体性能,通常添加两个超线程能够有效利用核心的大部分计算资源。如果超过这个数量,性能提升会逐渐变小,甚至可能出现性能下降。
总的来说,超线程技术虽然无法完全替代物理核心的效果,但它在成本较低的情况下提供了性能提升,尤其是在多线程的工作负载中。
讨论“乱序”和“顺序”执行。
在讨论处理器架构时,有两种主要类型的处理器:顺序执行(in-order)和乱序执行(out-of-order)。其中,x64处理器属于乱序执行类型,这意味着它们可以从指令窗口中灵活地选择并执行指令,而不一定按顺序逐条执行。这样,处理器能够更高效地利用计算资源,因为即使当前的指令被阻塞,它也能从其他指令流中获取可执行的指令。
相比之下,顺序执行的处理器必须按顺序执行每条指令,即使某一条指令被阻塞,其他指令也无法被执行。这种处理器往往会在处理过程中遇到更多的停顿,因此更依赖于超线程技术来提高性能。在顺序执行的处理器中,可能需要四个或更多的超线程来填补因指令阻塞而产生的空闲时间。
然而,乱序执行的处理器由于能够高效地使用相同线程中的不同指令,所以对超线程的需求相对较低。通常,乱序执行的处理器只需要两个超线程就能提供足够的灵活性和性能。
总结来说,顺序执行处理器和乱序执行处理器在对超线程的依赖程度上有所不同。顺序执行处理器通常需要更多的超线程来缓解指令阻塞的问题,而乱序执行处理器则因为能够更灵活地调度指令,因此对超线程的需求较少。
讲解操作系统中的“中断”。
操作系统的一个重要功能是通过中断机制来管理线程的执行。中断是处理器主动停止当前执行的任务,转而执行其他任务的一种机制。操作系统通过设置一个中断处理程序,这个程序会定期触发中断,例如使用定时器中断。每当中断发生时,操作系统会暂停当前正在执行的线程,将其寄存器状态(包括指令指针)保存在内存中,然后加载另一个线程的状态,从而切换到另一个线程。
具体来说,当操作系统启动时,它会安装一个中断处理程序,这个程序每过一段时间(比如每毫秒一次)就会被触发。触发时,处理器停止当前任务,执行操作系统的代码。操作系统的代码可能会决定将当前正在执行的线程的状态保存到内存中,然后从其他线程或进程中加载新的状态,切换执行到这个新的线程。
这种机制被称为抢占,它允许操作系统通过中断来打断正在执行的线程,控制不同线程的执行顺序。这种方式对于多任务操作系统尤为重要,因为它能确保系统能够在多个线程间公平地分配计算资源,而不需要线程自己主动释放控制权。
这种机制的作用是在操作系统层面控制和管理所有运行中的进程和线程,确保每个线程都有机会在处理器上运行,且可以在合适的时候进行切换,实现高效的多任务处理。
讨论“抢占”。
支持抢占式多任务的操作系统被称为抢占式多任务操作系统。这种操作系统允许操作系统在没有线程同意的情况下中断线程的执行。也就是说,操作系统通过中断机制,可以在任何时刻暂停当前线程,转而执行其他线程。这种方式保证了操作系统能够有效管理多个线程的执行,确保系统资源公平分配,不依赖于线程自身是否愿意释放CPU。
相比之下,协作式多任务操作系统则不使用中断机制。相反,每个线程在执行时需要显式地调用一个“yield”函数来主动让出控制权。这意味着线程在完成一定任务后,需要主动通知操作系统“现在轮到其他线程执行了”,操作系统才能切换到其他线程。协作式多任务操作系统虽然在效率上有优势,因为没有中断的开销,但它依赖于程序员编写良好的代码。如果某个线程未能正确调用yield或者出现了错误,操作系统将无法切换出这个线程,可能导致系统挂起或崩溃。
因此,现代操作系统大多采用抢占式多任务,主要因为其可靠性更高。在抢占式多任务系统中,操作系统能够按照预设的时间间隔强制中断线程,确保资源被均匀分配,避免某个不良线程占用过多的CPU时间。而协作式多任务虽然可以更高效地运行,但在实际使用中容易出现因代码问题导致的系统不稳定。因此,抢占式多任务被认为是更为可靠和适应复杂任务调度的方式。
讲解在项目中需要做的工作。
当前的目标是填满处理器的所有16个逻辑核心,将它们都分配给线程,使得处理器能够充分利用每一个核心。现在,操作系统仅将一个线程分配给所有这些逻辑核心,这意味着大多数核心在空闲等待,并没有执行任何任务。因此,当前大多数核心都是空闲的,操作系统只运行了一个线程,其他核心没有得到有效使用。为了更有效地利用处理器,目标是填充这些空闲的核心,并让操作系统分配更多的线程到每个核心,这样就能够让每个核心都开始执行任务。
通过观察操作系统的进程和资源使用情况,发现当前游戏程序只占用了大约11%的CPU资源,其他的资源可能处于空闲状态。例如,可以看到在进程列表中,有些进程如OBS占用了部分CPU时间,但是游戏本身并没有完全填满所有核心。为了提升游戏的多线程效率,下一步的目标是通过启动多个线程,利用这些空闲的核心,使得处理器能够同时执行多个任务,充分发挥处理器的性能。
运行程序并监控其CPU使用情况。
当前,游戏程序的CPU使用率大约为3%。这个占用率相对较低,可能是因为渲染工作负载不足,因此当前CPU资源并未得到充分利用。目标是尽可能地增加CPU使用率, ideally接近90%,这是在OBS运行之后剩余的CPU资源。为了实现这一目标,需要使程序的渲染工作量增加,以充分利用所有CPU核心。
目前,渲染已经优化得很好,但由于渲染的工作量相对较轻,处理器的使用率仍然较低。每个处理器核心占用6%的CPU资源,而游戏程序目前甚至没有完全使用一个核心的资源。如果不增加线程来进行多线程渲染,CPU的使用率最多也只能达到6%。因此,为了提升程序的性能,必须通过增加渲染的工作量或者增加线程数,才能让游戏程序的CPU使用率达到更高的水平。
在任务管理器中,如果看到程序只使用了10%的CPU,而程序运行却非常缓慢,就可能是因为程序的多线程没有得到充分利用,导致CPU资源未能被充分调度和使用。因此,增加线程并提升渲染负载是提升程序性能的关键。
切换到DrawRectangleSlowly函数。
当处理器支持多线程时,利用多核处理器的性能至关重要。如果一个程序只使用单线程,就无法充分利用处理器的全部潜力。以游戏渲染为例,当程序执行的是非常慢的单线程版本时,即便我们运行的是多核处理器,CPU的使用率也可能仅占6%。这意味着,虽然有很多计算资源未被利用,程序的运行仍然很慢。
在这种情况下,如果能够将任务分配到多个线程中,理论上可以显著提高程序的性能。比如,如果能将多个线程分配到16个核心(假设每个核心都能独立工作),理论上会得到16倍的性能提升。虽然由于一些限制因素(如逻辑核心、内存瓶颈、线程间的协作等),实际性能提升不会达到16倍,但即便获得10倍的性能提升,依然是非常显著的。
因此,将程序改为多线程处理,并充分利用每个核心,能够极大提升处理器的利用率,从而大幅提高程序的执行效率。如果程序仍然是单线程的,它只能使用处理器中一个核心的能力,而无法获得多核处理器提供的全部性能。这也是为什么现代处理器更依赖于多线程和多核,而不再只关注单线程性能的原因。
尽管多线程编程涉及更多的复杂性和挑战,但它是提升性能的关键。通过合理地分配任务,优化多线程使用,能够使程序充分发挥硬件的最大性能,避免大部分计算资源的浪费。
讨论为什么不进行更快的单线程处理。
处理器的性能提升面临着一个重要问题:热量。过去,处理器设计的目标是提高时钟频率,希望能够实现更高的处理速度,比如8 GHz的处理器。然而,当时的设计在达到4 GHz时就遇到了问题——处理器过热,导致芯片损坏。这一问题一直存在,至今仍没有找到完全解决的办法。因此,8 GHz的处理器未能问世。
面对这个热量问题,处理器设计者不得不转向其他优化方式。第一个方法是“宽执行”。既然无法提高时钟频率,那么就通过增加每个时钟周期内能执行的操作数来提升性能。例如,SIMD(单指令多数据)指令集允许处理器在同一时钟周期内同时处理多个数据。通过增加指令的“宽度”,可以显著提升处理速度,达到原本无法通过提高时钟频率达到的性能。
第二个方法是“更多的执行”,即引入多线程技术。如果无法提高每个线程的执行速度,设计者就通过让处理器并行执行更多的线程,来提升整体性能。处理器同时执行多个任务,这要求程序员将任务拆分成多个可并行执行的线程。现代处理器的优化大多围绕这两方面展开:如何让每个时钟周期执行更多的操作,以及如何同时执行更多的线程。
这些优化措施使得处理器能够在有限的时钟频率下仍然保持较高的性能,也为编程带来了新的挑战和机会。通过合理的线程管理和多核处理,程序能够有效地利用现代处理器的潜力,提高效率和速度。
为创建线程做准备。
在现代多线程编程中,核心思想相对简单:以前只能执行一个任务,而现在可以同时执行多个任务。虽然这个概念简单,但要实现高效的多线程程序,特别是在处理内存和线程同步问题时,会面临一些挑战。这些挑战通常涉及到如何管理内存、如何解决线程间的竞争条件、以及如何避免内存访问冲突等问题。
当程序从单线程转向多线程时,往往会面临一些具体的实现难题。例如,在处理矩形的绘制时,需要注意缓存行的边界,这可能导致需要调整程序的内存访问方式。此外,还需要为每个线程分配适当的任务,并确保线程间能够高效、正确地协调工作,这也是多线程编程的难点之一。
在 Win32 编程中,默认情况下程序运行时只有一个线程执行。这种设计方式沿用了过去的做法,Windows 启动应用程序时,会默认创建一个主线程,所有的操作都在这个线程中进行。如果需要创建更多的线程来并行处理任务,就需要通过操作系统提供的接口来请求新线程。这可以通过调用 CreateThread
函数来实现,它允许程序向操作系统请求分配额外的线程和执行上下文。Windows 操作系统的内核部分设计得相当精妙,许多系统功能,如 CreateThread
和 I/O 完成端口(IO Completion Ports),在性能和使用方面做得非常好。
总结来说,尽管多线程的概念简单,实际应用中遇到的挑战主要来自内存管理和线程同步等方面,需要开发者在这些细节上做好优化和管理。
CreateThread
是 Windows API 中用于创建新线程的函数,它允许程序启动一个新的执行上下文来并行处理任务。这个函数定义在 Windows.h
头文件中。通过它,可以创建一个线程并让操作系统管理该线程的调度和执行。
函数原型:
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes, // 指向安全属性的指针,可以为 NULL
SIZE_T dwStackSize, // 新线程的堆栈大小,如果为 0,使用默认大小
LPTHREAD_START_ROUTINE lpStartAddress, // 指向线程函数的指针
LPVOID lpParameter, // 传递给线程函数的参数
DWORD dwCreationFlags, // 创建标志,通常为 0
LPDWORD lpThreadId // 存储线程 ID 的指针,如果不关心 ID 可以传递 NULL
);
参数说明:
- lpThreadAttributes: 用于设置线程的安全性。它是一个指向
SECURITY_ATTRIBUTES
结构体的指针,通常可以传递NULL
,表示不指定安全属性。 - dwStackSize: 新线程的堆栈大小。通常设置为 0,表示使用默认堆栈大小。如果指定了大小,操作系统会为该线程分配指定大小的堆栈。
- lpStartAddress: 指向线程函数的指针,线程开始执行时调用此函数。
- lpParameter: 传递给线程函数的参数,可以是任何类型的指针。
- dwCreationFlags: 线程的创建标志。常见的标志有 0,表示立即开始执行线程,或者
CREATE_SUSPENDED
,表示创建线程但暂停,等待调用ResumeThread
启动。 - lpThreadId: 指向一个
DWORD
类型变量的指针,用来存储新线程的 ID。可以传递NULL
,如果不关心线程的 ID。
返回值:
- 如果函数成功,返回一个线程的句柄(
HANDLE
),可以用它来控制线程,如等待线程结束、取消线程等。 - 如果失败,返回
NULL
,并且可以通过调用GetLastError
获取错误码。
示例代码:
#include <windows.h>
#include <iostream>
// 线程函数
DWORD WINAPI ThreadFunction(LPVOID lpParam) {
int* p = (int*)lpParam;
std::cout << "线程参数: " << *p << std::endl;
return 0;
}
int main() {
int data = 42;
// 创建线程
HANDLE hThread = CreateThread(
NULL, // 默认安全属性
0, // 默认堆栈大小
ThreadFunction, // 线程入口函数
&data, // 传递给线程的参数
0, // 默认创建标志
NULL // 不关心线程 ID
);
if (hThread == NULL) {
std::cerr << "创建线程失败!" << std::endl;
return 1;
}
// 等待线程结束
WaitForSingleObject(hThread, INFINITE);
// 关闭线程句柄
CloseHandle(hThread);
return 0;
}
注意事项:
- 线程同步:多个线程之间可能会共享资源,因此需要考虑线程同步问题,避免数据竞争。常见的同步机制包括互斥锁(mutex)、事件(event)等。
- 错误处理:如果
CreateThread
失败,可以使用GetLastError
获取错误码,以便进一步调试和处理。 - 线程管理:在程序结束时,记得调用
CloseHandle
来关闭线程句柄,避免资源泄漏。
CreateThread
是多线程编程中的基本工具,但需要合理使用和管理线程,以确保程序的稳定性和性能。
介绍CreateThread1、2函数。
在 Windows 中,CreateThread
函数用于创建新的线程。通过调用此函数,可以启动一个新的线程并让操作系统管理该线程的执行。下面是对该函数如何工作以及相关的代码细节的详细解析。
CreateThread
函数解析:
CreateThread
函数的关键作用是返回一个线程句柄,并通过该句柄可以管理线程的生命周期和执行。
参数解析:
-
lpThreadAttributes: 这个参数指定线程的安全属性。如果不需要特别的安全设置,可以将其设置为
NULL
。在大多数应用中,不需要关注这一点,除非需要为线程指定特定的权限。 -
dwStackSize: 这个参数指定新线程的堆栈大小。如果将其设置为
0
,则使用系统默认堆栈大小。如果需要,可以通过设置这个值来自定义堆栈大小。 -
lpStartAddress: 这是线程的入口点,指定线程开始执行的函数。通常,这会是一个线程处理函数,该函数内包含线程要执行的任务。
-
lpParameter: 该参数用于传递给线程的参数。线程启动后,这个参数会传递给线程函数,允许线程根据外部输入执行不同的任务。
-
dwCreationFlags: 这个参数控制线程的创建方式。常见的值是
0
,表示线程立即开始执行。如果设置为CREATE_SUSPENDED
,则线程会在创建时暂停,直到显式调用ResumeThread
才会开始执行。 -
lpThreadId: 线程的标识符,函数返回线程的 ID。线程 ID 是一个
DWORD
类型的值,它可以在后续操作中使用,以便与线程进行交互。
运行代码并退出。
在使用 CreateThread
函数创建线程后,线程将开始执行其指定的函数,并在执行完毕后退出。我们通过以下步骤能够看到线程的启动和退出过程:
-
线程启动:通过调用
CreateThread
函数,我们创建了一个新线程,并指定了线程的入口函数(例如ThreadProc
)。当线程被创建时,操作系统会分配一个独立的执行路径,这个线程与主线程并行运行。 -
线程执行:线程在创建后会立即执行其指定的任务。在示例中,线程启动后打印出 “Thread started” 信息,然后执行完任务后退出。
-
线程退出:线程一旦完成其任务,就会自动退出。每个线程都是独立执行的,与主线程或其他线程的执行互不干扰。主线程通过
WaitForSingleObject
等方法等待线程的完成,确保线程在主程序退出之前执行完毕。 -
调试输出:我们可以在
ThreadProc
函数中使用OutputDebugString
打印调试信息,以便查看线程的启动和退出情况。在这个例子中,当线程开始执行时,会看到 “Thread started” 的调试信息。紧接着,线程完成后,线程会退出并终止执行。 -
线程的独立执行:每个线程都有自己独立的执行路径,这与程序的其他部分是分开的。这意味着,线程不会阻塞主线程的执行。它们可以同时运行不同的任务,最大化资源利用率。
-
多线程的优势:通过多线程,可以让程序同时执行多个任务。创建多个线程,可以使得程序在多核处理器上充分利用计算资源,提高程序的执行效率。
综上所述,CreateThread
是启动多线程的基础方式,通过它可以并行执行多个线程,提升程序的处理能力。每个线程都有自己的执行路径,不同线程之间是独立的。
稳定代码。
在多线程编程中,CreateThread
函数创建一个线程时会返回一个线程句柄,这个句柄允许我们对线程进行操作。以下是一些重要的操作以及它们的作用:
-
线程句柄:当我们调用
CreateThread
函数时,操作系统会返回一个线程句柄,允许我们对该线程进行管理。通过这个句柄,我们可以对线程进行各种操作,例如暂停线程、恢复线程、或者终止线程。 -
关闭线程句柄:如果我们不再需要与某个线程进行交互,我们可以使用
CloseHandle
函数来关闭线程句柄。这个操作只是释放了线程句柄本身,告诉操作系统我们不再管理该线程。需要注意的是,关闭线程句柄并不会终止线程的执行,线程仍然会继续运行,直到它自己完成任务并退出。因此,关闭句柄后,线程仍在执行其指定的任务。 -
线程的独立性:即使关闭了线程句柄,线程依然会继续独立执行。比如,在示例代码中,虽然关闭了线程句柄,但线程仍然会持续打印信息,说明线程的执行并不依赖于句柄的存在。线程执行路径一旦开始,它就会按照预定的逻辑运行。
-
线程资源管理:关闭句柄主要是为了释放资源,告诉操作系统我们不再操作这个线程句柄。线程本身的生命周期不受影响,依然会按照创建时分配的任务继续执行。这也意味着,我们可以在不关心线程具体状态的情况下,释放对线程的控制权。
-
线程的生命周期:线程在执行过程中,只有在任务完成或者主动调用线程终止操作时才会退出。关闭句柄后,我们不会再干涉线程的执行,但线程仍然可以独立运行,直到它完成所有的工作。
总之,通过 CreateThread
创建线程后,线程会在后台独立执行其任务,句柄仅用于管理线程的资源。关闭句柄只是表明我们不再管理该线程,但线程的执行将持续,直到它自己完成任务或被显式终止。
讨论退出时发生的情况。
在多线程程序中,确保线程在程序退出时能够正确结束是非常重要的。否则,即使主程序退出,线程仍然可能继续运行,导致程序无法完全退出。
-
程序退出与线程的关系:当程序退出时,所有的线程应该终止,否则它们会继续执行,导致程序无法结束。在这个例子中,主程序退出时,
ExitProcess
函数会被调用,确保所有的线程都被终止。 -
退出进程的机制:当按下
Alt+F4
键关闭程序时,操作系统会触发WM_CLOSE
消息。程序会设置一个标志(如GlobalRunning = false
),然后退出主循环。这时,程序会调用 C 运行时库中的清理代码。清理过程会调用ExitProcess
,这个函数会强制终止所有正在运行的线程。即使某些线程正在执行,ExitProcess
会无视它们的执行状态,直接终止所有线程。 -
线程的终止:
ExitProcess
是程序退出时终止所有线程的关键函数。它会结束所有线程的执行,并且无论线程在哪个状态下,都无法继续运行。这是保证程序能够完全退出的一个重要步骤。如果没有调用ExitProcess
,即使主程序退出,某些线程可能会继续运行,从而阻止程序的完全退出。 -
多线程程序的退出流程:当程序结束时,所有的线程都会被强制终止,这是由
ExitProcess
完成的。如果没有这种机制,线程的独立性可能导致主程序无法顺利退出,进而可能导致资源泄漏或其他不可预见的行为。
总结来说,在多线程程序设计中,正确地管理线程的生命周期是至关重要的。尤其是在程序退出时,需要确保所有线程都能够被正确终止,否则可能会导致程序无法完全退出,影响系统资源的释放和程序的稳定性。
打断点, 程序起来按下F4
强调在没有CRT的情况下构建。
在未来的游戏开发过程中,可能会考虑不使用 C 运行时库(CRT)。这意味着,在没有 CRT 的情况下,程序将不再依赖于标准的 C 运行时环境。这种做法通常是为了更深层次地控制程序的执行,尤其是在资源受限或者想要完全自定义内存管理和线程控制时。
如果不使用 CRT,程序需要手动管理许多本来由 CRT 提供的功能,特别是线程的生命周期管理。在这种情况下,要确保线程能够正确结束并释放资源,通常需要使用 exit process
函数来终止线程和整个进程。这是因为,当线程的句柄不再被使用时,单纯调用 close handle
并不能终止线程,它仅仅是释放了对线程的引用。线程依然会继续执行,直到程序主动调用退出进程的函数(如 exit process
),这会导致所有线程被终止。
另外,线程间共享相同的内存空间,因此它们能够访问彼此的全局变量和内存。在这种情况下,可以通过设置一个全局变量(如 GlobalRunning
)来控制线程的退出行为。只要某个线程检查到这个全局变量的值发生变化,就可以决定是否终止自身的执行。这样可以在没有 CRT 提供的线程管理功能时,依靠共享内存来管理线程的生命周期。
需要注意的是,虽然线程可以访问共享内存,但这也带来了一些潜在的风险。比如,如果多个线程同时访问或修改同一个全局变量,可能会导致数据不一致或者程序崩溃。因此,线程之间的同步和通信需要特别小心,通常需要使用锁机制(如互斥锁)来避免竞争条件和数据冲突。
编译器不了解线程。
接下来,我们将讨论与编译器和线程相关的技术细节。在多线程编程中,编译器的优化可能会影响程序的行为,特别是当线程共享全局变量时。例如,编译器在优化过程中可能认为某个全局变量(如 GlobalRunning
)的值不会改变,从而不再每次从内存中重新加载它。这种优化会导致程序出现不可预期的行为,因为实际上该变量的值可能会被另一个线程更改。
为了解决这个问题,可以使用一些特定的关键字和技巧,如 volatile
,来告知编译器不要对该变量进行优化。volatile
关键字表示该变量的值可能会被外部因素(例如其他线程)改变,因此每次使用该变量时都应从内存中读取最新值,而不是使用缓存的值。
目前我们将暂时跳过这些细节,但这在实际开发中是一个非常重要的问题,需要在多线程编程中考虑。到明天,我们可能会继续深入探讨这些问题,具体来说,会涉及如何管理多个线程,并将它们指向渲染器等任务。希望今天的介绍能够为理解线程的基本概念提供一个清晰的基础。
提问:释放线程句柄时,对操作系统做了什么?是否释放了资源并提高了性能?
在释放线程句柄给操作系统时,是否会影响资源管理或提高性能,实际上在我们当前的使用方式中并没有显著影响。我们计划创建多个线程(大约16个),其中15个是额外的线程,再加上一个当前的线程。在这种情况下,是否关闭这些句柄并不会造成明显差异。
释放句柄的一个潜在好处是,它可以释放操作系统的资源句柄槽位。操作系统对资源句柄有数量限制,因此在不再使用时关闭句柄可以避免资源占用过多。但是,实际上,当前的使用方式中,线程句柄的释放并不会对程序产生显著的性能提升。如果不关闭这些句柄,也不会影响程序的正常运行。
总的来说,在这种情况下,关闭句柄更多是作为一种良好的资源管理习惯(好的清理工作),并不会对性能产生明显的影响。因此,即使不关闭句柄,也不会对程序的执行或性能造成实质性差别。
提问:增加线程数以匹配超线程数量,是否会增加跨核心/跨上下文切换,从而降低小操作的效率?
在多线程编程中,是否要增加线程数以匹配超线程的数量,这实际上取决于引入的开销有多大,尤其是在线程间的通信和工作分配上的开销。如果每个线程所需的额外开销很小,那么增加线程数可能不会导致效率的明显下降,甚至可以充分利用超线程的性能。
然而,如果每个线程的开销很大,尤其是在超线程的使用上需要进行复杂的任务分配和管理,那么这种情况下,线程的增多可能导致性能下降,反而不如只使用物理核心的数量更有效。因此,实际的效果需要通过测试来验证。因为提前预测或假设可能并不准确,尤其是在不同的应用场景和工作负载下。
总的来说,创建多少线程最好,需要根据具体的工作负载和系统架构来决定。真正了解其表现的最好方法是通过实际的性能测试。
提问:在游戏开发中,创建单独的进程来渲染图形是否有优势?
在游戏开发中,通常没有太多理由要为渲染图形创建独立的进程,除非出于安全方面的考虑。一个可能的理由是在32位Windows系统中,由于每个进程有自己的虚拟内存空间,而32位Windows的内存分配是2GB给应用程序,另外2GB给内核空间。如果游戏需要超过2GB的内存,可以通过将游戏分成两个进程,每个进程各自获得2GB内存,从而实现总共4GB的内存使用。然而,这种情况已经非常少见,因为如今大多数系统都已经是64位。
在64位Windows系统中,内存地址空间非常大,远超过一般应用的需求,因此几乎没有必要将渲染部分拆分成独立进程。除非是出于某些特殊的需求,比如增加安全性分区,否则在64位系统下将图形渲染拆分为单独进程没有太多实际意义。
提问:我们是否还需要花一些时间在除了win32之外的其他平台层?
在开发过程中,不会在尚未准备好发布之前就开始处理多平台支持的问题。这样做的原因是,如果在早期阶段就为多个平台开发不同的支持层,往往会导致重复工作和浪费资源。因此,只有在游戏基本完成且知道需要哪些平台支持后,才会进行移植。通过这种方式,能够确保游戏的核心功能已稳定,并且只需为最终平台进行一次有效的移植,而不是同时维护多个平台的支持层。这样可以节省大量的时间和精力。
提问:为什么只用15个线程?为什么不为未来预留更多线程?
关于线程数量的问题,提到的16个线程是指针对特定机器的设置。在实际应用中,不会直接创建16个线程并认为这是理想的做法,因为对于只有一个或两个超线程处理器的系统来说,这样的线程数可能会过多。因此,实际做法是通过调用操作系统的函数,查询当前系统中有多少个处理器(核心),然后根据处理器的数量来决定创建相应数量的线程。这种方式确保了根据系统的硬件配置动态调整线程数量,从而避免线程数量过多或过少的问题,更具灵活性和适应性。
提问:我们会查询系统的CPU数量来决定使用多少个线程吗?
CPU的数量决定了可以使用的线程数。我们将根据处理器的核心数来分配并行线程,以便充分利用计算资源,提高效率。一般来说,CPU的每个核心都可以处理多个线程,因此核心数越多,可用的线程数也就越多。通过合理地设置线程数,我们能够使系统在运行多个任务时保持高效,并且避免因线程数过多而导致的性能下降。
在分配线程数时,我们会根据CPU的实际性能和应用程序的需求来进行优化。过多的线程会带来上下文切换的开销,反而可能影响系统的整体表现。因此,通常会依据系统的负载情况、任务类型以及CPU核心数来动态调整使用的线程数,以实现最佳的性能平衡。
提问:是否能控制线程在哪个核心上执行(线程亲和性)?
操作系统提供了可以查询处理器数量以及每个处理器的核心资源的信息。通过这些信息,可以了解哪些处理器或逻辑处理器共享缓存等资源。这对于多线程程序的调度非常重要,尤其是在有超线程技术的处理器上。超线程技术使得每个物理核心可以支持两个逻辑处理器,它们共享该核心的资源(如缓存)。
在有多个核心和超线程的处理器上,程序会根据操作系统返回的资源共享信息,合理安排线程的分配。例如,如果处理器有8个核心,每个核心有两个超线程,程序将尝试为每个核心创建两个线程,并确保这两个线程运行在相同的核心上,以便它们共享核心的缓存资源,从而提高性能。
Windows 操作系统提供了相关的API接口,允许程序控制线程的分配和调度。通过这些接口,程序能够向操作系统指示希望在哪些核心上运行哪些线程,进一步优化多线程的性能。
提问:你计划让路径寻找(path-finding)多线程化吗?
是否在路径寻找(pathfinding)算法中使用多线程的计划。实际的考虑是,路径寻找本身可能不会特别使用多线程,而是更倾向于在整个模拟过程中使用多线程。具体来说,模拟中的各个部分可以在多个线程中并行执行,从而提高效率。通过这种方式,模拟的不同任务可以同时进行,而路径寻找可能只是模拟的一部分,而不需要单独为其设计多线程机制。
不过,这个决定尚未最终确定,还需要根据实际情况进行评估。因此,虽然多线程可能不会直接应用于路径寻找,但模拟过程中的其他任务可能会通过多线程来加速执行。
提问:Jonathan Blow在他的编译器演示中创建了10,000个线程。有人觉得这不可思议,如何做到的?
线程实际上只是一个CPU的状态,因此理论上可以创建数以百万计的线程,关键限制因素是操作系统为线程状态分配的内存空间。每个线程需要占用一定的内存来存储其状态,而实际在CPU上同时运行的线程数量是有限的,这个数量通常取决于处理器的核心数量。
尽管线程的创建本身并不复杂,也不会直接占用CPU资源(因为CPU一次只会执行有限数量的线程),但创建大量线程会消耗系统资源,尤其是内存和管理线程的开销。这会导致性能下降,因为操作系统需要进行线程切换(即将线程从内存中加载到CPU中)。因此,通常并不推荐创建过多的线程。理想情况下,线程的数量应与可用的处理器核心数相匹配,以确保资源的高效利用。
然而,操作系统并没有硬性限制线程的数量,除了内存空间的限制。操作系统会根据内存资源分配线程的状态空间,直到系统资源耗尽为止。
说:没人会需要超过16个线程。
调侃线程数量和内存容量的限制,表面上看似暗示在实际应用中,没人会需要超过16个线程或2GB的内存。这种说法显然是为了强调资源限制的不可预测性。实际情况是,随着技术的发展,许多应用程序和系统已经远远超出了这种假设。例如,现代的处理器通常有多个核心,并且能够支持更多线程,而内存容量也远大于2GB,尤其是在高性能计算和大规模并行处理的需求下。
说:没人会需要超过256种颜色——GIF委员会。
有一种观点认为,技术和设计领域对颜色的使用存在着过高的限制,认为大多数情况下,最多只需要有限的颜色。例如,最多不超过20到56种颜色,甚至认为26种颜色就足够使用了。人们对于这种限制的理解,基于日常经验,认为颜色的种类是有限的,基本上就只有红色、绿色、蓝色、青色、洋红色这些常见的几种颜色。如果再进一步推测,可能只需要16种颜色就足够满足大部分需求。
这种观点的支持者会认为,市场上即使是大盒装的彩色蜡笔,颜色种类也不超过200种,因此对于色彩的需求也不会超过这个数量。从这个角度看,设定某些技术上对于颜色种类的上限,其实是没有问题的。
提问:不相关,但为什么使用C而不是C++?只是个人偏好吗?
在讨论编程语言时,有人提到使用了一些C++的特性,例如操作符重载、函数重载等。这些特性在某些情况下确实有用,但要注意,像“declare anywhere”(可以在任何地方声明)这类特性,现在已经出现在C99标准中,因此并不完全算作C++独有的特性。然而,早在90年代,当这些特性首次出现时,它们确实属于C++的一部分。
然而,选择不使用C++的原因是,C++的许多特性设计不佳,实施效果也不好,往往导致代码冗长并且产生低效的输出。由于这些问题,很多人认为C++的语言特性整体上是糟糕的,因此尽量避免使用它。
相对而言,C语言被认为是一个相对较好的语言,大多数特性都比较实用,虽然它也有一些缺失的功能,许多人希望C语言能加入更多的功能,但不幸的是,C++并没有提供这些期望的功能,而是增加了更多的问题特性。因此,虽然C++具有一些额外的功能,但它并没有弥补C语言的不足,反而引入了更多不必要的复杂性。
1)你推荐阅读哪些程序员的代码?比如Sean Barrett的公开GitHub(以及他的直播时不时地)开始列个清单。 2)我们什么时候讨论补丁/简单介绍?我假设它主要是处理资源更新和更新相关的代码?
在提到是否推荐阅读他人代码时,表示并不花太多时间去阅读别人的代码,也不太清楚具体指的是什么。如果是想通过阅读他人代码来培养良好的编码习惯或学习算法的实现方式,这种需求其实因人而异,并且更多是个人偏好问题,所以没有明确的建议。
至于关于“补丁”这一话题的讨论,指出如果要更新一个资源列表(asset list),这可能涉及到补丁更新的问题。对于补丁的具体实现,似乎并没有太多可以操作的空间,特别是当涉及到通过像Steam、GOG Galaxy这样的分发平台发布游戏时。补丁的方式通常由这些平台决定,开发者本身可能没有太多权力去操控或定制补丁系统。平台会自动处理更新和补丁,因此开发者不太可能需要直接处理补丁的相关内容。
补丁问题虽然可以简单理解为一个同步(sync)问题,但它与其他类型的同步问题并无本质区别。比如,可以参考标准的“diff”算法或者其他同步机制来处理数据差异和更新,游戏资产的补丁和普通数据的同步没有什么特别之处。总的来说,补丁的具体处理和更新方式主要还是由分发平台来决定,开发者在这方面的操作空间较小。
提问:早期为什么决定不使用纤程(fibers)?
在讨论使用纤程(fibers)时,认为纤程并没有解决当前的实际问题。纤程的设计目标并不是为了增加处理器的工作负载,而是为了在同一个CPU核心上交替执行任务。但是,目前面临的问题并不是需要在单个核心上交替执行工作,而是需要更多的工作可以分配到处理器上。纤程无法提供这种扩展性,因为它并不会增加可用的处理能力。
因此,认为现在没有必要使用纤程,也不预期将来会需要它们。当前更需要的是实际的线程,而不是纤程,因为线程能够提供真正的并行执行能力,能够更有效地分配和利用处理器的资源。