C# Task.Runの使い方と落とし穴|async/awaitとの違い・重い処理を安全に非同期化する方法

はじめに

C#のTask.Runは、重い計算処理や時間のかかる同期処理をスレッドプール上で実行し、呼び出し元のスレッドをブロックしないようにするためによく使われます。特にWPFやWindows FormsなどのUIアプリでは、画面のフリーズを避ける目的で登場することが多いAPIです。

一方で、Task.Runは「何でも非同期にすれば速くなる」魔法の仕組みではありません。使いどころを間違えると、逆にパフォーマンスが悪化したり、スレッドプールを圧迫したり、例外処理やキャンセル処理が複雑になったりします。

この記事では、C#のTask.Runの基本的な使い方から、async/awaitとの違い、使うべきケース・使わないべきケース、重い処理を安全に非同期化する実装例、よくある落とし穴までを整理して解説します。

1. C#のTask.Runとは?まず押さえる基本

Task.Runは、指定した処理を.NETのスレッドプール上で実行し、その処理を表すTaskまたはTask<TResult>を返すメソッドです。

簡単に言えば、次のような処理を書くためのAPIです。

C#
await Task.Run(() =>
{
// 重い処理
});

このコードでは、ラムダ式の中に書いた処理がスレッドプール上で実行されます。呼び出し元はawaitによって処理の完了を非同期的に待てるため、UIスレッドや現在の処理の流れをブロックしにくくなります。

1-1. Task.Runの役割:処理をスレッドプールで実行する

Task.Runの主な役割は、CPUを使う処理を現在のスレッドから切り離し、スレッドプール上で実行することです。

スレッドプールとは、.NETランタイムが管理する再利用可能なスレッドの集まりです。毎回新しいスレッドを作成するのではなく、既存のスレッドを効率よく使い回すことで、スレッド作成コストを抑えます。

たとえば、次のような処理はCPUを長時間使う可能性があります。

C#
int result = await Task.Run(() =>
{
var sum = 0;

for (int i = 0; i < 100_000_000; i++)
{
sum += i;
}

return sum;
});

この場合、重いループ処理を呼び出し元のスレッドではなく、スレッドプール上で実行できます。

1-2. Task.Runでできること・できないこと

Task.Runでできることは、主に次のようなものです。

重い計算処理を別スレッドで実行する、UIスレッドのブロックを避ける、同期的な処理をawait可能な形に包む、複数のCPUバウンド処理を並列に実行する、といった用途です。

一方で、Task.Runにはできないこともあります。

Task.Runを使っても、処理そのものが自動的に高速化されるわけではありません。また、ネットワーク通信やファイル読み込み、データベースアクセスなどのI/O待ち処理を本質的に効率化するものでもありません。

たとえば、HttpClient.GetStringAsyncのように最初から非同期APIが用意されている場合、通常はTask.Runで包む必要はありません。

C#
// 通常はこちらで十分
string html = await httpClient.GetStringAsync(url);

// 基本的に不要
string html2 = await Task.Run(() => httpClient.GetStringAsync(url));

I/O処理にはI/O処理向けの非同期APIを使うのが基本です。

1-3. Task.Runが使われる典型シーン

Task.Runが使われる典型的なシーンは、CPU負荷の高い処理を呼び出し元から切り離したい場合です。

代表例としては、画像のリサイズ、動画や音声データの変換、CSVや巨大ファイルの解析、暗号化・圧縮処理、複雑な計算、検索インデックスの作成などがあります。

UIアプリでは、ボタンをクリックしたときに重い処理をそのまま実行すると画面が固まります。

C#
private async void Button_Click(object sender, EventArgs e)
{
button.Enabled = false;

try
{
var result = await Task.Run(() => HeavyCalculation());
label.Text = result.ToString();
}
finally
{
button.Enabled = true;
}
}

このようにTask.Runを使うと、重い処理の実行中もUIスレッドが解放されるため、画面の応答性を保ちやすくなります。

1-4. Task.RunとTask/Thread/ThreadPoolの関係

Task.Runを理解するには、TaskThreadThreadPoolとの関係を押さえると分かりやすくなります。

Taskは、非同期処理や並行処理の「作業単位」を表すオブジェクトです。処理が完了したか、失敗したか、キャンセルされたかといった状態を持ちます。

Threadは、OS上の実行スレッドを直接表します。自分で作成・開始・管理できますが、コストが高く、扱いも慎重さが必要です。

ThreadPoolは、.NETが管理するスレッドのプールです。短時間から中程度の処理を効率よく実行するために使われます。

Task.Runは、内部的にはスレッドプールに作業を投入し、その作業を表すTaskを返します。

C#
Task task = Task.Run(() =>
{
Console.WriteLine("ThreadPool上で実行される処理");
});

つまり、Task.RunTaskThreadPoolを簡単に使うための高レベルなAPIと考えるとよいでしょう。

2. Task.Runの基本的な使い方

Task.Runの基本形は、ラムダ式を渡して処理を実行する形です。戻り値がある場合とない場合で、返されるTaskの型が変わります。

戻り値がない場合はTask、戻り値がある場合はTask<TResult>が返されます。

2-1. 戻り値なしの処理をTask.Runで実行する

戻り値がない処理は、ActionとしてTask.Runに渡します。

C#
await Task.Run(() =>
{
Console.WriteLine("重い処理を開始します");

Thread.Sleep(3000);

Console.WriteLine("重い処理が完了しました");
});

このコードでは、Task.Runの中の処理がスレッドプール上で実行されます。awaitを付けることで、処理が完了するまで非同期的に待機します。

awaitしない場合、処理は開始されますが、呼び出し元は完了を待たずに次へ進みます。

C#
Task.Run(() =>
{
Console.WriteLine("バックグラウンドで実行");
});

ただし、awaitしない書き方は例外を見逃しやすく、処理完了のタイミングも管理しづらくなります。基本的にはawaitするか、返されたTaskを適切に保持しましょう。

2-2. 戻り値ありの処理をTask.Runで実行する

戻り値がある処理では、Task<TResult>が返されます。

C#
Task<int> task = Task.Run(() =>
{
int total = 0;

for (int i = 1; i <= 100; i++)
{
total += i;
}

return total;
});

この時点では、taskは「将来intを返す処理」を表しています。結果を取得するにはawaitを使います。

C#
int result = await task;
Console.WriteLine(result);

awaitを使うことで、処理が完了したあとに戻り値を自然な形で受け取れます。

2-3. await Task.Runで非同期的に結果を受け取る

Task.Runは、awaitと組み合わせて使うのが一般的です。

C#
int result = await Task.Run(() =>
{
return HeavyCalculation();
});

Console.WriteLine($"結果: {result}");

awaitを使うと、処理が終わるまで現在のメソッドの続きは一時停止されます。ただし、スレッドを占有して待つわけではありません。

特にUIアプリでは、await後の処理は通常UIスレッドに戻って実行されます。そのため、次のように安全にUIを更新できます。

C#
private async void Button_Click(object sender, EventArgs e)
{
int result = await Task.Run(() => HeavyCalculation());

// await後はUIスレッドに戻るため、UI更新しやすい
label.Text = result.ToString();
}

ただし、Task.Runの中から直接UI部品を操作してはいけません。UI部品はUIスレッドから操作する必要があります。

2-4. ラムダ式・メソッド呼び出しで書く場合の違い

Task.Runにはラムダ式を直接書くことも、既存メソッドを渡すこともできます。

ラムダ式で書く例です。

C#
int result = await Task.Run(() =>
{
return HeavyCalculation();
});

メソッド呼び出しで書く例です。

C#
int result = await Task.Run(HeavyCalculation);

HeavyCalculationが引数なしで戻り値を返すメソッドであれば、後者のように簡潔に書けます。

C#
private int HeavyCalculation()
{
int total = 0;

for (int i = 0; i < 100_000_000; i++)
{
total += i;
}

return total;
}

引数が必要な場合はラムダ式を使うと分かりやすいです。

C#
int result = await Task.Run(() => Calculate(100));

ラムダ式を使うと、引数を渡したり、前後に処理を追加したりしやすくなります。

2-5. Task.Runの最小サンプルコード

Task.Runの最小サンプルは次のようになります。

C#
using System;
using System.Threading.Tasks;

class Program
{
static async Task Main()
{
Console.WriteLine("開始");

int result = await Task.Run(() =>
{
int total = 0;

for (int i = 1; i <= 100; i++)
{
total += i;
}

return total;
});

Console.WriteLine($"結果: {result}");
Console.WriteLine("終了");
}
}

このコードでは、1から100までの合計をスレッドプール上で計算し、その結果をawaitで受け取っています。

重要なのは、Task.Runは処理の実行場所を変えるものであり、awaitはその完了を待つための構文だという点です。

3. Task.Runとasync/awaitの違い

Task.Runasync/awaitは一緒に使われることが多いため混同されがちですが、役割はまったく異なります。

Task.Runは「処理をどこで実行するか」に関係する仕組みです。一方、async/awaitは「非同期処理をどのように待つか」を書くための仕組みです。

3-1. async/awaitは「非同期処理の待ち方」を書く仕組み

async/awaitは、非同期処理を同期処理のような読みやすい形で書くための構文です。

C#
public async Task<string> GetHtmlAsync(string url)
{
using var client = new HttpClient();

string html = await client.GetStringAsync(url);

return html;
}

このコードでは、HTTP通信の完了をawaitで待っています。awaitしている間、スレッドをブロックしません。

つまり、async/awaitは別スレッドを作るための仕組みではありません。非同期処理の完了を自然に待つための仕組みです。

3-2. Task.Runは「別スレッドで処理を実行する」仕組み

Task.Runは、指定した処理をスレッドプール上で実行するための仕組みです。

C#
int result = await Task.Run(() =>
{
return HeavyCalculation();
});

この場合、HeavyCalculationは呼び出し元のスレッドではなく、スレッドプール上のスレッドで実行されます。

async/awaitだけではCPU負荷の高い処理が自動的に別スレッドへ移動するわけではありません。

たとえば、次のコードはasyncメソッドですが、重い計算処理自体は呼び出し元のスレッドで実行されます。

C#
public async Task<int> BadAsync()
{
int result = HeavyCalculation();

await Task.Delay(100);

return result;
}

asyncを付けただけでは重い処理は軽くなりません。CPUバウンド処理を別スレッドで実行したい場合は、Task.Runの利用を検討します。

3-3. I/O待ち処理にTask.Runが不要な理由

I/O待ち処理とは、ネットワーク通信、ファイル読み込み、データベースアクセスなど、CPU計算ではなく外部リソースの応答を待つ処理のことです。

このような処理では、専用の非同期APIを使うのが基本です。

C#
string text = await File.ReadAllTextAsync("sample.txt");

string html = await httpClient.GetStringAsync(url);

var rows = await command.ExecuteReaderAsync();

これらのAPIは、I/O完了を非同期的に待てるように設計されています。わざわざTask.Runで別スレッドに逃がす必要はありません。

悪い例です。

C#
string text = await Task.Run(() =>
{
return File.ReadAllText("sample.txt");
});

このコードは同期I/Oを別スレッドで実行しているだけです。呼び出し元のスレッドは空きますが、代わりにスレッドプールのスレッドをI/O待ちで占有します。

非同期I/O APIがあるなら、次のように書くべきです。

C#
string text = await File.ReadAllTextAsync("sample.txt");

3-4. CPU負荷の高い処理にTask.Runが向いている理由

CPU負荷の高い処理は、計算そのものに時間がかかります。たとえば、画像処理、圧縮、暗号化、巨大データの集計などです。

このような処理をUIスレッドで実行すると、画面が固まります。

C#
// UIスレッドで重い処理を実行してしまう
var result = HeavyCalculation();

Task.Runを使うと、重い計算をスレッドプール上で実行できます。

C#
var result = await Task.Run(() => HeavyCalculation());

これにより、UIスレッドはユーザー操作や画面描画に使える状態を保ちやすくなります。

ただし、CPU負荷の高い処理を大量に並列実行すると、CPUコア数以上の仕事が詰まり、逆に遅くなる場合もあります。

3-5. Task.Runとasyncメソッドを組み合わせるときの注意点

Task.Runの中にasyncラムダを書くこともできます。

C#
await Task.Run(async () =>
{
await SomeAsyncMethod();
});

ただし、この書き方は慎重に使う必要があります。SomeAsyncMethodが本当に非同期I/Oであるなら、そもそもTask.Runで包む必要がないことが多いからです。

次のようなコードは基本的に不要です。

C#
await Task.Run(async () =>
{
await httpClient.GetStringAsync(url);
});

通常は次のように書きます。

C#
await httpClient.GetStringAsync(url);

Task.Runの中でasyncラムダを使うべき場面は、CPUバウンド処理の前後に非同期処理が混ざる場合などに限られます。ただし設計を見直すと、CPU処理とI/O処理を分離した方が分かりやすいことが多いです。

4. Task.Runを使うべきケース・使わないべきケース

Task.Runを使うべきかどうかは、処理がCPUバウンドかI/Oバウンドかで考えると判断しやすくなります。

CPUバウンド処理とは、CPU計算そのものに時間がかかる処理です。I/Oバウンド処理とは、外部リソースの応答待ちが中心の処理です。

4-1. 使うべきケース:重い計算・画像処理・ファイル解析

Task.Runが向いているのは、CPUを長く使う同期処理です。

たとえば、次のような処理です。

C#
var result = await Task.Run(() =>
{
return AnalyzeLargeCsv("data.csv");
});

CSV解析自体がCPUを多く使う場合や、読み込んだデータを大量に変換・集計する場合には、Task.Runが有効です。

画像処理も典型例です。

C#
var resizedImage = await Task.Run(() =>
{
return ResizeImage(originalImage);
});

画像のリサイズやフィルタ処理はCPU負荷が高くなりやすいため、UIスレッドから切り離す価値があります。

4-2. 使うべきケース:UIアプリで画面フリーズを避けたいとき

WPFやWindows Formsでは、UIスレッドが画面描画やユーザー操作を処理します。そのため、UIスレッドで重い処理を実行すると、画面が固まります。

悪い例です。

C#
private void Button_Click(object sender, EventArgs e)
{
var result = HeavyCalculation();

label.Text = result.ToString();
}

このコードでは、HeavyCalculationが終わるまでUIが応答しません。

改善例です。

C#
private async void Button_Click(object sender, EventArgs e)
{
button.Enabled = false;

try
{
var result = await Task.Run(() => HeavyCalculation());

label.Text = result.ToString();
}
finally
{
button.Enabled = true;
}
}

重い処理だけをTask.Runに渡し、UI更新はawaitの後で行うのが基本です。

4-3. 使わないべきケース:HttpClientやDBアクセスなどのI/O処理

HttpClientやデータベースアクセスには、多くの場合、非同期APIが用意されています。

C#
string json = await httpClient.GetStringAsync(url);

このような処理をTask.Runで包む必要はありません。

C#
// 不要になりやすい
string json = await Task.Run(() =>
{
return httpClient.GetStringAsync(url).Result;
});

この書き方は、スレッドを無駄に消費するだけでなく、.Resultによるブロッキングやデッドロックの原因になることもあります。

データベースアクセスでも同様です。

C#
var result = await command.ExecuteScalarAsync();

非同期APIがある場合は、それを直接awaitしましょう。

4-4. 使わないべきケース:ASP.NET Coreで安易にTask.Runする場合

ASP.NET Coreのリクエスト処理で、安易にTask.Runを使うのは避けるべきです。

たとえば、次のようなコードです。

C#
public async Task<IActionResult> Get()
{
var result = await Task.Run(() =>
{
return LoadFromDatabase();
});

return Ok(result);
}

このコードは、リクエスト処理を別スレッドに逃がしているだけです。Webサーバー全体としては、スレッドプールのスレッドを余計に使うことになり、同時リクエストが増えたときにスケーラビリティを下げる可能性があります。

DBアクセスであれば、非同期APIを使うべきです。

C#
public async Task<IActionResult> Get()
{
var result = await LoadFromDatabaseAsync();

return Ok(result);
}

ASP.NET Coreでは、I/O待ちを減らす、非同期APIを使う、処理をキューに逃がす、バックグラウンドサービスに分離する、といった設計の方が重要です。

4-5. 判断基準:CPUバウンドかI/Oバウンドかで考える

Task.Runを使うか迷ったら、次のように考えます。

処理時間の大部分がCPU計算なら、Task.Runの候補になります。処理時間の大部分がネットワーク、ディスク、DBなどの待ち時間なら、非同期I/O APIを使うべきです。

CPUバウンドの例です。

C#
await Task.Run(() => CalculateHashForLargeData());

I/Oバウンドの例です。

C#
await File.ReadAllBytesAsync(path);

「重い処理だからTask.Run」ではなく、「CPUを使う重い処理だからTask.Run」と考えるのがポイントです。

5. Task.Runで重い処理を安全に非同期化する方法

Task.Runを安全に使うには、単に処理を包むだけでは不十分です。UIスレッドをブロックしないこと、結果をawaitで受け取ること、キャンセルや例外を扱うこと、多重実行を防ぐことが重要です。

5-1. UIスレッドをブロックしない書き方

UIアプリでは、イベントハンドラをasyncにし、重い処理だけをTask.Runに渡します。

C#
private async void StartButton_Click(object sender, EventArgs e)
{
startButton.Enabled = false;
statusLabel.Text = "処理中...";

try
{
var result = await Task.Run(() => HeavyCalculation());

statusLabel.Text = $"完了: {result}";
}
finally
{
startButton.Enabled = true;
}
}

重要なのは、UI部品の更新をTask.Runの中で行わないことです。

悪い例です。

C#
await Task.Run(() =>
{
// UIスレッド以外からUIを触るため危険
statusLabel.Text = "処理中";
});

UIの更新は、awaitの前後で行うのが基本です。

5-2. awaitを使って結果を安全に受け取る

Task.Runの結果は、.Result.Wait()ではなくawaitで受け取ります。

良い例です。

C#
int result = await Task.Run(() => HeavyCalculation());

避けたい例です。

C#
int result = Task.Run(() => HeavyCalculation()).Result;

.Result.Wait()は現在のスレッドをブロックします。UIアプリでは画面フリーズの原因になり、状況によってはデッドロックの原因にもなります。

Task.Runを使うなら、基本的には呼び出し元もasyncにして、awaitでつなげる設計にしましょう。

5-3. CancellationTokenでキャンセル可能にする

長時間かかる処理では、CancellationTokenを使ってキャンセル可能にすることが重要です。

C#
private CancellationTokenSource? _cts;

private async void StartButton_Click(object sender, EventArgs e)
{
_cts = new CancellationTokenSource();

try
{
int result = await Task.Run(() =>
{
return HeavyCalculation(_cts.Token);
}, _cts.Token);

statusLabel.Text = $"完了: {result}";
}
catch (OperationCanceledException)
{
statusLabel.Text = "キャンセルされました";
}
finally
{
_cts.Dispose();
_cts = null;
}
}

private void CancelButton_Click(object sender, EventArgs e)
{
_cts?.Cancel();
}

private int HeavyCalculation(CancellationToken token)
{
int total = 0;

for (int i = 0; i < 100_000_000; i++)
{
token.ThrowIfCancellationRequested();

total += i;
}

return total;
}

Task.RunCancellationTokenを渡すだけでは、実行中の処理が自動的に止まるわけではありません。処理の中でThrowIfCancellationRequestedなどを呼び出し、キャンセル要求を確認する必要があります。

5-4. 例外をtry-catchで正しく扱う

Task.Runの中で発生した例外は、awaitしたタイミングで再スローされます。そのため、通常のtry-catchで扱えます。

C#
try
{
await Task.Run(() =>
{
throw new InvalidOperationException("処理に失敗しました");
});
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}

awaitしない場合、例外に気づきにくくなります。

C#
// 例外を見逃しやすい
Task.Run(() =>
{
throw new Exception("失敗");
});

安全に実装するなら、Task.Runの戻り値をawaitし、必要に応じてtry-catchで囲みましょう。

5-5. 進捗表示・ボタン連打防止・多重実行対策

重い処理を実行するUIでは、進捗表示やボタン連打防止も重要です。

ボタンを押すたびにTask.Runが開始されると、同じ処理が多重実行され、CPUやメモリを圧迫する可能性があります。

C#
private bool _isRunning;

private async void StartButton_Click(object sender, EventArgs e)
{
if (_isRunning)
{
return;
}

_isRunning = true;
startButton.Enabled = false;

try
{
var progress = new Progress<int>(value =>
{
progressBar.Value = value;
});

await Task.Run(() => HeavyWork(progress));
}
finally
{
startButton.Enabled = true;
_isRunning = false;
}
}

private void HeavyWork(IProgress<int> progress)
{
for (int i = 0; i <= 100; i++)
{
Thread.Sleep(50);
progress.Report(i);
}
}

Progress<T>を使うと、バックグラウンド処理から進捗を報告し、UI側で安全に表示しやすくなります。

6. Task.Runの落とし穴とよくある失敗例

Task.Runは便利ですが、誤った使い方をすると問題を引き起こします。特に、乱用、ブロッキング、例外の見逃し、過剰な並列実行、共有状態の競合には注意が必要です。

6-1. Task.Runを使えばすべて速くなるという誤解

Task.Runは処理を別スレッドに移すだけで、処理そのものを高速化するわけではありません。

たとえば、非常に小さな処理をTask.Runに渡すと、スレッドプールへの投入やコンテキスト切り替えのコストの方が大きくなる場合があります。

C#
// 小さすぎる処理には不向き
int result = await Task.Run(() => 1 + 2);

このような処理は、そのまま実行した方が効率的です。

C#
int result = 1 + 2;

Task.Runは「速くするため」ではなく、「呼び出し元のスレッドをブロックしないため」に使う場面が多いと考えましょう。

6-2. asyncの中でさらにTask.Runを乱用する問題

すでに非同期APIを使っているのに、さらにTask.Runで包むコードはよくある失敗です。

C#
public async Task<string> GetDataAsync()
{
return await Task.Run(async () =>
{
return await httpClient.GetStringAsync("https://example.com");
});
}

この場合、HTTP通信はもともと非同期なので、Task.Runは不要です。

C#
public async Task<string> GetDataAsync()
{
return await httpClient.GetStringAsync("https://example.com");
}

asyncメソッドの中で何でもTask.Runするのではなく、その処理がCPUバウンドかどうかを確認しましょう。

6-3. ResultやWaitでデッドロック・ブロッキングを起こす問題

Task.Runや非同期メソッドの結果を.Result.Wait()で待つと、現在のスレッドをブロックします。

C#
var result = Task.Run(() => HeavyCalculation()).Result;

UIアプリでは、この書き方は画面フリーズの原因になります。

また、非同期処理全般では、.Result.Wait()がデッドロックを引き起こすことがあります。特にUIアプリや古いASP.NETでは注意が必要です。

基本はawaitです。

C#
var result = await Task.Run(() => HeavyCalculation());

「非同期処理は最後までawaitでつなぐ」と考えると、トラブルを避けやすくなります。

6-4. 例外が握りつぶされたように見える問題

Task.Run内で発生した例外は、Taskに保持されます。awaitすれば例外として受け取れます。

C#
try
{
await Task.Run(() =>
{
throw new Exception("エラー");
});
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

しかし、awaitしない場合は例外が表面化しにくくなります。

C#
Task.Run(() =>
{
throw new Exception("エラー");
});

このような「投げっぱなし」のTask.Runは避けましょう。バックグラウンドで実行する必要がある場合でも、ログ出力や例外処理の仕組みを用意するべきです。

6-5. スレッドプール枯渇・過剰な並列実行のリスク

大量のTask.Runを一度に実行すると、スレッドプールが圧迫されることがあります。

C#
var tasks = Enumerable.Range(0, 10000)
.Select(i => Task.Run(() => HeavyCalculation()))
.ToArray();

await Task.WhenAll(tasks);

このようなコードは、CPUやメモリを過剰に使い、アプリ全体の応答性を下げる可能性があります。

並列数を制御するには、SemaphoreSlimなどを使います。

C#
var semaphore = new SemaphoreSlim(4);

var tasks = items.Select(async item =>
{
await semaphore.WaitAsync();

try
{
await Task.Run(() => ProcessItem(item));
}
finally
{
semaphore.Release();
}
});

await Task.WhenAll(tasks);

CPUバウンド処理では、CPUコア数や処理の重さを考えて並列数を制御することが重要です。

6-6. 共有変数・UI部品へのアクセスで起きる競合

Task.Runの中では別スレッドで処理が実行されるため、共有変数へのアクセスには注意が必要です。

悪い例です。

C#
int count = 0;

var tasks = Enumerable.Range(0, 1000)
.Select(_ => Task.Run(() =>
{
count++;
}));

await Task.WhenAll(tasks);

Console.WriteLine(count);

count++は一見単純ですが、複数スレッドから同時に実行されると競合します。

安全にするには、Interlockedなどを使います。

C#
int count = 0;

var tasks = Enumerable.Range(0, 1000)
.Select(_ => Task.Run(() =>
{
Interlocked.Increment(ref count);
}));

await Task.WhenAll(tasks);

また、UI部品はUIスレッドから操作する必要があります。Task.Runの中で直接UIを更新するのは避けましょう。

7. 実践コードで理解するTask.Runの使い方

ここからは、実際のコードでTask.Runの使い方を確認します。基本的な重い計算、UIフリーズ防止、キャンセル、例外処理、複数タスクの待機を順番に見ていきます。

7-1. 重い計算処理をTask.Runで実行する例

次の例では、CPU負荷の高い計算をTask.Runで実行しています。

C#
using System;
using System.Threading.Tasks;

class Program
{
static async Task Main()
{
Console.WriteLine("計算開始");

long result = await Task.Run(() => Calculate());

Console.WriteLine($"計算結果: {result}");
Console.WriteLine("計算終了");
}

static long Calculate()
{
long total = 0;

for (int i = 0; i < 500_000_000; i++)
{
total += i;
}

return total;
}
}

Calculateは同期的な重い処理ですが、Task.Runで包むことで呼び出し元から切り離して実行できます。

コンソールアプリではUIフリーズの問題はありませんが、Task.Runawaitの基本形を理解するには分かりやすい例です。

7-2. WPF/Windows Formsで画面フリーズを防ぐ例

Windows Formsの例です。

C#
private async void buttonStart_Click(object sender, EventArgs e)
{
buttonStart.Enabled = false;
labelStatus.Text = "処理中...";

try
{
int result = await Task.Run(() => HeavyCalculation());

labelStatus.Text = $"完了: {result}";
}
catch (Exception ex)
{
labelStatus.Text = $"エラー: {ex.Message}";
}
finally
{
buttonStart.Enabled = true;
}
}

private int HeavyCalculation()
{
int total = 0;

for (int i = 0; i < 100_000_000; i++)
{
total += i;
}

return total;
}

ポイントは、HeavyCalculationだけをTask.Runに入れていることです。

labelStatus.TextbuttonStart.Enabledの更新は、Task.Runの外で行っています。これにより、UIスレッド以外からUIを触る問題を避けられます。

WPFでも考え方は同じです。

C#
private async void StartButton_Click(object sender, RoutedEventArgs e)
{
StartButton.IsEnabled = false;
StatusText.Text = "処理中...";

try
{
var result = await Task.Run(() => HeavyCalculation());

StatusText.Text = $"完了: {result}";
}
finally
{
StartButton.IsEnabled = true;
}
}

7-3. キャンセル可能なTask.Runの実装例

長時間処理では、ユーザーがキャンセルできるようにしておくと親切です。

C#
private CancellationTokenSource? _cts;

private async void buttonStart_Click(object sender, EventArgs e)
{
_cts = new CancellationTokenSource();

buttonStart.Enabled = false;
buttonCancel.Enabled = true;
labelStatus.Text = "処理中...";

try
{
int result = await Task.Run(() =>
{
return HeavyCalculation(_cts.Token);
}, _cts.Token);

labelStatus.Text = $"完了: {result}";
}
catch (OperationCanceledException)
{
labelStatus.Text = "キャンセルされました";
}
catch (Exception ex)
{
labelStatus.Text = $"エラー: {ex.Message}";
}
finally
{
buttonStart.Enabled = true;
buttonCancel.Enabled = false;

_cts.Dispose();
_cts = null;
}
}

private void buttonCancel_Click(object sender, EventArgs e)
{
_cts?.Cancel();
}

private int HeavyCalculation(CancellationToken token)
{
int total = 0;

for (int i = 0; i < 100_000_000; i++)
{
token.ThrowIfCancellationRequested();

total += i;
}

return total;
}

CancellationTokenは、キャンセル要求を伝えるための仕組みです。実際に処理を止めるには、処理側で定期的にキャンセル要求を確認する必要があります。

7-4. 例外処理を含めた安全な実装例

Task.Run内で例外が発生した場合、awaitした場所で受け取れます。

C#
private async Task LoadAsync()
{
try
{
var data = await Task.Run(() =>
{
return LoadAndAnalyzeData();
});

Console.WriteLine($"件数: {data.Count}");
}
catch (FileNotFoundException ex)
{
Console.WriteLine($"ファイルが見つかりません: {ex.Message}");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"処理に失敗しました: {ex.Message}");
}
catch (Exception ex)
{
Console.WriteLine($"予期しないエラー: {ex.Message}");
}
}

例外処理の基本は、Task.Runの中で無理に全部処理するのではなく、呼び出し側でawaitしてtry-catchすることです。

もちろん、Task.Run内でリソース解放が必要な場合は、usingtry-finallyを適切に使います。

C#
var result = await Task.Run(() =>
{
using var stream = File.OpenRead("data.bin");

return Analyze(stream);
});

7-5. 複数のTask.RunをTask.WhenAllで待つ例

複数のCPUバウンド処理を並列に実行したい場合は、Task.WhenAllでまとめて待てます。

C#
var task1 = Task.Run(() => CalculateA());
var task2 = Task.Run(() => CalculateB());
var task3 = Task.Run(() => CalculateC());

var results = await Task.WhenAll(task1, task2, task3);

Console.WriteLine(results.Sum());

コレクションに対して処理する場合は、次のように書けます。

C#
var tasks = numbers.Select(number =>
Task.Run(() => HeavyCalculation(number))
);

int[] results = await Task.WhenAll(tasks);

ただし、要素数が多い場合にすべてを一度にTask.Runすると、過剰な並列実行になる可能性があります。

その場合は、Parallel.ForEachAsyncSemaphoreSlimによる並列数制御を検討しましょう。

8. Task.Runと関連APIの使い分け

C#には、Task.Run以外にも並行処理・非同期処理に関連するAPIがあります。それぞれの役割を理解して使い分けることが大切です。

8-1. Task.RunとTask.Factory.StartNewの違い

Task.Factory.StartNewは、Task.Runよりも細かいオプションを指定できる低レベルなAPIです。

C#
Task task = Task.Factory.StartNew(() =>
{
Console.WriteLine("StartNew");
});

一方、Task.Runは多くの一般的なケースで使いやすいように簡略化されたAPIです。

C#
Task task = Task.Run(() =>
{
Console.WriteLine("Task.Run");
});

通常の用途では、Task.Runを使う方が分かりやすく安全です。

Task.Factory.StartNewは、スケジューラや作成オプションを明示的に制御したい高度なケースで使います。

特にasyncラムダをStartNewに渡すと、Task<Task>のようなネストしたタスクになることがあり、扱いに注意が必要です。

C#
// 扱いに注意が必要
Task<Task> task = Task.Factory.StartNew(async () =>
{
await Task.Delay(1000);
});

通常はTask.Runを使う方が無難です。

8-2. Task.RunとThreadの違い

Threadは、明示的に新しいスレッドを作成して実行するためのAPIです。

C#
var thread = new Thread(() =>
{
Console.WriteLine("新しいスレッドで実行");
});

thread.Start();

Threadは直接的で分かりやすい一方、スレッドの作成コストやライフサイクル管理を自分で考える必要があります。

Task.Runはスレッドプールを使うため、通常の短時間から中程度の処理では扱いやすく効率的です。

C#
await Task.Run(() =>
{
Console.WriteLine("スレッドプールで実行");
});

長時間専有する専用スレッドが必要な特殊ケースではThreadを検討することもありますが、一般的な非同期処理や並行処理ではTask.RunTaskベースのAPIを使うことが多いです。

8-3. Task.RunとParallel.ForEachの違い

Parallel.ForEachは、コレクションに対するCPUバウンド処理を並列実行するためのAPIです。

C#
Parallel.ForEach(items, item =>
{
ProcessItem(item);
});

Task.Runは、任意の処理をスレッドプール上で実行するためのAPIです。

C#
await Task.Run(() =>
{
ProcessAllItems(items);
});

大量の要素を並列処理したい場合は、Parallel.ForEachParallel.ForEachAsyncの方が適していることがあります。

ただし、UIアプリでParallel.ForEachをそのまま呼ぶとUIスレッドをブロックする可能性があるため、必要に応じてTask.Runと組み合わせることもあります。

C#
await Task.Run(() =>
{
Parallel.ForEach(items, item =>
{
ProcessItem(item);
});
});

この場合も、並列数やリソース消費には注意が必要です。

8-4. Task.RunとValueTaskの違い

ValueTaskは、非同期メソッドの戻り値として使われる型の一つです。主に、結果が同期的に返ることが多い高パフォーマンスなAPIで、Taskの割り当てコストを減らす目的で使われます。

C#
public ValueTask<int> GetValueAsync()
{
return new ValueTask<int>(10);
}

一方、Task.Runは処理をスレッドプール上で実行するAPIです。

つまり、ValueTaskは「非同期結果を表す型」の話であり、Task.Runは「処理をスレッドプールに投入する方法」の話です。

通常のアプリケーションコードでは、まずTaskを使えば十分です。ValueTaskは、パフォーマンス要件が厳しく、使い方を正しく理解している場合に検討します。

8-5. Task.RunとConfigureAwaitの関係

ConfigureAwait(false)は、await後に元のコンテキストへ戻るかどうかを制御するためのものです。

C#
await SomeAsyncMethod().ConfigureAwait(false);

ライブラリコードでは、不要なコンテキスト復帰を避けるためにConfigureAwait(false)が使われることがあります。

一方、Task.Runは処理をスレッドプールで実行するためのものです。役割は異なります。

UIアプリでは、await Task.Run(...)の後にUIを更新したいことが多いため、通常はConfigureAwait(false)を付けません。

C#
var result = await Task.Run(() => HeavyCalculation());

// UI更新
label.Text = result.ToString();

ここでConfigureAwait(false)を付けると、await後にUIスレッドへ戻らない可能性があり、UI更新で問題が起きることがあります。

C#
var result = await Task.Run(() => HeavyCalculation()).ConfigureAwait(false);

// UIスレッドとは限らないため注意
label.Text = result.ToString();

アプリケーションコードでは、UI更新が必要かどうかを考えて使い分けましょう。

9. パフォーマンスを落とさないための設計ポイント

Task.Runは使い方次第でアプリの応答性を改善できますが、設計を誤るとパフォーマンスを落とします。導入前に、処理の種類、粒度、並列数、I/O APIの有無を確認することが重要です。

9-1. 小さすぎる処理をTask.Runしない

ごく短時間で終わる処理をTask.Runに渡すと、かえって遅くなることがあります。

C#
await Task.Run(() => list.Count);

このような処理は、そのまま実行すべきです。

C#
int count = list.Count;

Task.Runには、スレッドプールへの投入、タスク生成、スケジューリング、コンテキスト切り替えなどのコストがあります。

そのコストに見合うだけの重い処理でなければ、使うメリットは小さくなります。

9-2. 並列数を制御してリソースを守る

大量の処理を一気にTask.Runすると、CPU、メモリ、スレッドプールを圧迫します。

C#
var tasks = items.Select(item => Task.Run(() => ProcessItem(item)));
await Task.WhenAll(tasks);

itemsが数十件程度なら問題ない場合もありますが、数千件、数万件になると危険です。

並列数を制御する例です。

C#
var semaphore = new SemaphoreSlim(Environment.ProcessorCount);

var tasks = items.Select(async item =>
{
await semaphore.WaitAsync();

try
{
await Task.Run(() => ProcessItem(item));
}
finally
{
semaphore.Release();
}
});

await Task.WhenAll(tasks);

CPUバウンド処理では、むやみに並列数を増やすより、CPUコア数に合わせて調整する方が安定しやすいです。

9-3. 非同期I/Oは専用のasync APIを使う

I/O処理には、Task.Runではなく専用の非同期APIを使いましょう。

ファイル読み込みなら次のように書きます。

C#
string text = await File.ReadAllTextAsync(path);

HTTP通信なら次のように書きます。

C#
string response = await httpClient.GetStringAsync(url);

DBアクセスなら次のように書きます。

C#
var result = await command.ExecuteScalarAsync();

同期I/OをTask.Runで包む方法は、呼び出し元のスレッドを空けることはできますが、スレッドプールのスレッドを待機に使ってしまいます。

非同期I/O APIが存在するなら、そちらを使うのが基本です。

9-4. ASP.NET Coreではスレッドを増やすより待ち時間を減らす

ASP.NET Coreでは、1つのリクエストを処理するためにTask.Runで別スレッドへ逃がしても、サーバー全体の処理能力が上がるとは限りません。

悪い例です。

C#
public async Task<IActionResult> Get()
{
var data = await Task.Run(() => repository.GetData());

return Ok(data);
}

DBアクセスや外部API呼び出しなら、非同期メソッドを使います。

C#
public async Task<IActionResult> Get()
{
var data = await repository.GetDataAsync();

return Ok(data);
}

CPU負荷の高い処理をWebリクエスト内で実行する必要がある場合も、Task.Runで包めば解決というわけではありません。処理が重すぎるなら、バックグラウンドキュー、ワーカーサービス、別プロセス、外部ジョブ基盤などに分離する設計を検討します。

9-5. 計測してからTask.Runを導入する

Task.Runを導入する前に、本当にその処理がボトルネックなのかを計測しましょう。

処理時間を簡単に測るなら、Stopwatchを使えます。

C#
var stopwatch = Stopwatch.StartNew();

var result = await Task.Run(() => HeavyCalculation());

stopwatch.Stop();

Console.WriteLine($"処理時間: {stopwatch.ElapsedMilliseconds} ms");

計測せずにTask.Runを増やすと、問題が解決したように見えて、別の場所に負荷を移しているだけの場合があります。

特にWebアプリでは、単一リクエストの応答時間だけでなく、同時アクセス時のスループットやCPU使用率も確認することが重要です。

10. Task.Runに関するよくある質問

最後に、Task.Runについてよくある疑問を整理します。

10-1. Task.Runは別スレッドで必ず実行される?

Task.Runは処理をスレッドプールにキューイングします。通常は呼び出し元とは別のスレッドプールスレッドで実行されます。

ただし、「必ず新しい専用スレッドが作られる」という意味ではありません。スレッドプール上の既存スレッドが使われることが多く、実行タイミングもスレッドプールの状態に左右されます。

専用スレッドが必要な特殊なケースでは、Threadや専用の実行基盤を検討します。

10-2. Task.Runにasyncラムダを書いてもよい?

書くことはできます。

C#
await Task.Run(async () =>
{
await SomeAsyncMethod();
});

ただし、SomeAsyncMethodが非同期I/Oであれば、Task.Runで包む必要がないことが多いです。

C#
await SomeAsyncMethod();

Task.Runasyncラムダを書く前に、「この処理は本当にスレッドプールで実行する必要があるのか」を確認しましょう。

10-3. Task.Runの中でawaitしてもよい?

技術的には可能です。

C#
await Task.Run(async () =>
{
await Task.Delay(1000);
DoCpuWork();
});

しかし、単なるI/O待ちやTask.DelayのためにTask.Runを使う必要はありません。

CPUバウンド処理と非同期処理が混ざっている場合は、処理を分離した方が分かりやすくなることがあります。

C#
var data = await LoadDataAsync();

var result = await Task.Run(() => AnalyzeData(data));

このように、I/Oは非同期APIで待ち、CPU処理だけをTask.Runに渡す設計が基本です。

10-4. Task.RunはWebアプリで使ってはいけない?

絶対に使ってはいけないわけではありません。ただし、ASP.NET CoreなどのWebアプリで安易に使うべきではありません。

I/O処理をTask.Runで包むのは避けるべきです。

C#
// 避けたい
await Task.Run(() => repository.GetData());

非同期APIを使えるなら、次のように書きます。

C#
await repository.GetDataAsync();

CPU負荷の高い処理をWebリクエスト内でどうしても実行する場合は、Task.Runよりも、処理を別のワーカーやバックグラウンドジョブに分離できないかを検討した方がよいことが多いです。

10-5. Task.Runとawaitだけでマルチスレッドになる?

Task.Runを使うと、処理はスレッドプール上で実行されます。その意味では、呼び出し元とは別のスレッドで処理されることがあります。

しかし、awaitだけではマルチスレッドになるわけではありません。

C#
await SomeAsyncMethod();

このコードは、非同期処理を待っているだけです。別スレッドでCPU処理を実行しているとは限りません。

マルチスレッド実行になるかどうかは、Task.Runを使っているか、非同期APIの内部実装がどうなっているか、スケジューラがどう動くかによります。

awaitは「待ち方」、Task.Runは「スレッドプールでの実行」と覚えると混同しにくくなります。

まとめ

C#のTask.Runは、重いCPUバウンド処理をスレッドプール上で実行し、呼び出し元のスレッドをブロックしないようにするための便利なAPIです。

特に、WPFやWindows FormsなどのUIアプリで、重い計算処理や画像処理による画面フリーズを避けたい場合に役立ちます。

一方で、Task.Runは万能ではありません。HttpClient、ファイルI/O、DBアクセスなどのI/Oバウンド処理では、基本的に専用の非同期APIを使うべきです。ASP.NET Coreでも、安易にTask.Runを使うとスレッドプールを余計に消費し、スケーラビリティを下げる可能性があります。

Task.Runを安全に使うためのポイントは、CPUバウンド処理に使うこと、結果はawaitで受け取ること、.Result.Wait()を避けること、キャンセルと例外を正しく扱うこと、過剰な並列実行を避けることです。

async/awaitは非同期処理の待ち方を表す仕組みであり、Task.Runは処理をスレッドプールで実行する仕組みです。この違いを理解しておくと、C#の非同期処理をより安全で読みやすく設計できます。