C#での高精度な待機処理について考える
Windowsのフォームアプリケーションにおいて、ミリ秒およびマイクロ秒単位での待機処理を行う必要があった。
この時、待機処理の時間精度に大きな問題が生じたため、各手法における待機処理について考えることとした。
主な待機手法
待機処理について調査すると、以下の方法が一般的に使用されている。
- whileループ
- Thread.SpinWait
- Thread.Sleep
- Thread.Sleep (TimePeriod設定)
- Task.Delay
考慮すべき点は以下の2つである。
- 時間精度
- CPUの占有率
これらの方法を表にまとめると以下のようになる。
手法 | 時間精度 | CPU占有率 |
---|---|---|
1. whileループ | ◎ | × |
2. Thread.SpinWait | ◎ | × |
3. Thread.Sleep | × | ◎ |
4. Thread.Sleep (TimePeriod設定) | 〇 | ◎ |
5. Task.Delay | × | ◎ |
結論として、私が採用した手法は、1. while と 4. Thread.Sleep(TimePeriod設定) を組み合わせた処理である。
これで、時間精度の向上とCPU占有率の低下を達成した。
[DllImport("winmm.dll", ExactSpelling = true, CharSet = CharSet.Ansi)]
public static extern uint timeBeginPeriod(uint uPeriod);
[DllImport("winmm.dll", ExactSpelling = true, CharSet = CharSet.Ansi)]
public static extern uint timeEndPeriod(uint uPeriod);
public static void Sleep(double msec)
{
timeBeginPeriod(1);
Stopwatch sw = Stopwatch.StartNew();
// msec待機
int sleepTime = (int)(msec - 1);
if (sleepTime > 0) Thread.Sleep(sleepTime);
// usec待機
while (sw.Elapsed.TotalMilliseconds < msec) { }
sw.Stop();
Console.WriteLine($"処理時間:{sw.Elapsed.TotalMilliseconds:F3} ms ({sw.Elapsed.TotalMilliseconds * 1000:F3} μs)");
timeEndPeriod(1);
}
以下、それぞれの手法における待機処理の解説を記載する。
1. whileループ
指定時間になるまでループを回し、強制的に時間を監視する方法である。
時間精度は非常に高いが、無限ループによりCPUを占有してしまう。
while(true) {}
2. Thread.SpinWait
短時間の待機処理が可能だが、CPUを占有してしまう。
個人的には効果的に扱うことができなかった。
public static void SpinWait(int iterations);
下記のコードを実行し処理時間を測定。
private void MeasureSpinWaitTime()
{
Stopwatch sw = Stopwatch.StartNew();
Thread.SpinWait(1);
sw.Stop();
Console.WriteLine($"処理時間:{sw.Elapsed.TotalMilliseconds:F3} ms ({sw.Elapsed.TotalMilliseconds * 1000:F3} μs)");
}
処理時間:0.001 ms (1.000 μs)
処理時間:0.001 ms (0.500 μs)
処理時間:0.002 ms (1.500 μs)
処理時間:0.001 ms (1.000 μs)
処理時間:0.001 ms (0.700 μs)
だいたい1usecくらい待機する?
CPUを占有するし使い道がよく分からん。
3. Thread.Sleep
最もメジャーな待機方法の1つ。
Windowsのシステム上、16ms前後の精度になってしまうため、マイクロ秒単位の待機処理は不可能である。
ただし、CPUを占有しないのは利点である。
public static void Sleep(int millisecondsTimeout);
4. Thread.Sleep (TimePeriod設定)
Thread.Sleepに加えてWindowsのシステムクロックを変更する方法。
時間精度は1ms程度まで向上するが、マイクロ秒単位の待機は依然として不可能である。
システム全体に影響があるとの報告もあるが、現在はプロセスごとに管理されているため問題ないとされている。
[DllImport("winmm.dll", ExactSpelling = true, CharSet = CharSet.Ansi)]
public static extern uint timeBeginPeriod(uint uPeriod);
[DllImport("winmm.dll", ExactSpelling = true, CharSet = CharSet.Ansi)]
public static extern uint timeEndPeriod(uint uPeriod);
public static void Sleep(int millisecondsTimeout);
5. Task.Delay
最もメジャーな待機方法の1つ。
非同期処理で一般的に使用されるが、Thread.Sleepと同様に16ms前後の精度となってしまう。
私の環境ではTimePeriodを設定しても精度は向上しなかった。
public static Task Delay(int millisecondsDelay);
待機処理テスト
それぞれの手法で10ms、1ms、0.1ms (100μs) の待機時間を5回ずつテストした。
計測方法と結果は以下の通りである。
1. whileループ
private void SleepWhileLoop(double msec)
{
Stopwatch sw = Stopwatch.StartNew();
while (sw.Elapsed.TotalMilliseconds < msec) { }
sw.Stop();
Console.WriteLine($"処理時間:{sw.Elapsed.TotalMilliseconds:F3} ms ({sw.Elapsed.TotalMilliseconds * 1000:F3} μs)");
}
10ms
処理時間:10.000 ms (10000.100 μs)
処理時間:10.000 ms (10000.100 μs)
処理時間:10.000 ms (10000.200 μs)
処理時間:10.000 ms (10000.200 μs)
処理時間:10.000 ms (10000.100 μs)
1ms
処理時間:1.000 ms (1000.300 μs)
処理時間:1.000 ms (1000.200 μs)
処理時間:1.000 ms (1000.300 μs)
処理時間:1.000 ms (1000.100 μs)
処理時間:1.000 ms (1000.100 μs)
0.1ms (100μs)
処理時間:0.100 ms (100.200 μs)
処理時間:0.100 ms (100.300 μs)
処理時間:0.100 ms (100.200 μs)
処理時間:0.100 ms (100.200 μs)
処理時間:0.100 ms (100.100 μs)
非常に高精度の待機処理が可能。
どの結果も指定時間とのズレが 0.3μs 以下となっている。
ただし、CPUを占有してしまうため多用は厳禁。
2. Thread.SpinWait
private void SleepSpinWait(double msec)
{
Stopwatch sw = Stopwatch.StartNew();
while (sw.Elapsed.TotalMilliseconds < msec) { Thread.SpinWait(1); }
sw.Stop();
Console.WriteLine($"処理時間:{sw.Elapsed.TotalMilliseconds:F3} ms ({sw.Elapsed.TotalMilliseconds * 1000:F3} μs)");
}
10ms
処理時間:10.000 ms (10000.200 μs)
処理時間:10.000 ms (10000.200 μs)
処理時間:10.000 ms (10000.100 μs)
処理時間:10.000 ms (10000.100 μs)
処理時間:10.000 ms (10000.300 μs)
1ms
処理時間:1.000 ms (1000.200 μs)
処理時間:1.001 ms (1000.500 μs)
処理時間:1.000 ms (1000.200 μs)
処理時間:1.000 ms (1000.200 μs)
処理時間:1.000 ms (1000.400 μs)
0.1ms (100μs)
処理時間:0.100 ms (100.300 μs)
処理時間:0.100 ms (100.200 μs)
処理時間:0.100 ms (100.200 μs)
処理時間:0.100 ms (100.400 μs)
処理時間:0.100 ms (100.200 μs)
whileループと同様、非常に高精度の待機処理が可能。
ループの間にSpinWaitを実行しているため若干精度が落ちる。それでも1μs以下の精度。
結局whileループを回しているし、SpinWaitを実行している間もCPUを占有してしまう。
いまいち上手い使用方法が分からない。
3. Thread.Sleep
private void SleepThreadSleep(double msec)
{
Stopwatch sw = Stopwatch.StartNew();
Thread.Sleep((int)msec);
sw.Stop();
Console.WriteLine($"処理時間:{sw.Elapsed.TotalMilliseconds:F3} ms ({sw.Elapsed.TotalMilliseconds * 1000:F3} μs)");
}
10ms
処理時間:18.620 ms (18620.100 μs)
処理時間:13.033 ms (13033.100 μs)
処理時間:13.415 ms (13414.800 μs)
処理時間:17.720 ms (17719.600 μs)
処理時間:18.855 ms (18855.200 μs)
1ms
処理時間: 9.818 ms ( 9818.100 μs)
処理時間:13.270 ms (13270.300 μs)
処理時間: 7.576 ms ( 7575.500 μs)
処理時間:16.140 ms (16139.700 μs)
処理時間: 4.525 ms ( 4525.200 μs)
0.1ms (100μs)
処理時間:0.004 ms (3.900 μs)
処理時間:0.003 ms (3.100 μs)
処理時間:0.003 ms (2.900 μs)
処理時間:0.003 ms (2.900 μs)
処理時間:0.005 ms (4.800 μs)
前述の通り、16ms程度の精度しかない。
10msと1msではブレがあるものの似たような結果となっている。
0.1msに関しては0秒指定となるため待機処理依然の問題である。
4. Thread.Sleep (TimePeriod設定)
[DllImport("winmm.dll", ExactSpelling = true, CharSet = CharSet.Ansi)]
public static extern uint timeBeginPeriod(uint uPeriod);
[DllImport("winmm.dll", ExactSpelling = true, CharSet = CharSet.Ansi)]
public static extern uint timeEndPeriod(uint uPeriod);
private void SleepThreadSleepTimePeriod(double msec)
{
timeBeginPeriod(1);
Stopwatch sw = Stopwatch.StartNew();
Thread.Sleep((int)msec);
sw.Stop();
Console.WriteLine($"処理時間:{sw.Elapsed.TotalMilliseconds:F3} ms ({sw.Elapsed.TotalMilliseconds * 1000:F3} μs)");
timeEndPeriod(1);
}
10ms
処理時間:10.674 ms (10674.300 μs)
処理時間:10.202 ms (10201.600 μs)
処理時間:10.250 ms (10249.900 μs)
処理時間:10.212 ms (10211.800 μs)
処理時間:11.156 ms (11156.300 μs)
1ms
処理時間:2.047 ms (2047.200 μs)
処理時間:1.162 ms (1161.500 μs)
処理時間:1.122 ms (1122.400 μs)
処理時間:2.190 ms (2189.900 μs)
処理時間:1.124 ms (1124.200 μs)
0.1ms (100μs)
処理時間:0.003 ms (2.800 μs)
処理時間:0.005 ms (4.800 μs)
処理時間:0.004 ms (3.900 μs)
処理時間:0.004 ms (4.200 μs)
処理時間:0.003 ms (2.600 μs)
TimePeriodを設定することで精度が大きく向上。
10msと1msでは正常に待機することが可能となっている。
0.1msに関してはこれまでと同様に0秒指定となるためマイクロ秒の精度は無い。
5. Task.Delay
private async Task SleepTaskDelay(double msec)
{
Stopwatch sw = Stopwatch.StartNew();
await Task.Delay((int)msec);
sw.Stop();
Console.WriteLine($"処理時間:{sw.Elapsed.TotalMilliseconds:F3} ms ({sw.Elapsed.TotalMilliseconds * 1000:F3} μs)");
}
10ms
処理時間:17.406 ms (17405.600 μs)
処理時間:16.217 ms (16217.100 μs)
処理時間:16.587 ms (16587.000 μs)
処理時間:18.005 ms (18004.900 μs)
処理時間:17.760 ms (17759.900 μs)
1ms
処理時間: 8.343 ms ( 8343.200 μs)
処理時間:11.757 ms (11756.500 μs)
処理時間:10.677 ms (10676.700 μs)
処理時間: 9.200 ms ( 9199.800 μs)
処理時間: 4.415 ms ( 4414.800 μs)
0.1ms (100μs)
処理時間:0.001 ms (0.800 μs)
処理時間:0.001 ms (1.300 μs)
処理時間:0.001 ms (1.100 μs)
処理時間:0.002 ms (1.500 μs)
処理時間:0.001 ms (1.100 μs)
3.Thread.Sleep とほぼ同じ結果。
非同期処理になるため扱い方が異なる程度。
最適化
初めにも書いた通り、1. while と 4. Thread.Sleep (TimePeriod設定)を組み合わせることで、時間精度の向上とCPU占有率の低下を図る。
Thread.Sleep (TimePeriod設定)は1ms程度の精度であるため、ミリ秒単位の待機を行う。
その後、whileを用いてマイクロ秒単位の待機を行う (この時だけCPUを占有してしまう)。
待機処理コード再掲
[DllImport("winmm.dll", ExactSpelling = true, CharSet = CharSet.Ansi)]
public static extern uint timeBeginPeriod(uint uPeriod);
[DllImport("winmm.dll", ExactSpelling = true, CharSet = CharSet.Ansi)]
public static extern uint timeEndPeriod(uint uPeriod);
public static void Sleep(double msec)
{
timeBeginPeriod(1);
Stopwatch sw = Stopwatch.StartNew();
// msec待機
int sleepTime = (int)(msec - 1);
if (sleepTime > 0) Thread.Sleep(sleepTime);
// usec待機
while (sw.Elapsed.TotalMilliseconds < msec) { }
sw.Stop();
Console.WriteLine($"処理時間:{sw.Elapsed.TotalMilliseconds:F3} ms ({sw.Elapsed.TotalMilliseconds * 1000:F3} μs)");
timeEndPeriod(1);
}
実際にはシステムクロックの設定にも処理時間がかかってしまうため、プログラムの最初と最後に設定処理を行う。
待機処理は最低限の処理を実行する。
public static void Sleep(double msec)
{
Stopwatch sw = Stopwatch.StartNew();
// msec待機
int sleepTime = (int)(msec - 1);
if (sleepTime > 0) Thread.Sleep(sleepTime);
// usec待機
while (sw.Elapsed.TotalMilliseconds < msec) { }
}
msec待機で-1しているのはThread.Sleep()でブレがあり少し伸びてしまうため。
たまに1msec以上ブレて指定時間を超すことがある。10msecのところを見るとオーバーしている結果もある。
時間精度を担保するにはここを-3くらいにすればいい。その代わりCPU占有率が上がる。
10ms
処理時間:10.000 ms (10000.300 μs)
処理時間:10.000 ms (10000.400 μs)
処理時間:10.000 ms (10000.300 μs)
処理時間:10.193 ms (10193.400 μs)
処理時間:10.000 ms (10000.400 μs)
1ms
処理時間:1.000 ms (1000.200 μs)
処理時間:1.000 ms (1000.200 μs)
処理時間:1.000 ms (1000.300 μs)
処理時間:1.000 ms (1000.200 μs)
処理時間:1.000 ms (1000.100 μs)
0.1ms(100usec)
処理時間:0.100 ms (100.100 μs)
処理時間:0.100 ms (100.200 μs)
処理時間:0.100 ms (100.300 μs)
処理時間:0.100 ms (100.200 μs)
処理時間:0.100 ms (100.300 μs)
結論
windowsでマイクロ秒単位の時間なんて制御するもんじゃねぇ。
参考サイト