ひきぷろのプログラミング日記

プログラミングの日記です。

(C#) なぜ UI スレッドは1本だけなのか

2 つ前の記事で、マルチスレッド対応の int 型を作りました。

hikipuro.hatenadiary.jp

これを、例えばマルチスレッド対応の UI パーツの作成例として応用できないかと実験してみました。結果から言うと、マルチスレッド対応の UI パーツの作成はできそうなものの、記述が煩雑になるので現実的には作ることが困難 という結論に今のところ至りました。

色んな事象の組み合わせでそういう風に思ってしまったんですが、まずは、(書いている内容の読み取りが難しいと思いますが) 箇条書きで書くと、

  • マルチスレッド対応の int 型変数を、より大きなクラスでラップすると、ロックを維持したまま値の加工 (四則演算) を行うことが困難になる
  • スレッドの割り込みで分割される命令が細かすぎる
  • それらの問題を回避しようとすると、フレームワーク内部ではなく、 UI パーツを使う箇所でスレッドのロックを使用したプログラムが必要になる

というような問題があります。

より大きなクラスでラップするとは、具体的に言うと次のようなコードになります。

using System;

class ProgressBar {
	// 値が変更された時に発生するイベント
	public event Action Changed;

	// プログレスバーの値
	private MtInt _value;

	// コンストラクタ
	public ProgressBar() {
		_value = new MtInt();
	}

	// Value プロパティ
	public int Value {
		get { return _value; }
		set {
			// 値をセットする
			_value.Set(value);

			// 変更があったことを通知する
			if (Changed != null) {
				Changed();
			}
		}
	}
}

上のコードは、作りかけのプログレスバーのコードです。
_value 変数でマルチスレッド対応の int 型を使っています。
一見、何も問題なさそうに見えますが、簡単に問題を引き起こしてしまいます。

この ProgressBar クラスを使って、問題を発生させるコードを下に示します。

using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;

class ProgressBarTest {
	// プログレスバー
	private ProgressBar _progressBar;

	// コンストラクタ
	public ProgressBarTest() {
		_progressBar = new ProgressBar();
	}

	// テスト用のメソッド
	public void Test() {
		// スレッド 10 個を作成して、すぐに実行する
		List<Thread> threads = new List<Thread>();
		for (int i = 0; i < 10; i++) {
			Thread thread = new Thread(new ThreadStart(_ThreadMethod));
			thread.Start();
			threads.Add(thread);
		}

		// スレッド 10 個の終了を待つ
		foreach (Thread thread in threads) {
			thread.Join();
		}

		// Value 変数の内容を表示する
		Console.WriteLine("value: {0}", _progressBar.Value);
	}

	// 各スレッドで実行するコード
	private void _ThreadMethod() {
		for (int i = 0; i < 10000; i++) {
			_progressBar.Value++;
		}
	}
}

ProgressBarTest クラスをインスタンス化して Test() メソッドを実行すると、コンソールには次のように表示されます。

value: 82059
(毎回、表示される値は異なります)

2 個前の記事で紹介した、 MtInt クラス内に書いた lock キーワードが期待通りに動いていて、ラップに問題がなければ、ここでは 100000 と表示される予定でした。
上のコードで、問題がある箇所は次の部分です。

_progressBar.Value++;

ただのインクリメント命令ですね。この部分に問題があります。
インクリメント命令は次のように分解できます。

  • 現在の値を取り出す
  • 現在の値に +1 した新しい値を作る
  • 新しい値を変数に戻す

おそらく、 3 つの処理の隙間で、それぞれ別スレッドの割り込みが発生する余地が生まれます。 MtInt クラスを直に使う時には、この問題は発生しませんでした。ではなぜ大きなクラスにラップすると問題が発生したんでしょうか。
今のところの考えでは、次のように発想できると思っています。

  • MtInt クラスでは、使用時に operator ++ を直接実行していて、かつ、 lock をしていたので問題は発生しなかった (++ の操作がアトミックになり、割り込みの余地がなくなっていた)
  • ProgressBar で MtInt _value をラップする時、 Value アクセサの定義で問題が発生した
  • インクリメント命令の実行時に、 Value アクセサの get メソッドがまず呼び出され、次に set メソッドが呼び出されるようなコードに分解されたため、割り込みの余地が発生した

というように考えると、せっかく内部でロックを施して実装したものが、1 枚ラップ層を加えるだけで、はがれてしまったと言えると思います。この問題を回避するためには、やはり一番外側で再度ロックの仕組みを施す必要が出てきます。
例えば、 ProgressBarTest クラスで問題を回避した実装は次のようになります。

	// 各スレッドで実行するコード
	[MethodImpl(MethodImplOptions.Synchronized)]
	private void _ThreadMethod() {
		for (int i = 0; i < 10000; i++) {
			_progressBar.Value++;
		}
	}

(Synchronized 属性については、 1 つ前の記事を見てみてください。)hikipuro.hatenadiary.jp


これでは、もはや MtInt クラスを使うまでもなくなってしまっていて、一番アプリケーションコードに近い部分でロックを書いただけのプログラムになってしまいました。
ロック機構を内部に隠すという発想は、破たんしてしまったようです。

一番最初に挙げた 3 つの事象のうち、

  • スレッドの割り込みで分割される命令が細かすぎる

ということが、ここでの問題の本質的な部分になると考えています。
この問題を回避する方法は、簡単に思いつく範囲では 2 つあると思います。

  • 他のスレッドから値を操作させない (一般的な UI スレッド 1 つの実装)
  • VM の実装を変更する (ラップ後の operator ++ の実行をアトミックなものとして扱い、スレッドの割り込み余地をなくす)

さて、どちらが簡単でしょうか。。
やはり UI スレッド 1 つとして考える方が、簡単に回避できると思います。

しかしこれは、 UI に限った問題なんでしょうか。
おそらく、想像ですが、マルチスレッドプログラミング全般の複雑さの問題そのものではないでしょうか。

VM はリアルなマシンに足りない機能を加えるという意味でも有用ですので、アトミックな操作をより洗練させた VM 実装が出てくることを期待しています。
VM が変更されると土台が崩れてしまうし、今の .NET フレームワークの進化の先にはないのかもしれません。

マルチスレッドを扱うプログラムは、 Apache のように、お互いのスレッドが全く別の対象を扱う というくらいに分離されていないと、そもそも作るのが難しいのかもしれませんね。。