Thread.Sleep 与 Task.Delay 在使用 timeBeginPeriod / 任务调度时的区别

12

给定附加的LINQ-Pad代码片段。

它创建了8个任务,执行500毫秒并绘制图形以显示线程实际运行的时间。

在4核CPU上,可能看起来像这样: enter image description here

现在,如果我在线程循环中添加Thread.SleepTask.Delay,我可以可视化Windows系统计时器的时钟(约15ms):

enter image description here

现在,还有一个名为timeBeginPeriod的函数,可以降低系统计时器的分辨率(例如降至1毫秒)。这里就有了区别。使用Thread.Sleep会得到以下图表(符合预期):

enter image description here

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

enter image description here

问题: 为什么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);
    }
}

1
timeBeginPeriod 似乎是一个 Windows 全局设置,参见 https://msdn.microsoft.com/en-us/library/windows/desktop/dd757624(v=vs.85).aspx。当您启动一个仅设置 timeBeginPeriod 的应用程序,然后在另一个应用程序中再次运行您的任务示例时会发生什么? - Dennis Kuypers
Task.Delay 内部使用 System.Threading.Timer,根据 MSDN 的说法,应该遵守系统时钟。 - Tho Mai
确实。只要创建一个周期为1ms的定时器,我就可以看到它每15毫秒就会触发一次,尽管调用了timeBeginPeriod - Kevin Gosse
https://dev59.com/UnHYa4cB1Zd3GeqPJkYe - Kevin Gosse
1
@TheodorZoulias:不,完全没问题。我本来就打算开始悬赏,只是系统不让我提前这样做。 - Thomas Weller
显示剩余7条评论
2个回答

16

timeBeginPeriod()是一种传承自Windows 3.1的旧函数。微软想要摆脱它,但无法实现。这个函数具有相当严重的系统范围副作用,它会增加时钟中断率。时钟中断是操作系统的“心跳”,它决定线程调度程序何时运行以及何时可以唤醒休眠线程。

.NET Thread.Sleep()函数实际上并没有被CLR实现,而是将任务委派给主机。您使用来运行此测试的任何内容只需将任务委托给Sleep() winapi功能即可。正如MSDN文章所述,该功能受时钟中断率的影响:

为了提高睡眠间隔的准确性,请调用timeGetDevCaps函数以确定支持的最小计时器分辨率,并调用timeBeginPeriod函数将计时器分辨率设置为其最小值。在调用timeBeginPeriod时要小心,因为频繁的调用可能会严重影响系统时钟、系统功率使用和调度程序。

最后的警告是微软对此不太满意的原因。这确实会被误用,其中一个更为严重的案例被这个网站的创始人在此博客文章中提到。当心带着礼物的希腊人。

这种变化对计时器的准确性并非优点。您不希望由于用户启动了浏览器,程序行为就发生变化。因此,.NET 设计者采取了一些措施。Task.Delay() 在内部使用 System.Threading.Timer。它不仅盲目依赖中断率,而是将您指定的周期除以 15.6 来计算时间片的数量。这略微偏离理想值,即 15.625,但这是整数运算的副作用。因此计时器的行为是可预测的,当时钟速率降低到 1 毫秒时不会再出现错误行为,它总是至少需要一个时间片。实际上是 16 毫秒,因为 GetTickCount() 的单位是毫秒。


2
这可以解释问题,但我已经花了一段时间浏览CoreCLR代码,但我没有找到这个逻辑。https://github.com/dotnet/coreclr/blob/4be1b4b90f17418e5784a269cc5214efe24a5afa/src/vm/win32threadpool.cpp#L5066中的计时器逻辑似乎是基于滴答声的,没有时间片。我有什么遗漏吗? - Kevin Gosse
2
好吧,我最终会找到它的。我很肯定它不会发生在托管代码中,而本机计时器队列使用 SleepEx 正如你所提到的一样(https://github.com/dotnet/coreclr/blob/4be1b4b90f17418e5784a269cc5214efe24a5afa/src/vm/win32threadpool.cpp#L4641),这受 timeBeginPeriod 的影响。所以它必须发生在中间某个地方。 - Kevin Gosse
2
答案就在我的眼前。16毫秒的分辨率来自于Windows的GetTickCount方法,由计时器队列使用。GetTickCount不受timeBeginPeriod的影响。 - Kevin Gosse
2
不,它也变得更加准确了。.NET中的DateTime.Now墙上钟也是如此。它们的底层操作系统变量也会受到时钟中断处理程序的更新。 - Hans Passant
除非我的程序中有微小的错误,否则它不会:https://imgur.com/a/bu8eo - Kevin Gosse
显示剩余2条评论

0

Task.Delay() 是通过 TimerQueueTimer 实现的(参考 DelayPromise)。后者的分辨率不会根据 timeBeginPeriod() 改变,尽管这似乎是 Windows 的最近变化(参考 VS Feedback)。


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