13

Given the attached LINQ-Pad snippet.

It creates 8 tasks, executes for 500ms and draws a graph on when the threads were actually running.

On a 4 core CPU it may look like this: enter image description here

Now, if I add a Thread.Sleep or a Task.Delay within the thread loops, I can visualize the clock of the windows system timer (~15ms):

enter image description here

Now, there's also the timeBeginPeriod function, where I can lower the system timer's resolution (in the example to 1ms). And here's the difference. With Thread.Sleep I get this chart (what I expected):

enter image description here

When using Task.Delay I get the same graph as when the time would be set to 15ms:

enter image description here

Question: Why does the TPL ignore the timer setting?

Here is the code (you need LinqPad 5.28 beta to run the Chart)

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);
    }
}
Tho Mai
  • 835
  • 5
  • 25
  • +1 for writing a program which supports my theory about tasks and threads. Remind me of starting a bounty in ~2 days (or whatever that time span was). – Thomas Weller Dec 28 '17 at 12:36
  • 1
    `timeBeginPeriod` seems to be a windows global setting as per https://msdn.microsoft.com/en-us/library/windows/desktop/dd757624(v=vs.85).aspx What happens when you start one application that just sets `timeBeginPeriod` and then run your Task example again in a separate application? – Dennis Kuypers Dec 28 '17 at 13:03
  • @DennisKuypers : It's a global setting. The first program influences the second one. Actually, if you terminate the first program, the setting is restored - which also affects the second one. – Tho Mai Dec 28 '17 at 13:06
  • @thomai so you are getting the same odd behavior with `task.Delay()` when another program set the `timeBeginPeriod` before executing your example? – Dennis Kuypers Dec 28 '17 at 13:33
  • @DennisKuypers Task.Delay ignores the timeBeginPeriod - call. Only Thread.Sleep respects the new settings. – Tho Mai Dec 28 '17 at 13:36
  • Task.Delay internally uses a `System.Threading.Timer` which should - regarding to MSDN - respect the system clock. – Tho Mai Dec 28 '17 at 16:37
  • Indeed. Just creating a timer with a period of 1ms, I can see that it ticks every 15 ms despite the call to `timeBeginPeriod` – Kevin Gosse Dec 28 '17 at 17:02
  • https://stackoverflow.com/questions/16160179/why-is-minimum-threading-timer-interval-15ms-despite-timebeginperiod1 – Kevin Gosse Dec 28 '17 at 17:04
  • Sorry @ThomasWeller, I had no intention to add an answer to this question. I just saw accidentally this old comment about someone who wanted to be reminded to start a bounty, and I though that it was a good idea to do the reminding on OP's behalf. I didn't gave it much thought. It seems that this was a bad idea, that just costed you some rep points for no real reason. Again, I am sorry. – Theodor Zoulias Feb 06 '21 at 19:47
  • 1
    @TheodorZoulias: no, it's totally fine. It was my intention to start a bounty, just the system won't let me do so early. – Thomas Weller Feb 06 '21 at 19:56
  • I'm new to LINQPad and I really like the graphs you generated for this example. I'd like to try generating them myself but I'm having a small problem. "The name 'pool' does not exist in the current context". Also once that's fixed how do I actually generate the graph or will that be obvious? – LorneCash Feb 07 '21 at 20:06
  • Have look for the "Chart"-Call in the code above. That's all. – Tho Mai Feb 08 '21 at 05:59

2 Answers2

17

timeBeginPeriod() is a legacy function, dates back to Windows 3.1. Microsoft would love to get rid of it, but can't. It has a pretty gross machine-wide side-effect, it increases the clock interrupt rate. The clock interrupt is the "heart-beat" of the OS, it determines when the thread scheduler runs and when sleeping threads can be revived.

The .NET Thread.Sleep() function is not actually implemented by the CLR, it passes the job to the host. Any you'd use to run this test simply delegates the job to the Sleep() winapi function. Which is affected by the clock interrupt rate, as documented in the MSDN article:

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.

The caution at the end is why Microsoft isn't very happy about it. This does get misused, one of the more egregious cases was noted by one of the founders of this web site in this blog post. Beware of Greeks bearing gifts.

That this changes the accuracy of timers is not exactly a feature. You would not want your program to behave differently just because the user started a browser. So the .NET designers did something about it. Task.Delay() uses System.Threading.Timer under the hood. Instead of just blindly relying on the interrupt rate, it divides the period you specify by 15.6 to calculate the number of time slices. Slightly off from the ideal value btw, which is 15.625, but a side-effect of integer math. So the timer behaves predictably and no longer misbehaves when the clock rate is lowered to 1 msec, it always takes at least one slice. 16 msec in practice since the GetTickCount() unit is milliseconds.

Hans Passant
  • 922,412
  • 146
  • 1,693
  • 2,536
  • 2
    That would explain the issue, but I've been going through the CoreCLR code for a while and I haven't been able to find this bit of logic. The timer logic in https://github.com/dotnet/coreclr/blob/4be1b4b90f17418e5784a269cc5214efe24a5afa/src/vm/win32threadpool.cpp#L5066 seems to be tick based, with no time-slice. Am I missing something? – Kevin Gosse Dec 28 '17 at 17:34
  • This has been tinkered with many times, starting with .NET 4.0 and an abortive attempt to try to use CreateTimerQueueTimer(). I've lost track of what they've been doing, afaik the desktop version still relies on src/vm/win32threadpool.cpp. I did the original reverse-engineering on the SSCLI20 source. – Hans Passant Dec 28 '17 at 17:47
  • 2
    Oh well, I'll find it eventually. I'm fairly certain it does not happen in the managed code, and the native timer queue uses `SleepEx` just like you mentioned (https://github.com/dotnet/coreclr/blob/4be1b4b90f17418e5784a269cc5214efe24a5afa/src/vm/win32threadpool.cpp#L4641), which is influenced by `timeBeginPeriod`. So it has to happen somewhere in-between. – Kevin Gosse Dec 28 '17 at 17:52
  • 2
    The answer was right in front of my nose. The 16 ms resolution comes from Windows' GetTickCount method, used by the timer queue. GetTickCount isn't affected by timeBeginPeriod – Kevin Gosse Dec 29 '17 at 09:28
  • 2
    No, it gets more accurate as well. So does the wall clock, DateTime.Now in .NET. Their underlying OS variables get updated by the clock tick interrupt handler as well. – Hans Passant Dec 29 '17 at 10:44
  • Unless there's a subtle mistake in my program, it does not: https://imgur.com/a/bu8eo – Kevin Gosse Dec 29 '17 at 11:59
  • According to [this blog post](https://randomascii.wordpress.com/2013/07/08/windows-timer-resolution-megawatts-wasted/), Microsoft stopped discouraging applications from setting `timeBeginPeriod` in Windows 10 from version 1809. If you use `clockres` tool from SysInternals on your own system while a program is running, you may see a range of values from 1ms to 15.625ms in 0.5ms increments. Possibly the .NET framework itself may be changing this. My code in .NET Framework 4.5.2 calls Thread.Sleep(n) with small values of n and usually, but not always, gets the exact sleep length requested. – radfast Feb 16 '21 at 00:19
0

Task.Delay() is implemented via TimerQueueTimer (ref 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).

Sander
  • 25,685
  • 3
  • 53
  • 85