Delphi
DelphiDay 4

引数を渡せる匿名スレッド【改訂版2】

 ※このエントリーは諸事情により初版よりかなり改訂されました!(最終更新 12/4 17:50ごろ)

 昨年に続いて2回目のカレンダー参加になります。長門みらいと申します。まだFMXでADV作ってます。

 それはさておき!

匿名スレッドと無名メソッドが便利

TThread.CreateAnonymousThread + 無名メソッド便利ですよね。

Benry.pas
TThread.CreateAnonymousThread(
 procedure()
 begin
    // ここで何か
 end).Start;

 こんな感じでお手軽にスレッドを作って実行できるのは皆様知っての通りです。
これらの機構が使えないときは、それこそ古典的にTThreadを親に新しいクラスを定義してぇ~…… Executeオーバーライドしてぇ~……とやっていたわけで、たいへん便利に使えるようになりました!

どえらい人によるどえらい補足 (12/4 夕方ごろ追記)

 愛知県の某市内で用事をこなしている間、このエントリーについて、なんとあのシリアルゲームズのどらえい人こと、細川氏よりどえらいタレコミをいただきました。

 実は無名メソッドから参照できる呼び出し元の変数は変数バインディングなる機構により、基本的には寿命が延長されて、わざわざ参照渡ししなくとも大丈夫とのことなのです。

 詳細と実証サンプルコードをコメント欄に投稿いただいていますので、ぜひともご覧になってください。(※情報および一連の補足をいただきましたこと、この場を借りて御礼申し上げます。)

 しかし、これは由々しき事態! このままAdvent Calender 4日目を訂正しないで終えるのは気がひけます。ぬぬぬ……!! でも、本当の怖さは「参照渡しで変数が破棄される」ことではないのです。ええ……どういうこと?

 ということで、以下、実際には変数バインディングによりほぼほぼ安全ということになっています、ここからしばらくのあいだ、特に次のセクション「罠」は「変数バインディングは無効になっているもの」としてお読みください。これ、後々説明しやすくするための前座なんです。なんというご都合主義。ハウツー本ならこれ大返本祭り案件ですよ!

 そんなジョークはともかく、初版の文章をだだくさに残しつつ(名古屋弁)、「本当の罠」までお読みいただければ幸いです。

でも、ちょっと罠にハマるのが、呼び出し元スレッドから匿名スレッドにパラメーターを渡したいとき。

Warning.pas
var
 Str : String;
begin
 Str := '神野甘音たんはかわいいなあ!!';
 TThread.CreateAnonymousThread(
   procedure()
   begin
       Sleep(1000);
       OutputDebugString(PWideChar(Str));
   end).Start;

 例えば上記のコード。動作としては、サブスレッドを作って1秒経ったあと、IDEのデバッグ出力欄に「神野甘音たんはかわいいなあ!!」が出てくることを期待します。コンパイルは通りますが、なぜか何か変な文字列が出たり、AccessViolationが出るかもしれません。

 だって、スレッドが変数Strを参照した時点で、呼び出し元スレッドのStrの中身が解放されているかもしれない1んですから!2

 サブスレッド側で1秒。それは呼び出し側スレッドで作ったデータが破棄されるには十分すぎる時間です。3

 タイムラグがあってもなくても、上記のような形で値の受け渡しが成功することはあります。しかしそれは、呼び出し元スレッドの手続き/関数がまだ終わっていなかったり、変数バインディング・変数の参照カウント的に破棄されていなかったり、解放したメモリ領域にまだ中身が残っていたり、そういう状況で上手くいくことはあります。しかし、コーディング次第では毎回上手くいくとは限らず、発見しにくいバグの原因になったりします。2

Warning2.pas
procedure Warning2Test(Str:PWideChar);
begin
 TThread.CreateAnonymousThread(
   procedure()
   begin
       OutputDebugString(Str);
   end).Start;
end;

 上記例のStringをPWideCharで代替したものですが、個人的には避けたい書き方です。Strという引数・変数自体は生きていたとしても、PWideCharには参照カウントが無く、いまいち不安が募ります。

 しかもダミー待ち時間(Sleep)をカットしてしまったので、サブスレッドが始まるか、呼び出し元スレッドの手続きが終わるか、どちらが先に来るのかも想像しにくく、スレッド内の処理も含めて、やはりバグの原因になりそうです。

パラメーターを値渡しすれば良いのでは?

 実質の参照で渡すから危ないのであって、サブスレッド開始時に中身そのものの写しを渡せば(値渡し)、おおむね、問題は解決です。

 値渡しをDelphi標準の匿名スレッドで実現しようとすると、例えば、グローバルなTDictionaryやTStringListを使い、匿名スレッドのIDをキーにして渡したい変数を登録・呼び出すことでも実現できそうです。しかし、いまいちスマートではありませんし、TDictionaryはおそらくスレッドセーフではなく(読み出しは大丈夫との噂も)、そうなると保険でCriticalSectionも入れなきゃ……とだんだん大げさになっていきます。

 でも、それらをを解決するシンプルな手段があります。難しいことはありません。結論から行きましょう。

クラス宣言部

ThreadUser.pas
type
   TAnonymousThreadUser = class(TThread)
   private
       FProc: TProc<TArray<Variant>>;
       FArgs: TArray<Variant>;
   public
       constructor Create(const AProc: TProc<TArray<Variant>>; Arg:Array of Variant);
       procedure Execute; override;
   end;

純正の匿名スレッドを真似て、それらしいクラスを作ります。

コード記述部

ThreadUser.pas
constructor TAnonymousThreadUser.Create(const AProc: TProc<TArray<Variant>>; Arg:Array of Variant);
var
  i : Integer;
begin
  inherited Create(True);
  FreeOnTerminate := True;
  FProc := AProc;

  SetLength(FArgs,Length(Arg));
  for i := 0 to High(Arg) do FArgs[i] := Arg[i];
end;

procedure TAnonymousThreadUser.Execute;
begin
  FProc(FArgs); // 任意の手続きをサブスレッドで開始!
  SetLength(FArgs,0); // 一応要素数0にしておく
end;

 スレッド開始前に Array of Variant で任意の数の任意の引数を引き受け、内部のVariant配列に記録します。配列はサブスレッドに入ったら、手続きに受け渡します。

 補足:手続きの宣言(TProc~)が分かりにくいですが、要するに下記B行と同じ意味で、C行の類似品です。

// A = B ≒ C
TProc<TArray<Variant>>; // A
procedure (Param:TArray<Variant>) // B
procedure (Param:Array of Variant) // C

使用例

Test.pas
var
 s : String;
 i : Integer;
 b : Boolean;
begin
 s := '神野甘音たんはかわいいなあ!!';
 i := 10;
 b := True;

 TAnonymousThreadUser.Create(procedure(Param:TArray<Variant>)
 var
  s_copy : String;
  i_copy : Integer;
  b_copy : Boolean;
 begin

    // ↓ スレッド側では、値渡ししたかったものがVariantの配列に入ってる
    s_copy := Param[0];
    i_copy := Param[1];
    b_copy := Param[2];

    // ここで色々
    OutputDebugString(PWideChar(s_copy + i_copy.toString() + b_copy.toString()));

 // ↓ ここに値渡ししたいパラメーターを指定
 end,[s,i,b]).start;

 Variantを使っているので、StringでもIntegerでも大抵なんでも渡せます。Format関数のArray of Const的なノリで行けます。

 こういうの既にあるよ……とか言われたら非常にショックではありますが、少なくともXE6には無かったので作ってしまった次第です。(→ 17:04 コメント欄にて闇の解法を伝授いただきました!)

一応注意点

 いくら値渡しをしているとはいえ、みんな大好きTStringListやTBitmapに代表されるクラス、メモリのどこかを指すポインター、インターフェースなどなど、値渡ししたところで大して効果のないものは往々にしてあります。

 また、変数を渡す順番と受け取る順番を間違えるとシンプルにバグりますので、渡す変数を後から増やしたり減らしたりした場合などは要注意です。

 ポインターの指すメモリを渡したいとき

 新規にメモリ領域を確保、オリジナルのデータを複写しておき、サブスレッド側でそのポインタを受け取って用事を済ませたら忘れずに破棄・解放するといった手順が必要になるでしょう。

 クラスでも同様のやりかたで渡せますが、内容の複写ができるかどうかはクラスによります。たいていはAssignでいけますが、全部複写できなかったり、そもそもAssignが未実装(自作クラスにありがち)だと複写用のルーチンをセルフサービスで書く必要があるかもしれません。

 メモリを確保するスレッドと解放するスレッドが違うと、なんとなくメモリリークするリスクが増えそうな気がしないでもないですが、その辺りは実装アルゴリズムと多少の運によるでしょう。

Test.pas
var
 mem : PByte;
 str : PWideChar;
begin

 str := '神野甘音たんはかわいいなあ!!';

 // メモリ確保+コピー
 GetMem(mem, 1024);
 StrCopy(PWideChar(mem),str);

 TAnonymousThreadUser.Create(procedure(Param:TArray<Variant>)
 var
    mem_copy : Pointer;
    strtemp : Pointer;
 begin

    // ↓ ポインターをUInt64で受け取ってPointerにキャスト
    mem_copy := Pointer(NativeUInt(UInt64(Param[0])));
    try
        // ただキャストするだけ
        strtemp := PWideChar(mem_copy);

        // ここで色々
        OutputDebugString(strtemp);
    finally
        FreeMem(mem_copy); // 何かあってもほぼ必ず解放されるように
    end;

 // ↓ ポインターをUInt64にキャスト(Integerだとx64でバグるかも)
 end,[UInt64(NativeUInt(mem))]).start;

本当の罠

 何らかの理由でサブスレッドに値渡ししたいときは、汎用の匿名スレッドクラス・改を用意することで、無事に乗り切ることができるようになりました。

 先述の通り、実際には変数バインディングという最強の保険もあり、値の受け渡し失敗リスクはほぼゼロに近づいたといっても良いでしょう。

 ……本当に?

ポインターの指す先に

 今年の秋。本当にハマった怖い話。聞きたいですか? 年末ですし、大出血サービスシェアしちゃいましょう! とは言っても、業務上で書いたコードなので、コピペするわけにはいきません。代わりに、要点を絞った再現コードを書き下ろします。コードに潜む罠に あ゛! と思ったら、さすがです。

Fatal.pas
var
 ls : TStringList;
 i : Integer;
 Name : String;
begin

 { Qiitaに直接書いてるのでスペルミス等あったらスミマセン! }

 ls := TStringList.Create;
 try
   ls.Add('ダージリン');
   ls.Add('アッサム');
   ls.Add('オレンジペコ');
   ls.Add('ローズヒップ');
   ls.Add('ルクリリ');

   for i := 0 to ls.Count-1 do
   begin
     Name := ls[i];
     TThread.CreateAnonymousThread(
      procedure()
      begin
       TThread.Synchronize(nil,procedure()
         begin
           OutputDebugString(PWideChar(Name));
         end);
      end).Start;
   end;

   { ここで全スレッドが終わるのを待つ処理 (省略) }

 finally
   ls.Free;
 end; 

 これが本当にあった怖いコードの再現です。例によってコンパイルは通ります。そして、TStringListに入れた5つの文字列について、順番はともかく、1つずつデバッグ出力されることを期待します。

 期待します。

 ……アカーン!!

 まだ5件程度なので良いのですが、1,000件以上オーダーぐらいのリストを回していると、何かおかしなことが起きてきます。パーセンテージにして希(まれ)に起こる事象といえばそうなのですが、その「希」がまた厄介。4

 さて、何が起きるのか。あり得る出力結果(これも再現ですがご了承ください)をご覧ください。

期待する出力
ダージリン
アッサム
オレンジペコ
ローズヒップ
ルクリリ
実際は順番が不定
ダージリン
アッサム
ローズヒップ
ルクリリ
オレンジペコ

 ここから、変数バインディングの罠?が発動します。

本当にあった怖い出力
ダージリン
ダージリン
オレンジペコ
ローズヒップ
ローズヒップ

 謎の重複と上書き。5件程度では正直、こういうことにはなりにくく、先述通り、数千件回して初めて、あれっ、なんか数件減ってない? と気付くことになります。

 無名メソッドから直接、変数Nameを参照してしまったがために起きた悲劇。そう、変数Name自体は生きています。しかし、中身が頻繁に書き換わる状態でスレッドを作り、参照してしまうと呼び出し元スレッドの変数が書き換わるタイミング次第で二重に渡されたり、その結果、一つ欠けたりと歯抜けになってしまいます。

 これは呼び出し元の手続き/関数が終わるとかどうとかの話ではなく、単純にタイミング問題です。

 ループしてサブスレッドを作って制御すること自体珍しい5ことですが、珍しいことをするとこういう不思議なことも起きてきて、だからなるほどプログラムは面白い! となるわけですね。

怖い話はお盆まで

 解決策はシンプル。さきほど作ったクラスを使って、下記のように書き換えます。

Fatal_Fix.pas
var
 ls : TStringList;
 i : Integer;
 Name : String;
begin

 { Qiitaに直接書いてるのでスペルミス等あったらスミマセン! }

 ls := TStringList.Create;
 try
   ls.Add('ダージリン');
   ls.Add('アッサム');
   ls.Add('オレンジペコ');
   ls.Add('ローズヒップ');
   ls.Add('ルクリリ');

   for i := 0 to ls.Count-1 do
   begin
     Name := ls[i];

     TAnonymousThreadUser.Create(procedure(Param:TArray<Variant>)
     var
        Name_copy : String;
     begin
        Name_copy := Param[0];
        TThread.Synchronize(nil,procedure()
         begin
           OutputDebugString(PWideChar(Name_copy));
         end);
        end,[Name]).start;
     end;

     { ここで全スレッドが終わるのを待つ処理 (省略) }

 finally
   ls.Free;
 end; 

 サブスレッド開始時、順繰りに値の写しを渡しているので、途中でパラメーターが飛んだりすることが原理的に無くなりました。こうして、Delphiのマルチスレッド・プログラミングに更なる平和が訪れたのでした。

もうひとつのスマートな方法 (17:45ごろ追記)

 17:04、冒頭でご紹介したシリアルゲームズのどえらい人こと細川氏より、「闇の解法」として、知る人ぞ知るコーディングをコメント欄にてご教示いただきました。こちらを引用・紹介して、今回のカレンダー、閉幕といたします。

参考までに、闇の解法です。 (by @pik Jun Hosokawa)
GodsCode.pas
procedure Test2;
var
  ls : TStringList;
  i : Integer;
begin
  ls := TStringList.Create;
  try
    {省略}

    for i := 0 to ls.Count - 1 do
      (procedure(const Name: String)
      begin
        TThread.Synchronize(
          TThread.Current,
          procedure
          begin
            OutputDebugString(PChar(Name));
          end
        );
      end)(ls[i]);

    { ここで全スレッドが終わるのを待つ処理 (省略) }
  finally
    ls.DisposeOf;
  end;
end;

 一見するとスレッドを使っていないように見えて使っているところがポイント高すぎて、名古屋駅西とか栄に点在する隠れた名店に入ったような趣きを感じます。(名古屋県民的な感想)

 同じことをするにも、闇を通り越して謎になる言語も多いなか、こんなのもあるよ~! と可読性を保ちつつコードの多様性を確保している点は、やはりDelphiの良いところです。

引数を渡せる匿名スレッド〆

 今年は「引数を渡せる匿名スレッド」との題で、Delphi歴15年ぐらいの長門みらいがお送りしました。

 カレンダー参加2回目、本年は油断して記述自体が最初からバグだらけという悲惨な状況の中、皆様の知恵をお借りしつつ、Qiita記事をライブアップデートしていくというなりふり構わぬカレンダーとなってしまいました。

 コメント欄にて多くをご教示をいただきました細川さん、Twitterにてご指摘をいただきましたLynaさん、その他お読みいただいて、途中「ちがうだろー! ちがうだろ!」とツッコミなどいただいた皆様、なにはともあれ、本当にありがとうございました。15年経っても勉強になります。30年後も勉強してるといいな。

 さて、今年も気がつけば残り1ヶ月。Delphierの方もそうでない方も、良いお年をお過ごしください。


  1. Stringはかなり大丈夫な部類です。というか大丈夫です。 

  2. 先述の通り、実際には変数バインディングが働き、しばらくは問題なく参照可能です。 

  3. もちろんコーディングの仕方にもよります。 

  4. 余談ですが、サブスレッドが1,000件同時動作はあり得ない実装ですから、実際は同時動作スレッド数を設定してリミットを設定します。 

  5. XE6にはTTaskがありません……。使えたらきっと頼もしい!