仓库:https://gitee.com/mrxiao_com/2d_game_3
回顾并为今天的内容做准备。
昨天,当我们离开时,工作队列已经完成了基本的功能。这个队列虽然简单,但它能够执行任务,并且我们已经为各种操作编写了测试。字符串也能够正常推送到队列中,整体运作看起来很顺利,虽然没有进行深入的测试,但基本上已经能够正常工作。
不过,唯一没有完成的部分是主线程的处理。当前主线程的工作方式是,当它执行某些任务并将工作推送到队列时,会进入一个自旋锁,等待其他线程完成。这种做法存在两个问题:
-
浪费电池:主线程会一直等待其他线程完成任务,这样就浪费了电池,因为线程并没有做任何有意义的工作,仅仅是在等待。
-
浪费性能:如果主线程只是一直检查某个变量,重复地等待其他线程完成任务,这无疑是浪费了宝贵的处理器时间。这样做并不高效,因为可以让主线程去做其他有意义的工作,而不是空转。
因此,我们建议,主线程不应该无休止地等待。相反,主线程在将任务分配给其他线程后,可以自己也做一些工作,而不是等待其他线程完成。如果主线程需要等待,那么它可以在等待的过程中处理其他任务,而不是仅仅浪费时间和性能在等待上。
这样做不仅能避免浪费电池,还能提高性能,因为主线程将不再在等待时空闲,而是可以利用这些空闲时间去执行其他任务。这种方法会更加高效、合理。
win32_game.cpp: 引入 DoWorkerWork 函数。
我们提出了一个新的方案,目的是优化线程的工作方式。具体做法是,在线程的处理过程中,我们将引入一个内联函数,暂且叫它“DoWorkerWork",这个函数的作用是返回任务池的处理结果。
这个函数会执行一些原本在主线程中进行的工作,也就是我们之前讨论过的任务处理内容。通过这种方式,线程会在检查工作状态时,直接执行任务,并返回是否成功完成了工作。具体来说,线程在执行时,会标记是否完成了某些工作,然后返回这个状态。
一旦完成了这个检查,我们可以设置一个标志,比如“DidSomeWork”,初始化为“false”,然后在工作执行完成后,更新为“true”。这样,每次通过检查时,我们都会确认是否完成了工作,并返回这个状态。
接下来,我们可以在判断任务是否完成后,如果线程没有做任何工作,就让它进入休眠状态。这样,线程就不再处于空转状态,而是等待下一个任务的到来。
为了实现这个优化,我们需要传递一些参数给函数,比如线程的逻辑索引。这样就可以确保线程能够正确地处理任务并根据需要进入休眠。最终,这个过程能有效地减少空闲时间,提高效率,让每个线程在完成工作时能够及时休息,避免不必要的资源浪费。
让我们的常规线程开始工作。
通过这种方式,现在我们可以让主线程在执行任务时,持续地进行有效工作。具体来说,我们可以使用八个线程来并行处理任务,其中七个线程用于实际的工作,而第八个线程则作为主线程。
主线程的工作机制是,虽然它负责进行任务排队(即将任务分配给其他线程),但它并不会在排队后完全闲置。主线程会在整个过程中保持活跃,执行一些实际的工作。它会通过自旋锁的方式稍微等待其他线程完成任务,但这种自旋锁只会出现在非常末尾的地方。这样,我们就可以避免让任何一个线程处于完全闲置的状态。
我们希望通过这种设计,即使是在任务排队完成后,主线程也能持续地执行有意义的工作,只有在所有任务都完成后,主线程才会停止工作并结束。这种方式确保了主线程在没有任务可做时才会结束,而在有工作的时候它会全力运作,保持高效。这样就能最大化地利用每个核心的处理能力,避免任何一个核心处于空闲状态。
运行并查看线程在做什么。
如果现在运行这个方案,我们应该能看到与之前相同的行为。主线程仍然会等待,任务不会立即结束,但我们能够看到主线程(线程7)实际上也在执行一些工作。这正是我们想要的效果。
通过这种方式,我们避免了使用信号或其他机制来让主线程休眠,而是让主线程在没有任务时持续工作。这样,主线程就不会完全闲置,始终能够做一些实际的工作,直到所有任务都完成。
目前来看,这种方式是可行的,并且能够保持高效。我们可以等到最终发布时再回顾是否有改进的空间,如果出现任何不合适或性能问题,再做调整。但现在,这种做法应该是最优的。
遵循面向压缩的编程方法。
现在我们要做的工作是将现有的代码转换为一个格式,使得渲染系统能够实际使用这个多线程的功能。需要做的是将当前的多线程处理部分整理成一种可以被平台无关的代码所利用的形式。不过,目前我们还没有完全确定要采用什么样的方式。
通常情况下,我们会在开发过程中边做边设计,而不是事先有固定的设计思路。这样做的目的是让大家能够看到我们是如何一步步解决问题的。我并没有预设好要怎么做,而是打算从现有的代码开始,尝试一下,看看能如何调整和优化。
正如我们所提到的,我倾向于采用渐进式编程的方式:先把功能做出来,运行成功后再回头思考如何优化、简化,或者如何把一些可复用的部分提取出来。接下来,我们将直接进入 render group
部分,快速浏览一下,看看如何将渲染部分多线程化。
目前,渲染部分有一个问题需要解决,这个问题与多线程有关,我们会稍后讨论。虽然它不会导致程序崩溃,但可能会导致一些不理想的结果。这个问题我们可以等到后续再处理。但现在,重点是关注如何让 TiledRenderGroupToOutput
能够输出,并实现多线程化的处理。
game_render_group.cpp: 研究如何在多个线程上执行 TiledRenderGroupToOutput。
接下来想要做的事情是,当调用渲染函数时,能够将工作分配到多个线程中。具体来说,目标是将渲染中的每个瓦片(tile)分配给不同的线程处理。
目前,渲染函数的调用是在一个 for
循环内进行的。每个瓦片会按顺序依次处理,问题在于如何让这个 for
循环能够并行化,使得多个线程能从循环中获取任务并同时工作。
第一步是想要实现的是,不用完全改变现有结构的情况下,使得这些瓦片能在多个线程中独立处理。可能的方案是通过某种方式,使得每个瓦片任务能被多个线程并行地拉取和处理。这个过程并不需要完全重构现有的代码,而是可以通过调整工作队列(work queue)的方式,利用现有的通用队列进行任务分配。
工作队列(work queue)已经是一个通用的工具,可以用来管理这些任务。因此,直接在队列中推送这些任务,分配给多个线程来处理,是一种合理且简单的解决方案。这种方式可以使得任务分配和线程管理更为简洁,并且不需要对原有的代码做过多复杂的修改。
game_platform.h: 考虑引入 work_queue_entry。
现在决定将工作队列的处理进行抽象,直接将其提取出来,做成一个可以在平台相关的头文件中使用的功能。这意味着,工作队列的条目(work_queue_entry)将被独立出来,让不同的游戏模块都能够使用。
首先,工作队列条目需要以一种平台无关的方式进行处理,因此会在代码中定位到一个合适的位置,可能是在关于调试循环计数器的一些内容之后,接着进行这部分的实现。目标是让这部分代码在平台之间能够通用,简化代码的管理和复用。
接着,考虑到工作条目的大小问题,最初的想法是使用固定大小的条目,但后来觉得可以使用可变大小的条目。经过考虑,使用可变大小的工作条目几乎不会带来额外的性能损失,因此决定继续推进这一方案。
总之,接下来的步骤是将这部分内容进一步抽象化,确保工作队列可以更加灵活地应用,并在不增加额外复杂度的情况下,支持可变大小的工作条目。这种方式能够使得代码更具通用性和可扩展性。
win32_game.cpp: 将 PushString 重写为 AddWorkQueueEntry。
现在的目标是确保能够安全地将任务推送到工作队列中。当前的情况是我们只需要一个生产者线程来将任务放入工作队列,而多个消费者线程会从队列中取出并处理任务。所以目前并不需要考虑多个生产者同时向队列添加任务的情况。
考虑到我们目前只需要单生产者-多消费者的模式,且暂时没有考虑到多个生产者的需求,我们决定将添加任务到队列的功能封装成一个名为 AddWorkQueueEntry
的函数。这个函数的作用是将一个新的任务条目添加到工作队列中,并通过信号量来同步操作。
信号量的管理会通过一个全局变量来完成,具体的工作队列会被封装在平台层(platform layer)中。这样,游戏逻辑层将不会直接接触到工作队列的实现,而是通过平台层来与工作队列交互。平台层将负责将任务条目放入队列,并确保正确的同步机制。
在 AddWorkQueueEntry
函数中,我们将不直接传入任务条目的具体内容,而是假设任务条目已经在之前的步骤中被正确填充。该函数的主要作用是确保任务被成功加入到队列中,并释放信号量来通知消费者线程。具体来说,我们会在函数内部管理信号量的增加和计数,确保多个消费者线程能够有效地从队列中获取任务。
此外,我们还会在工作队列中维护一个 EntryCount
变量来跟踪当前队列中任务条目的数量。这个计数器将被声明为 volatile
类型,确保在多线程环境中能够正确地更新和访问。
通过这种设计,我们不需要将工作队列的管理细节暴露给游戏逻辑层,而是将所有的实现封装在平台层中。这样可以确保代码的清晰性和可维护性,同时也能避免将平台相关的代码混入游戏逻辑中。最终,这种方式能够保证任务的正确同步和多线程的高效执行。
注意 _mm_sfence 的必要性。
我们昨天讨论了一些关于内存排序的问题,今天我们又仔细检查了一下,特别是关于在x64(可能是指某种系统或架构)中的正确顺序。我们发现,昨天说的内容可能已经不准确了。经过再次确认,看起来x64的内存排序规则似乎已经不像以前在x86那样强了。之前,我们以为它的写排序(write ordering)还是很强的,但现在情况似乎变了。
具体来说,我们注意到,在x64架构上,内存排序的严格程度好像被削弱了,不像之前那样可以完全依赖硬件来保证顺序。为了确认这一点,我们打算再做一些研究,并且打算向Intel那边的一些人咨询一下,获取更准确的信息。不过,从目前观察到的现象来看,在x64上要想确保内存操作的顺序,尤其是写操作的顺序,可能确实需要显式地加入一些措施。
比如,我们觉得现在有必要在代码中加入存储屏障(store barrier),以确保写操作按预期顺序完成。以前,可能不需要这么做,硬件自己就能处理好,但现在看起来情况变了,存储屏障成了必须的。幸好我们在代码里已经加了这个屏障,看来这个决定是对的,它确实是必要的。
不过,我们还是觉得需要再多了解一些细节,不能完全确定现在的判断就是最终结论。所以接下来,我们会继续研究一下,确保彻底弄清楚这件事。但就目前而言,我们倾向于认为,在x64上确实需要加入存储屏障来保证顺序。以前可能不是这样,但现在情况已经不一样了。总之,我们会继续跟进这件事,确保万无一失。
将 work_queue_entry 拉入测试代码中。
我们正在讨论一个编程相关的实现方案,主要是关于如何在代码中加入一个读取屏障(_ReadBarrier()),并且对工作队列(DoWorkerWork)的处理进行优化和测试。以下是详细的总结:
首先,我们考虑在现有的代码结构中加入一个读取屏障。这个屏障的作用是为了确保在多线程环境下,数据的读取操作能够按照预期顺序进行,避免出现数据竞争或不一致的问题。我们计划将这个屏障的具体实现放到测试代码中,而不是直接修改上层的核心代码。这样做的目的是为了方便后续的调整和实验,同时保持主要代码的简洁性。
接着,我们将注意力转向了工作队列的条目(work_queue_entry)。我们决定把这个部分拉到测试代码中进行处理,而不是让上层的代码直接操作它。这样,我们就可以更自由地对工作队列的逻辑进行测试和调整。为了实现这一点,我们打算把工作队列条目设置为一个全局变量,但这个全局变量会被放在线程块(thread blocks)之下。这样设计的好处是,上层代码无法直接访问到它,从而保证了一定的封装性,同时我们也能在测试时更方便地操作。
在测试阶段,我们的目标是模拟和管理工作队列的行为。我们注意到,工作队列中的每一个条目都需要包含一个信号量句柄(SemaphoreHandle),这个句柄在任务完成时需要被释放。此外,每个条目还会有一些队列相关的元素,比如指针(pointer)等,用于追踪和管理队列的状态。为了更好地控制队列的行为,我们还考虑加入一个最大计数(MaxEntryCount)的机制,用来限制队列的规模,确保它不会无限制增长。这个最大计数可能会通过宏定义(macro)的方式实现,方便在代码中调整和维护。
总的来说,我们的计划是先在测试代码中搭建一个独立的环境,用来实验读取屏障和工作队列的实现。通过将这些功能下放到测试层,我们既能保证核心代码的稳定性,又能灵活地调整和优化这些新特性。接下来,我们会基于这个框架进行更多的调试和验证,确保整个系统的可靠性和性能。
将 DoWorkerWork 拆分成两部分。
我们现在正在深入探讨如何实现工作队列中工作线程的具体操作逻辑,特别是关于“DoWorkerWork”(worker work)的设计和实现。以下是详细的总结:
我们计划将“DoWorkerWork”的实现分为两个部分来处理。第一部分是“BeginWorkQueueWork”,第二部分是“EndWorkQueueWork”。这两个部分共同构成了工作线程从队列中获取任务并完成任务的完整流程。虽然“BeginWorkQueueWork”这个名字听起来有些奇怪,但它确实反映了我们想要实现的功能。
在“BeginWorkQueueWork”阶段,我们的目标是从工作队列中取出一个条目(work_queue_item)。具体来说,这个操作会返回一个条目索引(entry index),用来标识当前需要处理的工作项。我们考虑让这个索引以32位整数(int32)的形式返回,因为这样可以满足大多数场景的需求。为了简化设计,我们可能会去掉一些不必要的复杂逻辑,专注于核心功能。
而在“EndWorkQueueWork”阶段,我们需要完成任务的后半部分处理。这部分的主要操作是执行一个线程安全的递增(InterlockedIncrement),用来标记某个工作项已经完成。这个操作同样基于队列进行,确保多线程环境下的数据一致性。我们注意到,这两个阶段的操作实际上与之前代码中某些部分的逻辑高度相似,因此我们可以复用一部分现有实现,只需稍作调整即可。
为了让这两个部分更好地协作,我们设计了两者都需要接受队列(queue)作为参数。“BeginWorkQueueWork”会返回当前需要处理的工作项信息,而“EndWorkQueueWork”则负责更新队列状态,确认任务完成。具体来说,在“BeginWorkQueueWork”中,我们会从队列中取出一个条目,并将其索引返回给调用者。而在“EndWorkQueueWork”中,我们会通过递增操作更新队列的完成计数。
为了让这个机制更灵活,我们考虑定义一个返回值结构,比如叫做“work_queue_item”(work queue item)或者“工作队列结果”(work queue work result)。这个结构包含两个字段:一个布尔值(bool),表示是否存在可处理的工作项(exists);另一个是工作项的索引(index),用来标识具体的工作条目。如果队列中没有工作项,我们会返回一个结果,其中“exists”设为false,索引可以是无效值;如果有工作项,则“exists”设为true,并将对应的条目索引填入。
这个设计的实现过程非常直观。在“BeginWorkQueueWork”中,我们检查队列状态,如果有可用的工作项,就将其索引提取出来,设置“exists”为true,然后返回结果;如果队列为空,则直接返回“exists”为false的结果。在“EndWorkQueueWork”中,我们只需要对队列的完成状态进行更新,确保任务被正确标记为已完成。
将 while(EntryCount != EntryCompletionCount) 放入 QueueWorkStillInProgress 函数中。
我们计划从当前代码中提取一部分功能,把注意力集中在与工作队列相关的调用上。具体的想法是处理“EntryCount”和“QueueWorkStillInProgress”,并基于此新增一个功能调用。这个新调用的名字初步定为“所有工作是否完成”(is all work completed),它的作用是检查工作队列的状态,判断所有任务是否都已经处理完毕。
在实现“所有工作是否完成”这个功能时,我们的思路很简单:通过比较队列中的两个关键值来判断状态。这两个值分别是队列中任务的总数和已完成任务的数量。如果两者相等,就说明队列中的所有工作都已完成。我们注意到,之前的设计中可能已经用到了类似的逻辑,但方向可能是反的——之前可能是判断“是否存在未完成的工作”,而现在我们想要明确检查“是否全部完成”。所以,我们决定调整逻辑的方向,把它定义为“QueueWorkStillInProgress”。
具体来说,这个新调用的逻辑是这样的:如果队列中的任务总数和完成计数不相等,就说明还有未完成的工作,也就是“队列工作仍在进行”返回true;如果两者相等,则返回false,表示所有工作都已完成。我们觉得这样的设计更符合直觉,也更适合后续的使用场景。比如,我们可以用这个调用来判断是否需要继续处理队列中的任务。如果“队列工作仍在进行”返回true,我们就继续工作;如果返回false,就说明可以停下来了。
为了实现这个功能,我们会在现有的队列操作基础上新增这个检查逻辑。实现起来并不复杂,只需要从队列中获取当前的条目计数和完成计数,然后进行比较即可。我们还考虑到了代码的可读性和简洁性,尽量避免引入过于复杂的条件判断,直接用一个简单的比较就能解决问题。
重命名并完成这些函数的编写。
我们现在正在完善工作队列的实现细节,主要是围绕如何更逻辑化和清晰地处理队列中的工作项,以及如何优化相关的函数调用和数据结构设计。以下是详细的总结:
我们首先考虑如何更好地组织工作队列的操作流程。最初的设计是用“BeginWorkQueueWork”来获取一个工作项(work_queue_item),并用“EndWorkQueueWork”来标记完成。但我们觉得这个命名和逻辑可以更直观一些。于是,我们决定调整为“GetNextWorkQueueItem”(获取下一个工作队列项)这样的方式,这样更符合操作的实际含义。我们会从队列中取出一个工作项,并检查它是否有效(IsValid)。如果有效,就执行相应的任务;完成后,再调用一个函数来标记该工作项为已完成,比如“MarkQueueEntryCompleted”。
在具体实现上,“GetNextWorkQueueItem”会返回一个工作项,我们用一个布尔值(IsValid)来表示这个项是否存在。如果存在,我们就处理它,然后通过“MarkQueueEntryCompleted”更新队列状态。这个完成标记函数需要接收队列和具体的项作为参数。虽然当前我们可能并不直接需要工作项本身的数据,但为了保持设计的灵活性和可读性,我们还是决定让它接受这个参数,方便未来可能的扩展。完成标记的核心操作是对队列的计数进行线程安全的递增(InterlockedIncrement),以反映任务的完成情况。
接下来,我们开始优化整个工作流程的代码结构。我们希望让“DoWorkerWork”(执行工作者任务)函数能够基于队列直接操作,而不需要过多的额外参数。之前的设计中,这个函数可能需要同时处理队列和工作项索引,但现在我们觉得可以简化逻辑,让它直接依赖队列本身。工作项的索引(entry index)可以从队列中动态获取,不需要显式传递。这样,代码的抽象层次更高,也更符合平台无关的实现目标。
在这个过程中,我们还调整了工作队列条目(work_queue_entry)的命名和使用方式。一开始,我们在“item”和“entry”这两个词之间有些混淆,但最终决定统一使用“entry”来表示队列中的工作条目。这样在代码中,比如“AddWorkQueueEntry”(添加工作队列条目)这样的调用会更一致。我们还特别设计了一个场景:假设需要打印字符串,我们会通过“AddWorkQueueEntry”将字符串任务加入队列,然后由“DoWorkerWork”根据返回的条目索引(Entry.Index)来处理具体的打印操作。
为了支持这个流程,我们新增了一些辅助函数。比如“GetNextAvailableWorkQueueIndex”(获取下一个可用工作队列索引),这个函数会从队列中返回当前可用的条目索引。因为我们假设这是一个单生产者(single producer)的场景,所以这个操作相对简单,不需要复杂的同步机制。我们会先获取索引,然后将对应的条目信息写入队列,确保写入完成后才更新队列状态。这样可以保证数据一致性。
在实现队列本身时,我们注意到它的初始化非常简单:只需要将计数清零,并设置好信号量句柄(SemaphoreHandle)。更进一步,我们发现信号量句柄其实可以直接从队列对象中获取,不需要单独传递。这样,代码又精简了一层,函数调用只需传入队列本身即可。比如在“AddWorkQueueEntry”中,我们会基于队列直接操作,添加条目并更新索引。
最后,我们回顾了整个流程的逻辑:在“QueueWorkStillInProgress”(队列工作仍在进行)的条件下,工作线程会持续从队列中获取任务并处理。我们确保“DoWorkerWork”能够正确接收队列参数,并基于此执行任务。整个设计的重点在于清晰的抽象和简洁的实现:通过“GetNextWorkQueueEntry”获取任务,“MarkQueueEntryCompleted”标记完成,再加上必要的队列管理函数,构成了一个完整的工作队列处理框架。
总的来说,我们现在的方案已经逐步成型。通过调整命名、优化函数签名和简化参数传递,我们让代码更具可读性和可维护性。接下来,我们会继续验证这些功能的实现,确保它们在多线程环境下能够稳定运行。一步步推进,我们的目标是打造一个高效且易用的工作队列系统。
编译并运行,查看线程的执行情况。
讨论我们的选项。
我们现在已经完成了一个抽象化的工作队列版本,这个版本可以直接集成到平台的代码中,特别是在游戏开发相关的场景中。以下是详细的总结:
我们设计的这个抽象队列已经可以很好地支持任务的添加和移除操作。具体来说,当任务通过某个图形处理流程(graph)时,我们可以利用这个队列来实现任务的入队(enqueue)和出队(dequeue)。这样,游戏代码就可以通过调用平台的队列功能来管理任务。我们注意到,通常情况下,我们并不倾向于频繁地在游戏和平台代码之间来回调用函数(round trip functions),因为这可能会增加复杂性和开销。所以在添加这类回调机制时,我们会尽量保持谨慎,只在必要时引入。
考虑到性能问题,我们面临两个主要的选择:一是将队列操作内联(inline)到游戏代码中,以减少函数调用的开销;二是直接通过函数指针传递这些操作,让游戏代码动态调用。我们仔细权衡了两者的优劣。如果追求极致的运行速度,内联显然是更好的选择,因为它能消除函数调用的额外代价。但我们也观察到,这些队列操作本身的代码量非常小,每次调用的逻辑也很简单。而且,在实际应用中,任务的处理频率并不高,可能每秒只有50到60次操作,远没有达到需要大量调用的程度。因此,通过函数指针调用的开销其实是可以接受的。
在这种情况下,我们开始思考是否需要保留所有的函数接口。最初的设计包括“GetNextAvailableWorkQueueIndex”(获取下一个可用工作队列索引)、“AddWorkQueueEntry”(添加工作队列条目)、“GetNextWorkQueueEntry”(获取下一个工作队列条目)和“MarkQueueEntryCompleted”(标记队列条目完成)等调用。但我们意识到,“GetNextAvailableWorkQueueIndex”其实不是必须的,因为索引完全可以在队列内部管理,或者由调用方自己维护。这样,我们可以将接口精简到三个:“AddWorkQueueEntry”、“GetNextWorkQueueEntry”和“MarkQueueEntryCompleted”。
更进一步,我们发现还可以优化这些接口。观察到“MarkQueueEntryCompleted”和“GetNextWorkQueueEntry”这两个操作通常是成对出现的——完成一个任务后,通常会接着获取下一个任务。我们突发奇想,能否将这两个功能合并成一个单一的调用,比如叫做“complete and get next”(完成并获取下一个)。这个新函数的逻辑是:标记当前任务为已完成,同时返回队列中的下一个任务条目。这样不仅减少了函数调用的次数,还让代码的逻辑更加紧凑和流畅。
我们还考虑了队列的数据结构设计。一种替代方案是使用虚空指针(void pointers)来存储队列中的条目,这样可以让队列更加通用,适应不同的任务类型。不过,目前的简化方向已经让我们很满意。通过将接口精简到三个,甚至进一步合并为一个“complete and get next”的调用,我们显著降低了系统的复杂性,同时保持了足够的灵活性。
总的来说,我们对现在的设计方向感到满意。这个抽象队列既能满足游戏代码的需求,又能在性能和简洁性之间找到平衡。接下来,我们可能会继续调整细节,比如确定是否真的需要内联,或者进一步验证合并调用在实际运行中的效果。但目前来看,这个方案已经非常优雅且实用,我们很喜欢这个优化后的结果。
将 GetNextWorkQueueEntry 重命名为 CompleteAndGetNextWorkQueueEntry,并使其接受 work_queue_entry Completed 参数。
我们现在考虑如何把工作队列的操作简化,减少调用的次数,让整个系统更高效。以下是详细的总结:
我们觉得可以用一个更简单的方案来优化当前的队列操作。我们最初的想法是,当调用 GetNextWorkQueueEntry
(获取下一个工作队列条目)时,可以直接改成一个新的函数,叫做 CompleteAndGetNextWorkQueueEntry
(完成并获取下一个工作队列条目)。这个新函数的作用是把“标记完成”和“获取下一个条目”两个步骤合在一起,这样就不用分开调用两次了。
具体来说,这个新函数的工作方式是这样的:我们会传入一个工作队列条目(work_queue_entry
),表示当前已经处理完的任务。函数会先检查这个传入的条目是否有效(比如用 completed
判断)。如果有效,就对队列的完成计数(completion count
)做一个线程安全的递增操作(interlock increment
),把这个条目标记为已完成。然后,函数会继续从队列中取出下一个待处理的条目,并把它返回给我们。
这样设计的实际效果是,传入一个完成的条目,函数就处理完它,然后直接给我们下一个要做的任务,整个过程一步到位。我们觉得这个操作很像“一个进,一个出”的模式——完成一个任务,马上拿到下一个任务,减少了中间的步骤和调用次数。
我们认为这个方案很不错,因为它简化了流程,看起来也挺优雅。如果我们把代码改成这样,逻辑会更清晰。比如,假设传入的条目是有效的,函数会先递增完成计数,更新队列状态,然后再返回新的条目。如果条目无效,可能就直接返回空或者某种默认值,具体细节可以再调整。
为了实现这个想法,我们打算重写一部分代码,把现在的队列操作改成主要依赖 CompleteAndGetNextWorkQueueEntry
。这样,整个系统可能只需要两个核心函数:一个是 AddWorkQueueEntry
(添加工作队列条目),用来往队列里放任务;另一个就是这个新的 CompleteAndGetNextWorkQueueEntry
,用来完成任务并获取下一个。我们觉得这种“一个进,一个出”的方式很像军队里的流程,简单直接又高效。
总的来说,我们对这个优化方向很满意。它不仅能减少调用次数,还让代码逻辑更流畅。接下来,我们会动手改代码,试试这个新函数在实际运行中怎么样,看看能不能达到预期的效率和稳定性。
略微调整 ThreadProc 函数。
我们现在决定稍微调整一下代码的结构,特别是针对 DoWorkerWork
(执行工作者任务)这个函数的逻辑。我们希望让它更符合之前提到的 CompleteAndGetNextWorkQueueEntry
(完成并获取下一个工作队列条目)的设计思路。以下是详细的总结:
我们计划把 DoWorkerWork
的逻辑重新整理一下,主要是想把获取下一个队列条目的部分独立出来。我们觉得,与其让这个函数自己处理所有的队列操作,不如直接利用 CompleteAndGetNextWorkQueueEntry
来完成任务的切换和获取,这样代码会更简洁,也更符合我们之前的优化目标。
调整后的逻辑是这样的:我们先定义一个工作队列条目(work_queue_entry
),把它叫做 entry
,并且初始化为零,表示还没有任何任务。然后,我们调用 CompleteAndGetNextWorkQueueEntry
,把这个 entry
和队列(queue
)一起传进去。函数会处理传入的 entry
(如果它代表一个已完成的任务,就标记完成),然后返回一个新的 entry
,替换掉原来的内容。我们觉得这个过程非常直白:传入旧的条目,拿到新的条目,一步到位。
这样做的好处是我们不用再分开调用两次函数。以前可能需要先标记完成,再获取下一个条目,现在只需要调用一次 CompleteAndGetNextWorkQueueEntry
,就同时完成了这两个步骤。我们觉得这让我们摆脱了重复调用的麻烦,代码更高效了。
接下来,我们会根据返回的 entry
来决定下一步。如果这个新的 entry
是有效的(比如通过某种 Entry.IsValid
判断),我们就继续处理它;如果无效,就说明队列里没有任务了,我们就停下来。具体来说,如果 entry
有效,我们会调用 DoWorkerWork
来执行任务。不过,我们注意到,调整后的 DoWorkerWork
不再需要直接接收队列(queue
)作为参数,因为队列操作已经交给 CompleteAndGetNextWorkQueueEntry
处理了。我们只需要把新的 entry
传给 DoWorkerWork
,可能还要加上逻辑线程索引(ThreadInfo->LogicThreadIndex
),用来标识当前线程的上下文。
我们觉得这样的安排更合理。DoWorkerWork
的职责被简化成了纯粹的任务处理,不用再关心队列的管理。而 CompleteAndGetNextWorkQueueEntry
负责所有的队列交互,包括标记完成和获取新任务。我们还考虑把逻辑线程索引作为一个参数保留下来,因为它可能对线程特定的操作有帮助,虽然具体用处可以再细化。
调整 DoWorkerWork 函数。
我们现在决定进一步调整 DoWorkerWork
(执行工作者任务)这个函数的实现方式,让它更简洁、更专注。以下是详细的总结:
我们觉得,与其让 DoWorkerWork
接收整个队列(queue
)作为参数,不如直接只传入具体的任务信息。这样可以让函数的职责更明确,不用去处理队列相关的操作。我们计划修改它的参数,只传递两个东西:一个是工作队列条目(work_queue_entry
),也就是当前要处理的任务;另一个是逻辑线程索引(LogicThreadIndex
),用来标识当前线程的上下文。
调整后的逻辑是这样的:我们假设调用 DoWorkerWork
之前,已经通过 CompleteAndGetNextWorkQueueEntry
获取了一个有效的条目(entry
)。为了确保这一点,我们会在函数开始时加一个断言(assert
),检查传入的 entry
是否有效。如果断言通过,就说明我们拿到了一个合法的任务条目,可以直接使用它来执行具体的任务处理。
我们还决定让 DoWorkerWork
不返回任何值。之前可能考虑过返回某种状态或者结果,但现在我们觉得没必要。因为这个函数的主要目标是处理任务,任务完成与否已经通过队列的计数更新(比如 EntryCompletionCount
)反映出来了,函数本身只需要专注执行就行。这样,它就变成了一个纯粹的“执行者”,不需要额外的返回值来增加复杂性。
具体来说,新的 DoWorkerWork
会是这样:接收一个 work_queue_entry
和一个 LogicThreadIndex
,先断言条目有效,然后直接根据这个条目里的信息执行任务。因为队列的管理已经被 CompleteAndGetNextWorkQueueEntry
处理好了,我们不需要再传入整个队列对象,代码变得更简洁,职责也更清晰。
我们觉得这个调整很合理。通过去掉队列参数,函数的接口更轻量,我们也能更专注于任务本身的处理逻辑。逻辑线程索引保留下来,是因为它可能在某些线程特定的操作中还有用,虽然具体用途可以后续再细化。总的来说,这个改动让我们对整个工作流程的控制更明确,接下来我们会把这个新版本实现出来,看看实际运行效果如何。
微调 QueueWorkStillInProgress 循环。
我们计划在代码的下半部分实现一个循环,用来持续处理队列中的任务。具体来说,这个循环会先定义一个工作队列条目(entry
),用来存储当前的任务信息。然后,我们会调用之前提到的 CompleteAndGetNextWorkQueueEntry
(完成并获取下一个工作队列条目),把当前的 entry
传进去,获取一个新的 entry
,也就是下一个待处理的任务。
循环的逻辑是这样安排的:每次调用 CompleteAndGetNextWorkQueueEntry
后,我们会检查返回的新 entry
是否有效(比如通过某种 Entry.IsValid
判断)。如果这个新条目是有效的,我们就进入一个处理分支,直接用这个条目来执行任务。这个处理分支的逻辑和之前的循环非常相似,几乎是一模一样的,只不过现在的实现更简洁。关键区别在于,如果新条目无效,循环不会继续等待,而是直接退出,结束当前的任务处理流程。只有当有有效任务时,它才会继续运行,执行需要做的事情。
我们觉得这样的设计很自然。循环的核心就是不断地获取新任务,只要有任务就处理,没有任务就停下来。这样,代码既能保持高效,又能适时退出,避免无意义的空转。我们还注意到,这个循环会依赖之前调整好的 DoWorkerWork
函数,因为它现在只接受 entry
和逻辑线程索引(logical thread index
)作为参数,专注于任务执行,不用再管队列本身的管理。
好像有问题主线程没执行
如果线程的任务执行完成递增EntryCompletionCount 就行
如果Queue->EntryCount != Queue->EntryCompletionCount认为任务还在处理中
还是有段错误不知道什么原因导致的
如果无效的话不让执行DoWorkerWork
编译并考虑删除一个额外的调用。
我们现在讨论的是一个队列系统的简化设计,只需要暴露三个核心功能就能让这个队列正常工作。目前我们确定的功能包括:AddWorkQueueEntry、CompleteAndGetNextWorkQueueEntry,以及CompleteAndGetNextWorkQueueEntry。不过,我们进一步思考后觉得,其实可以再精简,甚至可以把其中一个功能去掉,最终只保留两个功能调用——AddWorkQueueEntry和GetNextAvailableWorkQueueIndex。
我们还考虑了一些更奇怪但可能有趣的优化方案。比如,可以让某个功能接受一个指针作为参数,这样就不需要额外调用另一个功能来处理某些情况。我们之所以在这里犹豫不决,是因为我们希望尽量减少不必要的函数调用。当前设计中有一个功能,它的主要作用是防止外部计数和内部实际计数出现不同步的情况。但我们觉得这个功能其实不是必须的,去掉它可以让整个系统更简洁。
我们反复权衡的原因是希望确保系统的设计既高效又可靠。虽然可以用额外的函数来避免计数不同步的风险,但如果能通过其他方式规避这个问题,这个函数调用就显得多余了。我们倾向于让设计尽量精简,同时保证队列的核心功能不受影响。现在的想法是,通过调整设计,可能只需要两个主要的函数调用就能完成所有必要的操作,这样不仅减少了复杂性,也降低了出错的可能性。
继续进行,并将 work_queue 拆分成两个独立的部分。
准备对这个工作队列的实现进行调整和优化。我们一开始的想法是保留一些扩展功能,因为我们感觉未来可能会需要更多特性。所以,我们考虑把工作队列分成两个部分:一个基本的队列结构,另一个是指向外部缓冲区的指针,这些缓冲区可以用来描述具体的工作任务。这样设计的优点是灵活性更高,队列可以支持各种类型的任务。
我们进一步讨论后觉得,当添加工作队列条目时,可以直接传递一个指针,这个指针本身就包含了任务的具体信息。这样既能保持设计的简洁,又能告诉系统这个条目到底是什么。我们决定暂时让这个概念保持简单,但同时保留外部操作的可能性,也就是说,队列本身不负责所有细节,而是通过指针指向外部数据。
接着,我们重新审视了之前打算移除的一个功能,决定把它恢复回来。这个功能涉及一个用户指针(UserPointer),用来和队列条目关联。我们觉得每个队列应该有一定数量的条目,比如256个,作为队列的大小。我们一开始考虑用这个数字来控制队列的容量,但后来意识到,可以通过指针直接管理数据,这样就不需要额外的“下一个条目”(next entry)计数了。添加新条目时,只需要把指针写入队列,然后递增索引即可。
为了确保队列不会溢出,我们加入了一个断言(assertion),检查是否有足够的空间来存储新指针。我们暂时不打算让队列变成循环缓冲区(rolling buffer),而是先保持简单设计。这个work_queue_entry_store其实就是一块用来存放指针的区域。我们最初还想为每个条目定义具体的数据结构,比如传入一个void*类型的指针来表示条目数据,但后来发现其实没必要这么复杂。
我们继续简化设计,意识到既然指针本身就能表示数据,就不需要单独的条目数据字段了。比如,如果任务数据是一个字符串,我们直接用指针指向这个字符串即可,不需要额外的封装。这样一来,添加条目时只需要传入字符串的指针,队列就知道如何处理。完成任务时,我们通过索引从队列中取出对应的指针,获取数据,然后递增索引,逻辑非常直截了当。
最终,我们把整个设计精简到只需要基本的操作:推送时直接传入字符串指针,完成时根据索引取回数据并更新队列状态。这样,系统中几乎没有多余的结构或函数,所有的操作都围绕指针直接展开。我们觉得这个方案已经足够简单高效,满足当前需求,同时也为未来可能的扩展留下了空间。总结来说,我们的目标是通过最少的代码和结构,实现一个可靠且易用的工作队列。
再次运行。
现在有了这个基础,我们感觉几乎可以随心所欲地扩展功能,一切应该都会运行得很顺利。不过,我们也注意到一个问题:目前的设计还没有提供重置队列的方法。如果队列满了,我们可能会触发之前设置的断言(assertion),但我们并不太担心,因为我们的计划是把这个队列改造成一个循环缓冲区(rolling buffer)。到时候如果遇到问题,我们再处理也不迟,毕竟循环缓冲区是我们未来的目标。
game_platform.h: 将这些函数提升到头文件中。
我们梳理了一下,目前有三个核心函数需要实现和调用:AddWorkQueueEntry、MarkQueueEntryCompleted,以及CompleteAndGetNextWorkQueueEntry。除此之外,还有一个QueueWorkStillInProgress。我们觉得这基本上已经涵盖了所需的一切,至少从目前来看是这样。
我们反复检查了一遍,觉得这些功能应该足够支撑系统的运转。实现的方式有几种选择,我们还没完全确定到底用哪一种最好。我们考虑了一些替代方案,比如是否可以再做些调整或优化,但最终觉得现在的设计已经够用了,没必要再增加复杂性。我们讨论了一下其他可能的做法,但很快就否决了,因为觉得多余的改动可能会适得其反。
总的来说,我们对现在的方案挺满意的。这三个函数加上进行中的状态,逻辑清晰,覆盖了基本需求。我们打算就这样开始推进,先把这些功能部署出去,看看实际运行效果。如果有需要,再根据情况调整。现在的重点是尽快上线,确保系统稳定运行,其他的细节可以边用边完善。
多思考一下。
好的,我们决定再多思考一下这个设计,看看还能不能进一步优化。我们一边讨论一边觉得,如果继续简化这个系统,也许可以把它变成一个纯粹的分发队列(dispatch queue)。我们的想法是这样的:工作队列的条目只需要包含两个信息——要调用的对象(比如“谁来处理”),以及交给这个对象的一组数据(就像一个数据包)。如果按照这个思路走,整个系统可能会变得更简单。
我们分析了一下,如果真的这么设计,可能只需要保留一个核心函数:添加工作队列条目(AddWorkQueueEntry)。然后再加一个函数,这个函数实际上是把当前的一些操作合并起来的。我们想象这个合并的函数有点像是一个循环操作的组合——它既能完成任务,又能获取下一个任务的功能合二为一。我们在脑海中过了一遍这个流程:添加条目后,这个组合函数会直接处理分发、执行和取下一个条目的逻辑。
我们之所以会想到这个方向,是因为觉得现在的三个函数(添加、完成、获取下一个)虽然已经很精简,但还是有些重复的步骤。如果能把后两者合并成一个操作,系统的调用会更少,逻辑也更紧凑。我们一边讨论一边尝试理清思路,觉得这个“分发队列”的概念挺有潜力。每个条目只负责指明“谁”和“做什么”,然后由一个统一的函数来驱动整个流程,这样既直观又高效。
不过,我们还在摸索具体的实现方式。比如,这个组合函数到底怎么写,才能既简洁又不失灵活性?我们暂时还没完全想清楚,但觉得这个方向值得一试。接下来,我们可能会先试着把这个想法落实下来,看看实际效果如何。如果可行的话,整个队列系统就只需要两个函数调用,结构会更加扁平,我们对此还是挺期待的。
game_render_group.cpp: 先编写使用代码。
我们现在决定先快速切换思路,把另一部分的代码写出来。我们觉得在深入优化之前,先写出使用代码(usage code)会更有帮助,因为这样可以直观地看到整个系统的调用方式是什么样的。我们想通过这种方式验证设计的合理性,所以准备模拟一个场景来测试。
我们假设要处理的任务是多线程的,比如一个瓦片渲染(tile render)的任务。我们提前知道瓦片的数量,这让我们可以设计一个简单的结构来表示工作。我们先考虑一个叫“tile_render_work”的结构,里面可以包含一些基本信息,比如瓦片的X和Y坐标。不过我们马上想到,其实只需要一个索引(index)就能推算出具体瓦片的位置,所以没必要存太多数据。但为了展示更复杂的情况,我们故意让这个结构稍微复杂一点,假设任务需要传入一堆参数,而不仅仅是一个简单的索引。
我们的想法是把这个系统当作一个完整的分发队列(dispatch queue)来处理。我们设计了一个函数,比如“DoTiledRenderWork”,它接受一个数据指针(data),然后通过类型转换(cast)识别出这是一个“TiledRenderWork”类型的数据,进而执行具体的渲染逻辑。我们觉得这个函数可以直接包含具体的操作代码,而不是仅仅作为一个分发入口。为了彻底验证这个思路,我们决定直接把渲染逻辑写进去,比如计算瓦片位置并执行相关操作。
目前我们还不打算实现真正的多线程(hyper-threading),而是先用单线程把所有瓦片处理一遍,模拟两组不同的任务。我们假设有一堆待处理的瓦片(stock of work),可以用一个数组来存储这些任务数据。瓦片数量是已知的,比如X方向和Y方向各有多少个,我们可以通过简单的乘法得出总数(TileCountX × TileCountY)。这样,我们就可以提前分配好任务队列的内容。
在具体实现时,我们准备先定义这个任务堆栈(stack),然后通过循环把每个瓦片任务加入队列。我们设想每个任务都包含足够的信息,比如瓦片的具体位置,然后分发函数会根据这些信息执行操作。我们一边讨论一边调整,确保逻辑清晰。比如,我们可以用一个简单的索引来遍历所有瓦片,然后通过计算得出每个瓦片的坐标。虽然现在是单线程,但我们觉得这个设计将来稍微改动就能支持多线程。
编译并对 const 表示不满。
有时候,面对编程中的一些规定和规则,感觉非常烦人。比如说,明明编译器在某种情况下能接受某个表达式,但在另一些情境下却完全不能理解。这种不一致性让人很困惑,尤其是在一些简单的语法上。例如,原本一个词可以直接省略,但在某些上下文中却必须写得非常明确,像是“const”和“cost”之类的。显然,“const”表示常量,这本身是显而易见的,但为了避免歧义和确保正确性,却必须明确标明。这种看似多余的要求令人觉得非常无奈和烦躁。
而且,尤其是在一些特殊情况下,某些写法会被接受,另一些则不行。尽管这些规则本身并没有错,但从用户的角度看,这些要求显得有些繁琐且不够直观。毕竟,编程的目的是解决问题,而不是让这些规则变得更加复杂,甚至让人感觉它们本身才是问题的根源。这种繁复的语法和格式要求让人感觉像是一直在被限制,不能自由地发挥。
因此,尽管能够通过这些规则来实现一些效果,但依然觉得这种强制要求非常令人沮丧,并且希望能有更为直观和简化的方式来处理这些编程问题。
完成编写 TiledRenderGroupToOutput 函数。
在实现这个过程时,首先,我们需要设定一个初始的工作索引(WorkCount
)为零。然后,对于每一项任务,我们可以将其添加到工作数组中,类似于 WorkArray
,并且每次迭代时都更新该索引,直到所有工作都被列出并加入队列。
在执行过程中,我们需要将任务添加到工作队列中,这个队列的类型是渲染队列(RenderQueue
)。每次添加任务时,需要指定具体的渲染工作和相关的参数。通过调用函数 AddWorkQueueEntity
,我们将这些工作条目传递到队列中,其中会涉及到具体的渲染任务函数(如 DoTiledRenderWork
),以及相应的指针信息。这些信息会帮助我们指明在任务队列中需要处理的具体内容。
接着,我们可以通过一种方式来管理和处理队列中的工作,利用 PlatformComplateWorkQueue
来处理和完成所有的任务。通过调用如 PlatformComplateWorkQueue
这样的函数,我们可以确保在渲染队列中的所有工作都得以顺利执行。具体来说,可以通过设置适当的函数指针来管理队列中的任务,从而实现队列的管理和工作项的执行。
一旦所有工作都添加到队列中,并且完成了相应的处理,最终的渲染任务会依次完成。在这个过程中,系统会继续迭代工作队列,直到所有任务都处理完毕。对于每一轮的工作,我们都要确保相关的渲染任务已经准备好并传递到队列中,确保渲染工作顺利进行。
在编译时,如果队列还没有完全实现或者某些部分还未完成,系统可能会处于一个编译错误状态,因此在开发过程中需要不断调试,确保每个部分正确地链接并处理。最终,通过调整工作索引和确保工作队列的执行,我们可以逐步完成目标任务,并最终在渲染队列上实现预期的效果。
最后,在实际运行时,可能会遇到一些没有立即可见结果的情况,特别是在调试阶段。此时,需要继续优化和检查队列中的每一项任务,以确保整个渲染过程能够按预期顺利进行。
编译并运行,程序崩溃。
回顾并展望多线程的未来。
现在,渲染工作终于开始顺利进行。所有需要的部分都已经准备好了,我们也搞清楚了相互之间的连接。接下来,我们只需要将这些部分连接起来,明天就能开始将它们整合到一起。这一切听起来非常顺利和令人兴奋。
一旦将这些部分连接起来,我们将能够实现一个通用的工作队列。通过这个工作队列,我们可以传递多个任务队列,并将任务分配到不同的队列中。这样一来,我们可以持续不断地将任务提交到队列,系统就可以在后台处理这些任务了。
例如,可以为后台创建一个任务队列,专门处理一些较重的计算任务,比如瓦片的渲染(tile rendering)。通过这种方式,系统能够高效地处理这些任务,而不会因为大量任务的堆积而影响其他操作。整个流程看起来非常强大,能够不断地从队列中提取任务并分配给合适的线程或模块处理。
这种架构不仅提高了任务处理的效率,也为系统提供了更好的灵活性。通过任务队列,可以将不同的工作分配到合适的时间和位置,让渲染过程更加流畅和高效。这种设计方式非常适合需要大量并行计算的任务。
你会为每个队列创建新线程吗?
我们不会为每一个工作单独创建新线程,而是打算以一种更加灵活的方式来处理线程的分配和任务调度。虽然系统需要处理多个任务,但我希望能设计出一种机制,让线程能够在不渲染的时候执行其他任务。这样,线程在渲染任务之外的时间就可以去做一些其他工作,提高效率。
具体来说,我们的目标是通过设置多个任务队列来管理工作流。在渲染时,我们希望将所有资源集中在一个队列上,确保渲染任务能够及时完成。所以,在渲染的关键时刻,系统只会从一个队列中提取任务,确保所有的计算和渲染都能在规定时间内完成。
而当没有渲染任务时,系统就有更多的灵活性。线程可以去处理其他不需要立即完成的任务。这就需要一个背景队列,用于存储那些不急需立即完成的工作。这样,当系统处于非渲染阶段时,可以把线程的计算力用于这些“后台工作”。
总的来说,系统将有两个主要的队列:一个是渲染队列,专门用来处理需要在特定时间内完成的渲染任务,另一个是后台工作队列,用于处理不急于完成的任务。通过这种方式,系统能够在保证实时渲染任务完成的同时,充分利用空闲时间来处理其他任务。
仍然不理解 volatile 和内存屏障的用法。
今天正好有时间,可以详细解答一些关于多线程编程的问题,尤其是 volatile
和内存屏障(memory barrier)这两个概念。其实这两个概念在多线程编程中非常重要,它们主要涉及到线程之间的内存可见性和顺序性问题。
首先,volatile
关键字的作用是告诉编译器,不要对标记为 volatile
的变量进行优化。它确保每次读取这个变量时,都是从内存中获取,而不是使用缓存或寄存器中的值。这样,多个线程访问该变量时,每个线程都能看到最新的值。但是,volatile
并不能解决线程之间的同步问题,它仅仅确保了变量的可见性,但不能控制操作的顺序,也不能避免竞态条件。
接下来是内存屏障(memory barrier),它是一种指令,用于控制特定操作的顺序,避免指令重排序带来的问题。在多线程程序中,处理器和编译器可能会对指令进行优化和重排序,这可能会导致某些变量在多线程环境下出现不一致的情况。内存屏障可以强制执行特定操作的顺序,确保在执行某个操作之前,其他相关的操作已经完成。
简单来说,volatile
只是保证了变量的可见性,而内存屏障则更关注操作的顺序性,它们在一起使用时,可以确保多线程环境下的内存访问是安全的。不过,volatile
和内存屏障并不能替代锁机制或其他同步工具,它们只是确保数据一致性的一个辅助工具。
这两个概念在多线程编程中起着至关重要的作用,理解它们的工作原理,可以帮助更好地管理线程间的交互和数据一致性。
黑板:内存和代码屏障。
问题的关键在于如何确保多线程环境下两个操作的执行顺序正确,避免数据不一致或程序崩溃。我们有两项关键操作:
- 设置数据:将数据放入队列中(
entry.data = data
)。 - 增加计数:增加队列中的计数器(
entry_count++
)。
在这里,有一个隐含的要求:当entry_count
增加时,其他线程可能会检查这个计数器,如果它被增加了,那么它们就会开始处理新的任务。然而,我们必须确保数据的写入操作(entry.data = data
)在计数器更新操作(entry_count++
)之前完成,否则其他线程可能会看到无效的数据,从而导致错误的行为。
这个问题的第一层复杂性来自编译器。在C++代码中,这两条指令是独立的,编译器并不知道我们希望它们按照特定顺序执行。编译器可能会为了优化,将它们重新排序,可能先执行entry_count++
,再执行entry.data = data
。这样一来,虽然代码看似没有问题,但在执行时,数据的顺序会被打乱,导致错误。
第一解决方案:编译器屏障(Compiler Fence)
为了解决编译器重排序的问题,可以使用编译器屏障(编译器屏障不会生成任何代码,它只是一种标记,告诉编译器不要重新排序这两条指令)。可以通过 volatile
或特定的屏障指令来告诉编译器,在这两条指令之间不能有任何重排。通过在这两条指令之间插入屏障,可以强制它们按正确的顺序执行,避免出现问题。
第二解决方案:处理器层面的问题(Out-of-order Execution)
即使编译器保证了指令顺序正确,处理器仍然可能重新排序操作。现代处理器为了优化性能,允许指令在执行时“乱序执行”(out-of-order execution)。比如,处理器可能会决定先执行entry.data = data
,然后再执行entry_count++
,这可能会导致其他线程看到不一致的状态。
为了解决这个问题,需要使用内存屏障指令(Memory Barrier)。这是一种指令,告诉处理器不要乱序执行,确保数据的写入和读取严格按顺序执行。内存屏障有不同的类型:
- 完整内存屏障(Full Fence):确保读写操作都不会被乱序。
- 存储屏障(Store Fence):确保写操作不会被乱序。
- 加载屏障(Load Fence):确保读操作不会被乱序。
这些屏障指令的作用是让处理器在执行时,确保内存操作的顺序性,避免乱序执行带来的问题。
综合解决方案
要确保两个操作按照正确的顺序执行,我们需要:
- 在代码中使用编译器屏障,确保编译器不会对这两条指令进行重排序。
- 在汇编级别或者更底层的代码中插入内存屏障,确保处理器不会重新排序操作。
如果编译器和处理器都保证了操作顺序,那么程序就可以按预期运行,不会发生线程间的竞态条件或数据不一致的问题。
总结:
- 编译器屏障确保代码顺序不被重排序。
- 内存屏障确保处理器执行时的顺序性。
- 这两者的结合能够有效解决多线程编程中的顺序问题,确保数据的一致性和程序的稳定性。
这些技术通常不需要手动实现,因为许多同步原语(如互斥锁、原子操作等)会自动处理这些问题,但在某些底层优化的情况下,开发者可能需要显式地插入屏障指令。
是否可以移除 Entry.IsValid,改用测试 Entry.Data != NULL 来判断?
可以将验证数据有效性的部分移除,改为测试 Entry.Data != NULL
。这是可行的,但需要对代码进行一些调整。明天会对这部分代码进行重新整理,因为之前在进行合并时做了一些修改。调整后的代码需要确保在检查数据有效性时,Entry.Data != NULL
,这样可以简化逻辑,同时保证程序的正常运行。
k对 Naughty Dog 使用纤程(加上手动管理)和线程亲和力绑定核心的做法怎么看,而不是使用经典的工作者/任务方法来实现多线程游戏玩法?
对于Naughty Dog使用纤程、手动内存管理和线程亲和力(将线程绑定到特定核心)的方式,而不是采用经典的工作者任务方法来实现多线程游戏玩法,这种方法的具体实现我并没有深入研究,因此没有太多意见。
工作队列是否可以接受任何函数并进行多线程处理?这个函数需要特别处理才能工作吗?
这个方法可以适用于几乎所有的函数,前提是这些函数在同时执行时不能发生冲突。例如,不能同时写入同一个内存位置。以瓦片渲染为例,需要将任务分解成多个瓦片,以确保不同的任务之间不会发生冲突。只要函数不在并发执行时互相冲突,它就能正常工作。
请写一个无锁队列,虽然我不知道那是什么,也不清楚你是否用过。
这是一个无锁队列,正如我们实现的那样。虽然目前它还不完全符合传统的无锁队列的定义,因为它还不是一个环形缓冲区,但最终我们会将它改成环形缓冲区,这样它就会成为一个更标准的无锁队列。
创建线程会消耗多少 CPU 周期?或者更好的是:在两个线程中工作来提高速度的最小周期数是多少?
线程的创建开销其实是非常大的,尤其是在高负载的情况下。所以,我们的目标是避免每次都创建新线程。相反,我们希望在程序启动时就创建好多个线程,并保持它们处于空闲状态,随时准备处理任务。这样,当有任务需要处理时,线程可以立刻工作,而无需经历启动线程的额外开销。通过这种方式,我们可以最大限度地减少多线程操作的开销,从而提高性能。
为什么你叫它队列,即使它可能是同时处理的?
即使多个工作可以同时进行,队列的元素是按照提交的顺序被取出的。具体来说,工作项被添加到队列时,按照提交的顺序进入队列,而当工作项被取出时,也按照相同的顺序被处理。这个过程保持着先进先出(FIFO)的规则,也就是队列的传统定义。因此,尽管队列中的工作项可以并行处理,但队列本身依旧是严格按照顺序管理工作项的。
win32 文件中不就已经有一些与线程相关的代码了吗?
在之前的代码中,确实有一部分与线程相关的内容,特别是能够显示当前线程的信息。但除此之外,暂时没有更多与线程直接相关的实现。这部分可能会在以后进一步加入,或者也可能不进行修改。
你会添加一个酷炫的图表,展示每个线程在每一时刻工作在什么任务上(例如来自哪个子系统)吗?
计划中会增加一个图形化的调试工具,用于展示每个线程在每个时刻执行的任务,以及这些任务来自于哪个子系统。这是为了帮助可视化线程的工作状态。虽然整个进度安排有些乱,实际上已经做了很多原本打算稍后再做的事情,这些准备工作会让后续的任务变得更加轻松。比如,动画部分会相对简单,因为已经完成了纹理映射等先前的工作,所以可以更容易地进行资源的调度和处理。虽然进度顺序有些变动,但目前的计划依然会继续推进,期待接下来的进展。
工作队列中条目之间的伪共享是否可能对性能造成问题?
当多个线程同时访问不同的工作队列条目时,如果这些条目在内存中很接近,可能会出现伪共享问题。伪共享是指多个线程虽然访问的是不同的内存位置,但由于缓存一致性协议的原因,这些内存位置被存储在同一缓存行中,导致缓存行在多个处理器之间不断地进行同步,从而引发性能问题。具体来说,如果多个线程操作的数据位于同一个缓存行,即使它们操作的数据并不冲突,也会因为缓存行的同步开销导致性能下降。因此,合理地安排数据在内存中的布局,避免多个线程访问同一缓存行,能够减少伪共享的影响。
volatile 是否通过将汇编寄存器压入栈中,然后再弹出恢复,来清除汇编寄存器?
volatile
关键字的作用是确保变量的值不会被优化掉,避免编译器对其进行优化处理。其实现方式是将寄存器中的值保存到内存中,并在需要时重新加载。通常,这个过程并不涉及栈操作,而是将变量存储在全局内存或堆中。通过这种方式,volatile
确保了每次访问该变量时都会直接从内存中读取最新的值,而不是使用寄存器中的缓存值。这种操作使得变量的值始终保持最新,避免编译器在优化时将其值缓存,进而保证了多线程环境下的正确性。
为什么你想要编译器屏障而不是进程屏障,反之亦然?
在一些平台上,编译器屏障(compiler fence)和处理器屏障(processor fence)的使用有不同的目的。一般来说,编译器屏障是为了防止编译器重新排列指令,从而确保代码按照预期的顺序执行。然而,在某些平台上,比如 x86-64,编译器屏障可能不太有用,因为处理器的内存写入顺序已经很强序(strongly ordered),这意味着处理器本身就会保证写入顺序。
因此,可能并不需要单独使用编译器屏障,因为处理器已经足够强大,可以处理这些顺序问题。而对于其他平台,尤其是在处理器不保证强序时,编译器屏障和处理器屏障可能会有更重要的作用。
总的来说,目前对编译器屏障的理解可能还需要更多的研究,特别是对 x86-64 处理器的内存排序特性。在某些情况下,编译器屏障可能没有必要,具体情况需要深入了解平台的内存模型。
_mm_fence() 不应该意味着编译器屏障吗?否则没什么意义…
在讨论 _mm_fence
和编译器屏障时,存在一定的疑问。有些人认为,如果没有处理器的屏障与编译器屏障的结合,那么就没有意义。但实际上,是否自动包含编译器屏障,这一点并不确定。很多时候,最安全的做法是明确地加入 read-write barrier
,即读写屏障,以确保数据的顺序不会出现问题。
目前还没有看到足够的文档说明 _mm_fence
是否一定会包括编译器屏障,所以无法完全信任其行为。在这种情况下,为了避免潜在问题,最好显式地在代码中加入屏障,以确保线程间的正确同步。
你实现了摩擦力吗?
已经实现了摩擦力的效果。我们的角色在移动时,受到类似摩擦阻力的影响,导致其速度逐渐减慢。这种效果模拟了摩擦力的存在,使得角色的运动看起来更自然,像是在一个有摩擦的环境中运动一样。
所以,线程管理有点像内存管理(你希望提前设置它,而不是按需分配它)。
线程管理和内存管理有相似之处,主要在于我们希望提前设置好线程,而不是根据需求动态分配。就像内存分配一样,创建一个线程在操作系统层面是非常昂贵的,虽然它的速度比较快,但在游戏规模的工作负载下,它依然会带来较大的开销。因此,通常的做法是先从操作系统获取所需的线程数量,然后再进行子分配管理。
与内存管理类似,我希望能够控制线程如何分配工作,而不是让操作系统频繁创建和销毁线程,这样会浪费大量时间。对于内存,我也不希望频繁地从操作系统申请和释放内存,而是希望自己管理内存的划分,避免操作系统带来额外的开销。
会添加代码来询问处理器同时会执行多少线程吗?
代码中需要添加的部分是,用来检查当前机器支持多少线程可以同时执行的功能。虽然还不确定具体如何添加,因为需要一个机制来判断机器的情况,确保在某些特定情况下不要让所有线程都处于休眠状态,否则可能会导致问题。虽然可以先实现这个功能,但在实际操作中,可以先把它注释掉,并强制设置线程数为8个,直到确定如何更好地处理这个问题。
是否有可能让一个工作队列条目生成另一个工作队列条目?
目前工作队列条目无法生成另一个工作队列条目。原因是现在采用的是单生产者多消费者的队列系统。如果希望一个工作条目能够生成另一个工作条目,就需要将队列改为多生产者单消费者模型,这会稍微复杂一些。因此,除非有必要,否则不打算这么做。如果以后发现必须要使用这种方式,再进行修改,但目前并不倾向于这样做,因为这会增加一些额外的复杂性,可能不是最佳选择。
(我并不是专家,所以才问这个问题)伪共享会导致处理器跳过缓存,当不同线程访问同一缓存行时。
"伪共享"会导致处理器跳过缓存,特别是当不同的线程访问同一缓存行上的数据时。伪共享问题通常出现在多个线程在同一个处理器核心上运行的情况。处理器无法知道两个不同的线程是否访问了同一个缓存行,直到缓存之间的协调发生。简而言之,多个核心上的线程在处理共享数据时,处理器可能需要进行缓存一致性协议的交换。
不过,这个问题是否存在以及它在不同处理器架构上的表现可能有所不同。例如,在某些架构中(如AIX 64),处理器可能会通过缓存来处理这些数据,并且不会轻易跳过缓存。但在其他处理器上,这个过程可能更复杂,可能需要一些特定的优化来避免伪共享问题。
另外,根据某些文档和错误报告,可能已经发现了一些优化方法,自动插入内存屏障(例如mfence
)来解决这个问题。这意味着处理器可以通过更智能的处理来确保数据一致性,而无需手动管理每个内存操作的顺序。
总的来说,伪共享可能在不同的硬件和架构上表现不同,需要根据具体的处理器特性和优化方式来进行调整。
其实,我能想到一种使用编译器屏障而不使用内存屏障的场景:写入 CPU 特殊寄存器,比如控制寄存器或 MSR。
在讨论编译器屏障(compiler fence)和内存屏障(memory fence)时,提到了一个可能的使用场景,那就是写入特殊寄存器(如控制寄存器或MSR寄存器)。在这种情况下,编译器屏障可能有其作用,而不需要涉及内存屏障。问题在于,是否这些操作算作"存储"(store)操作。
通常,存储屏障(store fence)是用于管理写入缓存的操作,但写入控制寄存器(例如MSR寄存器)是否被视为存储操作就不那么明确了。对于这些寄存器的写入操作是否会被存储屏障阻止重排序,存在疑问。存储屏障通常用于确保写入到缓存的操作顺序,而对特殊寄存器的写入操作是否需要类似的机制,尚不清楚。因此,是否使用内存屏障来防止对这些特殊寄存器的写入顺序进行重排序,仍然是一个待解答的问题。
总结来说,对于控制寄存器的写入是否需要存储屏障,或者是否能够通过编译器屏障来控制这些寄存器的写入顺序,仍然是一个技术上的疑问,可能需要进一步的研究和验证。
经过多年后重新回到 C/C++ 编程。不明白为什么你会混合使用 C 风格的结构体和 C++ 结构体?
重新回到C语言的编程中,发现符号(symbols)变得非常拥挤,这让开发过程变得更加复杂。虽然不太明白为什么要将C语言风格的代码和C++风格的代码混在一起,但可以明确的是,我们的代码中并没有C++风格的结构体(struct)。实际上,整个项目中使用的都是C语言风格的结构体。因此,在编程时,我们保持了一致性,主要使用C风格的代码和符号,而没有引入C++的特性。