为什么Monitor.PulseAll会在信号线程中产生“踩台阶”延迟模式?

时间:2013-12-31 16:59:37

标签: c# .net multithreading latency

在使用Monitor.PulseAll()进行线程同步的库中,我注意到从PulseAll(...)被调用到线程被唤醒的时间的延迟似乎遵循“踩台阶”分布 - - 步数极大。被唤醒的线程几乎没有工作;并几乎立即回到监视器上等待。例如,在一个包含12个内核,24个线程等待监视器的盒子上(2x Xeon5680 / Gulftown;每个处理器6个物理内核; HT禁用),Pulse和线程唤醒之间的延迟是这样的:

Latency using Monitor.PulseAll(); 3rd party library

前12个线程(注意我们有12个核心)需要30到60微秒才能响应。然后我们开始获得非常大的跳跃;高原在700,1300,1900和2600微秒左右。

使用下面的代码,我能够独立于第三方库成功重新创建此行为。这段代码的作用是启动大量线程(更改numThreads参数),它只是在监视器上等待,读取时间戳,将其记录到ConcurrentSet,然后立即返回等待。一旦第二个PulseAll()唤醒所有线程。它执行了20次,并将第10次迭代的延迟报告给控制台。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Concurrent;
using System.Diagnostics;

namespace PulseAllTest
{
    class Program
    {
        static long LastTimestamp;
        static long Iteration;
        static object SyncObj = new object();
        static Stopwatch s = new Stopwatch();
        static ConcurrentBag<Tuple<long, long>> IterationToTicks = new ConcurrentBag<Tuple<long, long>>();

        static void Main(string[] args)
        {
            long numThreads = 32;

            for (int i = 0; i < numThreads; ++i)
            {
                Task.Factory.StartNew(ReadLastTimestampAndPublish, TaskCreationOptions.LongRunning);
            }

            s.Start();
            for (int i = 0; i < 20; ++i)
            {
                lock (SyncObj)
                {
                    ++Iteration;
                    LastTimestamp = s.Elapsed.Ticks;
                    Monitor.PulseAll(SyncObj);
                }
                Thread.Sleep(TimeSpan.FromSeconds(1));
            }

            Console.WriteLine(String.Join("\n",
                from n in IterationToTicks where n.Item1 == 10 orderby n.Item2 
                    select ((decimal)n.Item2)/TimeSpan.TicksPerMillisecond));
            Console.Read();
        }

        static void ReadLastTimestampAndPublish()
        {
            while(true)
            {
                lock(SyncObj)
                {
                    Monitor.Wait(SyncObj);
                }
                IterationToTicks.Add(Tuple.Create(Iteration, s.Elapsed.Ticks - LastTimestamp));
            }
        }
    }
}

使用上面的代码,这里是启用了8核/ w超线程(即任务管理器中的16个核心)和32个线程(* 2x Xeon5550 / Gainestown;每个处理器4个物理核心;启用了HT)的盒子上的延迟示例):

Latency using Monitor.PulseAll(), sample code

编辑:为了尝试将NUMA排除在等式之外,下面是运行示例程序的图表,其中Core i7-3770(Ivy Bridge)上有16个线程; 4个物理核心; HT启用:

Latency using Monitor.PulseAll(), sample code, no NUMA

有人可以解释为什么Monitor.PulseAll()会以这种方式运作吗?

EDIT2:

为了尝试表明这种行为不是同时唤醒一堆线程所固有的,我已经使用Events复制了测试程序的行为;而不是测量PulseAll()的延迟,我正在测量ManualResetEvent.Set()的延迟。代码创建了许多工作线程,然后在同一个ManualResetEvent对象上等待ManualResetEvent.Set()事件。当事件被触发时,他们会进行延迟测量,然后立即等待他们自己的每个线程AutoResetEvent。在下一次迭代(之前500ms)之前,ManualResetEvent是Reset(),然后每个AutoResetEvent都是Set(),因此线程可以返回等待共享的ManualResetEvent。

我犹豫是否发布了这个因为它可能是一个巨大的红色听证会(我没有声称事件和监视器的行为类似)加上它使用一些绝对可怕的做法让事件表现得像一个监视器(我喜欢/讨厌看看我的同事如果将其提交给代码审查会做什么);但我认为结果很有启发性。

该测试在与原始测试相同的机器上进行; 2xXeon5680 / Gulftown;每个处理器6个内核(总共12个内核);超线程已禁用。

ManualResetEventLatency

如果不明显,这与Monitor.PulseAll有多么不同;这是覆盖在最后一个图上的第一个图:

ManualResetEventLatency vs. Monitor Latency

用于生成这些测量的代码如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Concurrent;
using System.Diagnostics;

namespace MRETest
{
    class Program
    {
        static long LastTimestamp;
        static long Iteration;
        static ManualResetEventSlim MRES = new ManualResetEventSlim(false);
        static List<ReadLastTimestampAndPublish> Publishers = 
            new List<ReadLastTimestampAndPublish>();
        static Stopwatch s = new Stopwatch();
        static ConcurrentBag<Tuple<long, long>> IterationToTicks = 
            new ConcurrentBag<Tuple<long, long>>();

        static void Main(string[] args)
        {
            long numThreads = 24;
            s.Start();

            for (int i = 0; i < numThreads; ++i)
            {
                AutoResetEvent ares = new AutoResetEvent(false);
                ReadLastTimestampAndPublish spinner = new ReadLastTimestampAndPublish(
                    new AutoResetEvent(false));
                Task.Factory.StartNew(spinner.Spin, TaskCreationOptions.LongRunning);
                Publishers.Add(spinner);
            }

            for (int i = 0; i < 20; ++i)
            {
                ++Iteration;
                LastTimestamp = s.Elapsed.Ticks;
                MRES.Set();
                Thread.Sleep(500);
                MRES.Reset();
                foreach (ReadLastTimestampAndPublish publisher in Publishers)
                {
                    publisher.ARES.Set();
                }
                Thread.Sleep(500);
            }

            Console.WriteLine(String.Join("\n",
                from n in IterationToTicks where n.Item1 == 10 orderby n.Item2
                    select ((decimal)n.Item2) / TimeSpan.TicksPerMillisecond));
            Console.Read();
        }

        class ReadLastTimestampAndPublish
        {
            public AutoResetEvent ARES { get; private set; }

            public ReadLastTimestampAndPublish(AutoResetEvent ares)
            {
                this.ARES = ares;
            }

            public void Spin()
            {
                while (true)
                {
                    MRES.Wait();
                    IterationToTicks.Add(Tuple.Create(Iteration, s.Elapsed.Ticks - LastTimestamp));
                    ARES.WaitOne();
                }
            }
        }
    }
}

2 个答案:

答案 0 :(得分:1)

这些版本之间的一个区别是,在PulseAll情况下 - 线程立即重复循环,再次锁定对象。

你有12个核心,所以12个线程正在运行,执行循环,再次进入循环,锁定对象(一个接一个),然后进入等待状态。其他线程等待的所有时间。在ManualEvent情况下,您有两个事件,因此线程不会立即重复循环,而是在ARES事件上被阻止 - 这允许其他线程更快地获取锁拥有权。

我通过在ReadLastTimestampAndPublish中循环结束时添加sleep来模拟PulseAll中的类似行为。这使得其他线程可以更快地锁定syncObj,并且似乎可以改善我从程序中获得的数字。

static void ReadLastTimestampAndPublish()
{
    while(true)
    {
        lock(SyncObj)
        {
            Monitor.Wait(SyncObj);
        }
        IterationToTicks.Add(Tuple.Create(Iteration, s.Elapsed.Ticks - LastTimestamp));
        Thread.Sleep(TimeSpan.FromMilliseconds(100));   // <===
    }
}

答案 1 :(得分:1)

首先,这不是一个答案,只是通过查看SSCLI来了解到底发生了什么。其中大部分都远远超出我的想象,但仍然很有趣。

兔子洞的行程始于对Monitor.PulseAll的呼叫,该呼叫在C#中实现:

clr\src\bcl\system\threading\monitor.cs

namespace System.Threading
{
    public static class Monitor 
    {
        // other methods omitted

        [MethodImplAttribute(MethodImplOptions.InternalCall)]
        private static extern void ObjPulseAll(Object obj);

        public static void PulseAll(Object obj)
        {
            if (obj==null) {
                throw new ArgumentNullException("obj");
            }

            ObjPulseAll(obj);
        } 
    }
}

InternalCall方法在clr\src\vm\ecall.cpp中路由:

FCFuncStart(gMonitorFuncs)
    FCFuncElement("Enter", JIT_MonEnter)
    FCFuncElement("Exit", JIT_MonExit)
    FCFuncElement("TryEnterTimeout", JIT_MonTryEnter)
    FCFuncElement("ObjWait", ObjectNative::WaitTimeout)
    FCFuncElement("ObjPulse", ObjectNative::Pulse)
    FCFuncElement("ObjPulseAll", ObjectNative::PulseAll)
    FCFuncElement("ReliableEnter", JIT_MonReliableEnter)
FCFuncEnd()

ObjectNative住在clr\src\vm\comobject.cpp

FCIMPL1(void, ObjectNative::PulseAll, Object* pThisUNSAFE)
{
    CONTRACTL
    {
        MODE_COOPERATIVE;
        DISABLED(GC_TRIGGERS);  // can't use this in an FCALL because we're in forbid gc mode until we setup a H_M_F.
        THROWS;
        SO_TOLERANT;
    }
    CONTRACTL_END;

    OBJECTREF pThis = (OBJECTREF) pThisUNSAFE;
    HELPER_METHOD_FRAME_BEGIN_1(pThis);
    //-[autocvtpro]-------------------------------------------------------

    if (pThis == NULL)
        COMPlusThrow(kNullReferenceException, L"NullReference_This");

    pThis->PulseAll();

    //-[autocvtepi]-------------------------------------------------------
    HELPER_METHOD_FRAME_END();
}
FCIMPLEND

OBJECTREFObject之上散布了一些魔力(->运算符已超载),因此OBJECTREF->PulseAll()实际上是Object->PulseAll(),它在{ {1}}并将呼叫转发至clr\src\vm\object.h

ObjHeader->PulseAll

class Object { // snip public: // snip ObjHeader *GetHeader() { LEAF_CONTRACT; return PTR_ObjHeader(PTR_HOST_TO_TADDR(this) - sizeof(ObjHeader)); } // snip void PulseAll() { WRAPPER_CONTRACT; GetHeader()->PulseAll(); } // snip } 检索ObjHeader::PulseAllSyncBlock使用AwareLock进行EnterExit锁定对象。 AwareLockclr\src\vm\syncblk.cpp)使用CLREventclr\src\vm\synch.cpp)作为MonitorEventCLREvent::CreateMonitorEvent(SIZE_T))创建,调用UnsafeCreateEventclr\src\inc\unsafe.h)或托管环境的同步方法。

clr\src\vm\syncblk.cpp

void ObjHeader::PulseAll()
{
    CONTRACTL
    {
        INSTANCE_CHECK;
        THROWS;
        GC_TRIGGERS;
        MODE_ANY;
        INJECT_FAULT(COMPlusThrowOM(););
    }
    CONTRACTL_END;

    //  The following code may cause GC, so we must fetch the sync block from
    //  the object now in case it moves.
    SyncBlock *pSB = GetBaseObject()->GetSyncBlock();

    // GetSyncBlock throws on failure
    _ASSERTE(pSB != NULL);

    // make sure we own the crst
    if (!pSB->DoesCurrentThreadOwnMonitor())
        COMPlusThrow(kSynchronizationLockException);

    pSB->PulseAll();
}

void SyncBlock::PulseAll()
{
    CONTRACTL
    {
        INSTANCE_CHECK;
        NOTHROW;
        GC_NOTRIGGER;
        MODE_ANY;
    }
    CONTRACTL_END;

    WaitEventLink  *pWaitEventLink;

    while ((pWaitEventLink = ThreadQueue::DequeueThread(this)) != NULL)
        pWaitEventLink->m_EventWait->Set();
}

DequeueThread使用crstclr\src\vm\crst.cpp),这是关键部分的包装。 m_EventWait是手册CLREvent

所以,所有这些都是使用操作系统原语,除非默认的主机提供程序是覆盖的东西。