欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 健康 > 养生 > 游戏引擎学习第182天

游戏引擎学习第182天

2025/3/30 0:31:33 来源:https://blog.csdn.net/TM1695648164/article/details/146500248  浏览:    关键词:游戏引擎学习第182天

回顾和今天的计划

在这里插入图片描述

昨天的进展令人惊喜,原本的调试系统已经被一个新的系统完全替换,新系统不仅能完成原有的所有功能,还能捕获完整的调试信息,包括时间戳等关键数据。这次的替换非常顺利,效果很好。

今天的重点是在此基础上继续深入研究,因为虽然数据已经成功采集,但还没有真正利用这些数据。因此,今天的目标是分析目前的系统,找出可能存在的缺陷,并尝试解决这些问题。如果时间充足,还会探索如何更好地使用这些数据。

目前有一个小问题需要解决,这是一个近期在实际开发中较少遇到的问题。由于代码结构的不同,之前的项目并不需要解决这一问题,但在当前的代码中,这个问题必须被解决。虽然已经有一个可能的解决方案,但仍需进一步验证其是否合适。

接下来的步骤包括:

  1. 分析当前系统的不足之处——确定哪些问题需要解决,以及哪些问题可以暂时忽略。
  2. 优化数据的可视化——如果时间允许,将开始改善数据的展示方式,使其更加直观。
  3. 改进性能分析工具——通过增强数据的可视化,进一步优化当前的性能分析系统。

数据的可视化改进将重点关注几个方面,例如:

  • 计算函数的独占执行时间:区分一个函数本身的执行时间和它调用的其他函数的执行时间,从而获得更准确的性能分析数据。
  • 事件发生的相对时间:分析不同事件之间的时间关系,以便更好地理解系统的执行过程。

最终目标是打造一个高效的内联性能分析系统。实际经验表明,开发这样一个系统并不需要太长时间,在几周的开发时间内,即便是非全职的工作量,也可以构建出一个功能完整、易用的性能分析工具。拥有这样的系统,可以大幅提升代码优化和调试的效率。

关于拥有一个优秀的内联性能分析系统的好处α

在开发过程中,始终保持对程序运行情况的了解是非常重要的。内联分析工具(inline profiler)能够帮助我们随时掌握代码的执行情况,而无需切换到“现在我要进行性能分析”的模式。相比于启动外部分析工具,内联分析工具的优势在于,它能够持续提供代码的执行信息,而不需要额外的步骤或中断开发流程。

即使在某些情况下仍然需要外部分析工具,比如它们具备某些内联工具不具备的功能,但内联工具依然是一个很好的补充。它的核心作用在于保持对代码的“态势感知”(situational awareness),使我们能够随时了解代码的运行状态。这不仅有助于优化性能,还可以更早地发现bug或异常行为,避免到了项目后期才发现严重问题。

如果在开发过程中没有持续关注代码的执行情况,那么最终可能会陷入“打开汽车后备箱,发现里面塞满了尸体”的尴尬局面——也就是说,直到项目后期才意识到代码存在严重的性能或结构问题,这会让解决问题变得更加困难。因此,始终掌握代码的执行情况是一个明智的选择,它可以帮助我们在开发过程中做出更好的架构决策,避免在项目后期被性能问题困扰,陷入“我该怎么办?”或者“我是不是把自己逼入了死角?”的困境。

构建一个基本的内联分析系统并不需要太多时间。通常只需要花费十几个小时,最多几十个小时,就可以从零搭建一个相当完善的系统。相较于整个项目的开发周期,这种投入的成本是非常低的,但带来的收益却是巨大的。因此,建议在自己的项目中保持这种做法,让代码的执行情况始终处于可监控的状态。

如果要跟随当前的代码版本进行学习,可以获取相应的源代码。例如,可以使用GitHub的标签功能找到特定版本的代码,也可以通过其他方式下载源代码。无论使用哪种方式,确保获取到正确的代码版本,就可以同步跟进开发进度。

性能分析系统只有在数据整理阶段会变慢,这是可以接受的

在上一次的进展中,我们刚刚完成了一个新的系统,并且确认它能够正常运行。整体来看,它的表现还算不错,虽然比旧系统稍微慢了一些,但总体而言依然能够接受。我们目前无法完全确定导致性能下降的具体原因,但直觉上认为这并不是由于插入了计时代码导致的。我们的计时代码应该仍然相当高效,而大部分额外的执行时间很可能是消耗在数据整理(collation)阶段。

这种情况并不是什么大问题。即使数据整理阶段的计算量较大,从而影响了垂直分析系统(vertical profile system)的运行速度,这也只是意味着在进行性能分析时,我们无法以完整的帧率运行游戏。然而,我们仍然可以采用其他方式来规避这个问题,比如以完整的帧率运行游戏,在需要查看分析结果时暂停游戏,或者使用其他类似的方法。因此,只要主要的开销集中在数据整理阶段,而不会影响关键的游戏代码逻辑,那就不会造成太大的困扰。

接下来的任务就是深入研究这个问题,看看是否真的如我们所预想的那样,性能开销主要集中在数据整理阶段。我们的内联分析工具如果影响到了游戏的实际运行代码,那才会构成真正的问题。但是,如果这些额外的计算都被隔离到了一个后处理阶段(post pass)中,就不会对游戏的实时执行造成影响。从目前的实现来看,我们已经做了合理的优化,使得所有的额外计算都被放在后处理阶段,因此理论上这不会影响游戏的正常运行。

只要额外的性能开销可以被限制在后处理阶段,就有很多方法可以解决它,而不会影响我们的使用体验。这种情况并不妨碍我们利用这个工具进行分析,因此目前来看,不需要过于担心性能问题。接下来的工作重点是继续深入优化,并确保它不会对关键代码路径造成干扰。

当前性能分析系统的局限性:

接下来,我们的目标是讨论当前缓冲区的工作方式以及它们的一些限制。首先,有一些限制可能不会真正影响我们,也可能永远不需要修复。尽管如此,我们仍然需要明确这些限制的存在,并在代码中标注“TODO”,以便记录这一点并进行讨论。

其中一个值得注意的限制是,目前我们并没有采取任何措施来确保最终的调试记录在写入事件数组后,能够在事件数组索引交换之前完整地输出。换句话说,在进行事件数组的轮换或索引交换时,可能会存在部分调试记录尚未完全写入的情况。

这意味着我们的调试系统依赖于全局事件数组,但在索引切换时,可能会导致部分数据的丢失或未完成的写入。这并不会对大多数情况下的调试工作造成影响,但仍然是一个潜在的问题。如果有必要,我们可以在未来考虑一些同步机制,确保调试记录在数组切换前完成写入。然而,目前来看,这并不是一个必须要解决的问题。

1) 在极端罕见的情况下,分析器可能会读取错误数据

当前的调试系统使用了调试事件数组(debug event array),并且采用了**双缓冲(double buffering)**机制。这样做的主要目的是为了支持多个线程的并发写入,并确保调试数据可以持续记录,而不会因为数据读取而阻塞写入进程。

由于事件流是从多个线程写入到调试日志中的,因此我们不能简单地等到缓冲区填满后再清空它并从头开始写入。必须保证在读取完所有数据之前,不会覆盖已有的数据。然而,同时还需要让其他线程能够继续写入数据,并且不能因为调试记录的机制导致额外的性能损耗。毕竟,计时代码的设计目标就是尽可能高效,直接写入调试日志,而不影响主程序的执行。

双缓冲的工作原理

为了实现这一点,系统使用了两个缓冲区:

  • 在一个缓冲区中写入调试事件;
  • 另一个缓冲区用于读取和处理这些事件。

当需要交换缓冲区时,会使用**原子操作(atomic swap)**来切换当前的写入缓冲区和读取缓冲区,从而确保不会发生竞争条件(race condition)。这样可以保证:

  1. 线程可以持续地写入调试事件,而不会因为读取操作而被阻塞;
  2. 读取线程可以完整地获取上一个缓冲区的数据,而不会受到新数据写入的影响。

潜在的问题

理论上,存在一种极端情况下可能发生数据错误的情况:

  1. 某个线程获取了当前调试事件数组的索引,但尚未写入数据;
  2. 在它执行写入之前,该线程被操作系统抢占(preempted),即被挂起;
  3. 这期间,另一个线程完成了调试事件数组的原子交换(atomic swap),并开始解析新缓冲区的数据;
  4. 如果这个解析操作一直进行到缓冲区末尾,可能会读取到尚未被写入的数据,从而导致错误。

然而,这种情况的发生概率极低,原因如下:

  • 事件数组的大小非常大,通常包含数十万条记录(目前已有超过64,000条),因此在缓冲区完全填满、交换、解析的整个过程中,特定线程刚好在关键时刻被抢占的概率极低。
  • 即便偶然发生了读取无效数据的情况,它也仅影响调试信息,而不会影响程序的正常运行。因此,这个问题可以忽略不计。

为什么不修复这个问题?

  • 修复成本高:要彻底解决这个问题,可能需要引入额外的同步机制,例如显式地确保某个事件在缓冲区交换之前完成写入。然而,这样会影响计时代码的性能,导致不必要的开销。
  • 影响极小:考虑到事件数组的大小以及极低的发生概率,这个问题基本上不会影响实际的调试工作。因此,没有必要为了修复这个罕见的极端情况而引入复杂的同步机制。

最终决定

综合考虑后,我们决定接受这一限制,因为它带来的影响微乎其微,同时修复它的成本过高。尽管存在理论上的潜在风险,但考虑到发生概率极低,我们认为这是可以接受的折衷方案。当然,如果未来实际使用中发现它真的引发了问题,我们再考虑更进一步的优化方案。

现在,我们的重点将转向那些确实需要修复的限制,以进一步完善调试系统。

2) 调试记录没有标记核心和线程索引

接下来需要关注的是一些尚未完成的部分,并不是系统本身存在问题,而是某些信息缺失,导致在调试时无法获得足够的上下文信息。

当前存在的问题

  1. 无法确定当前正在处理的核心(CPU Core)
    在查看调试数据时,无法知道当前的事件是在哪个 CPU 核心上执行的。这意味着,在分析多线程任务的调度情况时,缺少了关键的信息,不利于判断性能瓶颈或线程竞争问题。

  2. 无法确定当前的线程索引(Thread Index)
    目前没有记录每个事件所属的线程索引,导致在调试时无法确定是哪个线程生成了特定的事件。这对于多线程环境下的调试来说是一个较大的缺陷,因为不同线程的执行顺序可能会影响程序的正确性和性能。

问题的影响

由于缺少这些关键的标识信息,在调试时会面临以下问题:

  • 无法区分不同线程的执行情况
    例如,如果某个事件在某个线程中发生了性能问题,目前的调试系统无法直接提供信息来确定问题发生在哪个线程上,需要额外的手段来推断。
  • 无法分析 CPU 核心的任务分配情况
    在多核 CPU 环境下,任务可能会被分配到不同的核心执行。如果无法得知具体的核心编号,就无法分析任务的调度策略是否合理,或者是否存在线程迁移(Thread Migration)等影响性能的问题。

下一步改进方向

为了弥补这些信息的缺失,需要在调试事件系统中加入:

  1. 核心标识(Core ID):记录当前事件是在哪个 CPU 核心上执行的,以便分析任务的调度情况。
  2. 线程索引(Thread Index):为每个线程分配唯一的索引,并在调试记录中存储该信息,以便能够区分不同线程的执行情况。

这些改进将有助于提高调试系统的可用性,使其能够更准确地反映多线程和多核环境下的程序运行状态,从而更有效地进行性能分析和错误排查。

核心索引并不那么重要……

目前,由于缺少关键信息,导致实际调试工作受到一定限制。特别是在当前状态下,无法确定核心索引(Core Index)和线程索引(Thread Index),这对调试多线程程序带来了一定的不便。

核心索引(Core Index)

  • 这个信息属于**“锦上添花”**的类别,主要用于分析线程在不同 CPU 核心上的运行情况,以及它们是否被调度系统迁移(Thread Migration)。
  • 如果能够获取该信息,在进行性能分析时,可以更直观地观察不同核心的负载情况。
  • 但是,即使核心索引一直保持为 0,也不会对调试的主要工作产生实质性的影响,因此这个问题并不紧急。

获取核心索引的尝试

  • 原本希望可以通过 ARDI TSC(时间戳计数器,Time Stamp Counter) 来获取核心索引信息,但目前来看,这可能不是一个可行的方案。
  • 未来或许可以找到其他方式来获取该信息,但目前没有明确的解决方案,因此暂时搁置。

最终决定

  • 核心索引(Core Index)的问题并不紧急,可以忽略,即便一直保持为 0 也不会影响主要的调试工作。
  • 未来可能会继续研究如何更好地获取这个信息,但目前不会优先解决这个问题。
    在这里插入图片描述

在这里插入图片描述

……但线程索引却至关重要

在多线程环境中,线程索引(Thread Index)是非常关键的,特别是在性能分析和调试中。线程索引之所以至关重要,是因为多个线程可能会调用相同的函数,这带来了一些复杂性,尤其是在对渲染等关键时间敏感组件进行性能分析时。

问题的关键

当我们进行渲染函数的性能分析时,我们往往需要在渲染代码中插入性能计数器(profiling counters)。然而,问题在于 多个线程可能同时调用相同的函数,这使得我们无法简单地假设某个函数的开始和结束是在同一个线程中完成的。例如:

  • 我们可能会看到一个“开始事件”(begin event),但这个事件的结束(end event)却可能是由 另一个线程 完成的。
  • 这种情况使得我们无法确定是哪个线程实际完成了该函数的执行,导致我们不能准确地将开始和结束事件匹配起来。

为什么线程索引重要

为了确保准确地进行事件配对(比如将开始和结束事件配对),我们需要一种方式来跟踪每个线程的执行情况,特别是线程的执行顺序。在多线程环境中,线程的执行顺序是不确定的,可能会在不同的时间开始和结束同一个函数的执行,因此无法简单地通过事件顺序来判断它们是否来自同一个线程。

如何解决这个问题

  1. 线程内的执行顺序是确定的:在一个线程内,事件总是按照顺序发生的——首先是开始事件(begin event),然后是结束事件(end event)。因此,如果所有的事件都来自同一个线程,我们可以确保这些事件是成对出现的,即一个“开始”事件对应一个“结束”事件。

  2. 多线程时的挑战:但当多个线程同时执行时,情况就变得复杂。不同线程的开始和结束事件可能在任意时刻发生,这就导致了我们无法直接通过时间顺序将它们配对。我们需要一个 唯一的标识符 来区分每个线程的事件,只有这样才能确保我们能够正确地将“开始”事件和“结束”事件配对起来。

解决方案

为了能够正确地配对每个线程的开始和结束事件,我们必须确保:

  • 每个线程都有一个唯一的标识符(例如线程索引),这样我们可以知道哪个线程发出了某个事件。
  • 通过这个唯一标识符,我们能够跟踪每个线程的执行路径,并确保配对开始和结束事件时不会混淆不同线程的事件。

总结

线程索引是进行多线程调试和性能分析的关键,特别是在多个线程调用相同函数的情况下。为了确保准确的事件配对,必须依赖线程索引来标识每个线程的活动,并通过唯一标识符来避免线程间事件的混淆。这将帮助我们更精确地进行性能分析和调试工作。

解决线程区分问题的两个选项:

针对多线程环境下事件配对的问题,解决方案有两个主要的选择:

1) 获取线程索引

解决这个问题时,第一种方法是通过获取线程索引来为每个线程分配一个唯一的标识符。通过这种方式,可以将每个线程的执行与其对应的事件关联起来,从而正确配对开始和结束事件。这个线程索引不仅有助于准确配对事件,还能提供更多的额外信息,比如哪些线程在执行哪些操作,帮助我们更好地了解线程的行为。

这种方法的优势在于,它能提供 更详细的日志信息,不仅能够识别出线程执行的具体操作,还能为后期的调试和性能分析提供更多有价值的上下文。这对于需要追踪和分析线程行为的应用程序非常有用。

尽管如此,并不是所有的程序都需要过度依赖线程。在该项目中,线程数量并不多,主要包括一些资产线程、渲染线程和后台合成线程等。并且,程序并不是以多线程为核心特性,线程并非程序的主要关注点。因此,如果认为为每个线程分配唯一标识符的工作量过大,可以选择不这么做,这并不会对整体设计造成致命影响。这种额外的信息虽然有助于分析和调试,但并不是绝对必须的。

2) 使用原子操作生成唯一 ID,以匹配我们的起始和结束计时块

另一个解决方案是,通过引入一个额外的计数器来替代线程索引。这个计数器是一个32位整数,每次被原子操作地递增。我们不再使用线程索引,而是使用这个递增的唯一ID。具体的做法是,在每个事件的开始块时,我们同步地递增该计数器,并将当前值插入到事件数据中;然后在结束块时,使用相同的计数器值来确保开始和结束事件的配对。

由于我们知道,每次递增都会产生一个唯一的ID,且这个计数器的最大值是40亿,显然在一个帧内不会达到这个值,因此即使计数器递增到最大值,它也不会影响当前的调试输出,因为计数器会在这个范围内自动回绕。而且,即使它回绕,也不会对事件的正确配对产生问题,因为在调试中,每个ID都会是唯一的,并且能够正确配对事件的开始和结束。

这种方式提供了基本的配对功能,确保每个开始事件和结束事件能够正确地匹配。虽然这是一个较简单的方法,但它能够快速实现核心功能,确保调试事件的配对,并且在实际使用中很少会遇到ID溢出的问题。因此,这种方法是可行的,如果需要,完全可以立即实施并开始使用。

我们能用一个简单的函数调用获取线程索引吗?

现在的主要问题是,是否能够更容易地获取与线程索引对应的数字。为了解决这个问题,首先要讨论的是 Windows 环境下是否可以通过某个函数调用来获取线程 ID。

在 Windows 中,有一个函数 GetCurrentThreadId() 可以用来获取当前线程的 ID。不过,函数调用通常不适合在定时器块(timer block)中执行,因为这可能会带来性能上的问题。因此,在这种情况下,首先需要查看调用 GetCurrentThreadId() 函数会发生什么,确保它的使用不会导致不必要的复杂性或性能下降。

为了避免在实现过程中出现不必要的问题,决定将这个调用放在 Windows 特定的层次里,确保不会在不适合的环境中启动,如启动过程(booting)等。这样做的目的是简化事情,避免在不同的环境下造成复杂性,确保代码能更方便地运行。

GetCurrentThreadId 是如何工作的?

首先,想要了解调用 GetCurrentThreadId() 函数实际会做什么,目的是查看其执行过程中的性能表现。为了观察这一点,首先在 main 函数中设置断点,然后启动调试器,查看汇编代码,并分析调用 GetCurrentThreadId() 时的开销。

在调试过程中,查看了汇编代码,发现获取线程 ID 的过程非常直接。它通过访问线程特定的内存段(通常是通过 gs 寄存器来引用的)来获取当前线程的 ID。具体来说,线程 ID 是通过 gs 寄存器来进行寻址,加载到一个寄存器中,然后返回该值。

从这个分析可以看出,获取线程 ID 的过程非常简单,实际上它只是从线程本地存储(TLS)中读取一个指针,然后通过该指针来获得线程 ID。因此,这个操作在性能上非常高效,不会引起太大的开销。
在这里插入图片描述

在这里插入图片描述

单步进入
在这里插入图片描述

GS 寄存器是 x86 和 x86-64 架构中的一个段寄存器,主要用于存储特定线程或进程的线程局部存储(TLS)地址。它是 CPU 用于访问线程局部数据的一部分,特别是在多线程环境中,GS 寄存器通常指向与当前线程相关的内存区域。

主要作用:

  1. 线程局部存储(TLS)

    • 在多线程程序中,每个线程都有一块独立的内存区域来存储该线程的局部数据,这块内存区域通常被称为线程局部存储(TLS)。为了访问这些数据,每个线程的 GS 寄存器会指向该线程的 TLS 区域。
  2. 访问线程特定的数据

    • 在 32 位系统中,GS 被用来指向与线程相关的内存区域,而在 64 位系统中,GS 寄存器的用途更为广泛,可以存储对线程局部数据的指针(例如线程 ID、线程栈等)。
  3. 操作系统层面的使用

    • 操作系统通过使用 GS 寄存器来管理不同线程的状态和信息。对于应用程序来说,可以通过访问 GS 指向的内存区域来获取线程的相关数据,如线程的 ID。

在 64 位系统中的使用:

在 64 位操作系统中,GS 寄存器通常用来指向线程的局部存储区。操作系统和编译器会将线程的局部数据结构映射到这个区域,允许每个线程访问自己的线程特定信息。

看起来 GetCurrentThreadId 只需要执行几条指令就能返回线程 ID

通过对线程本地存储(TLS)的分析,得出了一个结论:获取线程的唯一标识符(线程ID)其实并不复杂。为了获取线程的 ID,可以通过 GS 段寄存器指向当前线程的线程局部存储(TLS)。具体做法是,通过 GS 地址获取线程本地存储的位置,然后再通过这个位置获得线程的 ID。

这就意味着,只需要通过两条指令:第一条指令通过 GS 获取当前线程的本地存储地址,第二条指令从这个地址读取出线程ID。实际上,线程本地存储的地址本身就代表了线程的唯一性,因为每个线程的 TLS 地址都是独一无二的。因此,线程 ID 就可以从这个地址派生出来。

从本质上讲,获取线程 ID 的过程非常简单,只需要一个简单的内存访问操作就可以完成。
在这里插入图片描述

线程本地存储(TLS)

线程本地存储(TLS)是指在现代操作系统中,每个线程在启动时会为自己分配一块独立的内存区域。这块内存是专门为线程保留的,确保每个线程都有自己的私有空间,不会与其他线程的内存数据发生冲突。操作系统会为每个线程设置特定的内存段地址,这个内存段是与线程相关联的,操作系统通过该段地址来管理和访问线程本地存储的数据。

具体来说,当一个线程启动时,无论是主线程还是通过创建线程的方式生成的子线程,操作系统都会分配一块内存,这块内存是唯一且专属于该线程的。操作系统在内存表中设置了一个特定的内存段地址,通过这个地址可以访问该线程的线程本地存储。在 x86 架构中,这通常是通过 GS 段寄存器来实现的,这个寄存器指向了线程的 TLS。

这种机制的核心是通过内存段来进行定位,虽然这源于早期计算机系统使用的分段寻址,但在现代操作系统中依然有效。每个线程都有自己的 GS 段寄存器值,指向线程本地存储的位置,这样每个线程就能访问自己的私有内存区域。
在这里插入图片描述

分段寻址

在计算机系统中,内存寻址可以基于不同的段寄存器进行,而不仅仅是使用默认的虚拟内存段。GS 段寄存器就是一种特殊的段寄存器,它允许线程通过该寄存器来访问与该线程相关联的线程本地存储(TLS)。线程本地存储是每个线程独有的内存区域,操作系统通过 GS 寄存器指向线程本地存储的位置,使得每个线程能够访问属于自己的数据。

每个线程在操作系统中切换时,操作系统会保存和恢复寄存器的状态,包括段寄存器。当一个线程被抢占并且另一个线程开始执行时,操作系统会将当前线程的寄存器状态保存,然后将新的线程的寄存器状态加载进来。GS 寄存器就是其中一个关键的寄存器,它指向了与当前线程相关的内存区域,从而确保线程能够访问到属于它自己的数据。

每个线程都有独立的 GS 寄存器值,指向不同的内存位置,这意味着不同的线程访问的内存区域是互不干扰的。通过这种方式,操作系统能够确保每个线程都能够安全地使用自己的内存空间,而不会影响到其他线程。

这种内存管理机制实际上源自早期的分段寻址技术,那个时候所有的内存访问都是基于段的。随着计算机架构的演变,分段寻址逐渐被现代的分页机制所取代,但 GS 寄存器仍然在操作系统中用于处理线程本地存储。在现代操作系统中,GS 寄存器作为一种优化手段,能够避免频繁的函数调用来访问线程本地存储,从而提高性能。

总的来说,GS 寄存器的使用让每个线程可以通过简单的内存地址偏移来访问属于自己的数据,而不需要复杂的函数调用。这种方法在多线程程序中非常有效,尤其是在操作系统层面进行线程管理时,能够保证每个线程的数据隔离和独立性。

有没有什么内建指令可以让我们复制 GetCurrentThreadId 的行为?

在讨论如何获取线程ID时,尝试寻找一种直接的方法来避免每次调用函数的开销。最初的想法是通过某种内建的指令(intrinsic)来直接获取线程ID,而不是通过常规的函数调用。在分析过程中,考虑了是否可以利用特定的内建指令来实现这一功能。

尝试通过不同的方式,包括使用 __rdtscp 或者其他类似的内建指令,来避免过多的函数调用。在查找过程中,发现了 moveaccess 等指令,但这些方法并不完全适用,或者没有提供预期的线程相关功能。更进一步地,使用的操作系统和编译器工具链中,某些功能(如fsgs 段寄存器)仅在内核模式下可用,这使得在用户模式下获取线程本地存储信息变得复杂。

另一个问题是,虽然可以使用一些内建的汇编操作来访问线程相关的信息,但这些操作往往依赖于特定的系统或硬件配置,并且在不同的操作系统或处理器架构上可能表现不同。尤其是,在 Windows 系统中,gs 段寄存器的访问通常被限制在内核模式,因此无法直接在用户空间使用。

总的来说,尽管理论上可以通过某些低级指令来访问线程信息,但由于操作系统的限制和架构差异,实际操作中会面临一定的挑战。对于线程ID的获取,可能需要依赖更标准的操作系统接口,或者接受一定的性能开销来获取线程本地存储信息。

有,_readgsqword

在讨论如何获取线程信息时,提到了一个关键概念,就是 gs 段寄存器,它被用来访问线程本地存储。这种方法正是所需要的,因为 gs 段寄存器是用于存储特定线程的信息,从而实现线程间的隔离。这个思路启发了进一步的操作,可能通过读取 gs 段寄存器的某个特定数据项(比如 _readgsqword)来获取线程本地存储中的信息,这恰好符合需求。

进一步的步骤是尝试使用 _readgsqword 来获取线程相关的数据,但由于这是一个不熟悉的领域,因此会采取实验的方式来验证其可行性。计划通过查看该操作在执行时具体的行为,确保其能正确地提供所需的信息。此过程中,考虑了先直接查看 _readgsqword 的内容,确保能理解它到底在做什么,从而避免不必要的错误。

最终的目标是找到一种方法,能够避免过多的函数调用,直接从硬件或操作系统提供的低级别指令中获取线程信息,这样可以减少性能开销,并且为进一步的优化提供支持。

复制 GetCurrentThreadId 的行为

决定进入调试,逐步执行代码,查看具体执行情况。在进行调试时,主要目标是确认操作是否如预期进行。通过逐步执行,可以更清楚地了解每一步的执行过程,特别是在操作 gs 寄存器时,确保获取线程本地存储的信息时不会发生错误。这个过程是为了验证是否能够通过低级别的指令,避免过多的函数调用,直接获得线程相关信息,从而提高效率并减少性能开销。

调试器:跳转后进去看看

为了避免进行系统调用并简化流程,通过反汇编分析,决定直接读取 gs 寄存器中的线程本地存储(TLS)信息。首先,假设从 gs 寄存器加载的地址指向线程本地存储的位置。接下来,读取该地址上的数据,获取线程的标识符。由于 TLS 是一个指向内存的指针,因此通过读取相应的四字节(Dword)可以获取线程本地存储的地址。然后,假设该地址偏移量为 48h,可以通过该偏移量来访问线程本地存储中的数据,从而获得线程ID。

这个过程不依赖于额外的函数调用,而是通过直接操作寄存器和内存地址来获取线程ID,减少了不必要的性能开销。

DWORD ThreadID = GetCurrentThreadId();
// 通过函数调用获取当前线程ID
00007FF71E5C4823  call        qword ptr [__imp_GetCurrentThreadId (07FF71E5CA190h)]  // 读取gs寄存器偏移48h处的数据,这通常是线程局部存储(TLS)的一个值,指向当前线程的相关数据00007FFDC99B8620  mov         rax, qword ptr gs:[48h]  // 返回到调用处00007FFDC99B8629  ret  
// 将获取的线程ID存储到变量ThreadID中
00007FF71E5C4829  mov         dword ptr [ThreadID], eax  
(void)ThreadID;

在这里插入图片描述

uint8 *ThreadLocalStorage = (uint8 *)__readgsqword(0x30);
// 通过gs寄存器读取线程局部存储(TLS)地址的偏移位置0x30
00007FF7956B482C  mov         rax, qword ptr gs:[30h]  
// 将读取的TLS地址存储到ThreadLocalStorage指针变量中
00007FF7956B4835  mov         qword ptr [ThreadLocalStorage], rax  
uint32 ThreadID_2 = *(uint32 *)(ThreadLocalStorage + 0x48);
// 将ThreadLocalStorage指针值加载到rax寄存器中
00007FF7956B4839  mov         rax, qword ptr [ThreadLocalStorage]  
// 从ThreadLocalStorage偏移0x48的位置读取数据,获取线程ID
00007FF7956B483D  mov         eax, dword ptr [rax + 48h]  
// 将获取到的线程ID存储到ThreadID_2变量中
00007FF7956B4840  mov         dword ptr [ThreadID_2], eax  

在这里插入图片描述

在这里插入图片描述

在 RecordDebugEvent 里记录线程 ID

在调试过程中,成功获取到线程ID后,考虑将其集成到代码中。首先,想通过查看当前调试事件来确认这个线程ID,然后通过相关代码操作,可以把线程ID和线程索引关联起来。虽然线程索引现在只在某些特定的上下文中有用,可能需要一些额外的步骤才能确保它在所有地方都适用。为了避免处理线程索引时的问题,可以暂时将其设为32,这样可以避免后续的复杂性。

在完成这些修改之后,整个过程似乎运行得很顺利,线程ID和线程索引的操作也没有遇到任何明显的障碍。接下来,计划继续查看调试的行为,确保一切按预期工作。

总体来说,这个过程主要是调试并集成线程ID的获取和管理,使得代码更加稳定并能更好地处理线程相关的任务。
在这里插入图片描述

在平台层抽象 GetThreadId

目前的目标是确保线程ID的准确性,并实现一个高效的性能分析工具。通过读取gs寄存器中的数据来获取线程本地存储(TLS),并使用合适的方式返回线程ID,避免了操作系统的干扰,确保在优化构建中这些操作能够内联执行,不会拖慢程序的性能。

通过这种方式,可以通过简化的线程ID获取方法替代GetThreadId,并确保在不同平台上也能够有效运行。这种方法将不会导致任何操作系统相关的开销,从而提高性能。线程ID的准确获取,为进一步的性能分析提供了更可靠的数据支持。

在构建更好的调试视图时,希望能够展示一个更具可视化的时间线视图,类似于饼图的形式,能够清晰地展示不同代码部分所消耗的时间。通过将性能数据以这种方式展现,可以更容易地识别出性能瓶颈,进而优化程序。例如,观察到某个蓝色的条形图占据了大量时间,而绿色条形图占比很小,显然蓝色部分是性能问题所在。

接下来,计划进一步改进调试视图,构建一个更具交互性和可操作性的性能分析工具,并为后续的优化工作打下基础。预计会在接下来的时间内深入进行这些改进。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

统一平台层和游戏层的计时器计数

今天的重点是去掉现有的调试框架和信息结构。如果还记得之前的实现,我们需要传递这些调试框架和信息参数,唯一的原因是当前平台代码无法正常使用调试计数器。但是现在希望找到一种方法,将这些调试计数器与平台代码整合到一起,使得可以在程序的两侧同时使用这些计数器。虽然这看起来有点复杂,但相信是可以实现的。

目前有三个编译单元,理论上应该可以通过设置三个数组来实现,并且这三个数组应该能够正确地写入全局的调试计数器。关键在于如何统一管理这些计数器,使得在不同的平台代码和常规调试代码中都能有效使用它们。虽然这有一定的挑战,但在技术上应该是可行的。

将调试系统暴露给平台层

目前面临的主要问题是如何将 game_debug.h 中的代码进行重构,使得它能更好地暴露出关键部分,特别是允许记录调试事件的功能。这些功能需要能够在 game_platform.h 中访问。目标是确保能够将这些功能整合到平台层,使得不同部分的代码能够统一工作。

计划开始着手处理这一问题,将 RecordDebugEvent 和计时块的相关代码提取到平台层,以便它们能够成为系统的一部分。这样,平台代码和调试代码的整合将更加顺利。

在这个过程中,也发现了一些冗余的部分。比如,原先使用的 AtomicAddU64 等原子操作,现在已经不再需要了,所以可以将其移除。去除不必要的代码后,留下的代码就是我们真正需要的部分。特别是 RecordDebugEvent 的起始和结束部分,这些仍然是必须保留的,而一些其他的部分则可以去除,比如 HitCount 和其他不需要存储的数据。

另外,某些原子操作和特定的函数调用(如 GetThreadID)也是必须整合进来以支持更高效的功能。这些功能目前没有自己的调用方法,因此需要为这些功能创建自己的调用机制。原子操作也需要集成进来,因为它们是实现关键功能所必需的。

总体来说,重构工作主要集中在清理代码、整合平台功能和移除不必要的部分。这一过程将有助于优化系统,使其能够更高效地处理调试事件并提供精确的调试信息。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

我们如何找到调试结构体的位置?

当前面临的紧迫问题是如何找到正确的地址,以便为调试系统中的相关部分进行写入。特别是对于一些如“调试事件记录””这样的数据,如何知道这些数据的确切位置就变得相对复杂。

在思考这个问题时,发现了一些关键点:系统中有两个全局规则需要在调试过程中被访问。这意味着,每次需要使用这些规则时,都必须知道它们的地址,也就是指向它们的指针在哪里。这两个指针是独立的,且都需要以某种方式被正确使用和尊重。

但目前还不确定最简单的解决方案是什么,如何有效地管理和访问这些指针仍然是一个挑战。

我们可以把它们存储在平台层……

可以考虑将这些全局变量始终存储在平台层(platform layer)上。这样,所有相关的全局值可以在平台加载时进行初始化。这个做法应该不难实现,通过确保它们在平台层加载时被正确初始化,可以有效地解决当前的问题。

……或者我们可以把调试数组放在 DLL 里,并通过 DLL 绑定来修正它们的地址

可以考虑利用绑定(deal binding)来处理全局变量的地址。虽然很久没有做过这类操作,但通过绑定应该能够顺利地修补全局变量的地址。这样一来,技术上就能够让整个系统正常运行,确保这一过程能够顺利完成。

通过 _declspec(dllexport) 进行 DLL 绑定

在处理数据导入时,可能需要通过导入地址表(Import Address Table)来管理数据的访问。根据之前的讨论,如果在构建函数时不使用导入地址表来访问数据对象,就不会有间接访问的问题。因此,数据可以继续在原始位置(deal)中声明和操作,而平台则从中获取数据。

虽然这种方法看似可行,但仍然存在一定的复杂性和不确定性。是否按照这种方式操作,确实有些棘手,需要进一步考虑和分析。

将所有调试结构体合并到一个单独的结构体(debug_table)中

为了简化当前的调试代码,计划将其结构进行合并。首先,考虑将调试相关的数据和数组整合到一个结构体中,这样就不需要在多个地方分别声明和管理它们。具体来说,可以创建一个结构体,该结构体包含调试记录数组、事件数组的索引等内容,这样就能把相关数据集中在一个地方管理。

为了进一步简化,可以定义一个最大调试事件数量的常量,并通过这些常量来管理数组的大小和事件的数量。此外,结构体将包含所有必需的调试信息,这样就不需要单独声明多个数组或指针,所有操作都能通过这个调试表来完成。

通过这种方式,只需要通过全局的调试表来访问这些信息,而不需要在多个地方进行重复声明。平台层也可以成为独立的翻译单元(translation unit),只需通过该调试表进行访问。

这种方法的好处是简化了代码结构,减少了冗余的声明,使得调试代码更加清晰、集中,所有操作都通过统一的接口进行管理。这种方式的最终目标是让调试系统更加高效和易于维护。

在这里插入图片描述

用 TRANSLATION_UNIT_INDEX 预处理符号替代 RecordArrayIndexConstant

可以将这一结构做得更加正式一些,比如通过使用“翻译单元索引”的方式来管理不同的翻译单元。可以定义一个类似的结构或常量,来表示系统中有三个翻译单元。这样,我们就可以更加明确地知道有多少翻译单元,并据此进行后续的操作。

这种做法看起来是合理的,能够清晰地管理调试信息的结构,确保每个翻译单元都能正确地处理相关的调试数据。通过这种方式,可以更有效地控制整个系统的调试流程,并减少可能的混乱和重复。
在这里插入图片描述

在调试相关的例程中只访问 debug_table

在这一过程中,计划将代码进行一些简化和清理。首先,原本存储调试信息的部分已经不再需要,关键的部分是将全球调试表(GlobalDebugTable)提取出来并整合在一起,不再使用多个数组,而是直接使用这个统一的调试表。

  1. 移除不必要的变量:
    许多不再需要的变量和数组已经被移除,减少了代码的复杂度。例如,原本存储调试记录的数组、当前事件数组的索引等,都不再需要单独声明或处理。这些部分直接被纳入了统一的调试表内。

  2. 简化调试表的结构:
    将调试表简化为一个结构体,结构体中包含了必要的数组,例如 DebugRecordArrayDebugEventArrayIndex_DebugEventIndex 等。同时,清理掉了重复或不再需要的代码,只保留核心部分,确保能够统一访问和操作这些调试数据。

  3. 全局调试表的访问:
    将原本分散的访问方式统一为访问全局调试表,确保各个翻译单元都能够通过这个全局表来获取调试信息。通过这种方式,所有的调试相关数据可以集中管理,减少了代码中冗余的部分。

  4. 去除冗余声明:
    不再需要多个地方声明调试记录、事件数组等数据,而是通过全局调试表来统一管理这些数据。具体实现中,所有调试记录的计数器、索引等都通过这个表进行访问和更新。

  5. 清理调试记录计数:
    将计数器整合到全局调试表中,原本在多个地方维护的计数器被合并为一个。通过这个方法,能够简化调试记录的管理,减少不必要的复杂性。

  6. 命名规范:
    对一些变量和数组的命名进行了清理和调整,使得命名更符合当前的结构和需求。例如,将 DebugRecordArrayIndex 改为 TranslationUnit,让命名更加清晰。

  7. 移除不必要的内部函数:
    一些本来负责处理调试记录的内部函数和操作也被移除,改为直接通过全局调试表来访问和操作调试数据。这减少了代码中的重复部分,并确保所有相关操作都集中在一个地方进行。

通过这些调整,整体代码变得更加简洁和高效,减少了重复的代码,也提高了调试信息管理的集中性和可维护性。这些优化让调试表成为了唯一的核心,简化了程序的架构,同时保留了原有的调试功能。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

测试。出现了一个 bug

目前代码出现了一个问题,导致结果不是预期的那样。通过快速检查和回顾代码,可能是翻译单元(translation unit)以及不同的组织方式上出了问题。为了调试这个问题,进行了以下几步检查:

  1. 调试记录的检查: 首先,检查了调试记录部分,特别是计数器数组的设置,确保它正确地与翻译单元(translation unit)相关联。看起来这部分设置是对的,主要是在处理主记录和优化记录时使用了正确的翻译单元。

  2. 平台层检查: 然后,检查了平台层的相关代码,确认翻译单元的设置是否正常。翻译单元应该是按零、一个、两个的顺序设置的,而这个设置是符合预期的,看起来也没有问题。

  3. 事件与记录数组: 确认了事件数组和记录数组的设置,看起来这些数组的定义和初始化都符合要求。没有发现异常。

  4. 原子交换与事件索引: 在处理事件的过程中,检查了原子交换(atomic exchange)的操作,特别是与当前事件索引相关的部分。确认了这是正确设置的,符合预期。

  5. 移除不再需要的部分: 代码中不再需要的部分已经被移除,这部分也被认为是已不再相关,保证了代码的简洁性。

虽然大部分检查看起来正常,但仍然没有解决问题,可能是由于翻译单元之间的交互或者索引管理上的细节问题。接下来的步骤可能需要更加深入地检查具体的代码实现,特别是在翻译单元和事件处理的部分。

移除 GlobalDebugEventArray

问题的根源在于代码中仍然存在一个名为“GlobalDebugEventArray”的部分,这是不应该出现的。这个数组在之前的代码设计中已经不再需要了,然而它仍然存在,并且成为了导致错误的原因。

具体问题是,代码中仍然在使用这个“GlobalDebugEventArray”,但它本应被移除或者替换为其他适当的结构。因此,应该去掉这个数组,确保代码逻辑与当前的需求一致。这个问题的关键就在于这个不再需要的数组的存在,它需要被彻底清除或正确替换。
在这里插入图片描述

在这里插入图片描述

提高汇编水平的有效方法是什么?看书/教程?在 VS 里阅读代码反汇编?Mike Acton 只需看几秒钟代码就能估算出执行周期和大致的汇编指令,我希望有一天也能达到这个水平

想要提高汇编语言的水平,可以采取几种有效的方法,并且最好同时进行。

首先,阅读汇编代码是非常重要的。通过查看编译器生成的汇编代码,能够逐渐理解它的运作方式。就像学习一门新语言一样,你可以通过“沉浸式学习”,就像听一部没有字幕的法语电影一样,逐渐理解其中的语言和模式。例如,在编写代码时,可以右键点击查看汇编代码,这样可以帮助你更好地理解编译器如何将高级语言转换为汇编。

其次,深入学习一些基础资料也很有帮助。比如阅读《Intel架构手册》,它详细解释了指令的格式和如何工作。即使只是偶尔读一读相关的内容,也能帮助加深理解和记忆,逐步建立起对汇编语言的感知。

最后,实践是最有效的学习方式。开始编写一些更接近汇编的代码,例如内嵌汇编(intrinsic),这样你就可以更直接地了解编写的代码如何与汇编语言对应。通过分析这些代码,你会发现它们和汇编之间的差距会变得越来越小,也能够更容易地理解编译器生成的汇编代码。

总的来说,阅读、学习手册和实践编写汇编代码是提升汇编技能的三大步骤,通过不断的实践和积累,逐渐缩小你写的代码和最终汇编代码之间的差距。

在一个应用程序中切换多个 API(如软件/硬件渲染器)应该怎么处理?它的结构会类似于平台层吗?

在处理单个应用程序中切换多个API时,方法会根据具体的使用场景有所不同。无论是软件还是硬件渲染,切换方式差异很大,因此首先需要明确的是:是为了接口与两个相似功能的API进行切换,还是为了支持完全不同的渲染平台。

1. API切换的基础问题:

  • 如果只是切换两个功能相似的API(比如OpenGL与Direct3D),它们的本质非常接近,很多情况下,只是语法上的不同,底层驱动也往往相似。这种情况下,切换API的工作会相对简单,因为大部分控制流程的逻辑可以放在平台无关的部分,只需要在平台相关的部分实现OpenGL或Direct3D特定的调用即可。

  • 但如果是从一种API切换到另一种完全不同的API,例如从OpenGL与视频扩展切换到iPad上的Metal,它们可能会有完全不同的控制流,甚至可能在不同的硬件平台上有着不同的实现和架构。这样就需要在架构层面进行更多的抽象和分层。

2. 控制流的分层:

  • 当面对类似OpenGL与Direct3D的切换时,可以将大部分控制流逻辑放在共享的、平台无关的部分,只在平台特定的代码中调用对应的API。这种做法能减少重复代码并提高效率。

  • 但在遇到差异很大的渲染API时(例如OpenGL与Metal),可能需要将控制流上移到更高层,这样每个API的实现就可以单独处理自己的渲染逻辑。这种方法虽然会增加开发的灵活性和定制性,但也会导致代码复用的减少。

3. 平衡代码复用与定制:

  • 将控制流上移到过高层次会导致平台相关代码的复用减少,这样虽然每个平台可以有高效的实现,但如果平台之间的API非常相似,这种做法会造成大量的重复工作。

  • 另一方面,如果控制流过低,导致不同平台的实现都在共享的控制流中进行适配,这样在遇到差异较大的平台时,可能会带来更多的困难,导致实现变得复杂且低效。

4. 多层架构:

  • 在某些情况下,可以采用多层架构来解决这个问题。比如,平台无关层和平台特定层之间,可能还会有一个共享的中间层,这个中间层专门处理一些平台之间共有的逻辑。这样可以保证不同平台之间有部分共享的代码,同时又能应对各自特有的需求。

  • 通过这种方式,可以有效减少不必要的代码重复,同时又能灵活地应对不同平台间的差异。

5. API设计的重要性:

  • API设计是一项非常复杂的任务,很多开发者在设计API时缺乏对架构和灵活性的考虑,导致很多API设计不够优化,甚至存在很多问题。要设计高效、可维护的API,需要深刻理解如何在不同平台和需求间做出正确的抽象和分层。

总的来说,在设计API切换时,关键在于选择合适的层次来分隔平台无关部分和平台特定部分,平衡代码复用与定制的需求,避免过于复杂的架构设计。这是一项重要的技能,尽管很少被充分重视,但它对架构设计的质量至关重要。

为什么编译器不暴露 API 让我们获取代码的信息(比如访问 AST 等)?因为当我们写一个自顶向下的解析器时,我们本质上是在写编译器已经拥有的东西

关于为什么API的设计和代码应该包含更多信息,以及如何通过这些设计帮助我们的代码,有一部分原因在于SEPA标准委员会的设计。其实,许多语言设计者在制定标准时犯了很多错误,这些错误导致了一些重要的功能没有被一开始就包含进去。比如,编译器提供的信息本应该在语言的设计之初就加以实现,但因为一些原因,这些功能一直没有实现。

编译器厂商并没有太大动力去提供这类信息,因为他们知道大多数开发者需要面向多个平台进行开发。如果他们提供了这样的信息,实际上对开发者没有太大帮助,因为他们并不打算针对某个特定平台去开发。此外,编译器厂商还需要设计并维护这些功能,而这并不是他们愿意去做的事情。

不过,历史上确实有一些成功的例子。比如,IBM曾经开发过一个叫Montana的编译器,它在设计上考虑到了许多开发者需求,提供了很多智能的功能。Montana编译器采用了增量编译的方式,可以避免需要声明头文件,简化了开发流程。此外,它也有插件式的API,可以让开发者通过编写自己的插件来修改编译过程,从而实现定制化的编译。

但是,尽管Montana编译器本身非常优秀,它却被困在了一个名为Visual Age的产品中,遗憾的是,这个产品没有获得足够的市场认同,最终也没有得到广泛应用。虽然它的设计理念和功能非常先进,但由于被锁定在一个无法普及的产品中,许多开发者并没有机会使用到它。

因此,虽然类似Montana编译器的设计已经被尝试过,并且实现得非常成功,但由于种种原因,这些优秀的设计并没有被广泛推广和应用。今天,虽然我们依然面临类似的挑战,但遗憾的是,过去的成功经验并没有成为编译器设计的主流。

你还在用软件渲染器,还是已经转向 DirectX 或 OpenGL?

目前仍然在使用软件供应商的情况,并且在渲染过程中遇到了困难。这个问题通常涉及多个方面,尤其是当软件需要适配不同平台时,可能会面临性能和兼容性的问题。在渲染的过程中,依赖特定供应商的工具和框架,可能导致效率低下或者在某些平台上的不兼容性。随着技术的发展,很多时候开发者需要寻找更优化的解决方案,避免长期依赖某一特定供应商的产品,尤其是在遇到技术瓶颈时。

这类问题可能会影响到开发效率和最终产品的性能,因此需要考虑是否需要换用不同的供应商,或者考虑其他更为高效、灵活的技术方案。同时,也要考虑如何在多个平台上确保渲染的一致性和性能表现,可能需要对现有的渲染流程进行调整或优化。

什么时候一个人算是“好程序员”?你觉得自己是什么时候成为一个好程序员的?

一个人成为优秀程序员的时刻是一个渐进的过程。一个人从七岁开始编程,到十六岁时才真正意识到自己在编程上有所进步。当时,十六岁被认为是一个转折点。然而,这个进步并不是一蹴而就的。十九岁时,接触了C++编程语言,并受到当时流行的技术影响,误认为它很优秀,因此变得相对较差。这个阶段大约持续了四到五年。

直到后来,在一些良师益友的帮助下,逐渐意识到自己过去的技术选择其实并不理想,慢慢回归到正确的编程路径上。这些朋友通过身教而非言教,帮助重新认识到技术的本质,最终明白了哪些才是真正值得追求的技术。这个过程中的支持和不断的自我反思,是走向编程之路的重要环节。

最终,经过一段时间的调整,重新找回了编程的方向,并从那时起,成为了一名比较成熟的程序员。这个过程证明了成长和反思对于成为优秀程序员的重要性,尤其是要有一个能帮助自己进步的环境和一些启发性的引导。

你是如何应对程序员的倦怠/抑郁的?那些你完全不想做任何事、不高效、没有动力、甚至宁愿做任何别的事情也不愿意写代码的日子

应对程序员的职业倦怠和抑郁,特别是那些日子里感到无法做任何事、情绪低落甚至无法完成最基本的任务时,最重要的是不要过于担心这种情况,因为每个程序员都会经历类似的困境。解决这种困境的一个有效方法是每天至少写一个小时的代码,不论这段代码的质量如何。

即使是在遇到瓶颈,感觉无法解决问题时,也要坚持编写代码。写的代码不一定要完美,也不必担心之后会重写。关键是通过写代码来保持进展。因为通常一旦开始写代码,逐渐克服困难后,就会发现自己能继续前进,并且逐渐摆脱低谷。心理上,最能阻止继续前进的并非缺乏知识,而是感觉自己卡住了,认为无法继续。

当面对困难时,重要的是通过尝试和实验,逐步写入代码并运行编译器。通过设定小目标,逐步取得进展,能够帮助走出低迷。保持编程的连续性非常关键,否则如果让自己完全停滞不前,可能会因为长时间没有产出而更加陷入困境。因此,保持持续的代码输入,不管结果如何,最终会帮助恢复动力,走出困境。

你真的更愿意省下两个无条件跳转(以免 CPU 管道被清空)而不是选择编译器和平台无关性吗?(指 GetThreadId 的实现方式)

首先,讨论的是平台独立性的问题。实际上,并不存在完全的“平台独立性”。因为在不同平台上,获取线程 ID 的方式是不同的。例如,在 Windows 和 macOS 上,获取线程 ID 的方法肯定不同。因此,需要为不同的平台调用不同的函数。换句话说,平台独立性在这里并不适用。

真正的目标是编译器独立性。每个平台都有不同的编译器,我们通常在 Windows 平台上使用某个特定的编译器,而在 macOS 上使用另一个编译器。如果我们切换平台,只需要切换对应的平台编译器即可,并不需要依赖多个编译器来实现代码的编译。

此外,提到为什么要在每个计数器中加入跳转指令,这似乎是没有必要的,因为这样做并不会带来任何实际的好处。这样做反而可能是一个糟糕的选择,增加了不必要的复杂性。

你为什么做游戏引擎而不是直接做游戏?

首先,制作游戏引擎和制作游戏在某种程度上是紧密相连的,但也有明显的不同。决定从头开始制作一个引擎是为了展示如何制作游戏引擎的每一个部分,让所有人都能看到过程和细节。虽然制作游戏和引擎看似是两个不同的目标,但实际上,它们的实现是高度关联的。制作引擎本身是为了展示如何实现引擎的各个功能,而游戏的开发则是为了让这些引擎的功能得以体现和应用。

选择先制作引擎的原因主要有两个方面。首先,作为引擎程序员,目标是展示如何编写一个引擎,而不仅仅是制作一个游戏。游戏的制作本身属于另外一个领域,而引擎编程是一个独立的专业,专注于引擎的功能和实现。其次,制作游戏引擎时,很多复杂的部分需要通过游戏来激励和展示。例如,当我们在编写碰撞检测或对象破碎的代码时,必须有一个游戏场景作为背景,才会有动力去做这些事情。如果没有游戏,很多引擎的功能就失去了意义。

因此,选择同时做引擎和游戏,是因为这样能够更好地理解为什么要编写某些功能。通过游戏的场景和需求,可以清楚地知道为什么要实现某些特性,哪些功能是必须的,哪些是根据具体游戏需求设计的。比如,在设计一个能够支持巨大世界的引擎时,需要考虑如何存储世界的部分信息。如果没有具体的游戏需求,就不容易理解为什么要实现这样的功能。

总之,做游戏引擎和游戏的结合,可以帮助更好地理解为什么做这些代码,并让学习者更清楚地看到代码背后的动机和原因。这样的方法不仅能帮助学习者理解如何实现引擎功能,还能激励他们在学习过程中理解每一个步骤的必要性。

你觉得实现一个正则表达式匹配器对元编程来说有意义吗?

关于“正则表达式”是否在编程中重要的问题,回答是不太看重正则表达式。其实,正则表达式这个概念一直都不是很受欢迎,个人也不喜欢正则表达式的相关内容。原因是,正则表达式往往显得过于简单化,它在很多情况下并没有太大的实际用途。对于一些编程任务来说,使用正则表达式显得有些局限,不够灵活,且没有足够的深度去解决复杂的编程问题。因此,正则表达式在编程中并不是很有价值,尤其在需要复杂计算和多样化处理的场景下。

你似乎对现代软件的质量很不满。你觉得以前的程序员更厉害吗?你自己写的软件总是完美无缺的吗?我是认真问的,想更清楚地理解你的观点,谢谢γ

在编程的历史上,过去的程序员确实普遍更优秀。过去编程是非常困难的,只有那些具备一定技能和知识的人才能真正从事编程工作,因此通过自然选择,优秀的程序员才能脱颖而出。相比之下,现在编程变得容易了很多,几乎任何人都能轻松地编写代码。如今,编程不再是一个专门的领域,任何人都可以拿起一段脚本开始编程。结果是,虽然优秀的程序员数量并没有显著变化,但大量不合格的程序员涌现出来,造成了很多软件的质量问题。

很多软件今天的质量问题就是因为程序员的水平差异。以Facebook为例,Facebook的iPhone应用程序质量非常差,很多人也注意到了这一点。尽管如此,Facebook却没有表现出任何改进的意图,反而在一些博客文章中自夸其成就。这种做法令人非常不解,因为应用程序的质量问题显而易见,而他们却选择忽视,甚至试图为它辩解。如果他们承认问题并表示道歉,或许会显得更为诚实。

虽然自己对代码质量有较高的要求,但也承认自己并不是最严格的标准。例如,与一些极为注重性能的程序员相比,自己容忍的性能问题要宽松得多。自己认为可以接受的性能问题,可能会被一些优秀的程序员视为不可接受的。

总体来说,随着行业的变化,很多人对软件的可靠性和性能的接受度已经大幅下降,导致了许多低质量的产品流入市场。自己始终没有超越那些自己尊敬的程序员的高标准,甚至觉得自己在很多方面比不上他们。但是,令人担忧的是,很多人已经习惯于接受低质量的产品,甚至忽视了这种现象带来的后果。

因此,结论是,如果你的软件质量和性能水平低于一个基本的标准,那么你就非常危险了。自己虽然不是最高标准,但如果连自己设定的标准都没能达到,那就真的很糟糕。

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com

热搜词