在现代软件开发中,多线程程序的调试是一个复杂且具有挑战性的任务。传统的单线程程序调试方法,如设置断点和单步执行代码,可能无法适用于多线程环境。这是因为多线程程序的行为依赖于线程之间的执行顺序,而这些顺序通常以微秒为单位。如果在调试过程中暂停或延迟任何线程,程序的行为可能会发生显著变化。因此,本文将介绍一种有效的多线程程序实时调试方法。
都喜欢使用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(),因为:
不要使用控制台进行追踪,而是将追踪信息写入内存。一旦问题发生,检查内存中发生了什么。写入内存的操作必须尽可能减少开销,类似于以下示例:
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版本时,得到了以下测量结果:
为什么第一次测试需要这么长时间?可能是因为.NET需要准备一些东西,但可以肯定的是,处理器需要将所有内容加载到缓存中才能执行。在下一个周期中,所有内容都已经在缓存中,因此执行速度更快。
应该使用哪个时间?有人可能会说,加载缓存是正常的,这将是现实中的时间。然而,如果想让代码更快,并想比较不同版本,建议只取最快时间。原因是结果是可重复的,这意味着如果重复测试,会得到相同的数字。
要小心这样的建议:
作为一般规则,当测试极快的算法时,建议实现循环运行10,000...100,000次,以提高测量的准确性。
这实际上并不正确!因为代码运行时间越长,操作系统随机事件的可能性就越高。想象一下,如果在一次测试中,突然有一个内存垃圾收集运行。那段时间将与代码无关。因此,要测量代码的执行时间,请保持时间短暂,并消除所有慢结果。如果测试在100微秒内运行10次,但有一次需要10毫秒,那么问题不在代码,而是有些无法控制的东西让微处理器忙。
即使RealTimeTracer只有几行代码,也必须进行彻底测试。建议首先顺序测试,只使用1个线程。一旦逻辑对1个线程工作得很好,那么再运行另一个使用多个线程的测试。
解决方案附带了控制台项目TestConsole。Program.Main()可以运行2个测试:
PC有8个处理器核心,所以使用1个主线程,6个测试线程,并为操作系统留出1个核心。只是为了好玩,也用100个线程运行它,但那时测试运行得更慢,因为所有线程切换。
RealTimeTracer是TracerLib.dll中的一个类。将RealTimeTracer设为静态,以便可以从代码的第一行开始追踪,无需设置任何东西:
RealTimeTracer.Trace("Some Trace Message");
如果需要更多历史记录,请更改常量RealTimeTracer.MaxMessages。调用RealTimeTracer.GetTrace()将追踪结果作为字符串返回。这在Visual Studio中查看追踪时很方便。它确保在构建追踪时不会追踪新信息。
使用RealTimeTracer.IsStopped,可以控制是否执行追踪。