💻

C#のThreadとThreadPoolとTaskについて理解する

2023/12/25に公開

この記事はスレッドと非同期処理について順番に理解するのを手助けするための記事です。

スレッド

まず最初にスレッドの理解から始めてみましょう。OSが提供する機能でスレッドというのがあります。WindowsPCは並列に処理を実行しているように見えますが、実際にはCPUは1つの処理しか実行できません。マイクロ秒単位で各アプリケーションの処理を少しずつ実行して並列に実行しているように見せかけています。

スレッドはOSによって管理されています。各UIアプリケーションごとにUIスレッドが1つあり、アプリケーションによってはさらにバックグラウンドスレッドが存在する可能性もあります。エクセル、ワード、chromeを立ち上げると3つUIスレッドがあり、7個のバックグラウンドスレッドがあるとしましょう。そうするとOSは合計10個のスレッドを少しずつ順番に実行する感じで動作します。

実際にはこれら全てのスレッドが実行が必要なわけではありません。スレッドは状態を持っており、何もする必要がないときは待機状態でリソースを消費しないようになっています。
https://atmarkit.itmedia.co.jp/ait/articles/1410/30/news150.html

とりあえずざっくりこんな感じでまずは理解しておきましょう。

C#からスレッドを呼び出す

このOSの機能であるスレッドをC#から簡単に呼び出せるようになっています。

Thread t = new Thread(new ThreadStart(SomeMethod));
t.Start();

SomeMethodはこんな感じ

static void SomeMethod()
{
    //Do something...
}

簡単ですね。

スレッドの注意点

スレッドの作成処理は結構重たい処理になります。スレッドコンテキストへ保存する情報でメモリをまあまあ使用します。下記の記事では1MBくらい?と言ってますが最終的にはWindowsを作ったマーク・ルシノビッチ氏に聞いてみたらと言っています。
https://stackoverflow.com/questions/28656872/why-is-stack-size-in-c-sharp-exactly-1-mb
いずれにしてもどの記事でも言及されているのは「スレッドの作成はコストが高い」ということです。

スレッドプール

ということで毎回スレッドを作成するのは避けたいよね、という話になります。そこで出てくる
アイディアが「事前に作成済みのスレッドを用意しておいてそれを再利用しよう」というアイディアです。これを実現したのがスレッドプールとなります。

C#からスレッドプールを利用する

当然このスレッドプールもC#から簡単に利用できるようになっています。ThreadPoolクラスのQueueUserWorkItemメソッドを使います。

using System.Threading;

void Main()
{
    ThreadPool.QueueUserWorkItem(SomeMethod, "MyValue1");
}
void SomeMethod(Object state)
{
    //Do something...
}

SomeMethodの実行が終わると利用したスレッドがスレッドプールに返却され、再利用できるようになります。スレッドの作成のコストがなくなりパフォーマンスが向上します。

スレッドプールの注意点

さてとても便利なスレッドプールですが一つ注意点があります。それはスレッドプール内で保持しているスレッドの数に上限があるということです。全てのスレッドが貸し出し中の場合、スレッドが借りられないので待つことになります。電子レンジが10個あって全てが利用中の場合は待つしかありませんね。ということで利用上の注意点としては「短時間で終わる処理のときはスレッドプールを使う」ということです。逆に長時間かかる処理は別にスレッドを作成してそちらを使うようにしたほうが良いでしょう。

スレッドが枯渇したら?

スレッドが足りなくなったらスレッドプールは利用状況を踏まえて内部のスレッド数を増やします。増やすときにはスレッドの作成処理が行われるのでパフォーマンスが低下します。完了に時間がかかるような処理をたくさん実行しているとスレッドの枯渇が発生する確率が高まります。

スレッドプールの調整

実行時にスレッドプールの内部スレッドの作成処理が行われるのを避けたい場合、事前に作っておくことができます。使用するスレッドの数が事前に予測できる場合は設定しておくのも良いでしょう。
https://learn.microsoft.com/ja-jp/dotnet/api/system.threading.threadpool.setmaxthreads?view=net-8.0
https://www.c-sharpcorner.com/article/thread-pool-in-net-core-and-c-sharp/

TaskとThreadPool

Taskクラスは裏でThreadPoolからスレッドを借りて処理を実行しています。ですので上記のスレッドプールの注意点がそのまま当てはまります。
さて一つ注意点はTaskでWait()メソッドやResultプロパティへアクセスするとThreadを借りたまま、処理の完了を待機することになります。その結果、ThreadPoolで利用可能なThreadの数が1個減ります。これが良く言われる「TaskをWaitするな」と言われる理由になります。

awaitしてあげると借りたThreadがThreadPoolへ返却されて他のコードがそのThreadを利用できるようになります。

またWEBサーバーのHTTPリクエストの処理はThreadPoolで行われています。async-awaitを使わないと以下の記事のようにパフォーマンスが低下します。
https://zenn.dev/microsoft/articles/async-sync-webapi-dotnetfw

TaskとValueTask

Taskクラスは呼び出し階層ごとに少しメモリを使うのでなるべくValueTaskを使用しましょう。本当は全てValueTaskでいけると良い感じです。ただ既存のコードがTaskだったり、overriderなどで親クラスの戻り値がTaskの場合は仕方ないですがTaskを使用しましょう。

まとめ

一度Threadクラスを作成してマルチスレッドなコンソールアプリケーションを作ってみましょう。その後、自分でThreadPoolみたいなクラスを作って見ると何をしてるかイメージがつかめると思います。

Discussion