【.Net C#】Thread.Sleep と Task.Delay が遅い

ゲームを作るときに Unity を使ったりする場合は気にする必要がないのですが、そういったツールを使わない場合、FPS を固定させるために1フレーム未満で処理が完了したら残りの時間待つ…という処理が必要になります。

※本稿での FPS は、ファーストパーソンシューターの略ではなく、フレーム・パー・セカンド(毎秒のフレーム数)のことです。

そうしないと、フレームレートが一定にならないので FPS も一定になりません。
ゲームのスピードが毎フレームバラバラになり、カクカクした動きになります。

FPS 60 の場合、1フレームは 16.6666…ミリセカンド以内に処理する。
FPS 30 の場合、1フレームは 33.3333…ミリセカンド以内に処理する。

ゲームループ(float delta_time) {
 ゲームの色んな処理をする
 if (1フレーム未満で処理が終わってる) {
  余った時間待つ
 }
 次のフレームの処理へ
}

疑似コードで表現すると、上記のような処理になります。
本稿では、上記疑似コード中の「余った時間待つ」処理を .Net を使って実装する場合の注意点について説明します。

.Net で一定時間待つ方法

.Net を使う場合、一定時間待つ方法として思いつくのは、Thread.Sleep と Task.Delay です。

ところがこのふたつ、まったくあてになりません。

おま環ではなさそう

stack overflow にこんなスレッドがありました。
要するに精度が悪いので、精度を求める場合は使い物にならないということです。

遅い理由

不明です。

using System.Threading.Tasks;

var start = System.DateTime.Now;

await Task.Delay(1);

var end = System.DateTime.Now;

System.Console.WriteLine("elapsed : {0} msec", (end - start).Milliseconds);

検証に使ったコードは↑になります。

Task.Delay(1) … 1ミリセカンド待つ

…と指定しているのですが、実際は5~25ミリセカンドかかります。
デバッグビルドでもリリースビルドでも、パブリッシュして exe を実行しても変わりません。

Thread.Sleep を使っても同じです。

ウィンドウズの内部クロックにひっぱられる作りになっているという噂もありますが、実装コードを見てないのでなんとも言えません。
後述の TimeBeginPeriod の設定が影響することから、そんな感じはします(それでも遅延するので他にも原因がありそう)。

原因は不明ですが、精度が悪いことは間違いないようです。

FPS 60 設定のゲームの場合、1フレームを17ミリセカンド未満で処理しないといけないので、5~25ミリセカンド遅れてしまうと、まともなゲームになりません。

遅延を減らす方法はある?

Thread.Sleep と Task.Delay の検証報告が以下の記事に記載されています。

CPU使用率100%になるのを防ぐには、Thread.Sleep を使う必要があるとのことなのですが、私の環境だと Task.Delay でも効果がありました。
実行環境によって変わるようです。

using System.Threading.Tasks;
while (true) {
  await Task.Delay(1);
}

こちらのコードを実行して、タスクマネージャーでCPU使用率を確認しました。
CPU使用率は20%程度でした(物理コア6、論理コア12環境)。

await Task.Delay(1); の行をコメントアウトするとCPU使用率100%になるので、Task.Delay がCPU使用率を減らしているのは間違いありません。

気になるところとしては、Main 関数を使っていないのが影響しているかも知れません。

また、↑の記事の環境は .Net Framework 4.x で、私が試した環境は .Net 5.0 という違いがあります。
.Net のバージョンによって動作が変わるのかも知れません。

対策
遅延の少ない(ほぼない)方法

stack overflow で提案されていましたが、Stopwatch を使うのが良さそうです。
Stopwatch クラスは .Net Core 2.0 でも .Net Standard 2.0 でも使えます。

この方法なら、1ミリセカンド単位で待つことができます(マシン性能に依存しそうですが)。

static public void Sleep(float msec) {
    var stopwatch = new Stopwatch();
    stopwatch.Start();
    while (stopwatch.Elapsed.Milliseconds < msec) {}
}

こんな感じの静的メソッドを作っておけば、使いまわしがききそうです。
ただ、この方法だとCPU使用率が100%になります。
1ミリセカンドの誤差も許されないような状況では、こちらを使うのが良さそうです。

Thread.Sleep の精度を上げる方法

前述のブログ記事で紹介されていた方法になります。

winmm.dll が必要なので、「ウィンドウズ以外では使えないかな?」と思ったのですが、そんなことはなさそうです。

Also, now .NET applications can be developed on Mac or Linux machine using the lightweight IDE Visual Studio Code and Visual Studio for Mac IDE has been released where Mono on MacOS X is integrated.

.Net Core がマルチプラットフォーム対応したので、MacOS も Linux でも使えるそうです。

using System.Runtime.InteropServices;
class Winmm {
    [DllImport("Winmm.dll")]
    public static extern uint TimeBeginPeriod(uint uuPeriod);
}

Winmm.TimeBeginPeriod(1);

System.Threading.Thread.Sleep(1); // 精度の高い Thread.Sleep になる

私の環境で試したところ、1ミリセカンド遅延します。
ただ、CPU使用率100%を防げるなら許容したくなる誤差です。

こちらも私の環境では Thread.Sleep だけでなく、Task.Delay の精度も上がりました。

コメントを残す

メールアドレスが公開されることはありません。