C#プログラムのパフォーマンスを向上させるため、非同期プログラミングについて触れてみます。

メインスレッド(UIスレッド)

Windowsフォームアプリケーションを例に説明します。
次の開発環境を参考にC#のWindows Formアプリケーションを作成します。

開発環境

Windowsフォームが起動するプロジェクトが作成されます。
ここでProgram.csファイルを確認します。

/// <summary>
/// アプリケーションのメイン エントリ ポイントです。
/// </summary>
[STAThread]
static void Main()
{
    Application.EnableVisualStyles();
    Application.SetCompatibleTextRenderingDefault(false);
    Application.Run(new Form1());
}

プログラムの開始はMainで始まり、その上に[STAThread]と記載があります。確認すると、
「アプリケーションの COM スレッド モデルがシングルスレッド アパートメント (STA) であることを示します。」
というコメントが出てきます。

難しいことが書かれていますが、ざっくり言うとプログラムの開始(main関数を)はシングルスレッドで動作するということです。

次に、Application.Run関数によりForm1が呼び出されています。この関数のコメントは、
「現在のスレッドで標準のアプリケーション メッセージループの実行を開始し、指定したフォームを表示します」
とあります。メッセージループはマウスやキーボードなどのメッセージ要求を1つずつ処理します。

重要なのは「シングルスレッドであるため複数同時には処理ができない」ということです。

Run関数を抜けると、シングルスレッド自体が終了してしまいます。
確認してみるとForm1を表示中はRun関数から先には進みません。
以下のプログラムに変更してみます。

        /// <summary>
        /// アプリケーションのメイン エントリ ポイントです。
        /// </summary>
        [STAThread]
        static void Main()
        {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);

            System.Diagnostics.Debug.WriteLine("Form1 start");

            Application.Run(new Form1());

            System.Diagnostics.Debug.WriteLine("Form1 end");
        }

Run関数の前後でテスト出力を行います。
Form1をクローズするまで “From1 end” の出力がされないことが分かります。

Form1をクローズするとアプリケーションが終了する訳ではなく、シングルスレッド(main関数)を抜けることによりアプリケーションが終了するという仕組みになります。

このシングルスレッド(main関数)のことをメインスレッドUIスレッドとも呼びます。

画面ロックする例

ここまでシングルスレッドについて話してきましたが、実際に動かして確かめてみます。

Form1にボタンを貼り付け、クリックイベントを作成します。
またLabelコントロールも貼り付けて、クリックした回数を表示します。

        /// <summary>
        /// クリック回数
        /// </summary>
        private int _count = 0;

        public Form1()
        {
            InitializeComponent();

            label1.Text = _count + "回クリックしました。";
        }               

        /// <summary>
        /// ボタンクリック
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void button1_Click(object sender, EventArgs e)
        {
            _count++;
            label1.Text = _count + "回クリックしました。";
        }

ここでボタンを連打した場合も問題なくカウントアップします。
これは連打する度にボタンクリックのイベント処理が順番に行われますが、クリックより処理の方が速いためです。

ボタンクリックイベントを次のように変更します。

        /// <summary>
        /// ボタンクリック
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void button1_Click(object sender, EventArgs e)
        {
            Thread.Sleep(1000);

            _count++;
            label1.Text = _count + "回クリックしました。";
        }

Thread.Sleep関数は指定時間、スレッド処理を停止します。
1回クリックする度に1000ms(1秒)停止するため、連打するとForm1が固まることが分かります。
1クリック=1秒間の処理時間が必要で、連続でクリックするとメッセージキューに保持され、順番待ちするためです。
シングルスレッドのため、1つの処理を行なう間は他の操作が行えなくなります。

タイマーがロックする例

次にコントロールとして貼り付けする「System.Windows.Forms.Timer」を使用してみます。
こちらもリファレンスにて「UIスレッドを使用するシングルスレッド環境向け」と記載があります。

        /// <summary>
        /// タイマー処理
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void timer1_Tick(object sender, EventArgs e)
        {
            Thread.Sleep(3000);

            label2.Text = DateTime.Now.ToString();
        }

このプログラムではThread.Sleep関数により1回の関数処理が3000ms(3秒)必要です。
このため、タイマーが100ms(初期値)にも関わらず、日時の更新は3秒間隔でしか行われません。

対策

左図のような1つの処理A(ボタンクリックやタイマーなど)に大きく時間がかかる場合の対応として、重たい処理が終わる前にメッセージ処理を終わらせる必要があります。
このため、時間のかかる処理Aをメッセージ処理の中で行なうのではなくUIスレッドとは別のスレッドを作成して行わせます。

  • ボタンクリックで行われる「重たい処理A」と、「Aの結果をコントロールに表示する処理B」があります。
  • 左図はこれをボタンクリックの中で行う場合です。
    処理に時間がかかり、その間は画面の描画が停止します。
  • 右図は別スレッドを作成し、重たい処理Aを行わせています。
    ボタンクリックイベントは別スレッドの開始後に即時終了するため、画面操作が行えるようになります。
  • 別スレッドで行われた処理Aの完了はUIスレッド側からの監視または処理A側からの通知で判断します。
    完了したら処理Bを行います。

画面操作が行えるということは処理Aが完了を待たずして、別の作業(ここでは処理Cとします)が行えることになります。
処理Aと処理Cは非同期となり、そのようなプログラムを非同期プログラミングと言います。

非同期プログラミング

C#の非同期プログラミングを調べると、複数のやり方があります。
結論としてTaskクラスを使用するのですが、理由について説明します。

非同期プログラミングの種類

種類は大別すると次の3つになります。

タスクベース (TAP)イベントベース(EAP) 非同期プログラミングモデル (APM)
非同期処理の開始と終了を1つの関数で扱う。Taskクラスを使用する。開始するための関数と、結果を受け取るためのイベントを用いた非同期処理。IAsyncResultインターフェイス
を使用して非同期処理を行う。
BeginとEndの関数を用いる。
.NET Framework 4 以降.NET Framework 2.0 以降.NET Framework 1.1 以降
推奨非推奨非推奨

.NET Framework 4 以降ではTaskクラスが用意されていて、TAPを使用することを推奨しています。
EAPやAPMは非推奨ですが、使えないといった訳でなくソースコードの可読性といった要因が大きい印象です。
また、Taskクラスは.NET Framework 4以降で機能が拡張されています。

また、Task / ThreadPool / Threadクラスを比較したサイトも多くあります。
ThreadPoolクラスやThreadクラスは、Taskが導入される前に使われていた非同期処理を行うためのクラスです。

クラスTaskクラスThreadPoolクラス Threadクラス
対応バージョン.NET Framework 4 以降.NET Framework 1.1 以降.NET Framework 1.1 以降
スレッドプール対応対応未対応
戻り値受け取り可受け取り不可受け取り不可
機能多い少ない少ない

ここでも、一般的にTaskを推奨する案内が多いです。
Threadクラスの利点もありますが、Taskの使用で問題ないかと思います。

Taskクラスを使用する

次のようにボタンクリックイベントを変更します。

        /// <summary>
        /// クリック回数
        /// </summary>
        private int _count = 0;

        public Form1()
        {
            InitializeComponent();
        }               

        /// <summary>
        /// ボタンクリック
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void button1_Click(object sender, EventArgs e)
        {
            Task.Run(() => CountUpAsync()).ContinueWith(count_up =>
            {
                _count += count_up.Result;
                label1.Text = _count + "回クリックしました。";
            }, TaskScheduler.FromCurrentSynchronizationContext());
        }

        /// <summary>
        /// 非同期処理・カウントアップ
        /// </summary>
        /// <returns></returns>
        private int CountUpAsync()
        {
            int count = 0;

            for (int i = 0; i < 5; i++)
            {
                count++;
                Thread.Sleep(1000);
            }
            return count;
        }
  1. Task.RunによりCountUpAsync関数を呼び出します。
  2. CountUpAsync関数は1秒間隔で5回のカウントアップします。
    つまり5秒間応答がない関数です。
    重い処理はここで実装します。
  3. ContinueWithにはTaskで実行した関数(CountUpAsync)が完了後、非同期実行する継続タスクを作成します。
    CountUpAsync関数の戻り値を取得し、メンバー変数(_count)加算後にラベル表示します。
注意

ContinueWith関数の中もUIスレッドと異なる別のスレッドになります。
この中でコントロール(ここではLabel1)を操作すると例外が発生します。

<例外>
System.InvalidOperationException: ‘有効ではないスレッド間の操作: コントロールが作成されたスレッド以外のスレッドからコントロール ‘label1′ がアクセスされました。’

これはC#のルールでコントロールへのアクセスがスレッドセーフでないためUIスレッド以外のスレッドからアクセスできないことが原因です。

対策として関数の引数にTaskScheduler.FromCurrentSynchronizationContext関数を指定します。これでContinueWithの継続タスクと呼び元のタスクを関連付けします。

TaskSchedulerの呼び元はUIスレッド上のため、UIスレッドと紐付けられてコントロールアクセスが行えるようになります。

まとめ

プログラムのパフォーマンスを向上させるため、非同期プログラミングについておさらいしました。
次はTaskクラスの様々な機能について触れていきたいと思います。