C#

【C#】非同期処理(1) – Threadクラス –

非同期処理

プログラムやコードが動くことを処理をする、といいますが、処理にはいくつかの分類があるので整理したいと思います。

逐次処理, 並行処理, 並列処理

逐次処理(Serial Processing)は、処理(コード)を上の行から順に実行していく処理です。何もしなければ普通は逐次処理になります。

これに対して並行処理(Concurrent Processing)とは複数の処理を切り替えながら実行していきます。一見すると、同時に複数の処理が動いているように見えますが、実際には切替ながら動いているだけなので、ある瞬間に動いている処理はひとつです。

逐次処理と並列処理は処理資源はひとつなので、シングルスレッドと呼ばれる処理です。

並列処理(Parallel Processing)とは、複数のコードが同時に動いています。このため、複数の処理資源が必要です。マルチスレッドと呼ばれます。

同期, 非同期

では同期と非同期の違いは何かというと、同期はある処理(コード)を行っているときには他の処理は行われません。つまり逐次処理や並行処理と同じ意味になります。非同期は並列処理は同じ意味で使われます。

同期はシングルスレッド、非同期はマルチスレッドです。

C#での非同期処理

C#で非同期処理を行う方法は、Threadクラス, Taskクラス, delegateを使う方法、async,awaitを使う方法などいくつかあります。この記事では、古くからあるThreadクラスを使う方法を解説していきます。

Threadクラス

ここではThreadクラスを使った非同期処理について解説します。今や使われないかもしれません。

ThreadクラスはSystem.Threading名前空間のクラスなので、下記のようusingディレクティブが必要です。

using System.Threading;

Threadクラスの基本

下記はMethodAとMethodBが定義されていて、Mainメソッド内でMethodAを非同期処理で実行した後にMethodBを実行しています。

コード

using System;
using System.Threading;

internal class Program
{
    static void Main(string[] args)
    {
        ThreadStart thrd_st = new ThreadStart(MethodA);
        Thread thrd = new Thread(thrd_st);
        thrd.Start(); //MethodAを非同期で呼び出し

        MethodB();
    }

    static void MethodA()
    {
        Console.WriteLine("メソッドA開始");
        for(int i = 0; i < 3; i++)
        {
            Console.WriteLine($"メソッドA:{i}回目");
            Thread.Sleep(i);
        }
        Console.WriteLine("メソッドA終了");
    }

    static void MethodB()
    {
        Console.WriteLine("メソッドB開始");
        for (int i = 0; i < 3; i++)
        {
            Console.WriteLine($"メソッドB:{i}回目");
            Thread.Sleep(3 - i);
        }
        Console.WriteLine("メソッドB終了");
    }
}

出力結果

メソッドA:開始
メソッドB:開始
メソッドB:0回目
メソッドA:0回目
メソッドA:1回目
メソッドA:2回目
メソッドB:1回目
メソッドA:終了
メソッドB:2回目
メソッドB:終了

上記コードの8~10行目がMethodAを非同期で開始するまでの手続きです。上記コードの実行結果を見ると、メソッドAとメソッドBの出力結果が入り乱れて表示されているのがわかります。

理解するために後ろから辿っていきましょう。10行目でthrd.Start()により非同期処理を開始しています。

9行目ではThreadクラスのオブジェクトthrdを宣言・初期化しています。Threadクラスのコンストラクタの引数には、ThreadStartクラスのオブジェクトが必要です。

ThreadStartクラスオブジェクトは8行目で宣言・初期化しています。ThreadStartクラスは引数・戻り値なしのデリゲート型です。したがってThreadStartデリゲートのコンストラクタには、引数・戻り値なしのメソッドしか指定できません。MethodAは引数・戻り値がないのでThreadStartデリゲート型のコンストラクタに指定できます。

上記コードの8~9行目を下記のように1行にまとめて書くことも可能です。ラムダ式を用いることもできます。

        //1行にまとめる方法
        Thread thrd = new Thread(new ThreadStart(MethodA));
        //ラムダ式を使う方法
        Thread thrd = new Thread(()=>MethodA());

さらに言ってしまうと、下記の1行目ようにThreadコンストラクタの引数に直接MethodAを書くこともできます。じゃあ今までの説明は何なのか?と思うかもしれませんが、これは下記2行目のようにThreadStartクラスへ暗黙的にキャストされていると理解してください。

        Thread thrd = new Thread(MethodA);
        Thread thrd = new Thread((ThreadStart)MethodA);

Threadクラスでメソッドの結果を受け取る

ThreadStartデリゲートは引数・戻り値を持ちません。では、引数や戻り値を持つメソッドをThreadクラスで呼び出すことはできないのでしょうか。無理すれば書けないこともないです。

コード

using System;
using System.Threading;

internal class Program
{
    static void Main(string[] args)
    {
        int val = 2;
        int result = 0;
        Thread thrd = new Thread(() =>
        {
            result = MethodA(val);
        });
        thrd.Start(); //MethodAを非同期で呼び出し

        MethodB();

        Console.WriteLine("MethodAの結果:{0}", result);
    }

    static int MethodA(int x)
    {
        Console.WriteLine("メソッドA開始");
        for (int i = 0; i < 10; i++)
        {
            Console.WriteLine($"メソッドA:{i}回目");
            Thread.Sleep(i);
        }
        Console.WriteLine("メソッドA終了");
        return x * x;
    }

    static void MethodB()
    {
        Console.WriteLine("メソッドB開始");
        for (int i = 0; i < 3; i++)
        {
            Console.WriteLine($"メソッドB:{i}回目");
            Thread.Sleep(3 - i);
        }
        Console.WriteLine("メソッドB終了");
    }
}

出力結果

メソッドA開始
メソッドB開始
メソッドB:0回目
メソッドA:0回目
メソッドA:1回目
メソッドA:2回目
メソッドA終了
メソッドB:1回目
メソッドB:2回目
メソッドB終了
MethodAの結果:4

上記コードでは、これまでと違いMethodAはint型の引数を受け取り、その値を2乗して返すメソッドになっています。すなわちMetohdAは引数と戻り値を持つメソッドです。このままではThreadStartデリゲートに代入できないんので、Threadクラスのオブジェクトthrdは、10~13行目のようにラムダ式を使ってローカル変数のvalとresultをキャプチャして初期化しています。ラムダ式自体は引数・戻り値のないメソッドですが、その中でローカル変数の値を参照していることで、疑似的に引数と戻り値のあるメソッドを非同期で実行しています。

Threadクラスのメソッド

Threadクラスには多数のメソッドが用意されていますが、ここではいくつか紹介します。
ここで紹介するメソッドには.NET6.0などのバージョンでは使用できないものもあります。その場合には、ターゲットフレームワークを.NET Framework4.8にしてみてください。

JoinメソッドでThreadが終了するまで待機する

実は「Threadクラスでメソッドの結果を受け取る」で示したコードには問題があります。

それは、非同期で実行するMethodAと、その後に実行するMethodBはどちらが先に終わるかわからない点です。MethodBが先に終わってしまうと、MethodAの完了を待たないまま、19行目の結果出力されてしまい、結果はresultの初期値の0が表示されてしまいます。したがって、19行目の結果表示の前に確実にMethodAが終了していなくてはなりません。そこで利用するのがJoin()メソッドです。Joinメソッドを利用することで、Threadが終了するまで待機することができます。

コード

using System;
using System.Threading;

internal class Program
{
    static void Main(string[] args)
    {
        int a = 2;
        int result = 0;
        Thread thrd = new Thread(() =>
        {
            result = MethodA(a);
        });
        thrd.Start(); //MethodAを非同期で呼び出し

        MethodB();

        thrd.Join();
        Console.WriteLine("MethodAの結果:{0}", result);
    }

    static int MethodA(int x)
    {
        Console.WriteLine("メソッドA開始");
        for (int i = 0; i < 5; i++)
        {
            Console.WriteLine($"メソッドA:{i}回目");
            Thread.Sleep(i);
        }
        Console.WriteLine("メソッドA終了");
        return x * x;
    }

    static void MethodB()
    {
        Console.WriteLine("メソッドB開始");
        for (int i = 0; i < 3; i++)
        {
            Console.WriteLine($"メソッドB:{i}回目");
            Thread.Sleep(3 - i);
        }
        Console.WriteLine("メソッドB終了");
    }
}

出力結果

メソッドA開始
メソッドB開始
メソッドB:0回目
メソッドA:0回目
メソッドA:1回目
メソッドB:1回目
メソッドA:2回目
メソッドB:2回目
メソッドA:3回目
メソッドA:4回目
メソッドB終了
メソッドA終了
MethodAの結果:4

上記の例では、MethodBのほうが先に終了しますが、18行目のJoinメソッドがあることでMethodAが終了するまで待機します。

Abortメソッドで処理を終了する

Abortメソッドは非同期処理を終了します。下記の例ではMethodBが終了した直後にAbortメソッドでthrd(非同期で呼び出されたMethodA)を終了しています。

コード

using System;
using System.Threading;

internal class Program
{
    static void Main(string[] args)
    {
        Thread thrd = new Thread(MethodA);
        thrd.Start(); //MethodAを非同期で呼び出し

        MethodB();
        
        thrd.Abort();
    }

    static void MethodA()
    {
        Console.WriteLine("メソッドA開始");
        for (int i = 0; i < 10; i++)
        {
            Console.WriteLine($"メソッドA:{i}回目");
            Thread.Sleep(i);
        }
        Console.WriteLine("メソッドA終了");
    }

    static void MethodB()
    {
        Console.WriteLine("メソッドB開始");
        for (int i = 0; i < 3; i++)
        {
            Console.WriteLine($"メソッドB:{i}回目");
            Thread.Sleep(3 - i);
        }
        Console.WriteLine("メソッドB終了");
    }
}

出力結果

メソッドB開始
メソッドB:0回目
メソッドA開始
メソッドA:0回目
メソッドA:1回目
メソッドB:1回目
メソッドA:2回目
メソッドB:2回目
メソッドA:3回目
メソッドA:4回目
メソッドB終了

Suspendメソッドで処理を中断し、Resumeメソッドで再開する

SuspendメソッドでThreadを中断し、Resumeメソッドで再開することができます。下記の例ではthrd(非同期処理のMethodA)を一時中断してMethodCを実行した後に再開しています。

コード

using System;
using System.Threading;

internal class Program
{
    static void Main(string[] args)
    {
        Thread thrdA = new Thread(MethodA);
        thrdA.Start(); //MethodAを非同期で呼び出し

        MethodB();

        thrdA.Suspend();
        MethodC();
        thrdA.Resume();
    }

    static void MethodA()
    {
        Console.WriteLine("メソッドA開始");
        for (int i = 0; i < 10; i++)
        {
            Console.WriteLine($"メソッドA:{i}回目");
            Thread.Sleep(i);
        }
        Console.WriteLine("メソッドA終了");
    }

    static void MethodB()
    {
        Console.WriteLine("メソッドB開始");
        for (int i = 0; i < 3; i++)
        {
            Console.WriteLine($"メソッドB:{i}回目");
            Thread.Sleep(3-i);
        }
        Console.WriteLine("メソッドB終了");
    }

    static void MethodC()
    {
        Console.WriteLine("メソッドC開始");
        for (int i = 0; i < 3; i++)
        {
            Console.WriteLine($"メソッドC:{i}回目");
            Thread.Sleep(3 - i);
        }
        Console.WriteLine("メソッドC終了");
    }
}

出力結果

メソッドB開始
メソッドA開始
メソッドA:0回目
メソッドB:0回目
メソッドA:1回目
メソッドA:2回目
メソッドB:1回目
メソッドA:3回目
メソッドB:2回目
メソッドA:4回目
メソッドB終了
メソッドA:5回目
メソッドC開始
メソッドC:0回目
メソッドC:1回目
メソッドC:2回目
メソッドC終了
メソッドA:6回目
メソッドA:7回目
メソッドA:8回目
メソッドA:9回目
メソッドA終了

lockによるオブジェクトの相互排他制御

複数のスレッド間で同じオブジェクトを使用するときには、lock文を使用します。lock文を使用することで複数のスレッドから同時に同じ変数にアクセスするのを防ぐことができます。構文は以下の通りです。

 lock (ロックするオブジェクト)
{
    ロック状態で行う処理
}

ここでロックするオブジェクトは参照型のオブジェクトでなければなりません。なおロックするオブジェクトは、複数のスレッドからアクセスするオジブジェクトそのものでも構いませんし、ロック専用のオブジェクトを作る場合もあります。

以下にstring型のオブジェクトをロックする例を示します。

コード

using System;
using System.Threading;

internal class Program
{
    static string lockstr = "";

    static void Main(string[] args)
    {
        Thread thrdA = new Thread(MethodA);
        Thread thrdB = new Thread(MethodB);
        thrdA.Start();
        thrdB.Start();

        thrdA.Join();
        thrdB.Join();

    }

    static void MethodA()
    {
        Console.WriteLine("メソッドA開始");

        for (int i = 0; i < 3; i++)
        {
            lock (lockstr)
            {
                Thread.Sleep(10);
                lockstr+="a";
            }
        }
        Console.WriteLine("メソッドA終了");
    }

    static void MethodB()
    {
        Console.WriteLine("メソッドB開始");
        for (int i = 0; i < 3; i++)
        {
            lock (lockstr)
            {
                Thread.Sleep(10);
                lockstr += "b";
            }
        }
        Console.WriteLine("メソッドB終了");
    }
}

出力結果

メソッドA開始
メソッドB開始
lockstr:a
lockstr:ab
lockstr:ab
lockstr:aba
メソッドA終了
lockstr:abab
lockstr:ababb
メソッドB終了

上記の例では、2つのスレッドthrdA,thrdBでlockstrにアクセスするために、lockstrをロックしています。

次にロック専用のオブジェクトを作る場合を示します。

コード

using System;
using System.Threading;

internal class Program
{
    static object lockobj= new object();
    static int val = 0;

    static void Main(string[] args)
    {
        Thread thrdA = new Thread(MethodA);
        Thread thrdB = new Thread(MethodB);
        thrdA.Start();
        thrdB.Start();

        thrdA.Join();
        thrdB.Join();

        Console.WriteLine("val={0}", val);
    }

    static void MethodA()
    {
        Console.WriteLine("メソッドA開始");

        for (int i = 0; i < 100000; i++)
        {
            lock (lockobj)
            {
                val++;
            }
        }
        Console.WriteLine("メソッドA終了");
    }

    static void MethodB()
    {
        Console.WriteLine("メソッドB開始");
        for (int i = 0; i < 100000; i++)
        {
            lock (lockobj)
            {
                val--;
            }
        }
        Console.WriteLine("メソッドB終了");
    }
}

出力結果

メソッドB開始
メソッドA開始
メソッドB終了
メソッドA終了
val=0

値型の変数はlock文では使用できませんが、上記コードのようにロック専用のオブジェクトを用意することで実質的にロックすることができます。上記の例ではMethodAでvalに10万回インクリメントしMethodBでvalに10万回デクリメントしているので、正常に排他制御されていれば最終的にはval=0となります。lock文をコメントアウトして実行すると排他制御されないためval=0になりません。

スレッド間で排他制御による競合が起きて、アプリケーションが止まってしまう状態をデッドロックと言います。lock文を使用するときはデッドロックを起こさないように注意しましょう。

終わりに

次はTaskについて書きます。

ABOUT ME
しかすけ
日々是好日。何とか何とか生きてます。

COMMENT

メールアドレスが公開されることはありません。 が付いている欄は必須項目です