游戏引擎学习第123天

仓库:https://gitee.com/mrxiao_com/2d_game_3

黑板:线程同步/通信

目标是从零开始编写一个完整的游戏。我们不使用引擎,也不依赖任何库,完全自己编写游戏所需的所有代码。我们做这个节目不仅是为了教育目的,同时也是因为编程本身就是一种乐趣。对于喜欢编程的人来说,深入了解底层工作原理、亲自动手实现功能是一件非常有成就感且有趣的事。

目前,我们正在进行多线程的工作。昨天我们刚刚开始着手这部分内容。之所以做多线程,是因为现代处理器在时钟频率(即主频)上已经无法再继续提升。大约在4 GHz左右,处理器遇到了热力学的瓶颈,不能再通过提高时钟频率来加速计算。因此,现代处理器的性能提升主要依赖于其他技术,例如宽寄存器(比如我们之前讨论过的SIMD指令集)和多核处理,后者能够让多个执行流并行工作,从而提高应用程序的性能。

我们现在正着手展示如何在渲染器中实现多线程。昨天我们简要介绍了什么是多线程,如果错过了的话,请回去看一下昨天的内容,其中涵盖了本周我们将会提到的所有基本概念。昨天的内容也介绍了如何在Win32环境下创建线程。接下来我们需要做的,就是讨论如何实际利用这些线程来做有用的工作。为了做到这一点,我们需要讨论线程之间如何通信、如何知道自己该做什么工作等问题。

因此,今天我们将进行另一个黑板讲解(可能不会像之前那么长),讲解线程之间如何协作,并解决一些基本的同步问题。之后,我们会回到Win32的代码中,展示如何在Win32下实现这些内容。这样一来,到明天为止,我们就能够让线程实际完成一些有用的工作了。至于今天能否做到这一点,我们还不确定,因为黑板讲解有时需要花费较多时间来彻底解释一些概念,往往会消耗不少时间。

让我们开始讨论线程同步(Thread Synchronization)以及线程间的通信。昨天我已经展示了在Win32中创建线程有多么简单。实际上,我们可以创建任意数量的线程,这一点没有问题,并且我们也展示了如何在Win32下创建与系统中处理器数量相等的线程。稍后我们会展示如何查询系统,了解应该创建多少个线程等信息。

目前的问题是,虽然我们能轻松创建线程,但这些线程究竟如何开始执行实际的工作,还不清楚。所以接下来我们需要思考几个关键问题。首先,我们需要讨论线程如何获取工作并执行任务。

黑板:进程

昨天提到的一个重要点是,线程和进程并不是相同的概念。回顾一下,我们讨论过进程的概念,进程有各自独立的内存空间。因此,如果两个进程要共享工作或者互相传递数据,它们需要做很多特别的事情来实现这一点。比如,它们必须在操作系统中建立某种管道,或者显式地请求操作系统允许它们共享特定的内存区域。为了让进程能够看到彼此的工作成果或者获取彼此产生的数据,必须采取这些额外的步骤和操作。

然而,线程与进程不同,线程是共享相同内存空间的。线程之间可以直接共享数据,无需像进程那样进行额外的通信或设置共享内存。这就是线程和进程的一个根本区别。

黑板:线程

在一个进程内,所有线程都运行在同一个内存空间中。这意味着线程之间可以共享和操作相同的数据。比如,在渲染器的例子中,我们有一个推送缓冲区(push buffer),它是我们需要操作的地方,还有一个帧缓冲区(frame buffer),以及一些位图等数据。所有这些数据已经预先布局,并且主线程可以访问它们。

当我们创建新线程时,这些线程也能够直接访问这些数据。就像昨天展示的那样,创建线程时可以共享全局变量,也可以通过Win32的线程启动函数传递指针。因此,我们可以很容易地让线程访问需要的数据。只要将数据位置传递给线程,它就能获得访问权限。

因为线程总是可以访问同一个进程中的所有内存空间,所以无需额外的工作来确保每个线程都能看到它们需要的数据。这与进程不同,进程之间需要通过特殊的方式共享数据,而线程之间则天然具备对同一内存空间的访问能力,因此这个问题不存在。

黑板:问题 #1 - 知道该做什么工作

当引入多线程时,首先会面临两个主要问题。第一个问题是:如何确定每个线程应该做什么工作。因为一旦有了多个线程,问题变得复杂了。如何让线程知道自己应该执行什么任务呢?

在之前写的单线程代码中,假设所有代码都是按顺序执行的,且只有一个核心负责执行。这种情况下,编程的思维非常简单:我们只是逐行编写代码,假设代码是按顺序执行的,并且是由一个执行流来完成的。但是,一旦我们引入了多个线程,这种顺序的假设就不成立了。代码中的每一部分都可能被任何一个线程在任何时候执行。

因此,必须重新思考如何管理线程的工作。我们需要一种方式,让每个线程知道自己应该执行哪些任务、处理哪些数据。具体来说,我们需要设计一种方法,使得线程能够知道在何时执行哪些例程(routines),以及它们需要在哪些数据上工作。

在过去的编程过程中,这一切都是隐含的,因为代码从一个地方开始,依次调用函数,任务就按顺序完成了。然而,当引入多线程后,情况变得更加复杂。我们不再是只有一个主线程按顺序执行,而是有多个线程并行执行。为了使代码能够正常运行,必须设计一个新的模型来确保每个线程知道自己应该做什么,什么时候做。

黑板:问题 #2 - 同步/工作何时完成/可见

在多线程编程中,除了需要解决如何分配工作给不同线程的问题之外,还需要解决线程同步和工作完成的可见性问题。具体来说,第二个问题是确保线程之间的工作能够在正确的时间进行同步,以及如何知道工作是否完成或可见。这个问题在单线程编程中是不存在的,因为在单线程中,所有操作都按顺序执行,不需要考虑线程间的同步问题。

首先,在单线程代码中,任务是顺序执行的。例如,函数A执行完之后,函数B才会执行,这种顺序关系是显式的,也就是通过代码的顺序来保证的。如果函数B依赖于函数A的结果,我们只需要确保在调用函数B之前调用函数A即可。但一旦引入多线程,问题变得更加复杂,因为多个线程可能会并发执行,而线程间的依赖关系不再仅仅依赖于代码的顺序。

假设有多个不同的方式来调用函数A,然后它们的结果需要传入函数B处理。在这种情况下,如果函数A的每个调用非常耗时(比如每次调用需要一毫秒),我们可能希望这些调用能够并行执行。于是,我们可以将每个函数调用分配给不同的线程去执行。比如线程0执行第一次调用,线程1执行第二次,线程2执行第三次,而函数B在所有线程完成它们的工作后执行。

问题就出现在这里:函数B不能在所有线程的工作都完成之前开始执行。如果有线程依赖其他线程的结果,那么我们就需要一种机制来确保只有在所有依赖的线程完成任务后,目标线程才能继续执行。这就是多线程编程中的一个关键问题:线程必须知道其他线程的工作是否已经完成,以便它们能正确地执行接下来的任务。

解决这些问题对于实现高效的多线程程序至关重要。虽然在渲染器这样的简单场景中可以通过将任务划分为"桶"来处理这些问题,但随着问题变得更复杂,我们仍然需要在多线程编程中仔细设计线程之间的协调和同步机制。

黑板:概念化线程如何工作

在多线程环境下,问题的解决方法变得更加复杂,主要是因为多处理器系统不再保证一些基本的假设,像是内存操作的顺序性等。要理解和解决这些问题,需要从线程如何协同工作以及它们如何看到彼此结果的基本模型来思考。

举个简单的例子,假设有几个函数A(如"渲染一个瓦片"),然后有一个函数B(如"显示到屏幕")。我们需要设计一种方式,使得多个线程可以并行执行,分别渲染不同的瓦片,最后有一个线程等待所有其他线程完成渲染后,再将所有结果合并,最终进行显示。这个模型很像渲染器中多线程处理渲染任务的方式。

具体来说,每个线程负责渲染一个瓦片,最后一个线程负责将这些渲染结果汇总并显示到屏幕上。在这种情况下,需要确保每个渲染线程完成后,最后的汇总线程才能开始工作。这个过程中涉及到线程之间的同步,确保最终的汇总操作在所有渲染工作完成之后执行。

黑板:进行忙等待循环

在多线程编程中,我们面临的第一个问题是如何安排线程去做任务。假设我们启动了多个线程,每个线程都在忙碌等待(即所谓的忙等待),它们会一直在一个无限循环中检查是否有工作需要做。如果有工作,它们就执行,否则就继续检查。

此时,线程的任务调度问题变得非常关键。如果我们将任务的状态存储在一个指针里,当有任务需要处理时,指针会指向任务的数据,否则指针为空,表示没有任务。线程在执行时会检查这个指针,发现指向任务时,就会去处理这个任务。

这个过程对于单个线程来说并不复杂,线程只需要读取指针,查看是否有任务需要处理。但问题出在当有多个线程同时运行时,多个线程可能会同时看到指针指向的任务,从而导致多个线程同时处理同一个任务。这样就造成了资源的浪费,导致不必要的重复计算。

为了避免这个问题,必须确保每个线程处理的任务是唯一的,也就是每个线程只能处理特定的工作,而不与其他线程重复。这就需要一种机制来管理线程之间的任务分配,确保每个线程都能独立执行不同的任务,避免竞争条件和数据冲突。

这个问题的复杂性在于,线程是并行执行的,而多个线程的操作可能会相互干扰,因此需要特殊的同步机制来协调它们的工作。这是多线程编程中的一个典型挑战。

黑板:防止多个线程做相同的工作

为了避免多个线程重复执行相同的任务,一种直觉的解决方法是:当一个线程看到指针指向工作任务时,它马上将该指针设置为零,这样其他线程就看不到该任务,避免重复执行。也就是说,在处理任务之前,线程会把任务指针保存并清空,确保其他线程不能再看到这个任务。

然而,这种方法实际上并不可行,原因在于多线程编程引入了一些细节,单线程程序员通常不需要考虑。这些问题源自于多核处理器同时执行多个线程时,线程之间的执行顺序是不可预测的。

例如,假设有两个线程同时在检查指针,如果它们都看到指针指向一个任务,它们都会进入"任务存在"的判断,并试图将任务指针设置为零。这时,它们可能会同时读取指针并保存该值,然后各自将指针设置为零并开始执行任务。问题在于,虽然指针被正确设置为零,但两个线程仍然会执行相同的任务,造成重复的工作。

这种情况发生的原因是,两个线程是并行执行的,它们可能在几乎相同的时间内检查指针、保存任务指针,并且互相之间并没有同步机制来确保它们不会同时处理同一任务。这种竞态条件是多线程编程中的一个常见问题,单纯依靠传统的编程方法并不能保证解决。

因此,仅凭当前的编程语言基础,尤其是C语言,无法保证完全避免这种问题,除非使用更高级的同步机制,或者转向像C++11这种提供了更强大线程控制和同步工具的语言。在C语言中,处理这种并发问题需要引入更多的同步原语,如锁、原子操作等,来确保线程之间的互斥和正确的任务分配。

黑板:x64 提供了特殊指令

尽管多线程代码存在许多挑战,实际上它是能够工作的,许多人都在实际项目中使用并发布了多线程代码。为什么这能做到呢?原因在于现代x64处理器提供了一些专门的指令,帮助解决并发执行中的问题。这些指令专门设计用来支持多线程编程,确保代码在多个线程并行执行时能够正确运行。

这些特殊指令的工作原理通常是在某些操作中提供一种保证,确保只有一个线程能够看到某个操作的结果,避免多个线程同时看到并操作同一个结果。通过这些指令,可以保证线程在执行时不会出现竞态条件,从而确保多线程代码能够正确且高效地执行。

例如,x64处理器提供的原子操作指令,可以让一个线程在执行操作时锁定内存中的某个值,确保其他线程在该线程完成操作之前无法访问这个值。这种机制通过确保操作的互斥性,避免了多个线程同时进行冲突的操作,确保每个线程看到的都是正确和独立的数据。

黑板:"锁交换"(locked exchange)

在多线程编程中,x64架构提供了一些特殊的指令,用来确保多线程操作的正确性,避免多个线程同时执行相同的操作。其中一个关键的指令是"锁交换"(Locked Exchange)。该指令的作用是替换内存中某个位置的值,并确保没有其他线程能够同时执行这个操作。这样可以确保线程操作的独立性和正确性。

具体来说,如果我们希望替换内存中某个位置(比如指针指向的位置)的值为零,且只允许当前线程执行这个操作,可以使用锁交换指令来实现。通过锁交换,操作会被原子化执行,即在进行替换之前,其他线程不能访问该位置的数据。这样,确保了只有一个线程能够成功地获取并替换该值,避免了并发冲突。

例如,我们可以将工作指针指向的值与零交换。如果成功执行,返回值就是替换前的指针值。如果工作指针指向一个有效值,就会继续进行工作。如果有多个线程同时尝试执行相同的操作,锁交换会确保只有一个线程能够成功地替换值,其他线程会看到已经被替换的零值,并跳过操作。

除了锁交换,还有其他类似的同步原语(如互锁递增),这些指令可以保证每个线程看到的值都是唯一的。例如,互锁递增指令可以确保每次递增操作的结果都不会被其他线程干扰,从而可以生成一个递增的整数序列。

总之,多线程编程的关键之一是通过使用这些特殊的同步原语来确保操作的原子性和线程间的正确同步。这样可以避免竞态条件,确保多线程代码在多个CPU核心上并行执行时能够按照预期的顺序正确执行。

黑板:"互锁比较交换"(interlocked compare exchange)

在多线程编程中,interlocked compare exchange 是一个非常强大的指令,它的功能类似于前面提到的"锁交换"(locked exchange),但它具有更高的灵活性。与"锁交换"指令不同,interlocked compare exchange 不仅能够进行原子交换操作,还能基于条件执行交换操作,确保只有在某个特定条件下,值才会被替换。

具体来说,interlocked compare exchange 允许你指定一个内存位置,如果该位置的当前值与预期值相等,则用新的值替换它。这意味着,如果两个线程同时尝试操作相同的内存位置,只有一个线程会成功执行交换,另一个线程会看到这个内存位置的值已经发生了变化,从而避免了竞态条件。

这个指令的优势在于它不仅确保操作是原子的,还允许在交换之前检查条件,使得编程更加灵活。例如,如果某个线程在执行任务时,需要检查某个值是否满足某个条件,才能决定是否继续执行任务,那么interlocked compare exchange 就是一个非常有用的工具。它可以保证,只有当内存中的值与预期值相符时,才会进行替换操作,从而避免了不同线程之间的冲突。

虽然这个指令在性能上可能比一些简单的原子操作稍微慢一些,但在大多数情况下,这种性能差异并不显著。而且它非常适用于需要条件判断的场景,使得线程同步变得更加简洁和易于管理。通过 interlocked compare exchange,我们能够在编程中采用一种更加灵活的方式来解决复杂的同步问题,而不需要将每一个操作都强行压缩成一个单一的交换操作。

总的来说,interlocked compare exchange 提供了一种强大且灵活的方式来进行线程同步,它不仅能保证操作的原子性,还能允许程序在多线程环境中进行更复杂的条件判断和操作。对于那些需要在执行前进行一些检查的多线程任务来说,这个指令是一个非常有用的工具。

interlocked compare exchange 使得多线程程序可以实现更灵活和高效的同步,特别是在处理工作分配和避免竞态条件时。它允许线程在进行内存交换操作时设置条件:只有在内存中存储的值与预期的值相匹配时,交换才会发生。如果当前的值与预期不符,交换就不会进行,这为线程提供了一个简单的检查机制。

具体来说,当一个线程试图交换内存位置的值时,interlocked compare exchange 会检查该位置的当前值是否与预期值相同。如果相同,才会将该位置的值替换为新的值。如果不相同,交换操作不会执行,意味着这个线程无法修改内存中的值。

这种机制的关键在于,它允许线程在进行工作前先"抢占"检查某个位置的值是否已经被其他线程修改过。比如,线程可以在进行工作之前先检查工作是否仍然有效,如果其他线程已经替换了工作内容,这个线程就不再进行操作,而是跳过这部分工作,重新开始查找新的任务。这样,就能有效地避免不同线程间的冲突和重复工作。

通过这种方式,interlocked compare exchange 使得线程之间能够更智能地协调工作,避免无意义的重复操作,同时提高程序的效率。对于处理需要条件判断的任务来说,它提供了一种简单而强大的同步机制。这种机制可以帮助程序在多线程环境中保持良好的执行顺序,确保只有一个线程能够成功修改内存位置,而其他线程则会感知到这个变更,从而避免竞争条件。

黑板:用其他原语巧妙处理

interlocked compare exchange 使得编写多线程代码时,可以在不进行复杂同步操作的情况下,依然保证线程安全。即使代码结构并不完美,只要合理使用这个原语,线程安全性仍然能够得到保证。这种机制允许开发者在多线程环境中做出一些不完美的设计,但依然能够避免竞态条件和同步错误。

然而,如果使用其他的同步原语,事情就没那么简单了。使用这些其他的同步原语时,必须非常小心和谨慎地设计代码,因为任何没有严格安排的操作,都可能导致同步问题,甚至可能导致崩溃。例如,在某些情况下,代码中的工作可能依赖于其他线程的执行顺序,如果这些操作没有在适当的时机和方式下进行,就可能发生竞态条件,导致程序无法按预期工作。

interlocked compare exchange 通过简单的内存比较和交换机制,能够避免这些复杂的同步问题。它的优势在于,如果两个线程并发地执行,它能确保只有一个线程会成功交换内存位置,而其他线程则会因不满足条件而跳过这次操作。这样,线程间的冲突就得以避免,程序能够更稳定地运行。

接下来,计划是利用这些同步机制来构建一个系统,使得多个线程能够安全地执行任务。

黑板:在多线程上下文中的"读和写"

在多线程环境中,一旦开始考虑多线程处理,必须时刻意识到每个线程在执行过程中可能处于的任意状态。每个线程的执行顺序和时机都是不可预测的,因此要特别关注多个线程可能同时访问同一块内存时所带来的问题。

读取操作通常没有问题。例如,如果多个线程要读取一个不变的数据集(例如常量),就不需要担心同步问题,因为多个线程可以安全地同时读取相同的数据。然而,写操作则非常复杂。一旦多个线程可能同时写入同一内存位置,就需要解决同步问题。这是因为多个线程可能会在同一位置写入不同的数据,从而导致不可预料的结果。更严重的是,如果两个线程的写入值不同,这时候就需要确定到底哪个线程的写入结果才是最终正确的。

如果两个线程写入的是相同的值,那么这并不算问题,因为结果不会发生变化,但问题的复杂性在于,当线程写入的内容不同或者在写入过程中发生冲突时,必须确保正确的同步机制,以避免数据的不一致性或丢失。

当多个线程可能会写入同一个位置时,即使它们不读取该位置的数据,情况也会变得更加复杂。这样会导致线程间的竞争,可能导致一个线程的写入被另一个线程覆盖,或者出现数据同步的问题,这些都需要特别关注和处理。

黑板:缓存行

在多线程环境中,处理器通过缓存行(cache line)来管理内存操作。缓存行的大小通常较大,比如128字节、256字节等,这取决于具体的处理器架构。处理器中的每个核心会将内存按缓存行划分,并在缓存中操作这些内存块。当多个线程访问不同内存位置时,它们可能会访问相同的缓存行,导致潜在的同步问题,尤其是在写入时。

假设有两个线程分别操作结构体中的不同变量,比如一个线程读写变量A,另一个线程读写变量B。表面上看,似乎不需要同步机制,因为它们操作的是不同的变量。但实际上,处理器可能将这两个变量A和B放置在同一缓存行中。这时,尽管线程操作的是不同的变量,但它们实际上是在访问同一个缓存行,这可能导致数据冲突和错误的结果。

例如,当线程1操作A时,它将整个缓存行加载到其缓存中,同时包含A和B。然后,线程2操作B时,也会将相同的缓存行加载到它的缓存中。如果线程1先更新A,线程2再更新B,最后两者分别将修改后的缓存行写回内存时,可能会出现问题。具体来说,缓存行的写回顺序可能导致其中一个线程的修改被覆盖,从而出现数据丢失或错误的情况。

尽管现代的x64架构通常保证写操作是按照一定顺序可见的,避免了这种错误发生,但不同的处理器架构之间可能有所不同。因此,即使在支持强内存顺序的处理器上,也需要考虑缓存行争用的问题,因为这可能会导致性能下降。如果多个核心频繁争用同一缓存行,处理器需要不断地交换缓存数据,这会增加同步成本并影响整体性能。

为了避免这种情况,可以尽量确保线程操作的内存位置不在同一缓存行内,确保每个线程的工作负载分布在不同的缓存行中。这样可以避免不必要的同步开销,并提高性能。即使在强内存一致性的架构上,也应考虑缓存行的布局,避免频繁的缓存行争用。

总之,了解缓存行和处理器如何管理内存访问是优化多线程程序性能的关键之一。

我们现在准备从基础开始操作线程,展示如何用线程开始执行一些简单的工作。虽然目前还很基础,但这将帮助理解如何实际使用线程进行任务处理。接下来,会打开之前创建的线程处理函数,并进行进一步演示。虽然此时的工作还很简单,但目的是让大家能够看到线程的工作机制,并能开始对线程的使用有所掌握。

win32_game.cpp:创建四个做不同工作的线程

接下来,我们打算实现一个简单的例子,创建15个线程,每个线程执行不同的工作。为了实现这一目标,我们首先创建一个结构体,暂时命名为 ThreadInfo,用于存储每个线程的逻辑索引。这个逻辑索引用来表示每个线程的编号(例如:线程0、线程1等),以便我们在后续操作中能够区分不同的线程。

我们会通过 win32_thread_info 来传递这些信息,并将其作为 lpParameter 传递给线程的启动函数,这样线程就可以知道自己的编号以及其他可能的额外信息。

在实现时,我们会用一个循环来创建多个线程。在循环中,每次创建一个线程并设置其逻辑索引,将结构体的地址传递给线程启动函数。线程开始后,暂时让它们休眠,等待进一步操作。此时,线程创建成功后,它们会按照顺序执行,但只是暂时没有实际操作,只是保证线程被成功创建。

让线程寻找待办的工作

接下来,我们希望让线程能够实际执行工作,具体来说,就是让每个线程从一个工作队列中获取任务并执行。为了简单起见,我们将使用一个数组来作为工作队列,这个队列充当了一个迷你队列(Mini Queue)的角色。我们在工作队列的每一项中包含一个字符指针,指向要打印的字符串,未来也可以根据需要扩展存储其他类型的工作内容。

首先,我们会定义一个工作队列项结构,包含字符串指针,并为这个队列预留一定数量的空间,比如256个项。此外,我们还会设置一个变量来跟踪队列中当前的任务数量(EntryCount),并将其初始化为0。

接下来,创建线程后,每个线程将会遍历队列,执行其中的工作。首先,我们向队列中推送一些任务,比如10个字符串任务。为了实现这个推送操作,我们将实现一个 PushString 函数,它将一个字符串添加到工作队列中。

这个 PushString 函数非常简单,首先检查队列是否已满(即当前项数是否小于队列最大容量),然后将传入的字符串存入队列的下一个可用位置。

但是,问题来了,我们需要从队列中获取任务并处理。我们在队列中记录了当前的任务数量,但没有记录下一个要处理的任务项。所以,我们新增一个变量 NextEntryToDo,用来表示下一个要处理的任务项。

每个线程在工作时会检查 NextEntryToDo 是否小于 EntryCount(即队列中是否还有任务)。如果有任务,它就从队列中获取任务,打印出相应的字符串,并输出一个格式化的调试信息,其中包含线程的编号和打印的字符串内容。

此时,代码是没有线程同步的保护的,因此这是一个不安全的版本,多个线程同时访问队列可能会导致竞态条件和数据不一致的问题。不过,这个版本展示了工作队列的基本结构和线程如何处理任务。接下来,会在这个基础上加入线程同步机制,以确保线程安全和正确的任务执行。

运行并检查调试输出

在运行程序时,输出的结果出现了一些异常,这与预期结果相差甚远。具体来说,线程14打印了许多奇怪的内容,这些内容看起来不应该是正确的输出。原本的预期是每个线程应该打印出被推入队列的字符串,但结果却非常奇怪,显然出现了问题。

最初认为这是由于线程同步问题导致的竞态条件,但通过进一步分析代码发现,问题的根本原因可能在于我们没有正确处理线程的任务分配,导致多个线程同时操作了共享资源(如工作队列),造成了数据的混乱。这些奇怪的打印结果本应是由不同线程各自独立地处理队列中的任务,但由于缺乏同步措施,导致了错误的输出。

这表明在没有任何同步机制的情况下运行代码是非常不安全的,尤其是在多线程环境中,多个线程可能同时访问共享的数据结构,导致数据丢失或错误。为了确保代码的正确性,需要添加适当的同步机制,以防止这种情况发生。

认识到的时刻:你需要让 ThreadInfo 的值保持

在调试过程中,发现了一个问题:原本设计用于保存线程信息的结构体在每次循环中都被覆盖,导致线程无法正确引用自己的信息。具体来说,线程信息(ThreadInfo)是存储在栈上的临时数据,这意味着每个线程的信息在创建之后会立即消失,因为栈上数据会被复写。因此,多个线程在执行时会引用错误的数据。

为了解决这个问题,需要确保每个线程的线程信息结构体在整个生命周期内都能持续存在。因此,应该将线程信息放到堆上,而不是栈上,这样线程在执行时可以安全地引用这些信息。

一旦修复了这个问题,程序应该能够正确地将每个线程的信息持续存在,并且每个线程能够正确地引用并处理其对应的任务,而不会发生信息丢失或错误引用的情况。

检查输出

在测试过程中,输出结果如预期般出现了问题,显示不同线程打印出不同的字符串,虽然部分结果有些合理,但其他部分则完全不符合预期。例如,线程 1 打印了 3 个字符串,线程 0 打印了 2 个字符串,虽然这些行为看似不完全错误,但这表明线程没有正确地同步,导致了不一致的输出。

这表明,尽管没有明显的代码错误,但因为线程没有适当的同步机制,线程之间的工作调度和访问顺序出现了问题。这种情况可能是由于多个线程在访问共享数据时,没有按照预期的顺序进行处理,从而导致了结果的错乱。为了避免这种情况,必须对线程进行同步控制,确保每个线程按照正确的顺序从队列中取出任务并执行。

解释发生了什么

在这段代码中,存在两个主要问题需要解决。第一个问题是关于NextEntryToDo++这一行代码的线程安全性。由于没有进行适当的同步机制处理,两个线程可能会看到相同的值,从而引发竞争条件。具体来说,多个线程可能同时读取到相同的NextEntryToDo值,然后同时进行自增操作,导致重复访问同一个工作项。

第二个问题是在编译器优化方面。如果编译器未意识到多个线程可能会修改NextEntryToDo这个变量,它可能会进行不当优化。例如,编译器可能会认为只有当前线程会修改这个值,因此它可能会在优化过程中提取或重排代码,从而影响程序的正确性。这种情况下,编译器可能会将变量缓存到寄存器中,忽略其他线程的更新,导致不一致的行为。

为了修复这些问题,首先需要确保线程安全,即对NextEntryToDo++操作使用适当的同步机制,避免并发冲突。其次,需要使用合适的C语言关键字,告知编译器该变量在多线程环境下可能会被多个线程修改,从而避免编译器对变量进行不当的优化。

对 EntryCount 的写入没有按顺序进行

另一个问题出现在这里:输入字符串。也就是说,在执行 entryCount++ 后,存在另一个问题,写操作的顺序并不正确。可以看到,在 entryCount 增加后,某个线程可能会读取到这个值并开始执行工作,加载数据。如果这个线程在此时开始工作,那么必须确保 StringToPrint 已经填充完成,否则它将读取到垃圾数据。

另外一个需要做的事情是确保写入操作按顺序进行,确保当一个线程看到 entryCount 增加时,相关的工作数据已经正确地刷新到内存中。要确保线程在读取时能够看到正确的内容,并且这个过程要严格按照内存模型来处理。

这个简单的代码片段中,实际上存在三个独立的问题需要修正,这显示了在进行多线程编程时必须小心处理的多个细节。

读取顺序也不对

在讨论多线程编程时,不仅写操作存在问题,读取操作也会出现类似的问题,尤其是在编译器的优化过程中。具体来说,尽管某些架构(如 x64 架构)保证了内存访问的强一致性,但在编译器层面,编译器可以对代码进行优化,例如将读取操作提前到不合适的地方,这可能会导致竞争条件,读取到未更新的值。

为了防止这种问题的发生,需要确保编译器不会进行不当的优化操作。为此,可能需要使用一些机制来显式地阻止编译器将读取操作和写入操作的顺序打乱。这些问题在多线程编程中是非常常见的,尤其是在涉及到不同线程间共享数据的情况下。

因此,通常建议在实现多线程功能时,先编写一些简单而有效的原语(例如工作队列),并集中处理这些多线程同步问题。这样可以避免在代码的各个地方重复处理同步问题,减少出错的机会。通过集中处理这些问题,可以确保代码的一致性,并减少因频繁处理多线程问题而带来的头痛和bug。

总体而言,解决这些多线程同步问题一次,并在代码中进行集中管理,要比在代码中到处纠结多线程问题来得更高效、清晰。

你使用"for (;😉"而不是"while(1)"是风格上的选择,还是有我忽略的好处/坏处?

在这段代码中,使用分号代替了其他形式的表达式,目的是为了避免编译器发出警告。一些编译器在面对特定的条件表达式时会抱怨,并提示"条件表达式常量"。这是因为某些编译器认为某些条件表达式不符合预期,可能会给出警告。为了避免这种警告,通常会禁用该警告,或者通过这种方式书写代码来避开编译器的检查。

这主要是为了避免在不同的代码库中工作时,特别是在使用一些较为严格的编译选项时,引发不必要的警告。因为有时这些警告并不会真正影响代码的运行,只会影响编译过程中的输出,所以通过使用这种方法可以使代码更干净,不会无端增加警告的数量。因此,这种做法更多的是为了保持代码的整洁性,避免引入多余的警告。

总结来说,问题并不在于这段代码的实际效果,而是为了避免特定的编译警告,并使得在跨不同代码库工作的过程中,避免引发潜在的不必要的警告。

如果没有互锁并看到相同的值,会有什么问题,就像你的"TODO"所提示的那样?

在这段代码中,出现了一个问题,即多个线程同时操作共享资源,导致多个线程看到相同的值,从而执行了重复的工作。具体来说,目标是确保每个工作项只能被一个线程执行一次,但是在当前实现中,线程0、2和1都执行了相同的工作项,这明显是不对的。

最初,只有一个线程时,代码表现正常,打印出从0到9的工作项,而且每个工作项都只执行了一次。然后,当增加第二个线程时,代码依然能够正常工作,线程0和线程1按顺序分别处理工作项,输出的顺序也合理。尽管如此,线程1在输出第6个工作项时稍微滞后,可能是由于线程的创建顺序或调度延迟问题,但仍然能够保持正确性。

然而,当线程数增加到更多时(例如4个线程),就开始出现问题,工作项1被两个线程(线程0和线程1)都处理了。也就是说,虽然目标是让每个工作项只处理一次,但由于线程之间没有进行正确的同步,导致了多个线程同时处理同一个工作项,从而造成了重复的工作。

这个问题的根本原因在于共享变量(如 NextEntryToDo)的操作没有使用原子操作,因此多个线程可能会同时读取相同的值,从而造成重复工作。虽然 OutputDebugString 可以保证只有一个线程能同时写入输出,但其他操作依然缺乏同步保障。

黑板:为什么两个线程做了相同的工作

这个问题的根本原因与CPU如何工作有关,特别是在多核处理器的情境下。处理器有寄存器,而不是直接在内存中操作数据。当一个核心(如核心0)需要处理某个工作单元时,它首先会检查 NextEntryToDo(即下一个要做的工作项)的值。假设 NextEntryToDo 的值是1,核心0就会把这个值加载到它的寄存器中。这时,核心0的寄存器中就存储了值1。

与此同时,另一个核心(核心1)也可能需要执行相同的操作,它也会加载 NextEntryToDo 的值,这样核心1的寄存器中也会存储值0。需要注意的是,处理器通常是先将值加载到寄存器中进行操作,而不是直接在内存中进行修改。因此,核心0和核心1的寄存器都存储了相同的值0,实际上它们各自都复制了一份相同的内存值。

接下来,核心0和核心1都执行相同的增量操作,它们都将 NextEntryToDo 的值从0更新为1。由于它们并没有同步,两个核心都写回了相同的值1到内存中,这意味着它们都认为工作单元0应该被处理,并且它们都将工作单元1标记为已完成。因此,工作单元1实际上被两个核心同时处理了,这会导致重复的工作。

如果这种情况发生在渲染任务中,意味着两个核心在渲染相同的图像块(tile),而不是分别渲染不同的图像块,从而浪费了计算资源。实际上,性能下降了,因为两个核心执行了重复的工作。

我在其他地方看到的用于多线程代码的互斥锁是否依赖这些互锁指令,还是它们完全不同?

在多线程编程中,mutex(互斥锁)通常是操作系统提供的原语,它们实际上是基于处理器的互锁指令(interlocked instructions)来实现的。互锁指令确保多个线程访问共享资源时能够正确同步,避免数据竞争和不一致性的问题。互斥锁通过这些底层的互锁机制来实现线程之间的互斥访问,从而保证同一时刻只有一个线程可以访问临界区的代码或资源。

此外,还有一种称为事务性内存(Transactional Memory)的方法,这种方法在英特尔的处理器中逐步推出。事务性内存的工作原理与传统的mutex有所不同,它并不完全依赖于互锁指令。事务性内存采用不同的同步原理来处理线程间的协调问题,目的是提供更高效的并发控制,但它与传统的互斥锁并不相同。

目前,互斥锁通常仍然是通过互锁指令来实现的,而事务性内存等新型同步原语则可能在未来成为更主流的选择。

你对无锁队列(lockless queues)有什么看法?

对于"无锁使用"(lock-less)这个概念,实际上并不完全清楚人们在说它时指的是什么。很多时候,不知道具体是指哪一种方式。如果是指完全不使用任何互锁指令,那么在某些情况下,这可能是有意义的,尤其是当需要处理大量队列操作时,因为这种方式可能会提升性能。然而,如果队列操作并不复杂,那么我会质疑这种方式是否值得采用,因为对于现代处理器来说,互锁指令的开销并不大。

此外,锁式增量(lock increment)队列的实现相对简单且容易正确实现,而没有使用锁的实现则可能会复杂得多,因此在大多数情况下,如果队列的复杂度没有增加到需要大量额外优化的程度,我认为使用标准的工作队列方式是最简单且可靠的。通常,我并不倾向于写复杂的多线程代码,而是采用标准的工作队列方法,并确保每个工作项的规模足够大,这样就不需要太多关于多线程细节的担忧。

至于无限循环,通常这类设计是为了保持线程持续运行,等待任务或条件触发继续执行,但具体的目的取决于实现的上下文。

无限循环的意义是什么?它不是最终会结束吗?

在多线程编程中,虽然理论上一个循环可以是无限的,但实际上线程通常会在某些条件下终止。例如,线程可能在进程退出时被终止,因此不必担心循环永远无法结束。尽管如此,使用线程时,循环不能真的是完全无限的。在多线程库的设计中,通常会确保线程在某些条件下能被正确地终止,避免无限循环导致的资源浪费或程序崩溃。

你推荐哪些线程库,为什么?(例如 boost 或 pthreads)

在多线程编程中,推荐使用简单的原子操作来实现线程同步,比如锁定增量、比较交换、交换锁、锁增量等,这些操作都非常容易实现。通过这些基本的原子操作,可以构建出一个简单有效的工作队列。相比使用其他第三方多线程库,自己实现同步机制更能确保代码的简洁和高效,同时避免了继承外部库中可能存在的bug。某些多线程库可能存在不必要的复杂性,甚至可能在某些情况下带来更多的问题。

什么更好:一个工作调度器,每个线程都可以从中获取工作,还是为每个线程设置独立的队列?

在多线程任务分配中,是否使用工作队列取决于工作负载的类型。以渲染为例,可以考虑不使用队列,直接将每个线程分配特定的任务区域(例如不同的图块),这样线程之间就不需要通信了。这种方式是有效的,并且可能在某些情况下比使用工作队列更高效。但同时,工作队列方式更为通用,适用于更多场景,因此即使这种策略可能不一定是最优的,使用工作队列仍然值得作为学习和教育目的的展示。

然而,即使在渲染这种场景下,直接分配任务给线程并不总是最合适的策略。原因在于不同的任务(如不同的图块)可能需要不同的时间来完成。如果某些任务特别复杂或需要更多计算,单独分配任务给固定线程可能会导致负载不均衡,从而影响性能。在这种情况下,使用工作队列可以动态地平衡负载,确保每个线程始终能处理下一个可用的任务,避免了某些线程长时间等待的情况。

黑板:"单生产者/单消费者"与"单生产者/多消费者"

在讨论多线程任务分配时,有两种常见的策略:一种是为每个线程分配独立的队列,另一种是使用单一的队列,所有线程从中取任务。两者的核心区别在于任务分配的方式以及线程如何从队列中取任务。

第一种方法中,每个线程有自己的独立队列,任务被分配到不同的队列中,这意味着每个线程负责自己的任务区域(比如渲染任务中的图块)。在这种方法下,任务是静态分配的,每个线程只处理自己负责的部分。缺点是,如果某个任务需要较长时间才能完成(比如某个图块需要更多的计算时间),那么分配到这个任务的线程将长时间处于忙碌状态,其他线程则可能空闲,导致性能浪费。

第二种方法是使用单一的工作队列,所有线程从同一个队列中获取任务。此时,如果有某个任务(例如一个图块)需要较长的时间,其他线程仍然可以从队列中获取剩余的任务,避免了其他线程的等待。因此,这种方法在负载不均的情况下可以更有效地分配任务,减少总体的处理时间。

这种策略的好处在于,假设有一个任务需要很长时间(比如100毫秒),而其他任务需要的时间较短(如1毫秒),那么使用单一队列的多线程方式可以使得任务的负载分布更加均匀。即使有线程在处理长时间的任务,其他线程也能及时处理自己的任务,从而减少总体的工作时间。与此相反,如果每个线程都有自己的队列,任务较重的线程将拖慢整体进度。

然而,如果任务量较少(例如只有几十个任务),使用单一队列的多线程方式可能带来一些额外的开销,因为需要处理队列的入队和出队操作。对于较少的工作单元,可能不值得为了解决负载不均而增加额外的队列操作成本。

总结来说,当任务数量较多时,使用单一队列(单生产者多消费者模式)更有利于任务的均匀分配,从而优化性能。而当任务较少时,使用独立队列(单生产者单消费者模式)可能会减少一些额外的同步开销。因此,选择哪种方式取决于具体的工作负载和性能需求。

我认为"无锁"的意义在于它不使用操作系统级别的锁

在并发编程中,Lock-Free(无锁)指的是一种设计或实现方式,能够避免在多个线程访问共享资源时使用传统的锁机制(如互斥锁)。传统的锁(如互斥锁或读写锁)会使得某些线程在访问共享资源时被阻塞,直到锁被释放。无锁编程的目标是通过原子操作和其他低级别的同步机制,避免线程因锁而产生阻塞,从而提高程序的并发性和性能。

Lock-Free 的核心思想是:即使多个线程同时访问共享数据,也不需要使用加锁操作来确保线程安全。相反,使用一些原子操作(例如比较并交换 CAS)来确保数据的一致性。这样,多个线程在尝试修改共享数据时,如果发生冲突,线程会重新尝试执行操作,而不是等待锁的释放。

总的来说,Lock-Free 并不是完全没有锁,而是避免了传统的锁机制,它通过原子操作等方式保证并发程序的安全性,因此能显著提高性能,尤其在高并发场景中。

黑板:"无锁"(lock free)

在讨论Lock-Free(无锁)时,有两种不同的理解方式:

  1. 完全无锁:这种情况下,程序设计完全不依赖于任何锁机制,包括操作系统级的锁或处理器级的锁。例如,在单生产者单消费者的场景下,可以通过一个队列来实现无锁操作。具体做法是,确保在一个线程中完成所有写操作后,再在另一个线程中读取索引变量,避免任何形式的锁。这种方式确保了线程不会因为等待锁的释放而被阻塞,因此可以实现真正的无锁。

  2. 不阻塞线程 :另一种无锁 的定义是指,程序不会因为一个线程的操作而阻塞其他线程。虽然这种方式仍然使用处理器的原子操作(例如处理器内的锁),但是它避免了线程等待某个线程完成操作后才能继续执行。换句话说,虽然处理器内部可能有锁机制,但不会导致线程停滞等待。虽然这种方式通常被称为无锁,但它的实际效果是"线程不会因为其他线程的操作而停止"。

因此,无锁有不同的解释。第一种方式代表完全无锁,第二种方式则是通过处理器锁来实现线程间的同步,避免线程被阻塞。这两者有着本质的区别,而第二种方式可能被称为"便捷的无锁",因为它仍然依赖于硬件级别的锁。

InterlockedIncrement 是一个无锁操作!(这个术语有点混乱,原始术语是"非阻塞"(non-blocking),这个术语更有用)

原始术语是"非阻塞"(non-blocking),它对于理解无锁操作更有用。如果大家都使用"非阻塞"来描述那些仅仅避免线程阻塞的情况,而使用"无锁"来描述完全没有任何锁的操作,那就更清晰明了。这样一来,术语"非阻塞"会更加准确,因为这确实是在说操作不会使线程停滞等待其他线程的完成。与此相比,所谓的"无锁"通常还是依赖于处理器级的锁来避免线程的阻塞,因此仍然存在锁的机制,尽管不是操作系统级别的锁。

如果我们将这些术语区分开来,就能更加明确:**"无锁"表示完全没有任何形式的锁,而"非阻塞"**表示即使使用了锁,线程依然不会因等待而被阻塞。实际上,很多人提到"无锁"时,通常是在谈论这种非阻塞的情况。

在一个同步线程中同步接收网络包是否合理?

在处理网络数据包接收时,通常只需要一个同步线程就足够了,因为大多数网络卡的速度不足以让现代处理器(如x64架构的处理器)感到瓶颈。x64处理器的读取速度远高于网络卡的速度。因此,除非是非常高端的网络卡,或者在超级快速的光纤链路上,否则几乎总是网络数据包接收的瓶颈来自于入站链路,而不是数据包的解队列。

然而,问题并不总是出在接收数据包本身,更多时候是处理这些数据包的过程。比如,操作系统的TCP/IP栈可能存在性能瓶颈,或者处理每个数据包所需的操作可能非常复杂,导致处理速度比接收速度慢。在这种情况下,可能需要多个线程来并行处理这些数据包。然而,单纯的将数据包从网络接口卡(NIC)读取出来,对于x64处理器来说,几乎不可能成为瓶颈。只有在使用极为优秀的互联网连接时,才可能达到超过x64处理器处理速度的水平。

在开发周期的后期,是否会讨论其他操作系统(如 Mac 和 Linux)的线程处理?

在开发周期的后期,可能会涉及到在不同操作系统(如Mac和Linux)上实现多线程。通常来说,这些操作系统的多线程实现与Windows上的并没有太大区别,基本的操作是调用类似于启动线程的函数,因此并不会非常复杂。然而,这部分内容通常会在项目开始进行移植时才会涉及。

关于多线程的优点,它主要体现在提升程序的并行处理能力。通过将任务分配给不同的线程,可以有效利用多核处理器的能力,从而提升整体性能,特别是在需要处理大量独立任务或计算密集型任务时,多线程能够显著提高效率。

拥有一个工作队列,而不是每次添加工作时都创建一个新线程,这有什么优势?

任务队列的使用不是为了每次添加任务时创建一个新线程,而是为了提升性能。任务队列的目的是更有效地管理和调度任务,使得多个线程能够更高效地执行,而不是单纯地依赖于线程的创建与销毁。通过使用队列,程序能够避免频繁创建和销毁线程带来的性能开销。队列的作用是为了更好地利用系统资源,实现更高效的并行处理。

此外,要理解任务队列的两个主要目的:一方面是为了处理任务的重叠,即利用多个线程并行处理任务;另一方面,队列的核心目的是为了优化性能,使得系统在多核处理器上能够更加高效地分配和调度任务。

黑板:重叠 vs 性能

线程有两种主要用途:一种是为了"重叠工作",另一种是为了"性能优化"。

  1. 线程用于重叠工作:这种情况通常是在多个任务同时进行的场景下使用线程,比如应用程序的UI线程和其他30个任务线程。目标并不是提高性能,而是确保多个任务并行进行,避免其中某个任务阻塞整个程序的运行。例如,如果有30个任务需要同时执行,其中一个是UI任务,其他是后台任务,通过多线程可以确保UI线程始终能响应用户输入,即使某些任务可能在执行过程中存在延迟。使用多线程只是为了让多个任务可以并行处理,而非优化每个任务的执行效率。

  2. 线程用于性能优化:在这种情况下,线程的目的是为了最大化地利用CPU的处理能力。例如,当一个CPU有多个核心时,如果创建的线程数超过了核心数,操作系统就需要将这些线程调度执行,而调度本身需要消耗大量资源。如果采用操作系统的调度机制,它会涉及到上下文切换、保存和恢复寄存器状态等额外开销。而如果自己管理任务队列,通过一个简单的操作(比如互锁交换)来获取任务,这样就能避免这些额外的开销,从而提高性能。通过自定义的工作队列,可以显著减少CPU周期的浪费,减少调度和上下文切换的消耗。

综上,线程在"重叠工作"场景下,目的是让多个任务可以并行执行,主要关注任务的并行性而非性能;而在"性能优化"场景下,线程的使用旨在减少操作系统调度带来的开销,最大化利用硬件资源,提升整体性能。对于性能优化,尽量避免让操作系统调度过多工作,自己管理任务队列能够减少不必要的成本,提高执行效率。

总结与未来展望

接下来的任务是开始实现工作队列,并将其应用到实际的多线程渲染工作中。通过今天的示范,可以看到我提到的那些问题并不是理论上的,而是实际会发生的情况,比如出现错误值或者内存被覆盖等问题,这些都是我们在开发过程中需要解决的。

明天我们会集中解决这些问题,首先修复现有的错误,然后将这个工作队列抽象成一个可以在游戏中使用的组件,进而实现渲染过程中的多线程。这一过程中还有一个需要注意的问题------缓存行对齐问题,特别是在内存访问时,避免不同线程的循环操作覆盖彼此的内存。这一点在单线程时并不明显,但一旦引入多线程后,这个问题就会显现出来。我们会稍后再详细讨论这方面的内容。

我还记得,当我第一次学习多线程时,特别是在多线程的处理上,x64架构给我带来了不少惊讶。x64处理器在同步方面有一些独特的行为和其他处理器不常见的特性,比如强排序写入等,这些功能比很多其他处理器都要好。这种强大的特性实际上减轻了开发者对内存顺序和同步的要求。

但是,理论上我们仍然可能遇到一些内存排序问题。虽然x64可能自动处理某些情况,但为了确保跨平台的兼容性,我们最好还是提前做一些准备,避免依赖特定平台的特性,特别是当我们移植到其他架构时。例如,如果后续我们需要移植到Android平台,可能会遇到一些特定架构(如Neon)的限制,导致在不同平台上的行为不一致。

总的来说,明天我们将专注于实现工作队列并解决相关问题,后续会确保渲染流程正常工作,并处理与内存对齐相关的潜在问题。即使在x64平台上,内存对齐问题可能不会直接影响性能,但为了确保代码的可移植性和稳定性,我们还是要考虑这方面的因素。

相关推荐
虾球xz1 小时前
游戏引擎学习第122天
学习·游戏引擎
ElE rookie1 小时前
matlab学习之路
学习
练小杰1 小时前
【Linux】Ubuntu服务器的安装和配置管理
linux·运维·服务器·经验分享·学习·ubuntu·系统安全
玩c#的小杜同学2 小时前
本地部署deepseek大模型后使用c# winform调用(可离线)
人工智能·学习·c#·软件工程
陈无左耳、3 小时前
HarmonyOS学习第7天: 文本组件点亮界面的文字魔法棒
学习·华为·harmonyos
Thinbug3 小时前
UE(虚幻)学习(五)初学创建NPC移动和遇到的问题
学习·游戏引擎·虚幻
蓑衣客VS索尼克3 小时前
如何成为一名合格的单片机工程师----引言介绍篇(1)
单片机·嵌入式硬件·学习
试试看1683 小时前
操作系统前置汇编知识学习第九天
汇编·学习
The_cute_cat3 小时前
小熊猫C++安装EasyX最新教程
学习
sakoba3 小时前
JAVAweb之过滤器,监听器
java·学习·servlet