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

C# 同一进程中线程之间的低延迟通信,c#,multithreading,performance,ipc,low-latency,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 Coun

控制台应用程序有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
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
在上述代码中,“延迟”约为40-50μs。倒计时事件信令调用非常便宜(小于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
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
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左右吗? 我想,对于生产者/消费者模式,它不会比使用CountdownEvent更快。 等待/脉冲也比倒计时事件更昂贵

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

有什么建议吗

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

我同意SpinWait()方法不适合生产使用。你的线程将不得不进入睡眠状态并被唤醒

我看到你在看等待/脉搏。您是否对.net中可用的其他原语进行了基准测试?乔·阿尔巴哈里(Joe Albahari)的《C#中的线程》(Threading in C#)详尽地回顾了您的所有选择


我想谈谈的一点是:您对ElapsedLogger生成的时间戳有多大信心?

因为另一个线程必须由操作系统调度,所以没有很多事情可以做

提高等待线程的优先级是唯一可能产生很大影响的事情,您已经做到了这一点。你可以走得更高


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

你试图将其过于简单化,然后无论你以何种方式转换,都会有东西咬到你。SpinWait(int)从来就不是单独使用的,也不是一个钝的工具。要使用它,您需要预先计算、基本上校准(基于当前系统信息、时钟、调度程序中断计时器间隔)自旋锁的最佳迭代次数。在你耗尽预算后,你需要自愿睡眠/屈服/等待。整个安排通常称为两级等待或两阶段等待

您需要知道,一旦您越过这条线,您的最小延迟就是调度程序中断计时器间隔(来自系统内部的时钟间隔,在Win10上至少为1ms,如果任何“测量”给您的值较低,则可能是测量中断或您没有真正进入睡眠状态)。2016年服务器上的最小值为12毫秒

如何测量非常重要。如果您调用一些内核函数来测量本地/进程内时间,这将给出诱人的低数值,但它们不是真实的。如果您使用QueryPerformanceCounter(Stopwatch类使用它),则测量分辨率为1000个实刻度(在3 GHz CPU上为1/3μs)。如果您使用RDTSC,标称分辨率是CPU时钟,但这非常不稳定,给您带来了精度不存在的错觉。这333纳秒是在没有VTune或硬件跟踪器的情况下可以可靠测量的最小间隔

在枕木上

Thread.Yield()是最轻的,但有一个警告。在一个空闲的系统上,这是一个nop=>你又回到了一个太紧的旋转器。在繁忙的系统上,它至少是到下一个调度程序间隔的时间,这几乎与睡眠(0)相同,但没有开销。此外,它将只切换到已计划在同一内核上运行的线程,这意味着它有更高的机会退化为nop

SpinWait结构是次轻的结构。它确实有自己的2级等待,但具有硬旋转和产量,这意味着它仍然需要真正的2级。Bit id为您进行计算,并会告诉您何时会产生,您可以将其作为进入睡眠状态的信号

ManualResetEventSlim是下一个最轻的,在繁忙的系统上,它可能比产量更快,因为如果涉及的线程没有进入睡眠状态,并且它们的量子预算没有耗尽,它可以继续

接下来是Thread.Sleep(int)。睡眠(0)被认为是较轻的,因为它没有时间评估,只对优先级相同或更高的线程产生影响,但对于低延迟的目的来说,这并不意味着什么。Sleep(1)无条件地允许低优先级线程,并且具有时间计算代码路径,但最小计时器片无论如何都是1ms。由于在繁忙的系统上总是有大量具有相同或更高优先级的线程,以确保它不会有太多机会在下一个片中运行,因此这两个线程都会睡得更长

将线程优先级提高到实时级别只能暂时起作用。内核有一种防御机制,在短时间运行后会降低优先级,这意味着每次运行时都需要不断地重新提高优先级。Windows不是RTOS

无论何时,通过任何方法进入睡眠状态,都必须至少有一个时间片延迟。避免这种延迟正是自旋锁的用例。无论何时,通过任何方法进入睡眠状态,都必须至少有一个时间片延迟。从理论上讲,条件变量可能是潜在的“中间地带”,但由于C#/.NET没有本机支持,因此您必须导入dll并调用本机函数,并且不能保证该函数具有超响应性。即使在C++中,也不能保证立即唤醒。要做这样的事情,你必须劫持一个中断——这在.NET中是不可能的