川獺の外部記憶

なんでも残しておく闇鍋みたいな備忘録

非同期処理考察2 - ReactでAPIを叩くまでの道

前回は比較的OSネイティブな書き方でマルチスレッドプログラミングをやってみました。

kawauso-lab.hatenablog.jp

今回は引き続き、ネイティブな書き方でのマルチスレッド実装における排他制御を書いてみます。

2020/12/06 追記
本記事で記載している「スレッド間のメモリ共有を濫用した排他制御」の対処方法としての IsAlive() 使用、本質的に複数スレッド間の排他制御という観点から見ると非常に微妙です。
今回の場合は C#lock(){} 構文でセマフォを取得して isProcessingHeavyTask を読み書きするほうがベターに感じます。
非常に偉そうに微妙なことを書いてしまった気がします…と言いつつ IsAlive() のほうが良いのかもしれない…わからんです。

今回やること

  1. OSレベルのスレッド操作(C言語のpthread)
  2. C言語の思想を受けたスレッド操作(C#のThread等)←この続きです
  3. スレッド操作を隠蔽した非同期処理(C#のasync/awaitキーワード等)
  4. スレッド操作を隠蔽し、内部的にもスレッドを使わない非同期処理(js、tsのasync/await、promise)

はじめに - 前回の続き

簡単のため、今回は時間のかかるタスクを以下のように1つだけに減らしておきます。

private void heavyTask()
{
    // 10秒かかる処理の例
    System.Console.WriteLine("Start Heavy Task...");
    Thread.Sleep(10000);
    System.Console.WriteLine("End Heavy Task...");
}
private void button1_Click(object sender, EventArgs e)
{
    System.Console.WriteLine("Normal Task1");
    // 10秒かかる処理を開始する ←完了を待たない点に注意!
    Thread workerThread = new Thread(new ThreadStart(heavyTask));
    workerThread.Start();
    System.Console.WriteLine("Normal Task3");
}
private void button2_Click(object sender, EventArgs e)
{
    System.Console.WriteLine("Normal Task2");
}

スレッド間のメモリ共有を濫用した排他制御

今回はheavyTask()関数が同時に一つしか走らないよう制御することをゴールにします。

この制御はマルチスレッド実装の中で最初に当たる壁であり、半端に制御すると不具合まみれになる危険な要素の一つです。

最初に、一番最初に思い浮かんだ以下の実装を試してみます。

gitlab.com

public partial class Form1 : Form
{
    // 「heavyTask実行中」のフラグ
    private bool isProcessingHeavyTask;
    public Form1()
    {
        InitializeComponent();
        // Formが作られたときに「heavyTask実行中」のフラグをfalseにしておく
        isProcessingHeavyTask = false;
    }
    private void heavyTask()
    {
        // 10秒かかる処理の例
        System.Console.WriteLine("Start Heavy Task...");
        Thread.Sleep(10000);
        System.Console.WriteLine("End Heavy Task...");
        // タスクが完了したときに「heavyTask実行中」のフラグをfalseに戻す。
        this.isProcessingHeavyTask = false;
    }
    private void button1_Click(object sender, EventArgs e)
    {
        System.Console.WriteLine("Normal Task1");
        // ボタンがクリックされたときに「heavyTask実行中」のフラグを確認して、trueならばタスクをスタートしない。
        if (!this.isProcessingHeavyTask)
        {
            this.isProcessingHeavyTask = true;
            // 10秒かかる処理を開始する ←完了を待たない点に注意!
            Thread workerThread = new Thread(new ThreadStart(heavyTask));
            workerThread.Start();
        }
        else
        {
            System.Console.WriteLine("Heavy Task is already running. Nothing to do..");
        }
        System.Console.WriteLine("Normal Task3");
    }
    private void button2_Click(object sender, EventArgs e)
    {
        System.Console.WriteLine("Normal Task2");
    }
}

この実装のアーキテクチャを下図に示します。

一見ちゃんとうまく動きますし、考えが及ぶ範囲では不具合も仕込んでいませんが、このアーキテクチャは一般に非常によろしくないと言われる「スレッド間のメモリ共有」という禁忌に触れています。 *1

スレッド間のメモリ共有の何が不味いのか

シングルスレッドのプログラムを書いている限り、「変数に代入した値はすぐに反映されること」や「変数に代入した値を次の行で参照したら、値が変わっていないこと」はある程度保証されます。一方で、マルチスレッドでスレッド間でメモリを共有すると、これらが保証できなくなる場合があります。

ではどうするべきなのか

この記事の動機と言ってることが真逆ですが、ライブラリに頼りましょう。例えば、C#ThreadオブジェクトはisAliveプロパティを持っています。

以下のようにボタンが押下されたときにメインスレッド内でこのisAliveプロパティを確認してやれば、ライブラリが保証してくれる「スレッドが生きてるか死んでるかの値」を得ることができます。

gitlab.com

public partial class Form1 : Form
{
    // ワーカースレッドを保持しておく
    Thread workerThread = null;
    public Form1()
    {
        InitializeComponent();
    }
    private void heavyTask()
    {
        // 10秒かかる処理の例
        System.Console.WriteLine("Start Heavy Task...");
        Thread.Sleep(10000);
        System.Console.WriteLine("End Heavy Task...");
    }
    private void button1_Click(object sender, EventArgs e)
    {
        System.Console.WriteLine("Normal Task1");
        // workerThreadオブジェクト生きてたら実行しない。
        if (this.workerThread != null && this.workerThread.IsAlive)
        {
            System.Console.WriteLine("Heavy Task is already running. Nothing to do..");
        }
        else
        {
            // 10秒かかる処理を開始する ←完了を待たない点に注意!
            this.workerThread = new Thread(new ThreadStart(heavyTask));
            workerThread.Start();
        }
        System.Console.WriteLine("Normal Task3");
    }
    private void button2_Click(object sender, EventArgs e)
    {
        System.Console.WriteLine("Normal Task2");
    }
}

どうしてもメモリ共有したいなら

どうしてもライブラリが提供している機能だけでは物足りず、メモリ共有したくなることがあると思います。
この場合、以下のような注意をすることでまだ安全な設計ができるかもしれません。

  • 共有している変数に書き込むスレッドは1つだけにする
  • そもそも共有されるタイミング以降で変数に書き込まない
  • 書き込みと読み出しのタイミングが一致しないように仕様でブロックする

ただし、どれだけ頑張ってもリスクはゼロにはならないことも意識しておく必要があります。

ちなみに私はデータベースシステムをタスク間データ受け渡しに使う方針をよく使いますが、これは一般にデータベースシステムは複数のタスクから同時にアクセスされることを想定した設計になっているという点に頼ってリスク管理を丸投げする設計思想です。メモリ共有と比べて大幅にレイテンシが高くなりますので許容できるシステムでしか使えませんが、かなり安全寄りの設計にできると思っています。 *2

今回の要点

繰り返しになりますが、安全なコードを書くのであればスレッド間のデータ共有はなるべく自分で実装せずにライブラリなどのツールの力を借りるべきです。

一般に使われているツールはマルチスレッドにおけるリスクマネジメントを知り尽くしたプロによる実装です。一介の素人にすぎない我々が、様々な知見をもとにデータ共有ライブラリを実装してきたプロより良い手法を編み出せると考えるのはナンセンスです。機能仕様的に実現できたところで、それは見えないリスクを抱えた危険なコードになる可能性が非常に高いです。

また、ライブラリなどのツールを使用する際にはそのツールの限界を知ることも忘れないようにしないといけないです。世の中ではツールの限界を突破するための裏をかくようなヤバいコードが結構出回っていますが、裏をかいたらそこから先のリスク管理は自分で責任を持って実装する必要があります。(ここは先輩の受け売りです。)

次やること

この記事を書きながら、最初に言っていた「1や2の思想」(ローレベルAPIを使ってマルチスレッドを実装すべき)が逆に危険なんじゃないかという気持ちになってきました。
多分3の思想を取り入れるためにこの「危険なんじゃないか」まで理解してることが必要…なんだと思います。

以上を踏まえて、次やることは「3の思想」(C#のハイレベルAPIを使ってマルチタスクを実装する)とします。
実装内容は「安全なスレッド間のデータ受け渡し」です。

  1. OSレベルのスレッド操作(C言語のpthread)
  2. C言語の思想を受けたスレッド操作(C#のThread等)
  3. スレッド操作を隠蔽した非同期処理(C#のasync/awaitキーワード等)←これにいきます
  4. スレッド操作を隠蔽し、内部的にもスレッドを使わない非同期処理(js、tsのasync/await、promise)

*1:今回は確実に安全であることは証明できますが、もっと複雑になってくると証明が困難になっていくので「禁忌」と表現しています。「そのソースは安全だよ」というツッコミをしていただいたとすればそれは正解です。

*2:データベースシステムの同時アクセスに対するロバスト性はネットワークシステムにおけるアプリケーションサーバの分散化などの思想の基礎になっているものです。データベースの完全性として理論的に確立されています。