使用timeBeginPeriod /任务调度时Thread.Sleep与Task.Delay

tho*_*mai 4 c# multithreading task-parallel-library

给出附加的LINQ-Pad片段.

它创建8个任务,执行500毫秒并绘制线程实际运行时的图表.

在4核CPU上,它可能如下所示: 在此输入图像描述

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

在此输入图像描述

现在,还有一个timeBeginPeriod功能,我可以降低系统计时器的分辨率(在例子中为1ms).这就是区别.随着Thread.Sleep我得到这个图表(我的预期):

在此输入图像描述

使用时,Task.Delay我获得与时间设置为15ms时相同的图形:

在此输入图像描述

问题:为什么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);
    }
}
Run Code Online (Sandbox Code Playgroud)

Han*_*ant 9

timeBeginPeriod()是一个遗留函数,可以追溯到Windows 3.1.微软希望摆脱它,但不能.它具有相当大的机器范围副作用,它增加了时钟中断率.时钟中断是操作系统的"心跳",它确定线程调度程序何时运行以及何时可以恢复休眠线程.

.NET Thread.Sleep()函数实际上并未由CLR实现,它将作业传递给主机.您用来运行此测试的任何操作都只是将作业委托给Sleep()winapi函数.这由时钟中断率的影响,因为在MSDN文章中记录:

要提高休眠间隔的准确性,请调用timeGetDevCaps函数以确定支持的最小计时器分辨率,并调用timeBeginPeriod函数以将计时器分辨率设置为最小值.调用timeBeginPeriod时请小心,因为频繁的调用会显着影响系统时钟,系统电源使用和调度程序.

最后的警告是微软对此不太满意的原因.这确实被误用了,本网站的一位创始人在这篇博客文章中指出了一个更令人震惊的案例.谨防带有礼物的希腊人.

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

  • 这可以解释这个问题,但我已经浏览了 CoreCLR 代码一段时间,但我一直无法找到这一点逻辑。https://github.com/dotnet/coreclr/blob/4be1b4b90f17418e5784a269cc5214efe24a5afa/src/vm/win32threadpool.cpp#L5066 中的计时器逻辑似乎是基于滴答的,没有时间片。我错过了什么吗? (2认同)
  • 哦,好吧,我最终会找到它的。我很确定它不会发生在托管代码中,并且本机计时器队列使用 `SleepEx` 就像你提到的一样 (https://github.com/dotnet/coreclr/blob/4be1b4b90f17418e5784a269cc5214efe24a5afa/src/vm/win32threadpool。 cpp#L4641),受`timeBeginPeriod`影响。所以它必须发生在两者之间。 (2认同)
  • 答案就在我眼前。16 毫秒分辨率来自 Windows 的 GetTickCount 方法,由计时器队列使用。GetTickCount 不受 timeBeginPeriod 的影响 (2认同)
  • 不,它也会变得更准确。.NET 中的挂钟 DateTime.Now 也是如此。它们的底层操作系统变量也由时钟滴答中断处理程序更新。 (2认同)