Thread.Sleep 对比 Task.Delay 使用 timeBeginPeriod / 任务调度

Thread.Sleep vs. Task.Delay when using timeBeginPeriod / Task scheduling

鉴于附加的 LINQ-Pad 代码段。

它创建了 8 个任务,执行了 500 毫秒,并在线程实际 运行ning 时绘制了一个图表。

在 4 核上 CPU 它可能看起来像这样:

现在,如果我在线程循环中添加一个 Thread.Sleep 一个 Task.Delay,我可以看到 windows 的时钟系统计时器(~15ms):

现在,还有 timeBeginPeriod 函数,我可以在其中降低系统计时器的分辨率(在示例中为 1 毫秒)。这就是区别。使用 Thread.Sleep 我得到这张图表(如我所料):

当使用 Task.Delay 时,我得到的图表与时间设置为 15 毫秒时的图表相同:

问题:为什么TPL会忽略定时器设置?

这是代码(您需要 LinqPad 5.28 beta 才能 运行 图表)

void Main()
{
    const int Threads = 8;
    const int MaxTask = 20;
    const int RuntimeMillis = 500;
    const int Granularity = 10;

    ThreadPool.SetMinThreads(MaxTask, MaxTask);
    ThreadPool.SetMaxThreads(MaxTask, MaxTask);

    var series = new bool[Threads][];
    series.Initialize(i => new bool[RuntimeMillis * Granularity]);

    Watch.Start();
    var tasks = Async.Tasks(Threads, i => ThreadFunc(series[i], pool));
    tasks.Wait();

    series.ForAll((x, y) => series[y][x] ? new { X = x / (double)Granularity, Y = y + 1 } : null)
        .Chart(i => i.X, i => i.Y, LINQPad.Util.SeriesType.Point)
        .Dump();

    async Task ThreadFunc(bool[] data, Rendezvous p)
    {
        double now;
        while ((now = Watch.Millis) < RuntimeMillis)
        {
            await Task.Delay(10);

            data[(int)(now * Granularity)] = true;
        }
    }
}

[DllImport("winmm.dll")] internal static extern uint timeBeginPeriod(uint period);

[DllImport("winmm.dll")] internal static extern uint timeEndPeriod(uint period);

public class Rendezvous
{
    private readonly object lockObject = new object();
    private readonly int max;
    private int n = 0;

    private readonly ManualResetEvent waitHandle = new ManualResetEvent(false);

    public Rendezvous(int count)
    {
        this.max = count;
    }

    public void Join()
    {
        lock (this.lockObject)
        {
            if (++this.n >= max)
                waitHandle.Set();
        }
    }

    public void Wait()
    {
        waitHandle.WaitOne();
    }

    public void Reset()
    {
        lock (this.lockObject)
        {
            waitHandle.Reset();
            this.n = 0;
        }
    }
}

public static class ArrayExtensions
{
    public static void Initialize<T>(this T[] array, Func<int, T> init)
    {
        for (int n = 0; n < array.Length; n++)
            array[n] = init(n);
    }

    public static IEnumerable<TReturn> ForAll<TValue, TReturn>(this TValue[][] array, Func<int, int, TReturn> func)
    {
        for (int y = 0; y < array.Length; y++)
        {
            for (int x = 0; x < array[y].Length; x++)
            {
                var result = func(x, y);
                if (result != null)
                    yield return result;
            }
        }
    }
}

public static class Watch
{
    private static long start;
    public static void Start() => start = Stopwatch.GetTimestamp();
    public static double Millis => (Stopwatch.GetTimestamp() - start) * 1000.0 / Stopwatch.Frequency;
    public static void DumpMillis() => Millis.Dump();
}

public static class Async
{
    public static Task[] Tasks(int tasks, Func<int, Task> thread)
    {
        return Enumerable.Range(0, tasks)
            .Select(i => Task.Run(() => thread(i)))
            .ToArray();
    }

    public static void JoinAll(this Thread[] threads)
    {
        foreach (var thread in threads)
            thread.Join();
    }

    public static void Wait(this Task[] tasks)
    {
        Task.WaitAll(tasks);
    }
}

timeBeginPeriod() 是遗留函数,可追溯到 Windows 3.1。微软很想摆脱它,但做不到。它有一个相当严重的机器范围的副作用,它增加了时钟中断率。时钟中断是 OS 的 "heart-beat",它决定线程调度程序何时 运行 以及休眠线程何时可以恢复。

.NET Thread.Sleep() 函数实际上并未由 CLR 实现,它是将作业传递给主机。您用于 运行 的任何测试都只是将工作委托给 Sleep() winapi function。其中 受时钟中断率的影响,如 MSDN 文章中所述:

To increase the accuracy of the sleep interval, call the timeGetDevCaps function to determine the supported minimum timer resolution and the timeBeginPeriod function to set the timer resolution to its minimum. Use caution when calling timeBeginPeriod, as frequent calls can significantly affect the system clock, system power usage, and the scheduler.

最后的警告是微软对此不太满意的原因。这确实被滥用了,该网站的一位创始人在 this blog post 中指出了一个更令人震惊的案例。当心希腊人带着礼物。

这改变了计时器的准确性并不完全是一项功能。您不希望您的程序仅仅因为用户启动浏览器而表现不同。所以 .NET 设计者对此做了一些事情。 Task.Delay() 在底层使用 System.Threading.Timer。它不是一味地依赖中断率,而是将你指定的周期除以15.6来计算时间片的数量。稍微偏离理想值 btw,即 15.625,但整数数学的副作用。因此,当时钟速率降低到 1 毫秒时,定时器的行为是可预测的,并且不再出现错误行为,它总是至少需要一个切片。实际上是 16 毫秒,因为 GetTickCount() 的单位是毫秒。

Task.Delay() 通过 TimerQueueTimer 实现(参考 DelayPromise). The resolution of the latter does not change based on timeBeginPeriod(), though this appears to be a recent change in Windows (ref VS Feedback)。