游戏引擎学习第178天

发布于:2025-03-24 ⋅ 阅读:(27) ⋅ 点赞:(0)

回顾和今天的计划

我们正在进行一场直播游戏开发,完全不使用任何引擎或库,所有的代码都由我们自己编写,甚至不调用 GPU。如果你能相信的话。现在,我们正处在调试代码的阶段,决定开始开发一些不错的调试工具,这些工具将帮助我们更好地可视化代码的运行,帮助我们更容易地找到代码中的错误,或者至少能知道错误的存在。

有时候,错误可能非常微妙,如果没有良好的可视化工具,就很难察觉到这些问题。你可能会发现程序有些行为怪异,但却很难确定到底出了什么问题。正确的调试工具能大大缩短调试时间,也能帮助发现比传统调试方法更难察觉的错误,特别是那些细微的性能问题。

今天的工作将几乎全部是编码,因为我们有很多事情需要做,所以不会有太多的解释。我们在之前的两天已经讨论过相关内容,今天主要是进行编码工作。

那么,目前我们进展如何呢?让我们来看看。之前我们已经添加了自动化的调试和性能计数功能,改进了之前的实现,但仍然有许多地方需要完善。首先,我们之前使用的字体代码是为了测试而写的,字体尺寸比较大,方便查看字符。显然,这种大字体并不适合用来显示调试信息,因为如果字体太大,屏幕上能显示的调试信息就非常有限,根本无法有效地展示足够多的调试数据。

将一个小型等宽字体打包到资源文件中

现在我们要做的事情是,去我们的资产打包器(Asset Packer)中,要求它为我们打包一个小字体。这个字体应该是可读性稍差,但能够在屏幕上显示更多信息的那种。我们期望这种字体能够展示尽可能多的调试信息。除此之外,我们可能还需要一种等宽字体(monospaced font),因为我们并没有很多复杂的排版功能。我们的调试输出不会做精美的排版,所以可能会希望使用等宽字体,这样就能通过手动空格对齐信息,类似于老式编辑器的样式。

我们之前也讨论过,为什么现代编辑器依然使用等宽字体,原因之一就是它们没有提供足够强大的排版功能。我们希望通过这种方式,确保调试输出能清晰地展示数据。

因此,我们的调试输出应该使用一种简单的等宽字体,这样可以在屏幕上更好地排列调试信息。

接下来,我们就要开始实现这一目标,首先需要确保能为资产打包器配置好字体,并为此做一些设置。我们已经有了一些字体写出功能,但可能还需要进一步改进,特别是在选择字体方面,目前我们还没有做太多的工作。所以,接下来要做的就是完善字体选择的功能,让我们能更好地处理这一部分内容。
在这里插入图片描述

在这里插入图片描述

支持字体选择

在我们的游戏中,assets.h 文件目前并没有特别支持处理字体相关的内容,但我们的资产系统完全可以用来处理这类需求。我们可以通过为不同的字体类型创建枚举标签来实现这一功能。这样,我们就能在资源系统中定义不同的字体类型,并且可以方便地管理和选择。

具体来说,我们可以在文件格式中添加类似 “Tag_FontType” 的字段。例如,我们可以定义几个字体类型的枚举值,其中 0 代表默认字体,1 代表调试字体等。通过这种方式,我们能够灵活地添加更多的字体类型,便于后期的扩展和管理。

为了实现这一点,我们可以为每个字体创建一个结构体,包含字体文件和字体名称等信息,然后在资源系统中通过一个函数来管理和加载这些字体。我们可以像这样创建一个 add_font 的函数,来处理添加字体的过程。这个函数接收字体的相关信息(如字体文件和字体名称),并把这些信息添加到资产系统中。

通过这种方法,我们可以为不同的字体设置指定的名称和类型,并在需要的时候进行调用。这样就能在游戏中方便地选择和使用不同的字体,同时也让管理和扩展变得更加灵活。

在实现的过程中,我们首先需要确保资源系统能够支持添加不同的字体类型,然后进行编译和测试,确保功能的正确性。总的来说,我们要确保资源管理能够顺利处理不同字体的添加和使用,且在需要的时候能够灵活切换。
在这里插入图片描述

在这里插入图片描述

添加调试字体

为了添加新的字体,我们需要在资源系统中传递一个字体类型(font type),以便正确地指定所需的字体类型。首先,我们可以为每种字体类型(如默认字体和调试字体)定义不同的类型,并在代码中传递相应的字体类型。

在处理调试字体时,我们选择了 “calibri” 字体,它是一个常见的等宽字体,适合用于调试信息的显示。在设置字体时,我们首先确定字体的名称,并将其作为参数传递到资源系统中。通过这种方式,我们能够灵活地在不同情况下选择不同的字体类型。

例如,在代码中,我们首先传递了 FontType_Debug,然后指定了使用 “calibri” 字体。在编译过程中,如果字体名称存在问题,我们会检查字体名称是否正确,并根据需要进行调整。

通过这种方法,我们能够确保调试字体正确加载并使用,同时可以在需要的时候灵活调整字体类型。这使得我们在调试过程中能够有效地显示更多的信息,并确保字体的适用性和可读性。
在这里插入图片描述

测试多个字体的打包

我们决定运行测试,先去数据目录并运行资产构建工具(Asset Builder)。运行后,测试字体的文件大小确实变大了,大约是原来的两倍,这正是我们预期的效果。尽管字体的尺寸仍然很大,但至少可以测试两种字体的效果,这样我们能更好地了解当前的工作是否正常。接下来,我们打算尝试缩小字体大小并单独调整它。

然而,测试过程中出现了一个问题。重新构建字体后,运行时似乎出现了错误。虽然我们重新构建了字体文件,但不确定为何仍然会出现问题。需要进一步调查和修复这个bug。
在这里插入图片描述

AssetCount 出错了。让我们调试一下

在加载文件时,发现资产计数(AssetCount)有很大问题,显然发生了某些严重错误。加载的文件数量远低于预期。通过检查,发现问题可能不在标签(Tagging)上,但其他部分出现了问题。

首先,已经确认多个字体文件合并操作是正确的,因此不太理解为什么会出现异常情况。为了进一步调试,决定简化问题,先删除除了字体文件以外的其他所有文件。这样就能集中精力调试一个文件,减少干扰。

删除多余的文件后,系统只加载了字体文件,理论上应该加载201个字体资产,但实际只加载了101个,这意味着我们丢失了大约100个资产。接下来需要找出为什么加载的数量不正确,并找出具体的问题所在。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

缺少整个字符集

在加载过程中,发现缺失了整个字符集,原因不明,显得有些异常。为了进一步调试,首先检查了当前的资源类型计数。,原本应该包含200个资源(每个字体100个),但是加载时只得到了101个,意味着有一个字体文件完全缺失。

接下来查看了资产类型的计数,发现该值为19,这似乎不太正常,因为没有找到具体原因,猜测可能是某些资源被错误地重复加载或者不小心生成了多余的类型。

文件的资产计数显示为201,应该是符合预期的,其中包括100个字体资源,剩下的为零和保留资源。检查标签数量时,发现有44个标签,这与预期数量不符,因为每个资源都应该有标签。这个异常令人困惑,因此需要进一步调查。
在这里插入图片描述

在继续深入检查时,发现问题出在读取字体字形(glyphs)时,当前只读取了类型ID为0的资源。然而,实际上没有这些字形资源,所以没有加载它们,这也说明没有错误。进一步的调查确认了没有加载的字体文件并不属于当前的读取范围。

最后,虽然看似一切正常,但在文件索引和源索引处,出现了很多源类型ID为0的情况,这也显得有些奇怪。需要更深入地分析为什么会有这么多源类型ID为零的记录,这可能是问题的根源。

由于代码结构的原因,我们不能对同一个 AssetType 调用 BeginAssetType 两次……

在检查代码时,发现资源类型的写入存在问题,尤其是关于资产类型数组的部分。虽然当前的系统只是一个简单的示例,并不打算作为一个完整的系统来使用,但目前的实现存在问题,导致资源类型ID总是为零,这显然无法正常工作。

尽管这只是一个示例系统,原本并不打算投入过多时间来改进它,但问题仍然必须解决。由于现有的写入方式不符合预期,必须找到一个可行的解决方案,而不是让它继续不正常工作。

在这里插入图片描述

……但我们可以将所有字体打包调用放入 WriteFonts 中来绕过这个问题

为了解决当前的资产加载问题,可以通过遍历字体资源并将它们作为一个整体来进行处理。具体来说,可以在循环中逐个处理每个字体,并在加载字体时为每个字体设置一个索引。这样,在开始加载资源时,可以通过这种方式更有序地加载字体文件。

通过这种方法,可以避免之前遇到的资源类型错误,并确保每个字体都能按照正确的顺序和方式加载。这种做法是一种合理的妥协,不会增加太多额外的麻烦,同时也能解决当前的问题。
在这里插入图片描述

在这里插入图片描述

给字体打标签

为了优化字体资源的加载和管理,决定不直接存储所需的标签,而是在加载时通过显式的方式进行标签的添加。在实现上,首先会处理每个字体并为其分配一个索引,接着在字体加载后,通过添加标签的方式来标记每个字体资源。这种方法简化了对标签的处理,而不是将标签预先存储,确保每次加载时都能动态地进行设置。

在加载完字体资源之后,会为每个字体创建相关的字体资源对象,并确保这些字体可以被正常访问和使用。接着,设计上还会加入一个功能,让字体的大小也能进行动态调整,以便更灵活地控制字体显示效果。虽然这一过程中的代码并不要求特别复杂或优化,但它确保了实现的灵活性,并能满足基本的需求。

测试新的字体打包

希望通过改进字体的加载和写出方式,能够解决之前字体资源被不正确写出的 bug。首先,通过运行测试并使用资产构建器进行验证,确保修复生效。接着,通过运行游戏来检查效果,看是否能够正确加载并应用不同的字体。

为了进一步验证,决定在游戏的代码中尝试选择其他字体,查看是否能正确地加载和展示。通过这种方式来确认更改是否已成功应用,并确保字体选择功能能够正常工作。
在这里插入图片描述

选择不同的字体

把公共部分移到公共文件中防止两边来回改
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

希望在选择字体时,能够通过指定字体 ID 来正确地设置和使用相应的字体。具体操作是,在选择字体时,将当前的字体类型设置为想要的字体类型。为了实现这一点,需要确保正确设置字体类型,并与字体 ID 配合使用,从而可以在渲染时正确显示所选字体。

在具体实现过程中,通过查找字体对应的权重向量(weight vector),并为其指定正确的字体类型,这样就能在使用时动态选择不同的字体。接着,通过设置调试时的字体类型(例如 debug),可以方便地验证字体加载和显示是否正常。

在这里插入图片描述

通过使用标签系统,现在可以轻松地选择不同的字体了。通过设置相应的标签,就能够从中获取所需的字体类型。这正是预期的效果,因为能够验证字体是否正确加载,确保在界面中呈现出不同的字体。测试显示,字体切换功能正常,能够展示不同的字体,因此整个系统运作良好。
在这里插入图片描述

指定字体的大小

目前加载字体时,字体大小是硬编码在代码中的,因此需要更改代码,使其能够根据需要设置字体的大小。首先,设定了一个默认的像素高度(128px),并尝试为其他字体(如Calibri)设定不同的大小,虽然对于Calibri的实际像素大小并不确定,但尝试将其设置为12px。运行后,发现仍然使用了默认的字体。

经过调整字体大小后,尝试了不同的尺寸,最终设置了20px。虽然对于调试显示来说,这个大小更为合适,但在实际应用中可能需要适当的调整,以便更好地适应不同的屏幕分辨率和视觉效果。调试界面中的内容较多,因此需要更大的字体空间来显示。

尽管显示的更新和渲染过程存在问题,特别是需要对性能进行优化,确保渲染过程正确显示。但在当前的调试模式下,能够查看调用次数、总成本等信息是很有帮助的,这为后续优化和性能分析提供了参考。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

换一个宋体50大小 字体缩小一半
在这里插入图片描述

在这里插入图片描述

添加一些额外的 TIMED_BLOCK 调用

在进行调试和性能分析时,开始尝试使用时间块函数。通过在代码中加入一些简单的性能分析,可以很快查看不同部分的性能情况。当前的目标是对游戏中的不同功能进行实时性能分析,而不需要停止执行或者进行复杂的性能测试。

首先,开始在代码中添加一些性能分析标记,例如在实体的重叠检查、碰撞检查等功能上添加时间块。这些时间块并不一定要非常精确或者合理,目的是为了快速查看代码中不同部分的执行情况。通过这些标记,可以看到代码的执行周期,了解每个部分的执行时间。

接着,分析过程中也可以继续添加更多的时间标记,尽管有些功能可能并未实际调用,但可以继续添加到其他区域,例如音频、渲染、输入等部分。这些标记能帮助识别性能瓶颈,并提供调试的可视化数据。

此外,除了游戏实体外,其他部分如声音播放、渲染操作等也被加入了性能分析,所有功能几乎都可以通过添加时间块来追踪它们的执行时间。这为后续的优化和调试提供了非常直观的数据,允许在开发过程中快速查看和调整性能。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

我们还有两个问题需要解决:

1) 包围渲染过程的任何操作时机都不正确

首先,提到的一个问题是关于渲染计时的错误。渲染时使用的计时块在打印时仍处于开启状态,因此它们的计时数据是不准确的。为了修复这个问题,需要对计时进行延迟,确保在尝试访问这些计时数据之前,计时块已经关闭。解决方法是将计时数据延迟一帧,并且在打印时使用前一帧的计时数据。这样可以确保渲染的计时块在访问时已经正确关闭,确保输出的数据是准确的。这就是需要进行清理和更新渲染的原因。

2) 访问计时器不是线程安全的

第二个问题是性能计数器的线程安全问题。当前的系统在多线程环境中使用时,可能会导致不准确的结果。因为如果两个线程同时打开和关闭同一个计时块,就无法确定结果会如何。更糟糕的是,线程在打印计时数据时可能仍在运行,导致计时块仍然处于开启状态,进一步影响数据的准确性。因此,如果要让这个系统能够正确处理多线程,就需要进一步完善它,确保线程安全。虽然也许在某些情况下,可能会觉得难以实现这一目标,但至少应该尝试进一步完善系统。

尽管如此,即便没有解决这些问题,当前的调试系统仍然提供了非常有用的信息。通过现在的输出,可以清楚地看到每个操作的周期计数和命中计数,比如每帧渲染的元素数目(大约七百六十个),每个渲染元素需要约三十八个周期。尽管当前没有进行优化编译,周期数的意义可能会有所偏差(如果进行优化编译,周期数会下降),但这些数据已经让系统运行的状态变得更加可见,提供了更有价值的调试信息。

接下来,首先需要回顾并思考是否有可能解决这些问题,或者如何改进系统以应对这些挑战。
在这里插入图片描述

第一个问题可以通过显示计数器延迟一帧来解决……

我们至少应该能够解决某些问题,特别是在渲染过程中保持某些内容的开放状态。只需要将结果延迟一帧,就能实现这一点。因此,我们需要看看是否有可以改进的地方,以使这一过程更加流畅。

目前的情况是,当执行叠加循环计数器(OverlayCycleCounters)时,它会触发重置。这意味着当调用相关函数时,所有需要进行叠加的部分必须在此时已经关闭。因此,需要一种机制来确保这一关闭操作能够顺利完成,并且需要某种“心跳”机制来定期进行拷贝,以便在每一帧结束时,将相关的内存数据复制一份。这么做的目的是确保数据在使用过程中不会被修改,或者处于使用中的中间状态。

仔细思考后,这里可能存在一些创造性的解决方案,因此需要更详细地进行讨论。可以通过在白板上进行分析,以便找到最佳的实现方式。
在这里插入图片描述

……但我们首先解决线程安全问题

其实,我们重新考虑了一下,决定改变方向。最初的想法是从某个特定的方案入手,但现在觉得应该先尝试一个线程化的方案。相比之下,线程化的实现更值得深入研究,因此决定优先处理这个部分。

既然如此,就从线程化方案开始探索。对这一部分的实现有浓厚兴趣,希望能先围绕这个方向展开讨论和思考。那么,就让我们直接进入线程化的实现吧。

(黑板)线程安全的性能计数器

接下来,我们决定实现线程安全的性能计数器。这将是当前探索的重点。

线程安全是关键点,需要确保多个线程在访问和修改性能计数器时不会发生竞争条件或数据不一致的问题。同时,性能也不能受到太大影响,因此在设计时需要权衡同步开销和并发性能。

目标是设计一个高效且线程安全的性能计数器,使其能够在多线程环境下稳定运行,并且不会影响整体系统的性能。接下来,就围绕这个方向展开具体的实现和优化。

性能计数器的实现回顾。使用 Record->CycleCount 作为临时存储有问题,因为它应该包含最终结果

首先,需要检查当前实现的性能计数器方式,因为现有的方法并不是最优解,存在一定的问题。

目前的做法是,先获取当前的周期计数(CycleCount),然后先减去 __rdtsc()(假设是某个时间戳或基准值),再加回来。这实际上是通过减去起始时间来计算时间间隔。

在这里插入图片描述

struct timed_block {
    debug_record* Record;
    timed_block(int Counter, const char* FileName, int LineNumber, const char* FunctionName,
                int HitCount = 1) {
        Record = DebugRecordArray + Counter;
        Record->FileName = FileName;
        Record->LineNumber = LineNumber;
        Record->FunctionName = FunctionName;
        Record->CycleCount -= __rdtsc();
        Record->HitCount += HitCount;
#if 0
        ID = FindIDFromFileNameLineNumber(FileName, LineNumber);
        BEGIN_TIMED_BLOCK_(StartCycleCount);
#endif
    }
    ~timed_block() {  //
        Record->CycleCount += __rdtsc();
#if 0
        END_TIMED_BLOCK_(StartCycleCount, ID);
#endif
    }
};

但这个方法的问题在于,如果在执行这些操作时,另一个线程恰好读取了周期计数,就可能获得一个完全错误的值。原因是当前的实现将该值用作了临时缓冲,而在计算过程中,该值处于中间态,并不能代表一个正确的时间总量。

可以具体分析当前的计算逻辑:

  1. total 代表总时间值,clock_start 代表起始时间戳,clock_end 代表当前时间戳。
  2. 计算时,先执行 total - clock_start,得到 t1,然后再加上 clock_end,得到 t2
  3. 目标是计算 clock_end - clock_start,即 Δclock(时间增量),然后累加到 total 中。

问题在于,在 t1 这个计算中间状态时,t1 其实是 total - clock_start,它并不是真正的总时间,而是一个临时值。如果此时有其他线程读取 t1,会得到一个错误的时间值。只有 t0(初始总时间)和 t2(最终计算后的总时间)才是正确的,而 t1 是错误的中间值。

因此,必须确保不会在 t1 这个状态时暴露数据,而是直接从 t0 过渡到 t2,这样任何线程在任何时间点读取总时间值时,都会得到正确的结果。

下一步的目标是调整代码逻辑,避免暴露错误的 t1 状态,确保无论何时读取性能计数器,都能得到正确的数据。

使用单独的值来跟踪 StartCycles

解决这个问题的方法非常简单,只需要调整计数器的计算方式,避免对周期计数(CycleCount)进行直接修改。

具体实现如下:

  1. 记录起始周期:获取 StartCycles 并存储它。
  2. 计算时间增量:当需要更新时,获取当前的周期计数 current cycles,然后减去 StartCycles,计算 delta cycles(增量时间)。
  3. 累加到总计数:将 delta cycles 累加到 CycleCount,而不是直接修改 CycleCount

这样做的关键点是:

  • 避免直接写入 CycleCount,确保在整个过程中 CycleCount 始终保持正确的值,不会出现短暂的错误状态。
  • 无论何时有线程读取 CycleCount,它始终是正确的,不会暴露错误的中间值(例如 t1)。

然而,尽管这样可以确保线程安全性并消除数据竞争问题,但仍然存在另一个新的问题,需要进一步优化和解决。
在这里插入图片描述

同时访问 HitCount 和 CycleCount 也是一个问题

目前仍然存在一个小问题,那就是线程安全的处理方式仍然有些不够优雅。

在当前实现中,多个线程可能会同时修改 HitCount(命中计数)和 CycleCount(周期计数)。虽然这些值最终都会被正确的信息覆盖,因此不会影响最终结果,但在并发情况下,仍然希望能够保证它们的修改是原子性的。

在这里插入图片描述

struct timed_block {
    debug_record* Record;
    uint64 StartCycles;
    timed_block(int Counter, const char* FileName, int LineNumber, const char* FunctionName,
                int HitCount = 1) {
        Record = DebugRecordArray + Counter;
        Record->FileName = FileName;
        Record->LineNumber = LineNumber;
        Record->FunctionName = FunctionName;
        AtomicAddU32(&Record->HitCount, HitCount);
        StartCycles = __rdtsc();
#if 0
        ID = FindIDFromFileNameLineNumber(FileName, LineNumber);
        BEGIN_TIMED_BLOCK_(StartCycleCount);
#endif
    }
    ~timed_block() {  //
        uint32 DeltaTime = (__rdtsc() - StartCycles);
        AtomicAddU32(Record->CycleCount, DeltaTime);
#if 0
        END_TIMED_BLOCK_(StartCycleCount, ID);
#endif
    }
};

解决方案:

  1. 使用原子递增:对于 HitCount,希望能够确保它的累加是原子的,因此应该使用 锁定递增(locked increment) 来确保累积值的正确性。
  2. 使用原子加法:对于 CycleCount,应该使用 atomic add(原子加法)来确保多个线程累加时间时不会发生数据竞争。
  3. 使用 32 位原子操作:考虑到时间增量 delta cycles 可能不会超过 4,294,967,295(即 2 32 − 1 2^{32}-1 2321),因此可以使用 32 位原子加法 来更新 CycleCount,避免额外的同步开销。

具体优化步骤:

  • 恢复原子递增:曾经有一个 atomic increment 操作,但被移除了,现在应该重新加回来。
  • 确保所有关键变量的原子更新
    • HitCount 需要使用 atomic increment 进行累加。
    • CycleCount 需要使用 atomic add 进行累加,确保多个线程更新时数据不会出错。
  • 优化代码结构
    • 先计算 delta time(时间增量),然后执行原子操作,使代码逻辑清晰,并确保所有涉及原子操作的代码都可以清楚地看到。
    • 由于几乎所有的现代处理器都支持原子加法,因此不需要使用 compare-and-swap(CAS) 之类的复杂同步机制,直接执行 atomic add 即可。

最终效果:

  • HitCountCycleCount 都能在多线程环境下安全更新,不会出现竞争问题。
  • 代码保持简洁,不需要额外的锁或复杂的同步机制。
  • 性能不会受到显著影响,因为使用的是低开销的原子操作。

接下来,就可以继续优化代码,确保它能够稳定高效地运行。

实现 AtomicAddU32

现在只需要一个核心函数,即 原子加法(atomic add),用于 32 位整数的累加操作。
具体实现思路如下:

  1. 需要实现 atomic add,用于 32 位整数的安全累加。
  2. 这个操作需要处理 volatile 变量,以确保数据不会因为优化或缓存机制导致不一致。
  3. 目标是使用 Interlocked 系列函数,它们提供了处理器级别的原子操作,避免了额外的锁开销。

查找合适的原子操作

  • 需要一个 Interlocked Add 变体,可以在不使用复杂同步机制的情况下执行高效的原子加法。
  • InterlockedAdd 允许安全地对共享变量执行加法,而不会发生数据竞争问题。
  • 经过查找,发现 InterlockedExchangeAdd 是合适的选择,它能够原子地更新一个变量并返回旧值。

筛选合适的编译器内建函数

  • 需要查找适用于 x86/x64 体系结构Intrinsic(内建)函数,确保计算高效。
  • 过滤掉了 MIPS 相关的函数,因为几乎已经没有人再使用 MIPS 进行主流开发。
  • 确定 InterlockedExchangeAdd 是可行的选项,能够满足需求。

最终方案

  • 使用 InterlockedExchangeAdd 进行原子加法,确保 hit countcycle count 这两个变量能在多线程环境下正确更新。
  • 保持代码整洁,确保 delta time 计算清晰可见,并直接传入 InterlockedExchangeAdd 进行累加。
  • 消除额外同步开销,避免使用 Compare-And-Swap(CAS) 或显式锁机制,从而保证性能。

接下来就可以基于这个方案,继续优化代码,并测试其在线程竞争情况下的正确性和性能表现。

查阅 _InterlockedExchangeAdd 文档

现在的目标是验证 InterlockedExchangeAdd 的返回值,并确保它能正确执行原子加法。

理解返回值的行为

  • InterlockedExchangeAdd 的文档没有直接说明它的返回值,但从经验来看,它返回的是 原始值,即在执行加法之前变量的旧值。
  • 这样,我们可以在进行加法的同时获取变量的旧状态,以便在某些情况下进行额外处理。
  • 进行了一次盲测(blind guess),猜测返回值,并准备查看其是否符合预期,同时期待论坛上的反馈,以验证这一点。

类型调整

  • cycle count 之前是 uint32_t,但实际上 不再需要 这个特定类型,因此可以灵活调整。
  • 这样可以减少不必要的类型转换,同时优化对齐方式,提高缓存利用率。

结构优化

  • 由于 cycle count 之前的类型调整,导致额外的填充(padding),可以利用这些空间进行一些优化。
  • 考虑重新排列数据结构,优化对齐,以减少内存浪费,并可能提高访问效率。
  • 但是,过度优化可能会导致不可预测的行为,例如 hit count 可能会溢出,因此目前决定不再进一步紧缩,保持稳定性优先。

最终决定

  • 保留 InterlockedExchangeAdd 进行 hit countcycle count 的原子加法。
  • 暂不进行填充优化,以确保结构稳定性,避免计数溢出等问题。
  • 继续测试返回值,确认 InterlockedExchangeAdd 是否按预期返回旧值,以确保计算的正确性。

接下来可以进行更多的测试,确保这一方案在高并发环境下表现良好。
在这里插入图片描述

struct debug_record {
    const char* FileName;
    const char* FunctionName;

    uint32 CycleCount;
    uint32 LineNumber;
    uint32 HitCount;
    uint32 Reserved;
};

在这里插入图片描述

在这里插入图片描述

这看上去应该没什么问题

修正时间计数器的 _snprintf_s 格式说明符

首先,发现问题的原因是因为打印的变量类型与实际类型不匹配。打印时使用了 int64_t 类型,但实际操作的是不同的数据类型。于是决定先解决这个类型不匹配的问题,去掉不必要的 int64_t 类型转换。

问题解决

  • 修复打印类型:去除了 int64_t 类型的打印,确保与实际使用的数据类型一致。
  • 这一步骤后,结果看起来更合理了,输出也变得更加符合预期。

当前状态

  • 经过修正后,程序输出结果变得更加平稳和一致。
  • 目前程序运行状态已经变得较为正常,没有出现异常或者不合理的结果。

接下来,可以继续保持这种状态,进行进一步的测试和验证,确保所有操作都符合预期。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

通过将 CycleCount 和 HitCount 合并成一个 U64 来原子地更新它们

接下来考虑如何确保命中计数(hit count)和周期计数(cycle count)能够一起以原子的方式更新。为此,提出了一个将它们合并成一个64位的整数的新方案,其中高32位存储命中计数,低32位存储周期计数。这个方法有几个优点:

方案分析

  1. 合并命中计数和周期计数

    • 命中计数放置在64位整数的高32位,将周期计数放置在低32位。
    • 这样可以确保这两个值能一起进行原子更新,避免了它们之间的不一致问题。
      在这里插入图片描述
  2. 利用64位整数的位操作

    • 通过位移操作,将命中计数和周期计数合并成一个64位的整数,之后只需要对这个64位的整数进行原子加法操作。
    • 命中计数和周期计数的合并方式是:命中计数左移32位,与周期计数相加。这样,两个值就被安全地封装在同一个64位整数中。
      在这里插入图片描述
  3. 原子操作

    • 使用原子加法(atomic add)来更新合并后的64位整数。这样,无论有多少线程同时修改这个值,都可以确保更新是安全且一致的。
      在这里插入图片描述
  4. 简化计数清除过程

    • 清零操作也变得简单,只需清除整个64位整数即可,同时清除命中计数和周期计数。
      在这里插入图片描述

具体实现步骤

  1. 合并命中计数和周期计数

    • 将命中计数(hit count)左移32位,然后和周期计数(cycle count)进行合并,形成一个64位整数。
  2. 执行原子操作

    • 使用InterlockedExchangeAdd64(或者类似的原子加法操作)来确保更新操作是原子的。
  3. 提取命中计数和周期计数

    • 当需要分别访问命中计数和周期计数时,通过位操作提取高32位(命中计数)和低32位(周期计数)。
  4. 清零操作

    • 清零64位整数,能够同时清除命中计数和周期计数,简化了计数器的重置过程。

解决的问题

  • 之前的问题是,在多线程环境下,命中计数和周期计数可能会被不同线程同时更新,导致读取到不一致的值。通过将这两个值合并为一个64位整数,并使用原子操作,可以确保无论在哪个线程中执行,都能够保证更新的原子性,从而避免了计数不一致的问题。
  • 之前可能会出现周期计数更新完成,但命中计数未更新的情况,从而导致错误的时间报告。新的方案通过确保两个计数同时更新,避免了这种错误。

最终结果

通过这种方式,不仅可以保证原子性,还简化了代码的实现。所有的计数器更新和清除操作都变得更加简单和高效,同时避免了多线程并发时的数据不一致问题。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

使用无条件的原子交换,使得读取和重置计数器也变得线程安全

目前已经几乎解决了之前的问题。之前的主要问题是读取和重置操作不是原子性的,这会导致可能错过某个调用,从而影响计数。为了防止这种情况发生,可以通过将读取和重置操作变成原子操作来解决。

解决方案

  1. 原子交换操作

    • 通过引入原子交换(atomic exchange)来解决这个问题。原子交换操作会用新的值替换当前值,并返回替换前的原始值,这确保了在交换过程中,不会有其他线程同时修改值。
    • 这种方法确保了我们不会在读取后清空计数器时遗漏任何一次调用,也避免了在清空计数器和报告计数之间发生并发问题。
  2. 原子交换实现

    • 使用 InterlockedExchange64 来实现这个原子交换操作,操作会原子地交换64位计数器的值。
    • 通过这种方式,计数器在被清空的同时,不会丢失任何数据。
  3. 确保清空与报告同步

    • 通过使用原子交换操作,确保在报告计数时,计数器能够正确地被清空,并且不会在两者之间发生任何并发修改。
    • 这意味着计数器在报告后被清空,从而避免了在重置计数器时被其他线程修改的情况。

总结

通过使用原子交换,确保了在读取计数器并进行重置时,不会错过任何调用。这种方法消除了并发访问时可能出现的问题,保证了计数器的操作是完全同步和原子的。这解决了之前因操作不原子而导致的计数不准确的问题,确保了计数的准确性和一致性。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

我们现在是线程安全的

目前的实现看起来已经相当安全,能够在线程并发的情况下正确地进行操作。现在,无论多少线程同时访问,都能正确地进行汇总并保证计算结果的准确性。通过一些额外的步骤,可以进一步改进线程信息的报告,但目前来看,报告已经非常稳定了。

总结

  1. 线程安全性

    • 所有线程都能安全地对计数器进行修改,不会出现并发冲突,确保线程安全。
    • 即使多个线程同时操作,也能够正确汇总结果,确保数据一致性。
  2. 性能

    • 目前的实现具有非常低的开销,特别是在时间块操作中,只有一个原子操作,这是性能瓶颈所在。
    • 原子操作的开销虽然不算低,但在整体系统的上下文中,这个开销是可以接受的,并且已经很小。
  3. 数据一致性

    • 计数器和周期计数看起来都非常合理,所有的报告都对得上,说明系统的运行是稳定的,计数结果是可信的。
  4. 进一步优化

    • 尽管当前实现已经很稳定,但依然可以考虑进一步优化,尤其是关于线程信息的更精确报告。

报告 DebugRecordsMain 和 DebugRecordsOptimized

这段代码涉及到优化和调试记录的输出。首先,代码希望能够打印出两个数组的调试信息——一个是“DebugRecords_Main”(调试记录主数组),另一个是“DebugRecords_Optimized”(调试记录优化数组)。目的是同时输出这两个数组的内容,并且避免使用标准输入输出流(Standard IO)库。

为了实现这个目标,代码需要处理以下几个问题:

  1. 数组计数和指针传递: 为了打印出这两个数组的调试记录,代码需要传递数组的计数(array count)和数组指针(debug record pointer)。这些计数和指针将用于打印调试记录的详细信息。
    在这里插入图片描述
debug_record DebugRecordArray[__COUNTER__];
internal void OutputDebugRecords(uint32 CounterCount, debug_record *Counters) {
    for (uint32 CounterIndex = 0; CounterIndex < CounterCount; ++CounterIndex) {
        debug_record *Counter = Counters + CounterIndex;
        uint64 HitCount_CycleCount = AtomicExchangeUInt64(&Counter->HitCount_CycleCount, 0);
        uint32 HitCount = (uint32)(HitCount_CycleCount >> 32);
        uint32 CycleCount = (uint32)(HitCount_CycleCount & 0xFFFF'FFFF);
        Counter->HitCount_CycleCount = 0;
        if (HitCount) {
#if 1
            char TextBuffer[256];
            _snprintf_s(TextBuffer, sizeof(TextBuffer), "%s: %ucy %uh %ucy/h\n",  //
                        Counter->FunctionName,                                    //
                        CycleCount,                                               //
                        HitCount,                                                 //
                        CycleCount / HitCount);
            DebugTextLine(TextBuffer);
            HitCount = 0;
            CycleCount = 0;
        }
    }
#endif
}
  1. 输出函数设计: 代码设计了一个函数,用于根据传入的数组计数和指针输出调试记录。该函数可以在调试过程中被调用两次——一次用于“DebugRecords_Main”,一次用于“DebugRecords_Optimized”。
    在这里插入图片描述
internal void OverlayCycleCounters(game_memory *Memory) {
    (void)Memory;
#if GAME_INTERNAL
    DebugTextLine("\\#900DEBUG \\#090CYCLE \\#^5COUNTS:");
    OutputDebugRecords(ArrayCount(DebugRecords_Optimized), DebugRecords_Optimized);
    OutputDebugRecords(ArrayCount(DebugRecords_Main), DebugRecords_Main);
#endif
}
  1. 避免使用标准IO: 代码强调不使用标准IO库,避免依赖外部库,尤其是在没有必要的情况下,保持代码简洁。

  2. 调试记录计数: 在代码中,记录的计数(例如“DebugRecords_Optimized count”)被动态计算,并传递给输出函数。为了获取正确的计数,可能需要通过某些额外的逻辑来确保准确性。

  3. 处理外部符号问题: 在实现过程中,代码可能遇到了外部符号链接问题,特别是在连接调试记录的计数和指针时。需要确保这些符号在代码中正确声明和定义,以便编译器可以正确链接。

  4. 修正代码错误: 如果在编译时遇到“未声明的标识符”或“外部符号未解析”的错误,需要检查变量和函数是否已正确声明,并确保它们在正确的作用域内。

总体来说,核心任务是通过扩展输出功能,能够同时输出主记录和优化记录的调试信息,并解决相关的符号和计数问题。

按道理程序要么是debug 要么是release

貌似这个DebugRecordsOptimized 之前理解的不一样 单独把DrawRectangleQuickly 函数提取到game_optimized.cpp 中用-O2 编译生成obj

在这里插入图片描述

函数移到新的game_optimized.cpp文件中
在这里插入图片描述

头文件中申明一下
在这里插入图片描述

在这里插入图片描述

添加DebugRecords_Optimized 数组
在这里插入图片描述

在game_optimized.cpp 文件中定义
在这里插入图片描述

编译game_optimized.cpp为obj然后链接一下
在这里插入图片描述

在这里插入图片描述

生成了obj
在这里插入图片描述

在这里插入图片描述

提示的是game.cpp.obj 链接不到给他链接
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

这个也得要改
在这里插入图片描述

链接不同的文件定义不同的宏
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

还要报告 TIMED_BLOCK 的行号

在调试过程中,出现了 DrawRectangleQuickly 被调用两次的现象,这让人觉得有些奇怪。我们意识到这是因为代码中存在两个部分,它们分别调用了 DrawRectangleQuickly。为了更清楚地了解调用的具体位置,可以考虑在输出时添加行号信息。这样,我们可以直观地看到这两个调用分别来自哪里,并能确定它们是否在不同的位置调用,或者是否有重复的调用发生。

此外,考虑到程序正在使用多线程运行,可能会带来更多的调试挑战。多线程的执行可能导致更复杂的调用情况,因此,在进行调试时,除了检查代码本身的逻辑外,还需要考虑线程的执行顺序和并发执行的影响。

在调试过程中,为了更好地理解代码执行的情况,可以进一步扩展输出的信息,比如输出每个调用发生的行号,甚至是其他调试信息,这样可以帮助更准确地定位问题。在时间紧张的情况下,可能无法完全解决所有问题,但这确实是一个值得注意的方向,能够帮助调试过程变得更加清晰和高效。
在这里插入图片描述

在这里插入图片描述

应该会有两次只显示一次很奇怪
在这里插入图片描述

值好像是2
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

很奇怪
在这里插入图片描述

添加更多的 TIMED_BLOCKs

如果我们的代码是线程安全的,那么我们可以直接把 phil gramm sunken 放进去。然而,这样做似乎没有太大意义,因为我们正在执行任务,这只是一个短语,把它放进去并没有特别的作用。

关于游戏资源管理,我们需要考虑如何直接加载资源,并且在某些情况下,我们需要一个机制来分配资源的内存。对于资源加载,我们已经放置了相关的逻辑,但是在某些地方,时间的控制似乎没有被正确地应用。

例如,资源加载过程中,某些操作应该有时间控制,但是目前并没有正确地进行计时管理。像加载音频数据、位图等资源时,当系统空闲时,我们应该利用这一空闲时间来执行资源加载任务,而不是等待特定的时机。

资源管理的核心在于高效分配内存并进行合理调度,确保在资源空闲时能够执行必要的任务,而不会造成不必要的延迟或资源浪费。因此,我们应该优化资源加载逻辑,使其能够在合适的时间点执行,从而提高整体性能。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

如果使用 union 来存储 HitCount 和 CycleCount 会不会有帮助?看起来不需要一直记住它们的偏移和长度

使用原子操作时,我们并不希望处理过多复杂的细节,比如偏移量计算或者低级别的管理。对于类似命中计数和周期计数的功能来说,某些优化手段可能并不是必要的,因为它们增加了额外的认知负担,而收益却未必明显。

在涉及原子交换操作时,避免额外的微管理是关键。使用原子操作的目的通常是为了保证多线程环境下的数据一致性,而不是引入额外的复杂性。因此,确保这些操作的简单性和可维护性比优化某些细节更加重要。

此外,依赖编译器的行为可能会带来额外的不可控因素。不同的 C 或 C++ 编译器可能会以不同方式优化代码,而语言标准委员会的决定也可能影响某些底层实现,比如对联合体(union)的处理方式。这种不可预测性可能会导致额外的调试成本,因此在使用这些功能时,更倾向于直接控制,而不是依赖编译器的内部优化策略,以确保代码的稳定性和可读性。

昨天我问过关于 FILELINE 等预处理常量的事。经过搜索,我发现了 TIMEDATE。也许我们可以在日志中加入日期时间戳?

在日志中包含时间和日期戳可能会有一定的用处,但需要考虑具体的使用场景。例如,如果只是想记录编译时间和日期,那么这些信息的实际价值可能并不大,因为它们仅仅反映了编译时的时间戳,而不是运行时的动态信息。

然而,在某些情况下,比如进行错误报告或调试时,时间戳可能会有所帮助。将时间和日期戳嵌入构建信息中,可以用于标识具体的编译版本,方便在出现问题时追溯错误来源。这种方式对于跟踪不同构建版本的运行表现可能会有所帮助,但在日常开发过程中,除非有明确的需求,否则这些信息的作用较为有限。

总体来看,时间和日期戳的使用需要结合具体需求,在某些情况下,比如调试和错误报告,它可能是有用的,但如果只是作为普通日志记录的一部分,其价值可能不大。

你能用 %32s 对齐文本吗?比如在 sprintf 中?

可以使用格式化字符串的方式来对齐文本到列,例如使用 printfsprintf 之类的函数,并指定格式,如 "%32s" 这样的方法来确保文本在输出时对齐到 32 个字符宽度的列中。如果需要对齐多个列,可以使用多个格式化字符串,并调整每个字段的宽度,使其符合需求。

假设输入的数据需要按照一定格式排列,例如日志记录、调试信息或状态输出,那么可以使用类似 printf("%-32s %-32s %-32s\n", col1, col2, col3); 这样的方式,将多个字段对齐并以列的方式呈现。

此外,如果有需要,还可以根据具体需求调整对齐方式,例如左对齐 %-32s、右对齐 %32s,或者使用其他方法来确保数据的美观和可读性。这样,在打印日志或者调试信息时,就能更方便地查看数据,减少因格式混乱带来的理解成本。

在这里插入图片描述

在这里插入图片描述

game.cpp: 将文本对齐到列

如果要将数值按列对齐进行格式化输出,特别是循环计数(cycle count),可能会遇到一些挑战。循环计数的数据往往比较复杂,可能由多个数值或字段组成,因此简单的格式化方式可能无法完美对齐。

在格式化字符串时,可以使用 printfsprintf 之类的函数,并结合适当的格式化控制符。例如,可以使用 "%32s" 来对齐文本,或者 "%10d" 之类的方式来对齐数值。不过,即便如此,在处理循环计数时,可能仍然需要进行额外的调整,以确保数据按照预期的方式排列。

此外,还需要考虑在调试系统中支持代码编辑功能的问题。实际上,这并不会涉及太多额外的工作,但仍需要记住去实现这一点,以便更顺畅地进行代码修改和调试。整体而言,调整格式化输出的工作仍需进一步优化,以确保最终的显示效果符合预期。

你昨天提到过析构函数会在对象作用域结束时调用。我只是想提醒一下,如果你用 “exit” 来退出函数而不是用 ‘return’,析构函数是不会被调用的!

昨天提到,当对象的作用域结束时,会调用析构函数。但是,需要注意的是,如果使用某种方式退出函数,而不是通过 return 语句,可能会导致析构函数没有被调用。

具体而言,如果某种方式被用来退出函数,而不是 return,那么可能会影响析构函数的执行。这引发了一个问题,即如何正确管理资源释放,确保对象的生命周期按预期结束并正确调用析构函数。

需要明确的是,“使用它来退出函数” 的具体方式是什么?例如,若是 longjmpexitabort 甚至是某些异常处理方式,可能会绕过正常的栈解开(stack unwinding)机制,从而跳过局部对象的析构函数调用。这种情况下,可能需要使用 RAII(资源获取即初始化)模式,或者其他异常安全的策略,以确保在非正常退出时,仍然能正确释放资源。
在C++中,exit 函数是一种直接终止程序的方式,它会立即结束程序的执行,而不会正常地调用析构函数。这是因为 exit 是C标准库中的函数,它的语义是直接退出程序,不走C++的正常销毁流程(如栈上的局部对象的析构)。

具体分析:

  1. 正常程序退出

    • 当程序通过 returnmain 函数返回,或者通过程序正常结束时,C++会确保栈上的局部对象按照构造的逆序调用析构函数。这是C++的RAII(Resource Acquisition Is Initialization)机制的核心。
  2. exit 的行为

    • 调用 exit(例如 exit(0)exit(1))会立即终止程序。它会执行一些清理工作,比如调用注册的 atexit 函数(C风格的清理机制),并刷新标准输出缓冲区,但它不会触发栈上对象的析构函数。
    • 这意味着,如果你依赖析构函数来释放资源(例如关闭文件、释放内存等),这些清理工作在调用 exit 时不会发生。
  3. 代码示例

    #include <iostream>
    #include <cstdlib>
    
    class Test {
    public:
        Test() { std::cout << "Constructor called\n"; }
        ~Test() { std::cout << "Destructor called\n"; }
    };
    
    int main() {
        Test t;
        std::cout << "Before exit\n";
        exit(0); // 直接退出
        std::cout << "After exit (unreachable)\n";
        return 0;
    }
    

    输出

    Constructor called
    Before exit
    
    • 你会发现析构函数 ~Test() 没有被调用,因为 exit 中止了程序的正常销毁流程。
  4. 解决方法

    • 如果你希望确保析构函数被调用,避免使用 exit。可以直接从 main 返回,或者抛出异常并捕获(如果适用)。
    • 如果必须使用 exit,可以考虑手动清理资源,或者使用 atexit 注册清理函数,但这不如C++的析构函数优雅。

结论:

是的,调用 exit 时,析构函数不会被调用。如果你需要依赖析构函数执行清理操作,建议避免使用 exit,而是让程序自然退出。

在这里插入图片描述

如果线程在构造函数和析构函数调用之间被抢占,并调度到另一个 CPU,rtdsc 会给出错误的结果吗?

如果在线程的构造函数和析构函数调用之间,线程被抢占并调度到另一个 CPU 上,确实可能导致计时结果出错。但这个问题需要具体情况具体分析。

首先,这种情况确实存在,但是否需要特别关注取决于具体需求。如果只是想知道某个执行过程中的时间变化,那么这类抢占只会导致数据上出现一个短暂的异常峰值,不会影响整体趋势。如果重点是精确测量时间,并且希望消除调度影响,那么就需要考虑更严格的同步机制。

操作系统的任务切换会影响缓存状态、寄存器等,因此无法完全消除上下文切换的影响。但在 Windows 中,系统提供了一种机制,可以在任务切换时保存和恢复时间戳计数(TSC,Time Stamp Counter),确保线程在恢复运行时能够正确计算 CPU 周期数。这个 API 允许获取任务在被切换出去前的计时信息,从而尽可能减少由于调度导致的误差。

具体的 API 名称一时记不清,但可以通过查阅 Windows 文档或在线搜索来找到相关函数。如果时间精度对应用非常重要,可以考虑使用这些操作系统提供的功能来进行更精确的计时。至于其他操作系统是否提供类似的机制,需要进一步查阅相关资料。

一般编程问题:你有什么优化代码性能的一般建议吗?例如,多线程,算法复杂度分析等?

关于代码性能优化和多线程优化的问题,首先需要回顾之前已经进行的优化工作。优化是一个系统性的过程,涉及多个层面的考量,包括算法选择、数据结构设计、缓存优化、并发控制等。

对于多线程优化,关键在于如何高效管理线程间的同步,避免资源争夺(如锁竞争),同时充分利用 CPU 资源提升并行度。一般来说,减少不必要的同步操作、使用无锁数据结构、合理划分任务粒度都是重要的优化方向。

优化不仅仅是提升执行速度,还需要权衡可维护性和可读性,避免过度优化导致代码复杂度增加。此外,优化并非一蹴而就,通常需要结合具体场景,通过性能分析工具定位瓶颈,然后针对性地进行改进。

如果需要一个系统性的优化入门,可以先回顾之前涉及优化的内容,其中已经涵盖了部分优化思路和实践。当然,这并不是优化的全部,后续还会涉及更多的优化内容,可以持续关注和深入学习。

我在前一个问题中打错了:你昨天提到过析构函数会在对象作用域结束时调用。我只是想提醒一下,如果你用 “exit” 来退出函数而不是用 ‘return’,析构函数是不会被调用的!

关于exit和函数返回值的问题,首先需要澄清的是,exit并不是用于退出函数的,它是用于退出整个应用程序的。当程序调用exit时,整个应用程序将会终止,而程序中的析构函数(destructor)不会被调用。因为程序已经终止,所有的资源都会被立即释放,析构函数没有机会执行。

而如果是通过return语句退出函数,函数的析构函数就会被正常调用,因为return只是结束函数的执行,而程序本身并没有终止。所以,exitreturn有本质的区别,exit是用来退出整个应用程序的,而return只是结束当前函数的执行。

当值每帧都在变化时,读取起来非常困难。不会更好地把它们平均一下吗?

关于数值的变化问题,虽然每一帧的数值变化很快,导致它们难以阅读,确实有想法提到是否应该对这些数值进行平均化处理。虽然不能直接说平均化处理就是最好的方法,但随着后续的进展,会进行一些改进和优化,特别是在可视化这些数值方面。虽然现在还没有达到那个阶段,但会有一些措施来帮助更好地展示这些数值,以便更清晰地理解和分析。

你是否考虑过将计时器放在线程本地存储中,而不是每次写入时都使用原子操作?

关于将定时器存储在线程局部存储中,而不是每次写入时使用原子操作的问题,首先,需要考虑到线程局部存储只有在某些平台上可用,而且使用线程局部存储涉及到一些操作,可能会影响性能。更重要的是,如果选择使用线程局部存储,就需要回答如何从中获取数据的问题,什么时候做这个操作,以及哪个线程负责执行这些操作。如果所有线程都在某个单一的调试点上等待,可能会导致性能瓶颈,进而影响到观测的准确性。

与此相比,原子操作几乎是免费的,大多数情况下,原子操作的开销非常小,只需要几次CPU周期。所以,把数据存储在线程局部存储中,可能会比直接使用原子操作带来更大的性能影响,甚至可能是显著的。因此,将数据存储在线程局部存储中并不是一个很好的选择,反而可能会对性能造成更大的负面影响。

无锁数据结构是否值得花时间去编写?

关于无锁数据结构的成本,是否便宜以及它们的写入时间,这实际上取决于具体的情况。如果某个操作对性能非常关键,那么可能需要特别注意选择无锁数据结构,这样可以避免锁带来的性能开销。然而,如果性能不是最主要的考虑因素,那么就不一定需要使用无锁数据结构。在不追求极致性能的情况下,可以选择其他类型的数据结构,这样能在开发上更简便,同时也能满足大多数需求。

你认为将时间记录做成层级结构(像调用树)是否值得?如果值得,你会怎么做?

关于将时间记录做成层次化的逻辑树,这个方法一般来说并不被认为特别有用。通常,层次化记录的方式是为了那些不清楚自己代码如何工作的开发者提供的。大部分情况下,代码调用是比较清晰的,只有偶尔才会遇到不知道哪个部分在代表什么调用的情况。

虽然如此,在一些特定情况下,确实有需要从内部调用中剥离掉一些时间的需求,这时候层次化的时间记录会有一些帮助。但问题在于,如果要跟踪层级调用,可能需要使用线程局部存储(thread local storage)来正确实现。这就需要谨慎,因为不小心可能会让系统变得更为复杂和侵入。

在平台支持线程局部存储的情况下(比如Windows平台),实现这个功能可能不会非常昂贵,所以可以考虑进行这种时间记录的跟踪。可是,在不支持线程局部存储的平台上,若要实现这种层次化的调用记录,则可能会变得相当复杂,并且影响代码的简洁性。

你能解释一下Interlocks和Mutexes操作的区别吗?

互锁(Interlocks)

在硬件层面,互锁通常指的是通过 原子操作 实现的同步机制,如原子加法或原子比较交换等。处理器会确保操作的 原子性,即一系列操作要么完全成功,要么完全失败,不会在执行过程中被打断。这些操作一般用于保证并发线程在访问共享资源时不会发生冲突,并且能在多个核心之间保持数据的一致性。互锁的关键特性是它们 轻量级,通常非常高效,特别是对于小规模的、频繁执行的操作。

互斥锁(Mutex)

Mutex(互斥锁)是一种用于多线程程序中的同步机制,目的是确保在同一时刻只有一个线程能够访问特定的资源。具体来说,mutex 是一个 编程概念,它通常基于 原子操作 来实现:

  • 当一个线程想要访问共享资源时,它会先尝试获取互斥锁。如果锁是空闲的(比如值为0),线程就会设置锁并执行代码。
  • 如果锁已经被其他线程占用(锁的值为1),那么线程会进入等待状态,直到锁被释放。

mutex 的实现通常比互锁更复杂,因为它可能涉及 线程的阻塞与唤醒,这使得 mutex 的性能开销相对较高,特别是在高竞争的情况下。为此,操作系统通常会为线程的等待提供优化,减少 CPU 资源的浪费。

总结两者的区别:

  1. 互锁(Interlocks):通常指硬件级的同步机制,通过原子操作来保证并发执行时对共享资源的访问互斥,开销小、效率高,适用于频繁的小操作。

  2. 互斥锁(Mutex):是一种较为复杂的编程概念,依赖锁机制来控制线程对共享资源的访问,可能会导致线程阻塞,需要操作系统的调度支持,因此开销较大,适用于较为复杂的同步操作。

关键区别:

  • 互锁(Interlocks)一般用于硬件级的原子操作,性能较高,适用于简单的同步任务。
  • 互斥锁(Mutex)是更为复杂的软件同步机制,涉及线程的阻塞和唤醒,开销较大,适用于复杂的同步场景。

黑板:互锁操作与互斥锁

处理器内部包含多个核心,每个核心都有自己的缓存,例如L1缓存。每个核心可能会有独立的L1缓存,同时还可能有共享缓存,这些缓存用于加速内存访问。

当执行原子操作(atomic operation)时,具体操作的核心会检查所访问的内存地址是否已经缓存。如果要执行一个原子加法(atomic add)操作,处理器首先会检查该内存地址是否在本地核心的缓存中。如果地址不在本地缓存中,处理器就会从共享缓存或其他核心的缓存中获取数据。这时,处理器需要确保它对该地址的访问是独占的。

例如,当核心执行原子加法时,它会尝试获取该内存地址的独占访问权限。如果该地址之前被其他核心拥有,处理器会标记该缓存中的数据为“过时”,并使该数据的状态变为不可用。这个过程可能涉及将数据从一个核心的缓存移除并加载到另一个核心的缓存中,但这并不增加额外的开销,因为如果数据不在当前核心的缓存中,处理器本来也需要从主内存加载数据来执行加法。

原子操作的关键在于,它确保了读取和写入操作是连续的,即不会发生“中途被抢占”现象。这个过程本身是非常高效的,通常不会产生显著的性能损耗。

而**互斥锁(mutex)**则是基于原子操作之上的更复杂的同步机制,主要用于保证多线程环境中,同一时刻只有一个线程能访问某些共享资源。互斥锁通过原子操作(如原子比较交换)来实现锁的获取与释放。具体来说,互斥锁使用一个共享的内存位置来表示锁的状态:

  1. 当一个线程希望获取锁时,它会尝试通过原子比较交换(atomic compare-and-swap)操作将锁状态设置为“已被占用”(如设置为1)。
  2. 如果交换操作成功(返回0),表示该线程成功获取到锁,开始执行代码。
  3. 如果交换操作失败(返回1),表示锁已经被其他线程占用,该线程将进入等待状态,直到锁被释放。

在代码执行完毕后,线程会释放锁(将锁状态设置为0),然后其他等待的线程可以尝试获取锁并执行它们的任务。

互斥锁的操作可以是自旋的,即当锁被占用时,线程不断检查并尝试获取锁,直到成功为止。但这种方式可能导致CPU空转,占用过多的处理器时间。为了避免这一问题,互斥锁还可以通过操作系统来管理,在线程无法获取锁时将其挂起,从而释放处理器时间给其他线程。

总结原子操作和互斥锁的区别

  1. 原子操作(如原子加法)是硬件级别的操作,确保对某个内存地址的访问是独占的,并且操作非常高效。原子操作本身不会引起阻塞或线程调度,它的开销通常较小。

  2. 互斥锁(mutex)是软件级别的同步机制,基于原子操作实现,但它涉及线程的等待和唤醒机制。当一个线程无法获取锁时,它通常会进入阻塞状态或自旋等待,直到其他线程释放锁。互斥锁可能导致较高的性能开销,尤其是在高竞争的情况下。

  3. 互斥锁的复杂性:在没有竞争时,互斥锁的性能与原子操作相近。然而,在竞争激烈的情况下,互斥锁的开销会显著增加,因为它可能涉及到线程的挂起和恢复,这需要操作系统的调度支持。

  4. 自旋锁与操作系统管理:互斥锁的自旋锁(spinlock)版本会不断地检查锁是否可用,而不进入阻塞状态,可能会导致CPU资源的浪费。为了避免这种浪费,操作系统可以通过睡眠(sleep)机制来管理线程的等待,使线程在等待锁时不占用处理器。

性能对比:

  • 无竞争的互斥锁原子操作的性能非常接近。
  • 竞争激烈的互斥锁(即多个线程同时尝试获取锁)可能会带来较大的性能损耗,尤其是在自旋锁或高频繁的锁请求情况下,可能导致其他线程的阻塞,甚至出现死锁

简而言之,原子操作(Interlocks)通常用于较简单、频繁的同步任务,而互斥锁(mutex)则适用于需要更复杂同步的场景,尽管它的性能开销相对较高。

你能修复声音结束时的点击 bug 吗,使用调试系统?

在处理音效时,出现了一个点击声的 bug,特别是在声音播放结束时。我们有可能通过调试系统来修复这个问题,虽然没有调试系统,我们也能解决这个问题。只是目前为止我们没有投入太多时间在音效的处理上。

然而,这个点击问题也可以作为一个测试调试系统的好机会。我们可以把它当作一个实验,看看调试系统是否能让我们更轻松地定位和修复这个 bug。这个想法并不是一个坏主意,因为它可以帮助我们验证调试系统的有效性和方便性。

我注意到你喜欢把东西放在栈上并按值返回。0) 这样有助于引用局部性,并且对缓存更友好吗?我没错吧? 1) 栈的大小是否需要关注? 2) 返回值时,复制对象并返回的时间是否值得担心?也许当结构体很大时会有问题?在这种情况下,你更喜欢返回指向对象的指针吗?

通常来说,使用栈来存储和返回值是为了提高局部性和写入效率。如果结构体较大且函数没有内联的话,通常会选择返回指向对象的指针而不是返回副本。对于栈的大小,一般情况下并不需要太过担心,因为在实际使用中,并不会使用特别大的数据结构。通过返回指针,可以避免复制大型对象,提高效率。

线程如何等待无锁数据结构?它们会等待吗?

在讨论无锁数据结构时,实际上“无锁”这个术语并不完全准确。正确的术语应该是“无等待”(wait-free),这是由Maurice Herlihy提出的概念。无等待同步意味着多个并发执行的线程永远不会进入一种状态,使得它们无法继续推进。换句话说,无等待结构保证了每个线程在某一操作后总会朝着前进的方向发展。

Herlihy的研究彻底改变了多线程编程的理解,并且解决了关于多线程同步的一些长期未解的问题。在他的研究中,最重要的概念之一是“共识数”(consensus number)。共识数是指多个线程如何达成一致的能力。如果系统只能提供像“原子自增”或“原子递减”这样的操作,那么它的共识数最大只能为1,也就是说,无法支持更复杂的多线程同步算法。而具有“原子比较交换”(atomic compare-and-swap)操作的系统则具有无限的共识数,可以支持无限多个线程在状态上达成一致。

这种“无等待”结构与传统的锁机制不同,后者会导致线程阻塞和等待,而“无等待”保证每个线程都可以在有限时间内完成自己的操作,并释放资源,不会发生相互等待的情况。

因此,要理解线程如何在无等待数据结构上等待,需要从“无等待”同步的角度去思考。简单来说,线程在操作时并不会阻塞或等待其他线程,它们只是执行操作并释放资源,确保每个线程的操作都能在固定时间内完成,而不会被其他线程的操作影响或阻塞。
在这里插入图片描述

https://cs.brown.edu/~mph/Herlihy91/p124-herlihy.pdf
在这里插入图片描述

为什么不使用指向内联函数的指针?

关于指向内联函数的指针,问题在于内联函数的工作方式。内联函数并不是在内存中有一个明确的存在,而是会在调用时被展开成代码,也就是说,内联函数的实现会直接插入到调用它的代码位置。因此,无法像普通函数那样创建指向内联函数的指针,因为内联函数本身并没有在内存中有一个实际的地址,它只是一个宏式的展开。

总结来说,内联函数不在内存中占有一个独立的位置,它仅仅在调用时展开为代码,所以无法像普通函数那样通过指针进行引用。


网站公告

今日签到

点亮在社区的每一天
去签到