同一进程中线程之间的低延迟通信

时间:2014-10-29 18:07:14

标签: c# multithreading performance ipc low-latency

控制台应用程序有3个线程:Main,T1,T2。 目标是尽可能以最低延迟(μs)从主线程“发信号”T1,T2(并让它们做一些工作)

注意:

  • 请忽略抖动,GC等(我可以处理)
  • ElapsedLogger.WriteLine通话费用低于50ns(纳秒)

看看下面的代码:

样本1

class Program
{
    private static string msg = string.Empty;
    private static readonly CountdownEvent Countdown = new CountdownEvent(1);

    static void Main(string[] args)
    {
        while (true)
        {
            Countdown.Reset(1);
            var t1 = new Thread(Dowork) { Priority = ThreadPriority.Highest };
            var t2 = new Thread(Dowork) { Priority = ThreadPriority.Highest };
            t1.Start();
            t2.Start();

            Console.WriteLine("Type message and press [enter] to start");
            msg = Console.ReadLine();

            ElapsedLogger.WriteLine("Kick off!");
            Countdown.Signal();

            Thread.Sleep(250);
            ElapsedLogger.FlushToConsole();
        }
    }
    private static void Dowork()
    {
        string t = Thread.CurrentThread.ManagedThreadId.ToString();
        ElapsedLogger.WriteLine("{0} - Waiting...", t);

        Countdown.Wait();

        ElapsedLogger.WriteLine("{0} - Message received: {1}", t, msg);
    }
}

输出:

Type message and press [enter] to start
test3
20141028 12:03:24.230647|5 - Waiting...
20141028 12:03:24.230851|6 - Waiting...
20141028 12:03:30.640351|Kick off!
20141028 12:03:30.640392|5 - Message received: test3
20141028 12:03:30.640394|6 - Message received: test3

Type message and press [enter] to start
test4
20141028 12:03:30.891853|7 - Waiting...
20141028 12:03:30.892072|8 - Waiting...
20141028 12:03:42.024499|Kick off!
20141028 12:03:42.024538|7 - Message received: test4
20141028 12:03:42.024551|8 - Message received: test4

在上面的代码中,“延迟”大约是40-50μs。 CountdownEvent信令调用非常便宜(小于50ns),但T1,T2线程被暂停,唤醒它们需要时间。

样本2

class Program
{
    private static string _msg = string.Empty;
    private static bool _signal = false;

    static void Main(string[] args)
    {
        while (true)
        {
            _signal = false;
            var t1 = new Thread(Dowork) {Priority = ThreadPriority.Highest};
            var t2 = new Thread(Dowork) {Priority = ThreadPriority.Highest};
            t1.Start();
            t2.Start();

            Console.WriteLine("Type message and press [enter] to start");
            _msg = Console.ReadLine();

            ElapsedLogger.WriteLine("Kick off!");
            _signal = true;

            Thread.Sleep(250);
            ElapsedLogger.FlushToConsole();
        }
    }
    private static void Dowork()
    {
        string t = Thread.CurrentThread.ManagedThreadId.ToString();
        ElapsedLogger.WriteLine("{0} - Waiting...", t);

        while (!_signal) { Thread.SpinWait(10); }

        ElapsedLogger.WriteLine("{0} - Message received: {1}", t, _msg);
    }
}

输出:

Type message and press [enter] to start
testMsg
20141028 11:56:57.829870|5 - Waiting...
20141028 11:56:57.830121|6 - Waiting...
20141028 11:57:05.456075|Kick off!
20141028 11:57:05.456081|6 - Message received: testMsg
20141028 11:57:05.456081|5 - Message received: testMsg

Type message and press [enter] to start
testMsg2
20141028 11:57:05.707528|7 - Waiting...
20141028 11:57:05.707754|8 - Waiting...
20141028 11:57:57.535549|Kick off!
20141028 11:57:57.535576|7 - Message received: testMsg2
20141028 11:57:57.535576|8 - Message received: testMsg2

此时'延迟'约为6-7μs。 (但CPU很高)这是因为T1,T2线程被强制激活(它们什么都不做,只是烧掉CPU时间)

在“真正的”应用程序中,我不能像那样旋转CPU(我有很多活动线程,它会使它更糟/更慢甚至杀死服务器)。

是否可以使用任何东西来降低10-15μs左右的延迟? 我猜使用Producer / Consumer模式它不会比使用CountdownEvent更快。 等待/脉冲也比CountdownEvent贵。

我在样本1中获得的是我能达到的最佳效果吗?

有什么建议吗?

当我有时间时,我也会尝试使用原始套接字。

3 个答案:

答案 0 :(得分:3)

您试图过分简化,然后无论您采用哪种方式扭转局面都会被您咬住。 Thread.SpinWait(int)决不打算单独使用或作为钝器使用。要使用它,您需要预先计算(本质上是根据当前系统信息,时钟,调度程序中断计时器间隔)校准自旋锁的最佳迭代次数。用完预算后,您需要自愿睡眠/屈服/等待。整个安排通常称为2级等待或2相等待。

您需要注意,一旦越过该行,您的最小延迟就是调度程序中断计时器间隔(Win10上至少1毫秒来自System Internals的ClockRes,如果有任何“测量”为您提供了较低的值,则测量被破坏或你并没有真正去睡觉)。在2016服务器上,最小时间为12毫秒。

测量方式非常重要。如果您调用某些内核函数来测量本地/进程时间,这将为您提供诱人的低数字,但它们不是真实的。如果使用QueryPerformanceCounter(Stopwatch类使用它),则测量分辨率为1000真实刻度(在3 GHz CPU上为1/3μs)。如果使用RDTSC,则标称分辨率为CPU时钟,但那确实令人不安,这给您带来了不存在的精度幻觉。这333 ns是您无需VTune或硬件跟踪器即可可靠测量的绝对最小间隔。

进入露宿者

Thread.Yield()最轻,但有警告。在闲置的系统上,这是一个nop =>您又回到了过于紧张的状态。在繁忙的系统上,至少到下一个调度程序间隔为止的时间几乎与sleep(0)相同,但没有开销。此外,它将仅切换到已计划在同一内核上运行的线程,这意味着它更有可能退化为nop。

SpinWait结构最轻。它执行自己的2级等待,但具有强制旋转和良率,这意味着它仍需要真正的2级。位ID为您完成计数数学运算,并会告诉您何时产生计数,您可以将其作为睡觉的信号。

ManualResetEventSlim是第二轻的,在繁忙的系统上,它可能比yield更快,因为如果所涉及的线程没有进入睡眠并且其量子预算没有用尽,它可以继续进行。

Thread.Sleep(int)是下一个。 Sleep(0)被认为更轻便,因为它没有时间评估,并且仅对具有相同或更高优先级的线程产生作用,但是对于您的低延迟目的而言,它并不意味着什么。 Sleep(1)甚至无条件地产生优先级较低的线程,并具有时间评估代码路径,但是无论如何最小计时器片为1 ms。由于在繁忙的系统上,总是有大量具有相同或更高优先级的线程,以确保在下一个切片中不会有太多的机会运行,因此两者都将休眠更长的时间。

将线程优先级提高到实时水平只会暂时有用。内核具有一种防御机制,可以在短期运行后降低其优先级-这意味着您需要在每次运行时不断提高其优先级。 Windows不是RTOS。

任何时候,通过任何方法入睡,您都必须至少等待一个时间片延迟。避免这种延迟正是自旋锁的用例。任何时候通过任何方法入睡,您都必须至少延迟一个时间片。从理论上讲,条件变量可能是潜在的“中间地带”,但是由于C#/。NET没有对此的本机支持,因此您必须导入dll并调用本机函数,并且不能保证它会具有超强的响应能力。即使在C ++中,也无法保证立即唤醒。要执行类似的操作,您必须劫持一个中断-在.NET中是不可能的,在C ++中是非常困难的,而且冒险。

如果您的核心受到内存的限制和饥饿,使用CPU时间实际上也不错,这通常是CPU超额订购(核心数量过多的线程)和大型内存搜寻器(索引,图形等)的情况您会锁定在GB级别的内存中)。那他们反正别无选择。

但是,如果您的计算量很大(绑定了ALU和FPU),那么旋转可能会很糟糕。

超线程总是不好的。由于它们是伪造的伪处理器,几乎没有真正独立的硬件,因此在压力下,它将使内核发热很多,并降低性能。发明Thread.Yield()或多或少是为了降低超线程带来的压力,但是如果您追求低延迟,则首要规则是-永久关闭超线程。

另外请注意,没有硬件跟踪器或VTune,也没有仔细管理线程核关联性,对这些事情的任何测量都是没有意义的。您会看到各种各样的海市ages楼,而看不到真正重要的是-浪费CPU缓存,它们的延迟和内存延迟的影响。另外,您真的需要一个测试盒,该测试盒可以复制生产中正在运行的内容,因为大量因素取决于具体使用模式的细微差别,并且在完全不同的配置中无法再现。

保留核心

您需要为延迟关键线程保留多个内核专用,如果非常关键,则每个内核保留1个。如果您选择1-1,那么普通旋转就很好了。否则,产量是完全可以的。这是SpinWait结构的真正用例,并且具有保留和干净状态是第一个前提条件。通过1-1设置,相对简单的测量再次变得有意义,甚至RDTSC也变得足够平滑,可以正常使用。

经过精心保护的内核和超线程领域可以成为您自己的小型RTOS,但是您需要非常小心,并且必须管理所有内容。无法入睡,如果这样做,您将回到调度程序时间片延迟。

如果您具有非常确定的状态,并且可以计算出其中N个有时间在花费通常的延迟预算之前就可以运行,则可以选择光纤,然后您可以控制一切。

每个内核中这些超线程的数量取决于它们在做什么,它们是否受内存限制,它们需要多少内存以及可以在同一缓存中共存而不浪费彼此的行的数量。需要对所有3个缓存进行数学运算,并且要保守一些。这也是VTune或硬件跟踪程序可以提供很大帮助的地方-然后您可以运行并查看。

哦,对于这些东西来说,硬件也不必太昂贵了。具有16核的Ryzen Threadripper可以做到。

答案 1 :(得分:1)

我同意SpinWait()方法对于生产使用是不现实的。你的线程必须进入睡眠状态并被唤醒。

我看到你看着等待/脉冲。你有没有对.net中可用的任何其他原语进行基准测试? Joe Albahari的“C#中的线程”对您的所有选项进行了详尽的审核。 http://www.albahari.com/threading/part4.aspx#_Signaling_with_Wait_and_Pulse

我想谈谈的一点:你对ElapsedLogger产生的时间戳有多大信心?

答案 2 :(得分:1)

没有一件事可以做,因为另一个线程必须由操作系统安排。

增加等待线程的优先级是唯一可能产生很大差异的东西,而且你已经做到了。你可以走得更高。

如果你真的需要尽可能低的激活另一个任务的延迟,你应该把它变成一个可以直接从触发线程调用的函数。