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

8

控制台应用程序有3个线程:主线程、T1线程和T2线程。

目标是在最低延迟(μs)内“信号”T1、T2线程并让它们执行一些工作。

注意:

  • 请忽略Jitter、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微秒。(但CPU占用率很高)这是因为T1、T2线程被强制活跃(它们什么也不做,只是消耗CPU时间)

在“真实”的应用程序中,我不能像那样旋转CPU(我有太多的活动线程,这会使它变得更糟/更慢甚至导致服务器崩溃)。

是否有其他方法可以将延迟降低到大约10-15微秒?我猜使用生产者/消费者模式不会比使用CountdownEvent更快。等待/脉冲比CountdownEvent更昂贵。

在示例1中我得到的是最好的结果吗?

有什么建议吗?

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


你调查过 ManualResetEventSlimSemaphoreSlim 吗?Monitor.WaitMonitor.Pulse 呢? - Jim Mischel
@JimMischel:我已经尝试了所有的方法,结果基本相同。 - Novitzky
3个回答

3
您试图过度简化这个问题,但无论您怎么做都会遇到问题。Thread.SpinWait(int)从未意味着单独使用和作为一种粗暴的工具。要使用它,您需要预先计算,本质上是根据当前系统信息、时钟、调度器中断计时器间隔来校准自旋锁的最佳迭代次数。在您用完这个预算之后,您需要自愿休眠/放弃/等待。整个安排通常称为2级等待或2阶段等待。
您需要知道,一旦您跨越那条线,您的最小延迟就是调度器中断计时器间隔(来自System Internals的ClockRes,在Win10上至少为1 ms,如果任何“测量”给出更低的值,那么测量可能有误或者您并没有真正进入睡眠状态)。在2016服务器上,最小值为12毫秒。
如何测量非常重要。如果您调用某些内核函数来测量本地/进程时间,那么将会给您带来诱人的低数字,但它们不是真实的。如果您使用QueryPerformanceCounter(Stopwatch类使用它)测量分辨率为1000个真实滴答声(在3 GHz CPU上为1/3微秒)。如果您使用RDTSC名义分辨率为CPU时钟,但这非常抖动,并给您虚假的精度感。这些333纳秒是您可以可靠测量而不使用VTune或硬件跟踪器的绝对最小间隔。
转到Sleepers
Thread.Yield()是最轻的,但有一个警告。在空闲系统上,它是nop=>您又回到了太紧密的旋转器。在繁忙的系统上,它至少是到下一个调度器间隔的时间,几乎与sleep(0)相同,但没有开销。此外,它仅切换到已在同一核心上计划运行的线程,这意味着它更有可能退化为nop。
SpinWait结构是接下来最轻的。它自己进行2级等待,但具有硬自旋和yield,这意味着它仍然需要真正的第二级。它会为您执行计数数学,并告诉您何时将要yield,您可以将其视为进入睡眠状态的信号。
ManualResetEventSlim是接下来最轻的,在繁忙的系统上,它可能比yield更快,因为如果涉及的线程没有进入睡眠状态并且它们的量子预算没有耗尽,则可以继续运行。
Thread.Sleep(int)是接下来的。Sleep(0)被认为更轻,因为它没有时间评估,并且仅向具有相同或更高优先级的线程yield,但对于您的低延迟目的来说,这并不重要。Sleep(1)无条件地向较低优先级线程yield,并具有时间评估代码路径,但最小计时器片段无论如何都是1毫秒。由于在繁忙的系统上总是有大量具有相同或更高优先级的线程,以确保它不会有太多机会在下一个片段中运行,因此两者都会睡得更久。
将线程优先级提高到实时级别只能暂时帮助。内核有一种防御机制,会在短时间内将其优先级降低-这
无论您使用何种方法入睡,都必须预计至少有一个时间片延迟。避免这种延迟正是自旋锁的用例。虽然条件变量在理论上可能是一种潜在的“中间地带”,但由于C#/.NET没有本机支持,因此您必须导入一个dll并调用本机函数,而且不能保证它们会非常响应。即使在C++中,立即唤醒也无法保证。要做到这样的事情,您必须劫持中断-在.NET中不可能,在C++中非常困难且危险。
如果您的核心受到内存限制和饥饿的影响,那么使用CPU时间实际上并不坏,这通常是CPU超额订阅(线程数量过多)和大型内存爬行器(索引、图形、任何其他在GB规模上锁定在内存中的内容)的情况。然后它们无论如何都没有其他事情可做。
但是,如果您的计算密集型(ALU和FPU绑定),那么自旋可能是不好的。
超线程总是不好的。在压力下,它会使核心发热并降低性能,因为它们是带有非常少真正独立硬件的虚假伪处理器。Thread.Yield()或多或少是为了减轻超线程的压力,但如果您正在追求低延迟,则首要规则是-永久关闭超线程。
此外,请注意,这些事情的任何测量,如果没有硬件跟踪器或VTune,并且没有仔细管理线程-核心关联性,都是无意义的。您将看到各种幻象,并且不会看到真正重要的事情- CPU缓存的崩溃效应、它们的延迟和内存延迟。此外,您确实需要一个测试框,该框是生产中正在运行的内容的副本,因为许多因素取决于具体使用模式的微妙差异,而且它们在大不相同的配置上是无法再现的。
保留核心
您需要保留一些核心供您的延迟关键线程专用,如果非常关键,则每个核心1个。如果您采用1-1,则纯旋转完全没问题。否则,yield是完全可以的。这是SpinWait结构的真正用例,并且拥有保留和干净状态是第一个前提条件。在1-1设置中,相对简单的测量再次变得相关,即使RDTSC也足够平稳以供常规使用。
那种小心守护的核心和超级线程的领域可以成为您自己的小型RTOS,但是您必须非常小心并且必须管理一切。如果您入睡了,就不能去睡觉,否则您将回到调度程序时间片延迟。
如果您具有非常确定的状态和在通常的延迟预算用尽之前运行N个计算的计算,则可以选择纤维,然后您可以控制一切。
每个核心的超级线程数量取决于它们正在做什么,它们是否受内存限制,需要多少内存以及它们在同一缓存中共存而不会互相碰撞的数量。需要对所有3个缓存进行计算,并保守估计。这也是VTune或硬件跟踪器可以帮助很多的地方 - 然后你只需运行并查看即可。噢,现在这些硬件并不一定非常昂贵了。拥有16个核心的Ryzen Threadripper可以很好地完成任务。

1

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

我看到你已经研究了wait/Pulse。你是否对.net中可用的其他原语进行了基准测试?Joe Albahari的“C#多线程编程指南”详尽地介绍了所有可用选项。http://www.albahari.com/threading/part4.aspx#_Signaling_with_Wait_and_Pulse

我想谈谈一个问题:你对ElapsedLogger生成的时间戳有多自信?


谢谢你提供的链接 - 我对他的“C#中的线程”非常熟悉。我尝试过很多其他东西的基准测试,总是使用相同的设置(硬件/操作系统/优化/GC/抖动等)。WaitAndPulse信号给我带来了与CountdownEvent相同的延迟。Monitor.PulseAll()调用比CountdownEvent.Signal()更昂贵,但都在100-150纳秒以下。关于ElapsedLogger,分辨率远低于1微秒,我非常确定。 - Novitzky

1

由于另一个线程必须由操作系统进行调度,因此可以做的事情并不多。

增加等待线程的优先级是唯一可能有很大作用的事情,而您已经这样做了。您甚至可以再提高优先级。

如果您真的需要另一个任务激活的最低延迟时间,您应该将其转换为一个函数,可以直接从触发线程调用。


Ben,感谢您的回答。有两个问题:1)我如何进一步提高线程优先级?最高的线程比任何其他优先级的线程都要先调度。2)如果我直接从调用线程执行函数,则根本没有多线程。我将一个接一个地执行任务,而不是并行执行。您是否建议此函数改为启动另一个线程?我已经尝试过这样做,但我看不到任何改进。 - Novitzky
  1. 你使用了“最高优先级”,但它并不如THREAD_PRIORITY_TIME_CRITICAL高。
  2. 目前有三个动作正在进行中——唤醒两个线程和继续当前函数。这三个线程负责的任何一个任务中,最需要低延迟的任务应该在当前线程上执行。
- Ben Voigt
感谢THREAD_PRIORITY_TIME_CRITICAL。我会试着使用它。关于第二点,在实际应用中,我有1到8个线程是延迟关键的(所有线程都需要由其他线程触发)。 - Novitzky
基于"1到8个线程"的条件,听起来有点像线程池/工作池。也许你可以这样设置,使得任何给定的工作线程能够同时使用CountdownEvent或自旋等待(spin wait)。也许下一个接收到工作的工作线程可以使用自旋等待,而其余的可以使用CountdownEvent。一旦一个工作线程接收到工作,下一个排队的工作线程开始自旋等待。这样,你就可以在几乎所有任务中获得自旋等待的较低延迟,但只使用一个核心而不是每个工作线程都使用一个核心。 - sevzas

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