C#のスレッド処理を理解する(基本編)

C#のスレッド処理を理解する(基本編)

今回はC#のスレッド処理について理解を深めてみようと思う。

並行して処理が行われるのはすぐにイメージできるが、プログラムにしてみると少しイメージが薄れてしまう。

なので、簡単なスレッド処理を動かし、それをイメージ図にしてみようと思う。

Parallel クラスを使ってみる

まずはこちらのサイトに掲載されていたプログラムの内容を少し書き換えて実行してみる。

マルチスレッド – C# によるプログラミング入門

以下のプログラムでは「Parallel.For~」より下の部分が並列で処理される。

using System;
using System.Threading.Tasks;
using System.Threading;

namespace csharp_Prallel
{
    class TaskSample
    {
        static void Main(string[] args)
        {
            const int N = 3; // スレッドの数

            Parallel.For(0, N, id => // こう書くだけで、並行して処理が行われる
            {
                Console.Write("ThreadID: {0} の処理が始まりました\n", id);

                Random rnd = new Random();

                for (int i = 0; i < 4; ++i)
                {
                    Thread.Sleep(rnd.Next(50, 100)); // ランダムな間隔で処理を一時中断

                    Console.Write("ThreadID: {0} Count {1}\n", id, i);
                }
                Console.Write("ThreadID: {0} の処理が終了しました\n", id);
            });
            // 並行して動かしている処理がすべて終わるまで、自動的に待つ
        }
    }
}

上記を実行した結果はこのようになる。

まずは「Parallel.For~」を過ぎた部分から各スレッド処理が開始される。今回の場合、スレッドの数を3に指定しているのでスレッドは3つ作成される。「~の処理が始まりました」の部分で各スレッド処理が開始されていることが分かる。

1つのスレッド内ではFor文が処理され、コンソールにカウントが表示されるようになっている。今回はこのスレッドが3つ同時に動いている。

上記の結果からはスレッド内の処理が終了したものから「~の処理が終了しました」と表示されているのが分かる。今回はThreadID 0が一番最初に終了し、次にThreadID 1、その次にThreadID 2のスレッドが終了している。この結果については毎回異なってくる。

上記の場合、全てのスレッドが終了するまでは自動的に待つようになっている。

イメージ図にしてみるとこのような感じ↓

以上がC#のスレッド処理における最も基本的な考え方である。

スレッドプールを作成してみる

次にスレッドプールを作成してスレッドを管理してみる。上記のサイトで紹介されていた素因数分解を行うプログラムを少し修正してみたのがこちら。

using System;
using System.Collections;
using System.Threading;

enum State
{
    ready,   // 計算開始前
    running, // 計算真っ最中
    wait,    // 計算一時停止中
}

class TestThread
{
    static long sNum;
    static State sThreadState;

    static void Main()
    {
        Thread thread = null;

        Console.Write(
          "素因数分解を行います。\n" +
          "何か数値を入力してください。\n" +
          "(計算途中で何かキー入力を行うと処理を中断します。)\n" +
          "(q と入力するとプログラムを終了します。)\n");

        sThreadState = State.ready;

        while (true)
        {
            Console.Write("Main > ");
            string line = Console.ReadLine();

            if (sThreadState == State.running) // 計算中
            {
                Console.Write("\nMain >State.wait\n");
                sThreadState = State.wait; // 計算中断

                // 計算を中止するかどうか確認する。
                Console.Write(
                  "計算を中断しました。\n" +
                  "  c     : 計算中止\n" +
                  "  q     : プログラム終了\n" +
                  "  その他: 計算続行\n" +
                  "# ");
                line = Console.ReadLine();
                if (line.Length != 0)
                {
                    if (line[0] == 'c' || line[0] == 'C')
                    {
                        Console.Write("\nMain >State.ready\n");
                        sThreadState = State.ready;
                        thread.Join();
                        Console.Write("計算を中止しました。\n");
                        continue;
                    }
                    else if (line[0] == 'q' || line[0] == 'Q')
                    {
                        return;
                    }
                }
                Console.Write("\nMain >State.running\n");
                sThreadState = State.running; // 計算再開
            }
            else
            {
                if (line.Length == 0) continue;

                // q が入力されたらプログラム終了。
                if (line[0] == 'q' || line[0] == 'Q') return;

                // 因数分解を開始する。
                try { sNum = Int64.Parse(line); }
                catch (FormatException)
                {
                    Console.Write("不正な文字列が入力されました。\n"); continue;
                }
                catch (OverflowException)
                {
                    Console.Write("値が大きすぎます。\n"); continue;
                }
                Console.Write("Main >State.running\n");
                sThreadState = State.running;
                thread = new Thread(new ThreadStart(ThreadFunction));
                thread.Start();
            }
        }
    }

    static void ThreadFunction()
    {
        Console.Write("\nSub  >素因数分解開始\n");
        IList factors = Factorization(sNum);
        if (factors != null)
        {
            Console.Write("\nSub  >素因数分解終了");
            Console.Write("\nSub  >");

            foreach (long i in factors)
            {
                if (sThreadState == State.ready) break;
                if (sThreadState == State.wait) continue;
                Console.Write("{0} ", i);
            }
            Console.Write("\nSub  >Sub Thread End\n");
        }
        Console.Write("Sub  >State.ready\n\n");
        sThreadState = State.ready;
    }

    /// <summary>
    /// 素因数分解を行う。
    /// (馬鹿でかい数字を素因数分解しようとすると非常に重たい。)
    /// </summary>
    /// <param name="n">素因数分解したい数値</param>
    /// <returns>因数のリスト</returns>
    static IList Factorization(long n)
    {
        ArrayList factors = new ArrayList();

        long sqrtn = (long)Math.Ceiling(Math.Sqrt(n) + 1);
        long i = 2;

        Console.Write("Sub  >");

        while (i < sqrtn)
        {
            if (sThreadState == State.ready) break;
            if (sThreadState == State.wait) continue;

            if (n % i == 0)
            {
                factors.Add(i);
                n /= i;
                Console.Write("{0}", i);
            }
            else
            {
                ++i;
            }

            Console.Write('.'); // 途中経過を表示
        }

        if (n != 1)
            factors.Add(n);

        return factors;
    }//Factorization
}

上記を実行するとこのようになる↓

簡単に内容を解説する。まず、素因数分解したい数値を入力するとメインスレッドの最初のIF文でelseのほうに処理が入り、sThreadStateがState.runningとなる。ここでサブスレッドが新規に作成されて素因数分解を実行するプログラムが呼ばれる。

このとき、メインスレッドとサブスレッドは同時に動いており、メインスレッドは入力待ち状態、サブスレッドは計算処理を実行している。

計算途中で入力があった場合、メインスレッド内でsThreadStateがState.waitとなり、計算が中断される。

上記をまとめるとメインスレッドのスレッドプールでサブスレッドは管理され、サブスレッドは入力があった場合に計算処理を実行するスレッドになっている。メインスレッドとサブスレッドは別に動いているため、サブスレッド側が計算途中であってもメインスレッドの方で入力値を受け付けることができる。

これがC#のスレッド処理におけるスレッドプールの考え方である。

Monitorクラスで排他制御をしてみる

次に複数のスレッドが同じ変数にアクセスする場合の排他制御(ロック)を実施してみます。使用するのは「System.Threading.Monitor」クラスです。

以下のプログラムを実行すると、Monitor.Enter ~ Monitor.Exitまでがロックがかけられた状態になります。ロックがかけられている場合、他のスレッドはこの間の処理を実行することができません。

using System;
using System.Threading;
using System.Threading.Tasks;

class TestThread
{
    /// <summary>
    /// THREAD_NUM 個のスレッドを立てる。
    /// それぞれのスレッドの中で num を ROOP_NUM 回インクリメントする。
    /// </summary>
    static void Main()
    {
        const int ThreadNum = 20;
        const int LoopNum = 20;
        int num = 0; // 複数のスレッドから同時にアクセスされる。

        var syncObject = new object();

        Parallel.For(0, ThreadNum, i =>
        {
            for (int j = 0; j < LoopNum; j++)
            {
                bool lockTaken = false;
                try
                {
                    Monitor.Enter(syncObject, ref lockTaken); // ロック取得

                    //↓クリティカルセクション
                    int tmp = num;
                    Thread.Sleep(1);
                    num = tmp + 1;
                    //↑クリティカルセクション
                }
                finally
                {
                    if (lockTaken)
                        Monitor.Exit(syncObject); // ロック解放
                }
            }
        });

        Console.Write("{0} ({1})\n", num, ThreadNum * LoopNum);
        // num と THREAD_NUM * ROOP_NUM は一致するはず
    }
}

実行した結果がこちらになります。numがインクリメントされた結果と、ThreadNum(スレッド数)× LoopNum(ループの回数)の結果が同じになります。ロックがかけられていない場合、numを各スレッドのタイミングでインクリメントしてしまうので結果に誤差が出てきます。

イメージにするとこのような感じ↓

ロック文で排他制御をしてみる

上記のMonitorクラスで実施した排他制御はロック文でもっとシンプルに記述することができる。ロック文で記述するときは以下のようにする。

using System;
using System.Threading;
using System.Threading.Tasks;

class TestThread
{
    /// <summary>
    /// THREAD_NUM 個のスレッドを立てる。
    /// それぞれのスレッドの中で num を ROOP_NUM 回インクリメントする。
    /// </summary>
    static void Main()
    {
        const int ThreadNum = 20;
        const int LoopNum = 20;
        int num = 0; // 複数のスレッドから同時にアクセスされる。

        var syncObject = new object();

        Parallel.For(0, ThreadNum, i =>
        {
            for (int j = 0; j < LoopNum; j++)
            {
                lock (syncObject)
                {
                    //↓クリティカルセクション
                    int tmp = num;
                    Thread.Sleep(1);
                    num = tmp + 1;
                    //↑クリティカルセクション
                }
            }
        });

        Console.Write("{0} ({1})\n", num, ThreadNum * LoopNum);
        // num と THREAD_NUM * ROOP_NUM は一致する
    }
}

 

Taskクラスを用いる

Taskクラスを用いても今までの処理を再現することができる。私はTaskクラスを用いることが少ないので今回は省略する。

以下のサイトの「Taskクラス」の項目のところに詳しい解説が書かれているのでそちらをご参照ください。

・[非同期処理] [雑記] スレッド プールとタスク

1枚の画像を複数のスレッドで読み込んでみる

1枚の画像を複数のスレッドで読み込んでみるとどうなるだろうか?ここではそれについて検証してみる。

まずはロックをかけない状態で「src1.png」という画像ファイルを複数のスレッドで読み込んでみる。

ここでのプログラムは画像をグレースケールで読み込んで、そのまま「dst1.png」として保存するというプログラムになっている。

using System;
using System.Threading.Tasks;
using System.Threading;
using System.Drawing;
using System.Drawing.Imaging;
using System.Drawing.Design;

namespace csharp_Prallel_load_image
{
    class TaskSample
    {
        static void Main(string[] args)
        {
            const int N = 50; // スレッドの数

            try
            {
                Parallel.For(0, N, id => // こう書くだけで、並行して処理が行われる
                {
                    Console.Write("ThreadID: {0} の処理が始まりました\n", id);

                // 画像の読み込み(グレースケールに変換)
                byte[,] img = LoadImageGray("src1.png");

                // 画像保存
                SaveImage(img, "dst1.png");

                    Console.Write("ThreadID: {0} の処理が終了しました\n", id);
                });
                // 並行して動かしている処理がすべて終わるまで、自動的に待つ
            }
            catch(Exception ex)
            {
                Console.WriteLine("例外が発生しました");
                Console.WriteLine(ex.StackTrace);
                Console.ReadLine();
            }
        }


        // 画像をグレースケール変換して読み込み
        static byte[,] LoadImageGray(string filename)
        {
            //System.Drawing.Bitmap img = new System.Drawing.Bitmap(filename);
            Bitmap img = new Bitmap(filename);
            int w = img.Width;
            int h = img.Height;
            byte[,] dst = new byte[w, h];

            // bitmapクラスの画像ピクセル値を配列に挿入
            for (int i = 0; i < h; i++)
            {
                for (int j = 0; j < w; j++)
                {
                    // グレイスケールに変換
                    dst[j, i] = (byte)((img.GetPixel(j, i).R + img.GetPixel(j, i).B + img.GetPixel(j, i).G) / 3);
                }
            }
            return dst;
        }

        static void SaveImage(byte[,] src, string filename)
        {
            // 画像データの幅と高さを取得
            int w = src.GetLength(0);
            int h = src.GetLength(1);
            Bitmap img = new Bitmap(w, h);
            // ピクセル値のセット
            for (int i = 0; i < h; i++)
            {
                for (int j = 0; j < w; j++)
                {
                    img.SetPixel(j, i, Color.FromArgb(src[j, i], src[j, i], src[j, i]));
                }
            }

            // 画像の保存
            img.Save(filename);
        }
    }
}

上記のプログラムを実行してみると大体は途中で以下のようなエラーが発生する。

つまりは1枚の画像を複数スレッドで同時に読み込もうとしているのがよくないらしい。よって、これにロック文を追加することで解決してみようと思う。ロック文を追加してみたプログラムがこちら。

using System;
using System.Threading.Tasks;
using System.Threading;
using System.Drawing;
using System.Drawing.Imaging;
using System.Drawing.Design;

namespace csharp_Prallel_load_image
{
    class TaskSample
    {
        static void Main(string[] args)
        {
            const int N = 50; // スレッドの数

            try
            {
                var syncObject = new object();

                Parallel.For(0, N, id => // こう書くだけで、並行して処理が行われる
                {

                    Console.Write("ThreadID: {0} の処理が始まりました\n", id);

                    lock (syncObject)
                    {
                        // 画像の読み込み(グレースケールに変換)
                        byte[,] img = LoadImageGray("src1.png");

                        // 画像保存
                        SaveImage(img, "dst1.png");
                    }

                    Console.Write("ThreadID: {0} の処理が終了しました\n", id);
                });
                // 並行して動かしている処理がすべて終わるまで、自動的に待つ
            }
            catch(Exception ex)
            {
                Console.WriteLine("例外が発生しました");
                Console.WriteLine(ex.StackTrace);
                Console.ReadLine();
            }
        }


        // 画像をグレースケール変換して読み込み
        static byte[,] LoadImageGray(string filename)
        {
            //System.Drawing.Bitmap img = new System.Drawing.Bitmap(filename);
            Bitmap img = new Bitmap(filename);
            int w = img.Width;
            int h = img.Height;
            byte[,] dst = new byte[w, h];

            // bitmapクラスの画像ピクセル値を配列に挿入
            for (int i = 0; i < h; i++)
            {
                for (int j = 0; j < w; j++)
                {
                    // グレイスケールに変換
                    dst[j, i] = (byte)((img.GetPixel(j, i).R + img.GetPixel(j, i).B + img.GetPixel(j, i).G) / 3);
                }
            }
            return dst;
        }

        static void SaveImage(byte[,] src, string filename)
        {
            // 画像データの幅と高さを取得
            int w = src.GetLength(0);
            int h = src.GetLength(1);
            Bitmap img = new Bitmap(w, h);
            // ピクセル値のセット
            for (int i = 0; i < h; i++)
            {
                for (int j = 0; j < w; j++)
                {
                    img.SetPixel(j, i, Color.FromArgb(src[j, i], src[j, i], src[j, i]));
                }
            }

            // 画像の保存
            img.Save(filename);
        }
    }
}

ロック文を追加して実行すると以下のように最後までエラーを発生させずにプログラムを終了することができる。つまりは複数のスレッドが画像の読み込みを同時に行わなくなるのでエラーが発生しなくなる。

複数のスレッドで画像処理を行いたいときなどはこのようなこともやりたくなったりするので、そういう場合は同じファイルを同時に読み込まないようにロック文を追加するとよさそうだ。

 

以上までの項目がC#でスレッド処理を実行するときに基本となる考え方である。

雑記カテゴリの最新記事