多线程程序的实时调试技巧

在现代软件开发中,多线程程序的调试是一个复杂且具有挑战性的任务。传统的单线程程序调试方法,如设置断点和单步执行代码,可能无法适用于多线程环境。这是因为多线程程序的行为依赖于线程之间的执行顺序,而这些顺序通常以微秒为单位。如果在调试过程中暂停或延迟任何线程,程序的行为可能会发生显著变化。因此,本文将介绍一种有效的多线程程序实时调试方法。

挑战

都喜欢使用Visual Studio进行调试,通过设置断点和单步执行代码来找出程序行为异常的原因。然而,在多线程应用程序中,设置断点或单步执行会完全改变程序的行为,因为线程之间的执行顺序至关重要。如果在多线程系统中停止或延迟任何操作,程序的行为将完全不同。

显然,不能在调试时停止单个线程。这意味着应该使用追踪技术,类似于以下示例:

Console.WriteLine("\nThread A requests someLock"); lock(someLock) { Console.WriteLine("\nThread A got someLock"); // do some work Console.WriteLine("\nThread A releases someLock"); } Console.WriteLine("\nThread A released someLock");

在可能出错的地方执行此操作,将在输出窗口中看到哪个线程导致了问题。唯一的问题是不能使用Console.WriteLine(),因为:

  • 它太慢了,在电脑上大约需要500微秒。被追踪的程序将与未被追踪的程序有很大不同。
  • Console.WriteLine锁定线程,如果多个线程同时尝试写入,这是实时多线程调试中的大忌。

解决方案

不要使用控制台进行追踪,而是将追踪信息写入内存。一旦问题发生,检查内存中发生了什么。写入内存的操作必须尽可能减少开销,类似于以下示例:

public const int MaxMessages = 0x100; string[] messages = new string[MaxMessages]; int messagesIndex = -1; public void Trace(string message) { int thisIndex = Interlocked.Increment(ref messagesIndex); messages[thisIndex] = message; }

Trace()方法将消息写入字符串数组。注意,Interlocked.Increment()是多线程安全的且非阻塞的,与.NET Framework中的许多线程安全方法不同,后者是阻塞的。这种绝对最小化的方法大约需要2微秒,这个延迟应该是可以接受的。

当然,上述代码也有一个问题,那就是当数组满时会抛出异常,可以这样解决:

const int indexMask = MaxMessages - 1; public void Trace(string message) { int thisIndex = Interlocked.Increment(ref messagesIndex) & indexMask; messages[thisIndex] = message; }

这将强制索引在到达消息数组末尾时回到0。缓冲区可以存储0x100条消息,以十进制表示为256。如果需要更多历史记录,增加MaxMessages。但要小心,0x1000000 % MaxMessages必须等于0。这意味着可以使用0x100, 0x200, 0x400, 0x800, 0x1000这样的数字,但不能使用0x300。

当messagesIndex达到int.Max(0x7FFFFFFF或2,147,483,647)时会发生什么?基本上,整数将溢出到int.Min(0x80000000或-2,147,483,648),但幸运的是,不会抛出异常。位运算符"And"操作符&会切掉前导1,也将非常负的数字映射到0 - 0x100的范围内。

追踪更多信息

知道哪个线程在写入消息时会很有价值。可以通过以下方式实现:

static string[] threadNames = new string[MaxMessages]; static long[] ticks = new long[MaxMessages]; static Stopwatch stopWatch; static RealTimeTracer() { stopWatch = new Stopwatch(); stopWatch.Start(); } public static void Trace(string message) { int thisIndex = Interlocked.Increment(ref messagesIndex) & indexMask; ticks[thisIndex] = stopWatch.ElapsedTicks; messages[thisIndex] = message; threadNames[thisIndex] = Thread.CurrentThread.Name; }

这将使Trace()所需的时间增加到大约3微秒。当然,可以添加任何其他想要的信息,但尽量最小化新对象的创建,因为内存管理可能会增加一些不必要的延迟。

检索追踪信息

需要编写一些代码,实时检测问题何时发生。在这个时候,只需调用RealTimeTracer.GetTrace(),它将返回所有消息为一个字符串。GetTrace()将停止所有追踪,以便返回一组一致的消息。也可以在错误发生时在Visual Studio中设置断点,并在快速监视窗口中显示RealTimeTracer.GetTrace()。最后,可以使用属性RealTimeTracer.IsStopped来控制是否执行追踪。

另一种方法是让追踪运行一段时间,然后停止一切并检查追踪是否有错误。

测量时间的解释

这样测量Tracer所需的时间:

string[] testResults = new string[maxTestCycles]; string testString = "abcdefgh"; for (int testIndex = 0; testIndex < maxTestCycles; testIndex++) { Stopwatch stopWatch = new Stopwatch(); stopWatch.Start(); for (int i = 0; i < RealTimeTracer.MaxMessages / 10; i++) { RealTimeTracer.Trace(testString); } stopWatch.Stop(); Console.WriteLine("\nTest " + testIndex + " : " + stopWatch.Elapsed.TotalMilliseconds / RealTimeTracer.MaxMessages * 1000 + " microseconds."); }

当运行最简短的Tracer版本时,得到了以下测量结果:

  • 测试0:1.52734375微秒。
  • 测试1:0.005859375微秒
  • 测试2:0.00390625微秒。
  • 测试3:0.00390625微秒。
  • 测试4:0.00390625微秒。

为什么第一次测试需要这么长时间?可能是因为.NET需要准备一些东西,但可以肯定的是,处理器需要将所有内容加载到缓存中才能执行。在下一个周期中,所有内容都已经在缓存中,因此执行速度更快。

应该使用哪个时间?有人可能会说,加载缓存是正常的,这将是现实中的时间。然而,如果想让代码更快,并想比较不同版本,建议只取最快时间。原因是结果是可重复的,这意味着如果重复测试,会得到相同的数字。

要小心这样的建议:

作为一般规则,当测试极快的算法时,建议实现循环运行10,000...100,000次,以提高测量的准确性。

这实际上并不正确!因为代码运行时间越长,操作系统随机事件的可能性就越高。想象一下,如果在一次测试中,突然有一个内存垃圾收集运行。那段时间将与代码无关。因此,要测量代码的执行时间,请保持时间短暂,并消除所有慢结果。如果测试在100微秒内运行10次,但有一次需要10毫秒,那么问题不在代码,而是有些无法控制的东西让微处理器忙。

多线程程序的测试方法

即使RealTimeTracer只有几行代码,也必须进行彻底测试。建议首先顺序测试,只使用1个线程。一旦逻辑对1个线程工作得很好,那么再运行另一个使用多个线程的测试。

解决方案附带了控制台项目TestConsole。Program.Main()可以运行2个测试:

  • testSingleThread()使用1个线程将运行数字写入追踪。然后测试验证每个数字是否正确且按正确顺序写入追踪。
  • testMultiThread()在多个线程上启动写入器,写入运行数字。过一段时间后,主线程停止写入器线程,并在追踪中验证每个线程是否按顺序写入其数字,并且没有遗漏任何数字。

PC有8个处理器核心,所以使用1个主线程,6个测试线程,并为操作系统留出1个核心。只是为了好玩,也用100个线程运行它,但那时测试运行得更慢,因为所有线程切换。

使用代码

RealTimeTracer是TracerLib.dll中的一个类。将RealTimeTracer设为静态,以便可以从代码的第一行开始追踪,无需设置任何东西:

RealTimeTracer.Trace("Some Trace Message");

如果需要更多历史记录,请更改常量RealTimeTracer.MaxMessages。调用RealTimeTracer.GetTrace()将追踪结果作为字符串返回。这在Visual Studio中查看追踪时很方便。它确保在构建追踪时不会追踪新信息。

使用RealTimeTracer.IsStopped,可以控制是否执行追踪。

沪ICP备2024098111号-1
上海秋旦网络科技中心:上海市奉贤区金大公路8218号1幢 联系电话:17898875485