回顾并规划今天的任务
现在,我们站在了一个关键的时刻,准备突破,拥有一些优秀的性能分析代码。从目前来看,我们已经能够看到时间的消耗情况,我对这一点感到非常兴奋。昨天的直播中我们勉强让一些东西工作了,但我们发现了一些bug,且没有时间深入查看这些问题。因此,今天我认为是我们终于能够看到这些内容取得实质性进展的一天。
我们将开始处理一些新的内容,这些内容应该会非常有趣,比如添加一些界面功能,让我们可以快速地在程序中导航,并深入挖掘具体的部分。这将是一个有趣的新尝试,也是对实际调试数据收集工作的一种突破。
我们将调试事件记录可视化
如果我没记错的话,我们已经有了一些绘制的内容,但是它并不正确。我们知道存在某种问题,但当时没有足够的时间深入了解到底出了什么问题。我们确定它的逻辑是对的,但我们不确定哪里出现了错误。
例如,在这里你可以看到,虽然我们开始时有一些看起来像是正确的内容,但它很快就滚动出了屏幕,接下来显示的几乎没有任何东西。理论上,这里应该显示的是所有的帧,但实际上我们只看到一个帧,之后就没有了。
这对我来说意味着,我们在将数据分配到帧中的过程中可能存在bug。我们没有正确地处理这些帧,导致只能看到一个空的区域,或者出现了某种奇怪的情况。所以我们需要深入调查,搞清楚到底发生了什么,并找出问题所在,最终解决它。
接下来,我们将仔细检查现有的代码,找出问题并解决它。
快速回顾与任务相关的代码
我们先花一点时间回顾一下目前的情况,看看代码是怎么处理的。
目前的结构是,每一帧我们都会创建一个新的内存临时存储空间,并在里面构建相关信息。这些信息包含一系列的帧,每帧有它的开始和结束标记,以及一系列的区域,这些区域本质上是表示某些操作发生的时间段。
这是我们当前要实现的目标,我们开始能够在屏幕上看到一些东西,但理想情况下,我们应该看到的是整屏显示的帧,以及每帧内包含不同颜色的区域,但目前并没有显示出预期的内容。
所以我的第一个问题是,为什么没有显示出完整的帧和区域?
我们是否正确构建了调试帧?
我们需要确认调试帧的内容是否正确显示,是否能够正确地构建调试帧。因此,接下来想要检查一下这些帧到底发生了什么,看看问题出在哪里。
有几种可能的原因会导致这个问题。首先,考虑到我们可能有更多的调试帧,但事件的触发数量可能不够。我目前还不清楚这些问题是如何影响到当前的实现的。
检查帧计数是否正确
我想先查看一下当前帧的情况,了解到底系统认为有多少帧。通过观察,启动时似乎出现了一些奇怪的现象,最初帧数没有推进,可能这些是初始帧。然后,当帧数达到一定数量时,帧开始滚动。问题是,帧似乎是反方向移动的,且没有从左侧或右侧加载新的帧,这让我感到困惑。理应看到帧数据的流动,但实际上什么都没有显示出来。
因此,我决定先确认系统认为有多少帧,并了解这个问题的根源。通过调试,我发现系统认为我们有63帧,这个数量与我预期的差不多。
帧内的区域数量太少
查看这些帧时,立刻就能看到问题所在。基本上,我们没有看到任何有效的区域数据,感觉这应该是个问题。实际上,帧里应该有多个区域,而不是只有两个区域。两个区域出现在一端,其它部分都是零,这显然是不对的。
这表明我们可能在相关数据的处理中存在一个非常基础的错误,因为这样的数据结果完全没有意义。因此,怀疑我们在数据收集和处理的过程中可能有基本的逻辑问题,接下来需要进一步检查和调试,找出导致这个问题的根本原因。
步进代码查找错误的原因
在检查代码时,首先我们创建了一个帧数组来存储帧数据,并确定数组的大小足够让它溢出。接着,我们初始化了所有的缩放相关参数,并决定暂时不调整帧条的缩放。
接下来,每次处理事件数组时,我们遍历事件并为每一帧创建一个新的调试数据块。我们会在帧的起始部分记录一个帧标记,并将当前帧分配给该标记。然后,检查是否已经有时钟数据,如果没有时钟数据,当前帧的区域数量将为零。
在此过程中,我们查看了当前线程的状态,并确认它在调试表中正确记录。我们也确保了线程的开始和结束标记是匹配的,确保每个块的开始和结束事件都有记录,并且所有的块都已正确关闭。
在调试过程中,查看了每个打开的块以及与之相关的事件数据,确保了在整个过程中没有遗漏或者异常。最终,我们发现所有的记录和块都已正确生成,并且没有发现任何明显的问题。
当遇到带有子任务的游戏更新事件时,我们确保每个子任务的开始和结束都能被正确捕捉到,并且能够跟踪所有相关的块是否在预期时关闭。每次遇到新的块时,我们会设置断点,确保每个块的处理都按预期顺序进行,直到所有事件都得到正确记录。
整体来看,调试流程基本正常,没有发现明显的异常。
BEGIN_ 和 END_BLOCK 配对似乎有问题
看起来在块配对方面出现了一些问题,因为我们不应该能够进入新帧,而没有关闭游戏更新的块。如果查看游戏更新的情况,会发现其输入处理的结束块和更新的结束块是匹配的,然而我们并没有看到块在顶部正确关闭。
为了解决这个问题,需要找到一个方法,在发生这种情况时暂停并查看到底是什么原因导致了这种失败。现在的目标是在调试代码中找到原因,并找到能够准确捕捉此问题的方式。
首先,可以在代码中某个位置添加检查,查看当前帧的状态,尤其是在特定情况下的帧。具体来说,我们希望能够检查这些帧在进入新帧之前,为什么没有关闭游戏更新的块。由于每个线程上的块是按顺序进行打开和关闭的,可以通过查看线程中的第一个块来找出问题。如果有多个线程,就需要在每个线程中逐一检查,确保每个线程上的块都是按预期进行的。
当我们触发 GameUpdate 计数器的 END_BLOCK 事件时停止
当我们到达结束块事件时,我们希望知道是否是这个特定的结束块事件,特别是对于游戏更新块的情况。为了确认这个事件,我们可以通过检查块的名称来实现这一点。具体来说,可以通过比较该块的名称是否为“游戏更新”(game update)来确认。
为了做到这一点,可以通过简单的字符串比较来实现。例如,可以通过检查当前块的源名称是否与目标字符串匹配。如果匹配,就可以确定这是我们要找的结束块事件。虽然我们可以使用C运行时库提供的字符串比较函数,但由于其实现相对简单,不妨自己编写一个字符串比较函数。
虽然当前并没有现成的工具函数可用,但为了快速解决这个问题,可以直接在当前代码中实现一个字符串比较函数。这样做不复杂,可以很方便地处理这个需求。
实现 StringsAreEqual
在处理字符串比较时,目的是检查两个字符串是否相等,并返回一个指示它们是否匹配的值。为了实现这一点,可以编写一个简单的字符串比较函数。该函数的核心思想是逐个字符比较两个字符串,直到遇到空字符(null terminator)为止。只要两个字符串的字符一一对应,且都到达字符串的结尾,就认为它们是相等的。如果在比较过程中遇到不同的字符,比较就会停止。
该函数的工作原理是:通过指针遍历字符串,每次检查两个字符串的当前字符是否相等。如果相等,则继续向后移动指针。如果有一个字符串到达了空字符,而另一个没有,则可以判断为不相等。最终,当两个字符串的指针都到达空字符时,如果它们相等,就说明两个字符串完全匹配。
在实际操作中,编写了一个比较函数,并将其用作调试工具。为了调试字符串比较,可以在关键代码处设置断点,以便在程序运行时检查字符串是否正确匹配。然而,在调试过程中出现了一些意外情况,如发生了访问违规错误(access violation),并且调试事件的数量与预期不同,导致程序运行时出现了异常。这些异常虽然奇怪,但可以在后续进行检查和处理。
接下来,重点放在找出为什么"游戏更新"(game update)块没有被正确关闭的问题。通过调试流程,发现可能存在问题的地方,包括一些关键的块操作,如开始和结束块。需要进一步深入分析这些块操作的状态,确认为什么某些块没有按照预期关闭。
打开的和关闭的 GameUpdate 事件完全不匹配
在分析游戏更新的结束块时,发现了线程中的一些异常情况。首先,检查了线程的第一个打开块,它是有效的,但在查看相关的事件时,发现打开事件和结束事件之间的记录存在不匹配的情况。具体来说,事件的记录索引和翻译单元不一致,这意味着这些事件实际上属于完全不同的记录。因此,这显然是导致问题的原因。
接着,检查了调试信息,发现线程的第一个打开块非常深,存在多个父块。这表示线程在某些时刻打开了多个层级的块,且没有正确关闭。通过检查这些层级,感觉像是存在一个未关闭的块,这可能是导致问题的根本原因。
DrawRectangle 被多个线程调用
经过进一步分析,发现问题可能与多个线程同时执行DrawRectangle
有关,而不是事件跳转的问题。确认了当前观察的线程发生了切换,并且能够看到标记指示线程转换的地方。因此,需要确保在执行填充操作之前,所有块都已经正确关闭。
接下来,检查代码执行流程,发现END_BLOCK
的调用和结束调用是存在的,并且能够正确记录结束事件。从调试信息来看,线程ID与之前的记录有一定偏差,但总体上仍然在正常范围内。因此,当前问题的根本原因尚不明确,因为多线程命中相同函数本身不应该导致错误。
尽管如此,仍需继续排查,以确保所有调试记录都是一致的,并且没有遗漏的关闭操作。可能需要进一步检查END_BLOCK
的调用逻辑,确认所有线程都能正确记录和结束相应的事件。
处理多个线程的代码可能出错?
可能是我们处理多线程相关代码的逻辑出现了问题,因此需要检查这一部分的实现是否正确。为了进一步确认问题,我们决定采取与之前相同的调试方法,但这次不仅仅关注END_BLOCK
,而是同时观察open_block
和END_BLOCK
,以便更全面地理解整个过程。
通过这种方式,可以更清晰地看到线程在进入和退出代码块时的行为,确认是否有未正确关闭的块或者异常的记录写入。下一步将检查所有线程的操作,确保每个线程的事件匹配,避免因多线程调度导致的调试数据错乱。
步进代码,查看 DrawRectangle 事件
为了进一步确认问题,我们决定对DrawRectangle
的调用进行全面检查,确保它在执行过程中没有异常。
首先,我们在调试代码中设置了一个断点,确保每当DrawRectangle
被调用时,我们都能捕获到相关信息。然后,我们让程序继续运行一段时间,以确保已经跳过了那些可能因为运气好而正常运行的帧,并进入确定存在问题的帧。
接下来,我们观察DrawRectangle
的首次调用情况,发现它的调用来源主要是调试代码本身,这一点比较有趣。由于DrawRectangle
在渲染过程中被频繁调用,因此需要进一步深入分析它的执行逻辑,看看是否有未正确关闭的块,或者是否有异常的数据写入导致调试信息混乱。
调试代码中绘制矩形可能是错误的根源
很可能是因为在调试代码中调用了DrawRectangle
,导致了这个 bug,这本身就有点讽刺。
当前的情况是,线程6040
在短时间内连续打开了多个DrawRectangle
,但是它们并没有被正确关闭,这本不应该发生。正常情况下,每次打开DrawRectangle
,都应该有一个对应的关闭操作,否则就会导致未匹配的记录堆积,最终引发问题。
为了进一步确认问题,我们检查了线程 ID,发现它是有效的,因此并不是读取了超出范围的内存。同时,我们查看了debug_records
中的数据,索引和值也都是正常的,所以逻辑上应该是正确的。
然而,从观察到的行为来看,似乎在某个环节上,记录的打开和关闭操作没有正确匹配,可能的原因包括:
- 递归调用未正确退出:可能在
DrawRectangle
的执行过程中,某些情况下递归调用了自己,但没有正确返回,导致打开的数量超过了关闭的数量。 - 多线程同步问题:如果
DrawRectangle
在多个线程中执行,而调试工具的记录方式没有考虑多线程的同步问题,那么可能会导致数据不一致,导致打开和关闭的匹配错误。 - 错误的调试记录更新:可能在调试代码中,我们错误地记录了某些打开事件,但没有正确记录它们的关闭,导致出现多个未关闭的
DrawRectangle
调用。
接下来,需要深入查看代码逻辑,找到导致这些未关闭DrawRectangle
的具体原因,并修复这个问题。
在我们计时 DrawRectangle 时,全球调试表格是什么样的?
尝试了一种新的方法来调查问题,即在render_group
内调用DrawRectangle
时,查看global_debug_table
的状态,以判断到底发生了什么。具体方法如下:
-
设置断点:
在进入DrawRectangle
时,设置一个断点,以便在写入global_debug_table
时可以观察数据的变化。 -
检查事件数组索引:
event_array_index
的值控制了写入的位置。通过位移操作,我们可以找到具体的索引位置,并观察写入情况。 -
等待异常行为出现:
为了确保观察的有效性,先让程序运行到已知存在问题的时刻,再设置断点,避免误判。 -
观察数据写入:
通过查看global_debug_table
的event
数据,发现写入的thread_id
值是不同的,意味着多个线程都在执行DrawRectangle
。- 例如,
thread_id
的值分别有52, 64
,61, 16
,16, 28
等,表明多个线程都在进行此操作。 - 但是,存在某些情况下,某个线程出现了连续两次写入但没有对应的关闭,比如
thread_id
616,在事件索引2769, 62
时,发现它之前没有正确关闭。
- 例如,
-
发现异常:
通过观察,发现global_debug_table
在某些情况下被写入了错误数据,即某些DrawRectangle
调用并没有正确关闭,而是连续打开了两个事件。这表明问题不在事件合并逻辑,而是在写入调试数据的过程。 -
可能的原因:
- 写入数据时发生了错误:在
record_game_event
中,使用了atomic_add
来进行索引增加,但似乎仍然存在错误,导致事件未正确匹配。 - 编译器优化问题:怀疑编译器可能优化了某些变量,导致某些数据没有被正确刷新。但由于
atomic_add
强制为volatile
,按理说不应该有这种问题。 - 并发问题:多线程情况下,可能某些事件的匹配逻辑有问题,导致某些线程的事件记录出现错位或重复。
- 写入数据时发生了错误:在
-
当前困惑:
- 如何出现了两个未关闭的
DrawRectangle
? - 为什么某些事件没有正确记录关闭?
- 是否有可能是由于多线程竞争导致的记录错误?
- 如何出现了两个未关闭的
接下来,需要进一步调查record_game_event
的逻辑,确保所有的begin
事件都有匹配的end
事件,同时检查是否存在跨线程数据竞争的问题。
同一个 ThreadId 的 END_BLOCK 太多
在调试过程中发现了异常现象,即相同的线程 ID(如 164
)出现了多个连续的 “结束”(end block) 事件,这种情况理论上不应该发生。
-
具体异常
- 观察到线程
164
多次结束,但没有相应的开始(open block) 事件。 - 事件的索引值显示
0, 1, 2
,其中2
代表结束,但这个模式完全不符合预期。 - 数据表现得非常混乱(full banana cakes),说明某个地方可能有严重的数据记录错误。
- 观察到线程
-
可能的原因
- 数据写入错误:某些结束事件可能被错误地追加到日志,导致多个 “end block” 叠加出现。
- 多线程竞争问题:如果不同线程在错误的时机访问
global_debug_table
,可能会导致数据不一致。 - 索引更新问题:可能某个地方的
event_index
没有正确维护,导致多个 “end block” 误写到同一个线程 ID。 - 事件记录流程有 bug:某些代码可能在不恰当的时机调用了 “end block”,导致日志记录混乱。
-
下一步调试方向
- 追踪
event_index
在 “open block” 和 “end block” 之间的变化,确保它们是成对出现的。 - 检查是否有未关闭的 “open block”,导致后续的 “end block” 记录异常。
- 验证
global_debug_table
是否存在线程安全问题,可能需要加锁或使用原子操作来维护一致性。 - 观察
record_game_event
的执行顺序,确保多线程情况下不会写入错误数据。
- 追踪
当前的现象说明调试日志系统有根本性的问题,需要更深入分析事件记录的逻辑,尤其是 end block
事件为何会被多次记录。
GetThreadId 是否出错?
在调试过程中发现了异常情况,即日志系统中记录的线程事件完全混乱,导致多个 “end block” 事件不合理地堆叠。
-
怀疑点:
GetThreadID
是否有问题- 可能
GetThreadID
返回了错误的线程 ID,导致事件被错误地关联到不属于它们的线程。 - 需要检查
GetThreadID
的实现,确保它正确返回当前线程的 ID,而不是缓存的、错误的或跨线程混淆的数据。
- 可能
-
测试
GetThreadID
的方法- 由于当前环境中无法直接调用 Windows API(
GetThreadId
),可以考虑**使用线程局部存储(thread local storage, TLS)**来验证返回的线程 ID 是否一致。 - 另一种方法是将
GetThreadID
的返回值直接打印出来,观察在不同线程调用时是否有异常值。 - 也可以在记录日志时同时记录线程 ID,然后手动检查是否有同一个线程 ID 交错记录了不属于它的事件。
- 由于当前环境中无法直接调用 Windows API(
-
其他可能的原因
- 日志写入竞争:多个线程同时写入
global_debug_table
,但没有适当同步,导致数据错乱。 - 线程 ID 误用:某个地方可能复用了旧的线程 ID,而没有正确获取当前线程的实际 ID。
- 数据读写错误:日志系统的索引可能指向了错误的位置,导致错误的数据被覆盖或错位存储。
- 日志写入竞争:多个线程同时写入
-
下一步调试方向
- 重点检查
GetThreadID
的实现,确保它返回的是当前执行线程的正确 ID。 - 测试
GetThreadID
是否在不同线程中返回了相同 ID,如果是,则可能是实现问题。 - 手动记录每次
GetThreadID
的调用情况,对比线程 ID 和事件记录是否匹配。 - 检查
global_debug_table
的访问是否是线程安全的,是否有可能多个线程同时修改同一位置的数据。
- 重点检查
当前问题表明线程 ID 可能被错误记录,进而导致日志数据混乱,因此需要优先验证 GetThreadID
的正确性。
在线程创建时测试 GetThreadId,结果正常
在调试过程中,我们尝试验证 GetThreadId,结果正常
的正确性,以确定它是否返回了正确的线程 ID。方法是:
- 在
CreateThread
之后,立即调用GetThreadId,结果正常
进行检查, - 通过
assert(TestThreadId == GetCurrentThreadId())
断言其返回值是否与 Windows APIGetCurrentThreadId()
一致。
经过测试,结果表明 GetThreadId,结果正常
返回的线程 ID 是正确的,所以问题并非来自于线程 ID 记录错误。这说明问题出在其他地方,但目前仍无法确定具体原因。
观察到的问题特点:
- 问题并非立即出现,在开始时数据记录是正确的,但在多线程高负载的情况下,日志记录逐渐变得混乱。
- 数据错误的表现:
- 线程 ID 记录正常,但某些事件类型(event type)变得完全不合理,例如 “end block” 事件重复出现。
- 可能的情况是某些事件未正确关闭,导致数据结构中的状态失衡。
- 怀疑原子操作(atomic operations)有问题:
- 由于
AtomicAddU64
负责管理事件索引,可能是索引更新出错,导致错误的数据被覆盖或误用。 - 代码中
volatile
关键字已标记,但可能存在优化问题,导致某些写入操作被编译器优化掉。 - 可能某个
cast
操作不当,影响了数据存储的正确性。
- 由于
- 正确的数据模式 vs. 错误的数据模式:
- 在最初的帧中,数据记录看起来完全合理,线程 ID 和事件类型都符合预期。
- 但是,在一段时间后,数据开始出现异常,特别是 “end block” 事件错误重复,导致逻辑混乱。
- 这表明错误可能是渐进式的,或是由于某个状态未正确维护。
下一步排查方向:
- 深入检查
AtomicAddU64
的实现,确保它在多线程环境下的行为符合预期,避免竞态条件导致数据错乱。 - 手动追踪事件记录的过程,尤其是:
- 事件索引的增长是否正确?
end block
事件是否确实与open block
配对?- 是否可能有某个线程跳过了 close 事件的记录?
- 查看编译器优化是否影响了原子操作,可以尝试加
memory barrier
以确保数据同步。 - 检查是否有其他非原子方式修改了全局事件数组,可能有地方绕开了
AtomicAddU64
,导致索引混乱。 - 分析反汇编代码,看看是否有某些意外的优化或寄存器分配问题,影响了数据记录的正确性。
当前的问题表明,在多线程高负载情况下,事件日志的记录逻辑可能存在同步问题或数据一致性错误,需要进一步细化分析。
用配对的 BEGIN_ 和 END_BLOCK 代替 TIMED_FUNCTION,以避免构造函数/析构函数对
在调试过程中,尝试进一步缩小问题范围,去除可能干扰分析的部分。具体方法如下:
-
修改代码结构:
- 将
BEGIN_BLOCK(DrawRectangle)
和END_BLOCK(DrawRectangle)
直接显式调用,避免使用构造函数/析构函数的方式,确保可以更直接地观察其调用过程。 - 这样可以明确地看到
BEGIN_BLOCK
和END_BLOCK
的执行路径,排除构造析构时序可能导致的问题。 - 观察发现,即使更改了调用方式,问题依然存在,这说明错误不在构造/析构调用方式上,而是更深层次的问题。
- 将
-
设置断点,逐步跟踪
END_BLOCK(DrawRectangle)
的执行情况:- 在
END_BLOCK(DrawRectangle)
处设定断点,观察event array
的索引值和写入的数据是否正确。 - 当前
event array
处于索引3
,事件编号2271
,执行END_BLOCK
之后写入2272
。 - 在写入数据时,线程 ID 和事件类型都是正确的,符合预期。
- 继续执行后,观察到事件索引递增,表明
AtomicAddU64
仍然在起作用,多个线程同时访问此区域。
- 在
-
问题仍然存在:
- 在逐步跟踪
END_BLOCK(DrawRectangle)
的执行时,未发现明显的错误,数据写入过程符合预期。 - 但当代码继续运行,最终输出的调试信息仍然出现异常——例如 某些事件未正确匹配,或者
END_BLOCK
记录异常。 - 可能的原因:
- 竞争条件(race condition):多个线程同时访问
event array
,可能导致索引错误或数据覆盖。 - 索引递增错误:如果
AtomicAddU64
发生异常,可能会导致事件数据写入到错误的位置,或者发生跳跃写入。 - 数据同步问题:即使
AtomicAddU64
具有原子性,可能仍然存在数据未正确同步的情况(如 CPU 缓存或指令重排序影响)。
- 竞争条件(race condition):多个线程同时访问
- 在逐步跟踪
-
下一步排查方向:
- 更精细地记录日志,尝试在
BEGIN_BLOCK
和END_BLOCK
之间记录每一个操作的具体数据(包括线程 ID、索引变化等),以便更详细地分析问题。 - 尝试锁机制(如互斥锁),虽然
AtomicAddU64
是原子操作,但可能存在其他数据共享问题,可以尝试短暂加锁,以观察问题是否消失。 - 引入调试辅助方法,例如手动存储所有事件的写入顺序,检查是否存在跳跃、不匹配、或数据被覆盖的情况。
- 检查
event array
结构,确保所有线程访问该数据结构时不会因为某种原因导致非法访问或写入错误。
- 更精细地记录日志,尝试在
目前的问题表明,多线程环境下 event array
可能出现索引错误或竞争访问问题,导致数据记录错误,需要更深入地排查并验证同步逻辑。
当我们按顺序运行线程时,问题似乎消失了。但为什么?
在调试过程中,进一步确认问题可能与竞态条件(race condition)有关,因为当强制线程串行化(即不让多个线程同时访问 debug array
),错误情况明显减少,不再出现错误的线程 ID 匹配情况。
观察与分析:
-
强制线程串行化后,错误情况显著减少:
- 当多个线程同时访问
debug array
时,之前经常出现BEGIN_BLOCK
和END_BLOCK
线程 ID 不匹配的情况。 - 现在,通过手动冻结其他线程,使得每次只有一个线程能够运行,所有的
BEGIN_BLOCK
和END_BLOCK
完美配对,线程 ID 也完全正确。 - 这表明错误可能在多线程并发访问
debug array
时触发。
- 当多个线程同时访问
-
怀疑
GetThreadID
可能出现问题,但暂未找到确凿证据:GetThreadID
负责获取当前线程的 ID,理论上它应该是线程安全的,不应该引发竞态条件。GetThreadId
本质上调用GetCurrentThreadId
或类似 API,而这些 API 通常不会有竞态问题。- 但仍然不能完全排除
GetThreadID
返回的 ID 可能在某些情况下不正确,需要进一步测试。
-
进一步检查
event index
的存取逻辑:event index
作为索引指向debug array
的写入位置,多个线程同时操作可能导致覆盖或错位写入。- 当前只观察到
event index
自增时没有明显错误,但需要确认多个线程同时写入时,是否会导致不一致的行为。
-
可能的竞态问题来源:
debug array
访问缺乏适当同步,可能多个线程同时修改event index
或写入debug event
,导致数据错乱。- 线程 ID 在写入
debug array
之前可能被另一个线程意外修改,导致BEGIN_BLOCK
和END_BLOCK
记录的 ID 不匹配。 - 可能
AtomicAddU64
没有正确保证索引的唯一性,导致不同线程写入相同索引位置,覆盖原始数据。
下一步调试方案:
-
增加
GetThreadID
验证逻辑:- 在
GetThreadID
返回后,立即对比前后两次的值,确保不会在某些条件下出现 ID 变化异常。 - 确保返回的 ID 可以正确转换为
uint16_t
,防止某些异常情况下返回超出范围的值。
- 在
-
检查
event index
递增的正确性:- 记录每次
AtomicAddU64
的调用,并检查event index
是否有跳跃或重复的情况。 - 让不同线程在
event index
更新时打印日志,观察是否发生交错写入。
- 记录每次
-
测试
debug array
访问的同步方式:- 目前
debug array
可能被多个线程同时访问,尝试使用锁或其他同步机制确保写入正确性。 - 测试更严格的同步方式,例如使用线程本地存储,避免直接竞争全局
debug array
。
- 目前
-
在高并发场景下运行更长时间,观察错误是否随线程数量增加而更频繁发生:
- 目前冻结线程后,错误几乎消失,表明并发可能是问题核心。
- 让程序运行更长时间,看看错误是否仍然随机发生,并寻找规律。
初步结论:
当前调试结果表明,竞态条件极有可能是问题的核心,需要进一步调查 event index
的正确性,以及 debug array
在多线程访问时的数据一致性。
下一步重点排查 GetThreadID
是否存在潜在问题,并改进 debug array
的同步逻辑,以防止并发写入冲突。
验证 GetThreadId 返回的是 u16
目前的调试进入更谨慎的排查阶段,开始怀疑所有可能影响线程 ID 记录的细节,包括数据截断等问题,即使理论上这些问题不应该发生,但由于无法确定根本原因,所以需要全面检查。
当前怀疑点与检查方案:
-
检查线程 ID 是否被截断或错误转换:
- 可能的情况是
GetThreadId
返回的值在存储或计算过程中被错误截断,导致 ID 发生变化。 - 计划在存储 ID 之前,增加额外的检查和日志记录,确保写入的值与读取的值一致。
- 目前不认为截断是问题,但仍然要验证,因为竞态条件尚未完全排除。
- 可能的情况是
-
提高警惕,对所有细节进行怀疑与验证:
- 由于无法明确指出具体的错误来源,现在需要对所有相关变量、操作和存储过程都进行详细检查。
- 包括但不限于:线程 ID 读取、索引自增、数据写入
debug array
时的竞争情况等。 - 任何可能影响最终日志写入的细节都可能成为潜在的错误来源。
-
下一步计划:
- 在多线程运行环境下,对所有
GetThreadId
相关操作增加日志,确保 ID 在整个流程中的一致性。 - 检查所有变量类型转换,确保
uint16_t
类型不会截断GetThreadId
返回的值。 - 继续观察
debug array
访问模式,确认竞态条件是否仍然是主要问题。
- 在多线程运行环境下,对所有
当前结论:
虽然竞态条件仍然是最有可能的原因,但现阶段缺乏确凿证据。
因此,现在需要以更谨慎的方式验证所有可能的异常情况,即使某些问题看起来不太可能,也必须排除它们的影响。
令人困惑的 bugα
目前认为这是迄今为止遇到的最难调试的问题,虽然不一定是最棘手的 bug,但确实是最令人困惑的。主要的困难在于,难以正确推测问题的方向,导致排查进展受阻。
排除的可能性
-
Windows 原子操作 (
AtomicAddU64
) 不是问题的直接原因- 该函数一直在其他地方正常使用,并没有发现类似的问题。
- 如果
AtomicAddU64
本身有问题,应该早在渲染逻辑等其他高频调用的地方就暴露出来。 - 当然,这次的调用频率可能比以往都要高,但目前没有证据表明它是问题所在。
-
原子操作 (
InterlockedExchangeAdd64
) 本身应该是可靠的- Windows 提供的
InterlockedExchangeAdd64
用于执行无锁的 64 位加法,并且经过长期验证。 - 目前没有发现明确的异常,也没有其他地方出现类似 bug,因此暂时不认为该 API 是问题所在。
- Windows 提供的
当前困惑点
-
最可能的竞态问题仍然未找到
- 在强制串行化线程后,问题明显减少,表明多个线程同时访问 debug array 时可能出现竞态。
- 但是,目前还不清楚具体哪个变量或操作在多线程环境下失控。
-
现阶段缺乏明确方向
- 以往的调试思路是假设一个可疑点,然后验证,但目前没有一个明显的怀疑对象。
- 原子操作看似无问题,线程 ID 也在检查,但问题仍然存在。
下一步调试思路
-
更深入检查
AtomicAddU64
在此上下文中的行为- 尽管目前认为
AtomicAddU64
没问题,但仍然要确认在高负载下是否仍然保持正确性。 - 可能需要增加日志,检查
AtomicAddU64
是否在极端情况下出现异常行为。
- 尽管目前认为
-
更严格地分析
debug array
的并发访问- 目前最可能的猜测是
debug array
由于某种原因在多个线程访问时发生数据覆盖或错乱。 - 需要检查是否所有线程都严格按照预期的顺序执行写入。
- 目前最可能的猜测是
-
增加更多线程调试手段
- 继续使用冻结线程的方法,但尝试让多个线程在受控情况下交错运行,观察是否能捕捉到问题发生的瞬间。
- 记录每次写入 debug 事件的完整数据流,确保没有未预料的修改。
当前结论
- 目前的问题极有可能是竞态条件导致,但仍然无法确定确切的竞态点。
- 需要通过更精细的日志记录和受控环境调试来找出哪个变量或操作在多线程环境下失控。
检查 EventArrayIndex 是否对齐到 8 字节边界
当前尝试检查是否存在数据未对齐的问题,例如变量是否跨越了缓存行(cache line),导致原子操作异常。虽然这看起来像是随意猜测(grasping at straws),但仍然值得确认。
对齐性检查
-
确保目标变量的地址是 8 字节对齐的
- 目标是确保变量地址的低 3 位全为 0,即按 8 字节(64 位)对齐。
- 通过
address & 0b111 == 0
来检查变量是否满足 8 字节对齐。 - 依次确认:
address & 0b001 == 0
确保 2 字节对齐address & 0b011 == 0
确保 4 字节对齐address & 0b111 == 0
确保 8 字节对齐
-
代码逻辑调整
- 之前思路有些混乱,导致写错了部分对齐检查逻辑,重新理清后,确认变量正确对齐在 8 字节边界上。
- 既然对齐性没问题,那么原子操作 (
atomic_add_u64
) 也应该没有问题,因为它的正确性依赖于对齐。
发现的额外问题:字符串比较(string comparison)
- 发现某些 block name 可能是
nullptr
,导致比较字符串时可能出现问题。 - 修改
StringEquals
函数,允许nullptr
作为合法输入:StringEquals(a, b)
如果a == nullptr
且b == nullptr
,应该返回true
,因为它们指向相同的(空)字符串。- 这样可以避免空指针导致的崩溃,同时仍然符合逻辑。
最终结论
- 内存对齐已确认无误,原子操作应该没有问题。
debug array
竞态问题仍然存在,尚未找到根本原因。- 当前仍然没有找到明确的 bug 线索,需要进一步深入排查可能的竞态点。
将问题外包给流
当前的问题已经清晰地描述出来,也基本明确了错误的表现,但至于为什么会发生,目前仍然毫无头绪。
当前已知信息
-
问题描述明确
- 现象:多线程环境下,某些
begin block
和end block
记录的线程 ID(Thread ID)不匹配,导致调试数据出现异常。 - 初步判断:可能是**竞态条件(race condition)**导致的错误,但具体原因不明。
- 复现方式:当强制让线程串行执行时,问题不会发生;但在并行执行时,错误频繁出现。
- 现象:多线程环境下,某些
-
已排除的可能性
- 原子操作(atomic_add_u64)应该没问题
- 因为它在其他地方广泛使用,从未出现类似问题。
- 数据对齐性检查通过
- 变量的地址对齐在 8 字节边界上,不存在跨缓存行的问题。
- 获取线程 ID(get_thread_id)本身应该是安全的
- 但仍需验证是否可能返回错误的值。
- 原子操作(atomic_add_u64)应该没问题
当前状态
- 问题仍未解决,但所有明显的错误点都已经检查了一遍,仍然找不到问题的根源。
- 可以考虑让其他人一起排查,看看是否能发现新的思路。
我们可以在更简化的代码版本中单独调试这个问题
当前问题仍然没有明确的解决方案,暂时没有好的调试思路。决定先休息,明天再重新思考调试方案。
下一步计划
-
简化问题,创建最小复现(Repro Case)
- 目标:将问题缩小到最小的可复现测试用例,这样可以在更独立和可控的环境中调试,而不是依赖当前大量的调试事件。
- 这样可以减少干扰因素,更容易找到错误的根源。
-
其他人可能有思路,但缺乏调试环境
- 由于调试是依赖当前运行的程序状态,即使有人知道可能的原因,也很难在没有调试环境的情况下直接指出问题。
- 这意味着仍然需要继续排查,找出问题的核心。
当前结论
- 错误仍然存在,但根因不明
- 可能是竞态条件导致的,但没有直接证据
- 接下来的重点是简化问题,创建最小复现案例
- 暂时休息,明天重新评估调试策略
尝试在不计时 DrawRectangle 的情况下运行
尝试在不进行 DrawRectangle
计时的情况下运行,以确定问题是否仅与这一函数有关。这是一个合理的实验,可以提供更多信息,帮助判断 DrawRectangle
是否有特殊问题。
实验结果
-
问题仍然发生,但似乎并不局限于
DrawRectangle
。- 这表明错误不是
DrawRectangle
代码的特殊问题,而是更广泛的问题。 - 这个错误最终都会发生,无论是否计时
DrawRectangle
。
- 这表明错误不是
-
多线程竞争仍然是主要嫌疑对象。
- 渲染组中的计时操作是多线程的,可能涉及多个线程的争用问题。
- 进行实验时,在
RenderGroup
里替换了计时函数,使用#define
让编译器忽略计时操作,以排除计时代码的影响。
-
优化代码中的计时函数也被移除,进一步减少潜在的多线程同步问题。
- 这样可以观察程序在没有额外计时开销的情况下是否仍然出现错误。
实验结论
- 多线程竞争的可能性进一步增加,因为当多线程计时被移除时,问题的发生情况有所改变。
- 仍然缺少明确的"罪魁祸首"(Smoking Gun),无法确定确切的错误点。
- 仍然是一个复杂的调试问题,需要更进一步的分析和测试。
MSDN 文档中的 __readgsqword 说明“这些内建函数仅在内核模式下可用,且这些例程仅作为内建函数提供”。如果是这样,你是如何使用 __readgsqword 来读取线程 ID 的?(我不太明白内核模式和用户模式之间的区别)
根据 MSDN 文档,read gs
关键字指出这些内建函数仅在内核模式下可用。然而,通过 read gs
读取线程 ID 的做法似乎与这一说法相矛盾。实际上,这种说法可能是不准确的。
-
内核模式与用户模式的区别:
- 内核模式和用户模式的区别在于内核模式具有更高的权限,可以直接访问硬件资源和操作系统内核,而用户模式的权限受限,不能直接访问系统的关键部分。
- 但在这个特定的案例中,微软的代码在用户模式下能够直接读取
GS
寄存器中的线程 ID。
-
GetCurrentThreadId
的工作原理:- 我们检查了
GetCurrentThreadId
的实现,发现它通过汇编指令直接读取GS
寄存器。 - 这表明,读取线程 ID 并不需要进入内核模式,微软的代码就是在用户模式下完成这个操作的。
- 我们检查了
-
对 MSDN 文档的质疑:
- 由于
GetCurrentThreadId
在用户模式下能够正确工作,因此认为read gs
只能在内核模式下使用的说法似乎并不准确,可能是 MSDN 文档中的一个错误或误导。 - 按照目前的观察,并不需要在内核模式下才能使用
read gs
来读取线程 ID。
- 由于
总之,当前的代码和方法与 GetCurrentThreadId
的实现是一样的,证明在用户模式下也能成功读取 GS
寄存器。
你说它似乎在一段时间内正常工作,但在压力下停止工作。也许可以让线程先“疯狂”运行一段时间,然后再冻结它们?还是这在调试器中不可能做到?
在程序运行初期,一切似乎正常,但在高负载状态下,它会逐渐停止正常工作。因此,尝试让线程自由运行一段时间后再冻结,以观察问题是否依然存在。实际上,已经进行了类似的尝试。
-
尝试不同的调试模式
- 切换回“损坏模式”(broken mode)进行测试。
- 但在此之前,先尝试仅启用
draw_rectangle
以确定该操作是否足以复现问题。
-
异常行为分析
- 关闭某些功能后,反而出现了更多的区域(regions),这本身就很奇怪。
- 可能的原因:因为
draw_rectangle
没有父级(parent),导致大量的draw_rectangle
调用。 - 由于
draw_rectangle
的调用会生成新的区域,而这些区域本身又触发draw_rectangle
,导致了某种类似“无限反馈循环”的情况,尽管并非真正的无限循环,但仍然是不理想的情况。
-
帧率与调试事件的异常
- 观察到第一帧的调试事件(debug events)明显多于其他帧,这也显得异常。
- 可能的原因:第一帧可能需要执行大量初始化工作,从而触发了更多的调试事件。
- 这个现象虽然可能是一个独立的问题,但仍然值得关注。
目前的问题仍未完全清楚,但已经发现了一些异常现象,比如 draw_rectangle
可能导致的递归调用,以及第一帧出现过多调试事件的问题。这些可能为进一步分析提供线索。
是否有可能在开始/结束记录期间 ArrayIndex 部分被交换了?
是否可能在 begin
和 end
记录之间,数组索引部分被意外交换?即使发生了这种情况,理论上也不应影响正常运行,因为代码本身并不依赖索引值保持不变。
你创建的颜色是否对眼睛友好?如果没有,它们是如何生成的?
创建颜色时,考虑到是否对眼睛友好。如果不是,那么这些颜色是如何产生的?同时,也提到了关于网格的设计。
你不是已经移除了 EndBlock 调用吗?
没有移除EndBlock
调用,知道它仍然存在,因为它是隐式的。如果查看time
函数的实现,它自动执行begin
和end
,这就是它的职责,它利用构造函数和析构函数的配对技巧来确保开始和结束。因此,问题的解决办法是,在渲染组中添加一个额外的函数,这样就不会为每个矩形调用添加区域了。这样可以避免推送不必要的区域,从而避免问题。
另外,观察到draw rectangle
在没有封闭父级的情况下被调用了,这是不应该发生的。虽然draw rectangle
确实会在一些特定情况下被调用,但这些情况应该是嵌套在适当的父级下。通过进一步的排查,发现render group
的输出是唯一会在这种情况中发生的地方,而该函数本应在封闭的父级下执行。
game_render_group.cpp: 将 DoTiledRenderWork 设为 TIMED_FUNCTION
所以,tiled render work
实际上不会算作问题,因为它不在预期的范围内。现在,一旦这些函数被正确地嵌套在预期的位置,就不会再出现之前的问题。这样,draw rectangle
调用现在变得有序,渲染也能正常进行,所有的操作看起来都正确无误。之前的draw rectangle
调用显得有些混乱,而现在问题已经解决,渲染可以按预期顺利进行。
如果调试代码比游戏的实际运行速度慢,我们如何依赖调试系统来准确测量游戏中的时间,因为调试模式自然会花费更多时间?
在调试模式下,调试代码自然会比游戏的实际运行慢,这使得我们如何依赖调试系统来准确计时游戏中各个部分的运行时间成为了一个问题。实际上,调试模式的慢速并不重要,关键在于它是否会干扰到游戏的实际运行。我们关心的并不是整体游戏运行的慢速,而是那些直接影响游戏运行的部分的慢速,因为这些才会影响到我们计时的准确性。
如果只是调试时的绘制等操作变慢,并不会影响到我们获取的时长,只要不影响到核心的计时部分,整个过程就可以被接受。所以,通过观察这些帧的显示情况,可以清楚地看到游戏和调试输出的情况。具体来看,首先呈现的是游戏渲染的部分,而上面的一些部分则是调试信息的渲染线程。通过这些信息,可以清楚地看到不同线程的工作情况以及它们对渲染的影响。
game_debug.cpp: 为较少的帧渲染调试信息
这个过程让人很感兴趣,因为通过调整渲染的内容,我们可以确保它们适当地显示在屏幕上。如果我们能减少渲染的帧数,画面会更清晰,从而得到一个更直观的效果。具体来说,通过使用类似“frame count - frame index”的方法,可以实现只渲染最近的几帧。比如我们可以选择渲染最近的十帧,或者用其他方式来控制渲染的帧数。
虽然即便如此,仍然可能会出现更多的渲染框架,但我们能通过这样的方式,让绘制的矩形等内容减少,避免它们影响游戏的表现。从这一点来看,至少能够让我们更清楚地了解发生了什么。通过这种方法,尽管还没有精确的调试,我们已经能大致看到游戏中的主要线程以及它是如何被划分的。例如,游戏更新部分会呈现为一个模糊的实心部分,等待帧变更的部分也很容易辨认出来。此外,还可以看到开始阶段的一些重要操作,以及渲染线程的启动过程。甚至可以看到渲染线程二次被用来渲染调试的覆盖层。
即便有人质疑调试代码为何会占用过多时间,其实通过这几周的工作,我们能通过调试看到许多有价值的信息。即使这只是简单的可视化,并且还没有进行详细的调试,这些初步的发现已经非常有价值。甚至可以从中看到一些之前没有意识到的细节,比如线程启动前所花费的时间,实际上比预想的要多得多。
花时间研究这类代码确实是个好主意,因为从中能够学到很多在其他情况下可能无法察觉的信息。这些信息在优化和改进游戏性能时非常重要,并且它们对系统的理解也有极大的帮助。
如果我没记错的话,前几天你提到过当缓冲区满时会丢弃事件。你确定所有缓冲区的大小足够吗?
如果出现错误,且当另一天的事件被丢弃时,缓冲区已满,你是否确定所有缓冲区足够大?实际上,这并不会导致问题,因为我们看到的问题是事件没有对应的开始事件。如果在缓冲区满时丢弃事件,那么结束事件也会被丢弃,因此丢弃事件是没有问题的。不过,我们并没有遇到这个问题,因为实际上我们在代码中加入了断言。
build.bat: 切换到 -O2
我很想看看如果进入到构建过程中,差异会是什么,因为我运行了一个最小化模式,可以看到这里花费的时间明显少了。虽然不确定那些画布的情况,似乎有一些有趣的波动,但我不知道这些波动是什么。可能有些东西需要进一步调查。
哦,你知道吗,我们错过了帧的记录。从画面上看,似乎丢失了这一帧。这也需要进一步调查。再次,这些都是非常有趣的事情。
release
是否有可能是线程跨越了帧边界?
有可能是线程跨越帧边界的问题吗?嗯,我不确定,因为我并不知道具体是什么,也不清楚它不是为什么。这个代码其实并不关心是否发生了这种情况。假设开始事件(begin events)发生得很靠后,落在了事件竞争的最后阶段,那么我们可能会从最远的帧开始处理。这种情况下,如果没有对应的事件,我们就会把它们丢弃。
这不会造成问题,也不会造成我们看到的那种问题——即打开的块数组中包含了那些从未关闭的块。所以,线程跨越帧边界本身并不重要。我们应该仍然能够正常地弹出这些块。
关键是我们并不会在处理过程中重置帧堆栈(frame stack)。我们不会在这里重置线程或打开的块堆栈,所以它们跨越帧边界应该不影响处理。