VB のたまご

作成日: 2017/10/01, 更新日: 2017/10/01


非同期処理その4(2017年10月版)


GUI アプリケーション上での注意点

  •  以降の処理で登場するいくつかのクラスは、それぞれ以下の名前空間をを使いますので、あらかじめインポートしておきます。 都度、各メソッドには書きませんので注意してください。
  • Imports System.Threading ' Thread.Sleep()
    Imports System.Threading.Tasks ' Task クラス
    Imports System.Collections.Concurrent ' ConcurrentXxx クラス(コレクション、ディクショナリ...)
    

  •  コンソールアプリケーションとは違い、GUI アプリケーションではさらに注意しなければいけない点があります。

  •  最初は、同期処理のデッドロック待機についてです。

  •  例えば、以下のようにコーディングすると、デッドロックが発生して、ボタン処理が動かなくなります。
  • ' 同期処理の待機でデッドロック
    Private Sub Button_Click(sender As Object, e As RoutedEventArgs)
    
        ' Wait / Result(内の Wait) は、非同期処理から同期処理として待機する(タスク処理完了を待って値を取得したい)
        Dim i = UIThreadDeadLockAsync().Result
        Console.WriteLine(i)
    
    End Sub
    
    Public Async Function UIThreadDeadLockAsync() As Task(Of Integer)
        ' Await は同期処理として非同期処理の完了を待機する(タスク処理完了を待って値を返却したい)
        ' しかし、Result(内の Wait) がすでに同期処理として待機しているため、待機したくても待機できない
        ' 待機できるまで(Result が同期処理を放して、同期処理を掴めるまで)待機※
        ' ※この待機は、Task.Run() の待機ではなく、同期処理を掴むための待機 ←デッドロック
        Return Await Task.Run(Function() 3)
    End Function
    

  •  これは、非同期処理を呼び出した側(同期処理)と非同期処理内で Await や Wait() した際の待機処理(同期処理)が、 同じ同期処理での待機というふうにかぶってしまうことが原因です。

  •  これを回避するためには、いくつかの対策があります。1つ目の対策です。
  • ' 対策1.全ての関連するメソッドを Async / Await で統一する
    Private Async Sub Button_Click(sender As Object, e As RoutedEventArgs)
    
        Dim i = Await UIThreadAsync()
        Console.WriteLine(i)
    
    End Sub
    
    Public Async Function UIThreadAsync() As Task(Of Integer)
        Return Await Task.Run(Function() 3)
    End Function
    

  •  この対策では、呼び出す・呼ばれるメソッド全てに、Async / Await を付けることです。 連鎖させることにより、ブロッキングを阻止することができるようになります。

  •  続いて、2つ目の対策です。
  • ' 対策2.作業メソッド内では Async / Await しない
    Private Sub Button_Click(sender As Object, e As RoutedEventArgs)
    
        Dim i = UIThreadAsync().Result
        Console.WriteLine(i)
    
    End Sub
    
    Public Function UIThreadAsync() As Task(Of Integer)
        Return Task.Run(Function() 3)
    End Function
    

  •  作業メソッドなどの呼び出されることを前提とする側では、Await / Wait() は使わないようにする対策です。 これにより、片方の待機のみとなるため、デッドロックを防止することができるようになります。

  •  3つ目の対策です。
  • ' 対策3.タスクの戻り値に ConfigureAwait(False) をつなげて、非同期処理のまま返す
    Private Sub Button_Click(sender As Object, e As RoutedEventArgs)
    
        Dim i = UIThreadAsync().Result
        Console.WriteLine(i)
    
    End Sub
    
    Public Async Function UIThreadAsync() As Task(Of Integer)
        Return Await Task.Run(Function() 3).ConfigureAwait(False)
    End Function
    

  •  Await + ConfigureAwait メソッドの組み合わせなら、非同期処理のまま待機させることができます。 これにより、片方は非同期処理で待機、片方は同期処理で待機となり、デッドロックが発生しなくなります。

  •  4つ目の対策です。
  • ' 対策4.待機場所を変更する
    Private Sub Button_Click(sender As Object, e As RoutedEventArgs)
    
        ' 呼び出し先のメソッド内の Await や Wait() に対する、呼び出し元の待機場所をずらす
        ' (非同期処理内で結果を待機するように変更)
        Dim t = Task.Run(Async Function()
                             Return Await UIThreadAsync()
                         End Function)
        'Dim t = Task.Run(Function() UIThreadAsync()) ' これでも OK
        Dim i = t.Result
        Console.WriteLine(i)
    
    End Sub
    
    Public Async Function UIThreadAsync() As Task(Of Integer)
    
        ' 処理順序が重要な処理
        Dim i1 As Integer = Await FirstMethod()
        Dim i2 As Integer = Await SecondMethod(i1)
    
        Return i1 + i2
    
    End Function
    
    ' 悪いサンプル。数字を返すだけなのに、わざわざ非同期処理させるだけ重い・遅いメソッドになってしまう
    Private Function FirstMethod() As Task(Of Integer)
        Return Task.Run(Function() 3)
    End Function
    
    Private Function SecondMethod(i As Integer) As Task(Of Integer)
        Return Task.Run(Function() i + 3)
    End Function
    

  •  ここでは、メソッドを呼び出す側が対策することによるデッドロック回避方法です。 待ち合わせ場所が同じ事が原因なので、こちら側は非同期処理内で待機するように、タスク処理を挟み込んで迂回するように仕込みます。 対策3と同じことです。

  •  注意点その2です。これはほとんどの方に知られた仕様ですが、非同期処理内からコントロールの値を変更しようとすると、例外エラーではじかれてしまいます。
  • Private Sub Button_Click(sender As Object, e As RoutedEventArgs)
    
        ' 同期処理だと直接変更できる
        Me.textblock1.Text = "change"
    
        ' 非同期処理だと直接変更しようとすると例外エラー
        Task.Run(Sub() Me.textblock1.Text = "change in Background")
    
        ' InvalidOperationException
        ' 型 'System.InvalidOperationException' の例外が WindowsBase.dll で発生しましたが、ユーザー コード内ではハンドルされませんでした
        ' 追加情報:このオブジェクトは別のスレッドに所有されているため、呼び出しスレッドはこのオブジェクトにアクセスできません。
    
        ' ただし、.NET Framework 4.5 以上の場合、補足してシステムダウンする挙動が、
        ' 補足しないで無視する挙動に仕様変更されたため、気付けなくなった(あれ?処理が動かないな~で終わりに変わった)
    
        ' 見た目の変更点はあるものの、結局は、例外エラーではじかれてしまうので、
        ' 非同期処理内からコントロールを変更する場合は、同期処理としてアクセスしないといけない
    
    End Sub
    

  •  .NET Framework 4.5 以降では、デフォルトでもみ消しができてしまうので、処理が走らない原因すら知ることができなくなってしまったという意味では、 難しくなってしまった仕様変更だと考えられます。

  •  原因は、InvalidOperationException なので、非同期処理内からコントロールを変更する場合は、Invoke 系メソッドを通して変更処理をおこないます。
  • Private Sub Test1()
    
        ' WinForms: Me.Invoke or Me.BeginInvoke / WPF: Me.Dispatcher.Invoke or Me.Dispatcher.BeginInvoke
        ' と言う風に、Invoke メソッドにジェネリックデリゲート経由で、コントロールを変更する処理を書いたものを渡して、
        ' 同期処理内で実行してもらうように依頼する
        Task.Run(Sub()
    
                     ' 非同期処理から、同期処理として動いてもらえるように依頼する
                     Me.Dispatcher.BeginInvoke(
                     Sub()
                         Me.textblock1.Text = "change in Background"
                     End Sub)
    
                 End Sub)
    
    End Sub
    

  •  このようなコントロールの変更が頻繁にある場合は、メソッドとして外に出した方が、呼び出す側が気にしなくてもよくなるので、いいかもしれません。
  • ' いちいち気にするのが面倒くさい場合は、チェック&対策処理を隠ぺいしたヘルパーメソッドに任せる
    Private Sub SetTextBlockToText(value As String)
    
        ' 呼び出された場所が、非同期処理であれば、同期処理として呼び出し直す
        ' WinForms の場合は、If Me.InvokeRequired() Then で判定する(できれば、IsHandleCreated も一緒に見たいところ)
        If Not Me.Dispatcher.CheckAccess() Then
            Me.Dispatcher.BeginInvoke(Sub() SetTextBlockToText(value))
            Exit Sub
        End If
    
        ' ここのタイミングでは、同期処理なのでコントロールを更新
        Me.textblock1.Text = value
    
    End Sub
    

  •  最後は、Async / Await をライブラリに使う場合の注意点です。 ここでいうライブラリとは、メインプログラム側で使われるために作成される dll ファイルを指しています。 ただ、これに限定されず、イベントハンドラ以外の作業用メソッドに対しても、適用してもいいのでは?とも思える話かなとも思います。 要するに、タスクメソッドを(他の人が安心して)使われることを考えられているか。どのような操作をされてもちゃんと動くか、という点です。

  •  1つ目は、非同期処理に適しているか適していないかの見極めです。
  •  ・CPU バウンドの場合は、同期処理のままにしておく(非同期処理に変えない)
  •  ・I/O バウンドの場合は、非同期処理に変える

  •  ある処理が、CPU バウンドなのか I/O バウンドなのかの見極め方としては、 コードが何かを「待機」している場合は I/O バウンドです(ネットワーク、データベース、ファイル...)。 コードが非常に負荷の大きい計算を実行している場合は CPU バウンドです。

  •  ライブラリ利用者は、ライブラリ提供の処理が CPU バウンドのメソッドしか用意されていないとわかったら、自分で非同期処理内で呼び出すことができます。 しかし、ライブラリ内部で非同期処理で動かしてしまうと、非同期処理に関する共通のリソースを食いつぶしてしまう可能性があり、 これが原因で、アプリケーション全体に悪影響を及ぼすことも考えられるためです。

  •  この意味を知るためには、スレッドプールによるスレッドの再利用の仕組み、スレッドがアプリケーション全体で共有して使うものということ、 有限個であるスレッドの使用と使用時における準備処理が高価なこと、スレッドを再利用しながら回すのは高価だということ、 スレッドを扱うのはライブラリ利用者側であるべきだということ、等を理解している必要があります。

  •  これをまとめると、ライブラリ内では、Task.Run メソッド、Task.Factory.StartNew メソッドは極力使わない方がよいということになります。 これらのメソッドはスレッドを生成するからです。スレッドを生成して使っていいのはライブラリ利用者側だけだという考え方からです。

  •  となると、.NET Framework 標準で組み込まれている非同期処理を呼び出して扱うもののみに絞られます。 つまり、I/O バウンド系の処理が、自然に非同期処理として提供されることになります。 対して、CPU バウンド系の処理は、自然に同期処理として提供されることになります。 ユーザーコードを非同期処理に変換してくれるのは、Task.Run メソッド、Task.Factory.StartNew メソッドだけだからです。 ※Thread, ThreadPool, BackgroundWorker など、以前の非同期処理の技術提供も含みます。

  •  ・デッドロックを起こさない

  •  これらに加えて、デッドロックを起こさないように気遣うことも考えなければいけません。 ユーザーは、提供されたタスクメソッドの内部処理を知りません。タスクを扱った非同期処理なんだな~くらいの認識しかありません。

  •  Await や Wait メソッドを使うと同期処理がブロックされてしまいます。 ライブラリ利用者は、楽に扱える Await や Wait メソッド呼び出し(Result プロパティアクセス含む)を使用してプログラミングしていくので、 ライブラリ側でも同じくやってしまうと、デッドロックが発生してしまいます。

  •  これを回避するために、Task.ConfigureAwait(False) メソッドを呼び出して非同期で待機するなどの対策をする必要があります。

  •  ライブラリ内に非同期処理を組み込むのは、制限が多い中では非常にシビアな処理となります。自由な非同期プログラミングができなくなります。 それくらい、非同期処理は扱いづらいものなのです。Async / Await + Task で簡単に非同期処理を扱えるようになった現在でも、結局はこれらの内部事情を知る必要があるのです。

  •  ライブラリ作成におけるベストプラクティスをもっと知りたい方は、参照に記載した各サイトをご覧ください。


まとめ

  •  当初は1記事に収めるはずだったものが、4記事に分けて書かないといけないようなボリュームになってしまいました。 記事が進むにつれて、簡単に理解できない難しい内容になってしまいました。再度書きますが、非同期処理は扱いづらいものなのです。 Async / Await + Task で簡単に非同期処理を扱えるようになった現在でも、結局は Thread 時代からの内部事情を知る必要があるのです。

  •  内部処理を知る必要なく安全に機能を使えることが、.NET Framework の便利さ・強みなのですが、非同期処理に関しては例外な気がします。

  •  何かを理解する時、専門用語は理解の邪魔をしてきます。今回であれば、スレッドとかスレッドプールとかに当たります。 これらの言葉を使わずに、【正しく】、理解できるような記事を目標に作成しましたが、達成できたかどうかは自信がありません。

  •  これらの言葉は後から、別で学ぶことで、つながっていくと考えています。余力がある方は調べてみてください。 最後までこの記事を読んでいただき、ありがとうございました。