C#锁语句的性能表现问题

9
更新 - 我已经找到了造成lock()消耗大量CPU周期的原因。我在最初的问题之后添加了这些信息。这些都变成了一堵文字墙,所以:

简短概述 在某些情况下,C#内置的lock()机制将会使用异常的CPU时间,如果您的系统正在运行高分辨率系统计时器。

最初的问题:

我有一个应用程序从多个线程访问资源。该资源是连接到USB的设备。它是一个简单的命令/响应接口,我使用一个小的lock()块来确保发送命令的线程也获得响应。 我的实现使用lock(obj)关键字:

lock (threadLock)
{
    WriteLine(commandString);
    rawResponse = ReadLine();
}

当我以尽可能快的速度(在一个紧密循环中)从3个线程访问此内容时,在高端计算机上的CPU使用率约为24%。由于USB端口的特性,每秒只执行大约1000个命令/响应操作。 然后,我实现了在这里描述的锁机制SimpleExclusiveLock,现在代码看起来类似于这样(一些try/catch的代码被删除以释放异常处理IO锁定):
Lock.Enter();
WriteLine(commandString);
rawResponse = ReadLine();
Lock.Exit();

使用这个实现方式,当使用相同的3线程测试程序时,CPU使用率降至<1%,同时仍然能够获得1000个命令/响应操作每秒。
问题是:在这种情况下,使用内置的lock()关键字存在什么问题?
我是否偶然发现了一个lock()机制开销异常高的情况?进入临界区的线程将仅持有锁约1毫秒。
更新: 造成lock()占用大量CPU的原因是某些应用程序使用winmm.dll中的timeBeginPeriod()增加了整个系统的计时器分辨率。在我的情况下,罪魁祸首是Google Chrome和SQL Server - 它们使用以下代码请求了1毫秒的系统计时器分辨率:
[DllImport("winmm.dll", EntryPoint = "timeBeginPeriod", SetLastError = true)]
private static extern uint TimeBeginPeriod(uint uMilliseconds);

我通过使用powercfg工具发现了这一点:
powercfg -energy duration 5 

由于内置的lock()语句存在某种设计缺陷,这种增加的计时器分辨率会像疯狂一样消耗CPU(至少在我的情况下是这样)。因此,我杀掉了请求高分辨率系统计时器的程序。现在我的应用程序运行速度变慢了一点。每个请求现在将锁定16.5毫秒而不是1毫秒。我想原因是线程被调度得更少了。任务管理器中显示的CPU使用率也降至零。我毫不怀疑lock()仍然使用了相当多的周期,但现在这已经隐藏起来了。
在我的项目中,低CPU使用率是一个重要的设计因素。USB请求的低1 ms延迟对整体设计也是有益的。因此(在我的情况下),解决方案是放弃内置的lock()并用正确实现的锁机制替换它。我已经扔掉了有缺陷的System.IO.Ports.SerialPort,转而使用WinUSB,所以我没有恐惧 :)
我制作了一个小型控制台应用程序来演示所有这些内容,如果您有兴趣获得副本,请私信联系我(约100行代码)。
我想我回答了自己的问题,所以我只是把这个留在这里,以防有人感兴趣...

2
让我感到困惑的是,链接的文章使用Semaphore而不是lock进行等待; 这应该会更昂贵(需要进入操作系统层等)。顺便说一句,“只有大约1毫秒”:对于计算机来说,1毫秒是非常长的时间。 - Marc Gravell
哦,是的马克,“仅 1 毫秒”是基于线程调度器认为 1 毫秒是一个短时间(据我所知)。 - Mikael
Henk,工作量不大。但“设备”是一台复杂的机器。我原以为可以创建一组线程来控制机器的各个(独立)部分,而无需进行任何不必要的等待。每秒只能进行1000次控制I/O操作,因此大多数线程将等待获取锁。显然,使用内置的lock()是不好的设计,但如果我切换到更便宜的锁机制,则是良好的设计 :) 这就像内置的lock()使用某种忙等待一样? - Mikael
1
我猜测lock可能会导致线程上下文切换,而这些上下文切换会消耗CPU。请使用SpinLock替换lock (threadLock),并查看结果。我怀疑你会得到类似于使用SimpleExclusiveLock的结果。旋转操作可以防止上下文切换,除非等待时间特别长。 - Jim Mischel
1个回答

5
抱歉,这是不可能的。三个线程中有两个在锁上阻塞,另一个在需要一毫秒的 I/O 操作上阻塞,无论如何也无法实现 24% 的 CPU 利用率。链接的文章可能有趣,但 .NET Monitor 类执行的是完全相同的操作,包括 CompareExchange() 优化和等待队列。
您可以通过程序中 其他 代码的运行来达到 24%,通常情况下界面线程会被频繁调用而导致核心利用率过高。这是一个经典错误,人眼无法读取得那么快。进一步推断是您编写了一个不更新 UI 的测试程序,因此不会烧毁核心。
当然,分析器会准确告诉您这些周期的去向。这应该是您接下来要采取的步骤。

分析器显示Monitor.Enter使用了异常高的循环次数。这可能是由于我的系统运行1毫秒线程调度与lock()中某种设计缺陷相结合。我在原问题中添加了我的发现。 - Mikael
关于“1毫秒线程调度”并没有什么特别的,只需使用Chrome即可。它还会调用timeBeginPeriod(1)。许多SO用户喜欢使用Chrome浏览SO,他们从未抱怨过Monitor.Enter或lock中的错误。我不相信这个说法。你需要将你的断言提交给Microsoft支持。 - Hans Passant

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接