Windows允许您为进程中的线程指定名称,然后调试器可以显示这些名称。这是一个很好的解决方案,但这是一个很好的解决方案。Windows 10 Creators更新(SetThreadDescription)中添加了一个新的线程命名API。Chrome现在使用SetThreadDescription来命名它的线程(当这个函数可用时)。chromiumrepo还包含一个工具,可以使用GetThreadDescription转储进程的所有线程名称。xperf/WPA支持SetThreadDescription API——线程名称会显示在CPU使用率图和通用事件图中,这非常棒。VS 2017 15.6(更新6)和Windows应用商店版本的windbg现在支持线程名称,包括实时调试和小型转储!name列现在包含您给定的任何名称(请注意未命名的TppWorkerThread,因为Windows仍然落后)。已经被注入到线程池中,等待最早更新的线程是2019年6月。我们看看效果如何。其他需要命名的Microsoft线程,因为它们最终会出现在其他开发人员的进程中,包括:

  • DbgUiRemoteBreakin
  • CManagerImpl::_DelegateThreadProc
  • CRpcThreadCache::RpcWorkerThreadEntry

目前,在windbg的进程和线程窗口中,这些线程看起来都是相同的,并且让完全不同的线程看起来无法区分是个坏主意。这也使得在Chrome浏览器进程中很难找到Chrome忘记命名的线程。

Orbit Profiler支持使用GetThreadDescription()获取线程名称–太棒了!

 

下面是WPA中的线程名称示例,它使这些事件在进程和线程之间的分布更易于查看:

 

Visual Studio中的线程名称也非常棒,尤其是现在即使在进程启动很久之后附加线程名称也会出现:

线程名非常棒,应该会让Chrome调试变得简单一点。

看看WPA中线程名有多有用!

所有线程命名问题的状态摘要–剩下的唯一任务是Microsoft需要开始命名其线程:

示例代码中的Const正确性:Fixed

/analyze示例代码中的警告:已修复

调试器中的竞争条件:在VS2015更新2中修复

操作系统线程命名功能:在Windows10 Creators更新中添加(文档称1607/周年纪念版,但这是不正确的)

线程命名函数的工具支持:xperf/WPA、visualstudio和windbg的存储版本都显示线程名称。“经典”版本的windbg通过非常明显的命令“dx-g”显示线程名称@$curprocess.线程“–这里讨论了更多的想法

Windows需要为自己的线程命名,尤其是它注入到其他进程中的线程,比如ntdll.dll!TppWorkerThread(应命名为ThreadPoolWorker)。这也适用于创建线程的第三方库、注入线程的图形驱动程序等等。查找SetThreadDescription并使用它(如果可用)。并选择描述性但简短的线程名称
线程的imageNaming无疑是有帮助的。它在调试时提供了额外的上下文,可用的信息越多越好。右边的屏幕截图显示了调试UIforETW时visualstudio中threads窗口中的name列。
然而,线程命名存在许多缺陷,主要是因为它只是一个调试器约定,而不是一个操作系统特性。

出现主要缺陷是因为Windows上的线程命名是通过引发异常来实现的。有一个约定,调试器应该注意异常代码0x406D1388–是的,这只是一个没有内在含义的任意幻数–并在相关的异常记录中查找幻数值。调试器必须执行以下操作:
打电话给WaitForDebugEvent。如果出现异常,并且异常代码为0x406D1388,并且参数的数目是正确的值,则重新解释异常信息。查看该结构,如果dwType等于4096,dwFlags为零,则使用ReadProcessMemory从调试对象的内存中获取线程名称。
哦,从哪里开始…
这一准则的特殊性是显而易见的。debuggee设置一组参数,然后使用RaiseException向调试器发出信号。如果调试器支持线程命名,那么它将处理这个特定的异常(ExceptionCode、NumberOfParameters、dwType和dwFlags的匹配值),然后从调试对象的内存中读取线程名称。无论调试器是否支持异常,调试对象都会处理异常并继续。这是IPC的一种粗略的方法。
这种技术的好处是它存在。如果这个特性被建议作为一个操作系统的特性,那么它可能会在设计审查或规划中被捆绑几十年。使用这种黑客技术意味着visualstudio调试器团队(MS_VC_异常代码背叛了他们的手)可以实现调试器端,记录如何在客户端调用它,让它立即在所有版本的Windows上运行,然后重新开始工作。Windbg很容易实现了相同的特性,一切都很好。
但是,这种权宜之计给我们留下了一些问题。

不是缺陷

当我第一次在tweet上发布这些问题时,我得到了一个回复,声称64位构建的结构定义是错误的。这个机制确实很脆弱,但并没有被打破。Win32调试API的一个限制是调试器和调试对象的位必须匹配—32位进程不能附加到64位进程。而且,只要调试器和debuggee具有相同的结构布局,那么它是什么并不重要。由于调试器和调试对象将使用相同的编译器和相同的结构定义,因此它们将具有相同的布局,这就是所需的全部内容。
不需要使用pragma pack指令来强制执行结构的任何特定打包,但需要它们来确保两边都具有相同的布局。
visualstudio是32位的,它可以调试64位进程,但它通过使用IPC与64位调试器代理进行通信来实现这一点。它使用msvsmon.exe,所以64位进程的调试基本上是本地远程调试。


小问题


我想先解决这些问题,尽管它们不是严重的问题。

示例代码已很长时间没有更新。因为它的threadName参数被声明为char*,所以不能用constchar数组调用它,如果使用/Zc:strictStrings(非常方便)构建它,甚至不能用string常量调用它。这个小错误现在已经被粘贴到数千个代码基中。我建议在代码中修复这个问题,但是使用剪切粘贴编码意味着这个糟糕的示例将永远存在。现在修好了!

示例代码触发两个/analyze警告。其中一个抱怨筛选器表达式是常量,另一个则抱怨\uuExcept块为空。analyze团队应该将SetThreadName文档视为这些构造通常不是bug的证据,文档团队应该添加必要的警告抑制杂注。仍然相关的示例代码应该在高警告级别下编译干净。现在修好了!

大问题

所有这些大问题都源于设计的一个后果:如果在引发异常时没有调试器在监听,那么线程名称将永远丢失。
如果调试程序在线程命名完成后附加,则调试器将不知道它错过了这些异常。无法要求调试人员再次引发异常。我想调试人员可以每隔几秒钟重命名一次线程,但这充其量是一种减少危害的策略,如果在进程崩溃时附加调试器,它仍然会完全失败。当前的策略意味着大多数Windows错误报告崩溃都没有线程名。
事实证明,调试器并不是唯一可以从了解线程名称中获益的工具。探查器,如Windows Performance Toolkit(xperf)将通过使用thread name列得到极大的增强,按线程名分组将是非常棒的。但是,作为调试器附加到被分析的进程是一个糟糕的主意,所以Windows性能分析器(WPA)无法获取这些信息。
另外,两个主要的Windows调试器都有一个可以避免的争用条件,这会导致SetThreadName频繁地静默失败。如果线程创建事件在线程命名异常之前到达,则这两个调试器似乎只命名线程。如果您创建了一个线程,然后立即从creator线程中命名它,那么在线程开始运行之前引发异常是非常容易的(尤其是在多核处理器上)!修复不应该很难——只需修复调试器,这样它就可以处理以任意顺序显示这两个事件。容易的。在这些调试器修复其竞争条件之前,每个命名其线程的应用程序都必须非常小心。

解决它

想出一个解决所有这些问题的办法是一个有趣的练习。我可以很容易地创建一个导出SetThreadName函数的DLL。然后,这个DLL将与另一个进程通信,该进程将维护一个内存中的数据库,该数据库将线程id映射到名称。为了避免客户机上的速度减慢,这将是一个选择加入进程,可能是通过让程序执行LoadLibrary/GetProcAddress舞蹈来实现的,以查看是否安装了DLL/进程对。这很简单,但是这个想法有两个问题,严重到足以让我不去烦。

我天真的方法无法判断线程何时死亡,这意味着线程数据库将迅速增长到数千个条目。这些线程中的大多数都是未使用的,而且许多线程表示重用已命名但现在已死线程的id的线程。添加unsethreadname函数可以减少混乱,但无法解决问题,尤其是在线程突然退出时。在用户模式下解决这个问题而不引入竞争条件将是一个挑战。

更大的问题是线程命名API需要广泛的支持。我可以说服成千上万的开发人员采用一种新的线程命名API,但是如果没有工具支持,这将是毫无意义的。我希望这个线程名数据库由windbg、visualstudio调试器和ETW跟踪查询。我没有这些工具的源代码,也找不到在哪里创建拉请求。
事实证明,驱动程序可以使用PsSetCreateThreadNotifyRoutine可靠地跟踪线程的创建和销毁,从而创建一个线程名称数据库。但是,这仍然缺少对工具的支持,所以微软已经实施了一个官方的解决方案。
在操作系统中添加这种类型的工具是有先例的。gflags工具允许开发人员跟踪堆分配、对象创建者类型跟踪等等。现在是微软添加线程名称作为gflags选项的时候了。内核可以捕捉现有的异常,可以更新调试器和探查器来查询内核的线程名称数据库,这样世界会变得更好。
我希望微软也能修复当前设置中的const正确性,/analyze警告和竞争条件。三个都修好了。

在那之前如果您正在编写调试器,请考虑添加一个更健壮的线程命名机制。也许会流行起来。您还应该支持现有的标准,并在调试器中修复其竞争条件。如果您正在编写一个程序,其中您想命名您的线程,那么您所能做的就是稍微改善一下情况。可以用两种方法之一避免线程命名竞争条件。最简单的解决方案是让线程自己命名。这保证线程创建事件在异常事件之前到达,因为子线程在开始运行之前不能调用SetThreadName。如果您想创建线程,然后从creator thread中命名它们,那么您必须等到线程完全启动。这可以通过等待线程发出一个事件的信号来完成,或者如果这不方便,那么就等“一会儿”然后交叉手指。
您还应该将thread name参数设为constchar*,并使用“#pragma warning(disable:63206322)”来抑制伪/analyze警告

 

这是一个相当简单的错误。使用字节计数而不是字符计数调用了宽字符串函数,从而导致缓冲区溢出。找到问题后,修复方法很简单,只需将sizeof更改为_countof,很容易的。但像这样的BUG浪费时间。由于崩溃,playtest被取消了,而且由于缓冲区溢出破坏了堆栈,因此找到错误代码并非易事。我知道这种类型的错误是可以避免的,我知道还有很多工作要做。

我所做的工作包括:

尽量早点诊断

如果程序在发出错误的宽字符函数调用的函数内部崩溃了,那么找到错误将是微不足道的——代码检查会很快发现它。但是,一旦执行从该函数返回,垃圾堆就模糊了bug的位置,使其调查变得更加棘手。

事实证明,有一个VC++编译器开关可以防止在破坏堆栈后返回。所以,我做的第一件事就是打开开关。这个开关告诉VC++在堆栈上添加一个标志,并在返回之前进行检查。当这个开关打开的时候,溢出的功能被抓住了,找到它花了几秒钟。GS开关是作为一种安全特性,用于防止恶意的缓冲区溢出,但它也可以作为开发人员的生产力工具很好地工作。它确实有一些运行时开销,但这种权衡通常是值得的,尤其是在内部构建上,推荐使用。

修改bug

一旦我打开/GS并重新调试了这个bug,找到这个bug就很简单了,所以下一步就是修复它,如前所述。

保护证据

当缓冲区溢出破坏堆栈时,buggy函数返回到一个垃圾地址,这个地址正好在堆中。抹掉bug所在位置的证据已经够糟糕的了,但更糟糕的是,在返回堆中的地址后,会将堆中的数据作为指令执行。这扰乱了寄存器和堆栈,通常会使事情变得混乱。

还有,一个可执行堆?真正地?这是一个首要的安全漏洞。因此,下一个任务是将链接器设置更改为/NXCOMPAT。这将告诉Windows使堆和堆栈不可执行。这大大提高了安全性,还可以简化调试。而且,这个选项没有运行时成本,推荐使用。实际上,这应该被认为是必要的。

 

我还打开了我们的发布分支中的/DYNAMICBASE链接器开关,以进一步提高安全性,同时也没有运行时开销。

规避崩溃风险

在这一点上,我已经修复了这个错误,使以后的此类错误更容易调查,并提高了安全性。但还有很多事情要做。原来这种错误很容易犯。当开发人员传递缓冲区和大小时,传递错误大小的方法至少有六种。将来避免这些错误的最好方法是避免传递大小。创建以数组为参数并以100%的精度推断大小的模板函数非常容易。与其写这个:

mywprintf(buffer, _countof(buffer), …); // Verbose and dangerous

你可以写成下面的:

mywprintf_safe(buffer, …); // Compact and safe

它打字少,阅读少,而且保证是正确的。接受数组引用的模板化函数的语法有点粗糙,但您只需要在几个地方正确地使用它。如果有一个原始指针,模板技术就不起作用,但是对于任何其他目标,您应该能够创建一个重载来处理它。手动传递的大小应该很少。因此,我的下一个任务是为所有字符串函数添加模板重写,并鼓励每个人在所有新代码中使用它们,从而使避免再次编写此类错误变得非常简单。

避免过去的风险

虽然safe模板函数可以让我们在将来避免编写这种类型的bug,但它们对现有的数百万行代码却无能为力。可以肯定的是,有更多的尺寸不匹配等着咬我们。所以我在字符串函数中添加了SAL注释,启动了visualstudio的/analyze,并开始编译。到目前为止,这是最大的任务。任何没有运行静态分析的大型代码库都会有很多可检测的bug。缓冲区溢出、逻辑错误、格式字符串不匹配、释放后使用等等。分析了上千个项目,修复了5个主要的机器故障。这是几个月的工作分散了几年,但时间是值得花的。今天它仍在发现新的编码错误。

符号服务器允许Windows上的开发人员工具自动查找符号。他们做得很好,以至于大多数开发人员都不必担心内部机制。然而,当事情出了问题时,了解它们是如何工作的是有帮助的,事实证明,这一切都非常简单。

我对Windows符号服务器的讨论利用了我笔记本电脑上的符号服务器,用于我自己的个人项目。每当我发布一个新版本的分形极限(64位优化,多核,快速和流动的分形探索,这里的演示版本)我把符号和二进制文件放在我的符号服务器上,这样我就可以轻松地调查我收到的任何崩溃报告。这对于一个家庭项目来说似乎有点过头了,但实际上本地符号服务器只是文件的副本,以特定的方式排列以便于检索,而且设置起来很简单。对于我公开发布的可执行文件,比如UIforETW,我将PE和PDB文件发布到Google存储上的一个公共符号服务器上——详细信息在这里。

Visual C++编译器的分析功能是提高代码质量的一个好方法。它基本上是一个21世纪的“lint”来识别许多编码错误。许多错误对于程序员来说是很难看到的,但是,通过对代码进行适当的注释,/analyze的不屈不挠的眼睛将可靠地找到它们。
我最近在一个大型代码库上使用/analyze,发现了大约500个值得修复的bug。这些错误包括超出范围的读写、printf参数错误、逻辑错误等等。这些错误有可能导致内存损坏、崩溃和所有类型的意外行为。在我进行代码清理时,有几个崩溃bug咬了我的同事,因此证明了这些bug不仅仅是理论上的问题。
列出/analyze发现的所有bug,创建起来很乏味,阅读起来也很无聊。相反,我创建了示例代码来演示/analyze在哪些方面做得最好。分析工作做得很糟糕的地方会被保存到以后的文章中。

缓冲区溢出

当调用需要传递指针和缓冲区大小的函数时,很容易传递错误的大小。我看到的一些错误包括传递错误的硬编码常量、传递错误的命名常量或传递错误的缓冲区大小。在本例中,当需要_countof()时使用sizeof(),这意味着该计数是应该的两倍,并且可能存在缓冲区溢出:

void TestBufferWarnings(const wchar_t*pSource)
{
wchar_t buffer[
10];
wcscpy_s(buffer,
sizeof(buffer), pSource);
}

分析是一种强大的VisualC++特性,可以帮助发现bug。然而,它使用了一些相当奇特的启发式方法,这使得很难决定如何认真对待它的警告。今天我们将讨论其中一种特殊的启发式方法,并展示一个案例,其中/analyze是完全错误的。这些特殊的警告大多在VS2012中修复。在我与微软分享的repro项目中,大多数错误警告都是被处理的,所以假阳性率较低。这些测试都是用Visual Studio 2010 SP1,C/C++优化编译器版本16.00 .40219.01为80×86。