テックカリキュラム

非同期処理・マルチスレッド・並列処理

非同期処理・マルチスレッド・並列処理

本章では、VB.NETにおける非同期処理、マルチスレッド、並列処理について学習します。

業務アプリケーションでは、DB検索、ファイル読み込み、CSV出力、帳票生成、外部API通信、大量データ処理など、 時間のかかる処理が頻繁に発生します。 これらの処理をUIスレッド上でそのまま実行すると、画面が固まり、ユーザーから見るとアプリケーションが停止したように見えてしまいます。

そのため、VB.NETのデスクトップアプリケーションや業務システムでは、 重い処理をバックグラウンドで実行し、画面の応答性を保つ設計が重要になります。

一方で、非同期処理やマルチスレッドは、使い方を誤るとRace Condition、Deadlock、UIスレッド違反、 データ不整合、例外の取りこぼしなど、非常に発見しにくい不具合の原因になります。

本章のゴールは、単にAsync / Awaitを使えるようになることではありません。 UIフリーズを防ぎ、安全にバックグラウンド処理を実行し、排他制御やキャンセル制御を理解したうえで、 業務現場で使える堅牢な非同期処理を設計できるようになることです。


1. 同期処理と非同期処理の違い

同期処理とは、ひとつの処理が完了するまで次の処理に進まない実行方式です。 一方、非同期処理とは、時間のかかる処理の完了を待っている間も、 他の処理を継続できる実行方式です。

同期処理の例

Private Sub btnSearch_Click(sender As Object, e As EventArgs) Handles btnSearch.Click

    Dim users = _userService.SearchUsers(txtKeyword.Text)

    DataGridView1.DataSource = users

End Sub

このコードでは、SearchUsersの処理が完了するまでDataGridViewへの反映は行われません。 SearchUsersが数秒かかる場合、その間画面が操作できなくなります。

非同期処理の例

Private Async Sub btnSearch_Click(sender As Object, e As EventArgs) Handles btnSearch.Click

    btnSearch.Enabled = False
    lblStatus.Text = "検索中です..."

    Try
        Dim users = Await Task.Run(Function()
                                       Return _userService.SearchUsers(txtKeyword.Text)
                                   End Function)

        DataGridView1.DataSource = users
        lblStatus.Text = "検索が完了しました。"

    Catch ex As Exception
        MessageBox.Show("検索中にエラーが発生しました。")

    Finally
        btnSearch.Enabled = True
    End Try

End Sub

この例では、検索処理をTask.Runでバックグラウンド実行し、Awaitで完了を待っています。 その間、UIスレッドはブロックされにくくなり、画面の応答性を保ちやすくなります。

非同期処理が必要になりやすい処理

  • 大量データの検索
  • CSVやExcelの読み書き
  • PDFや帳票の生成
  • 外部API通信
  • ファイルアップロード・ダウンロード
  • 画像処理
  • バッチ処理
  • 長時間かかる集計処理

ただし、すべての処理を非同期にすればよいわけではありません。 短時間で終わる処理まで過剰に非同期化すると、コードが複雑になり、保守性が下がる場合があります。


2. Thread / Task / Async / Await

VB.NETで非同期処理を扱う場合、Thread、Task、Async / Awaitなどの仕組みを理解する必要があります。

Threadとは

Threadは、処理を実行する最小単位のひとつです。 新しいThreadを作成すると、メイン処理とは別の流れで処理を実行できます。

Dim thread As New Threading.Thread(Sub()
                                       ' バックグラウンドで実行する処理
                                       Threading.Thread.Sleep(3000)
                                   End Sub)

thread.Start()

Threadを直接扱うと細かい制御が可能ですが、 スレッドの生成や破棄、例外処理、戻り値の受け渡しなどを自分で管理する必要があります。 そのため、現在の.NET開発では、Threadを直接使うよりもTaskを使うことが一般的です。

Taskとは

Taskは、非同期処理を表すオブジェクトです。 Threadよりも高レベルな仕組みで、戻り値、例外、完了待ち、キャンセルなどを扱いやすくなっています。

Dim task As Task = Task.Run(Sub()
                                Threading.Thread.Sleep(3000)
                            End Sub)

Await task

戻り値を持つTask

Dim task As Task(Of Integer) = Task.Run(Function()
                                            Return 100
                                        End Function)

Dim result As Integer = Await task

Task(Of T)を使うと、バックグラウンド処理の結果を取得できます。 DB検索結果や計算結果を非同期に取得する場合に利用します。

Async / Awaitとは

Async / Awaitは、非同期処理を同期処理に近い読みやすい形で書くための構文です。 Asyncを付けたメソッド内では、Awaitを使って非同期処理の完了を待てます。

Public Async Function LoadUsersAsync() As Task(Of List(Of User))

    Dim users = Await Task.Run(Function()
                                   Return _userRepository.FindAll()
                               End Function)

    Return users

End Function

Awaitを使うと、処理の完了を待ちながらも、UIスレッドをブロックしにくくなります。 これにより、重い処理中でも画面の応答性を維持できます。

Async SubとAsync Functionの使い分け

VB.NETでは、Async SubとAsync Functionの両方が使えます。 ただし、Async Subは原則としてイベントハンドラでのみ使用するのが望ましいです。

Private Async Sub btnSearch_Click(sender As Object, e As EventArgs) Handles btnSearch.Click
    Await SearchAsync()
End Sub

通常の非同期メソッドでは、TaskまたはTask(Of T)を返すAsync Functionを使います。

Public Async Function SearchAsync() As Task
    Await Task.Run(Sub()
                       ' 検索処理
                   End Sub)
End Function

Async Subは呼び出し元が完了を待てず、例外も扱いにくくなります。 そのため、イベントハンドラ以外では避けるべきです。


3. UIフリーズの原因と対策

Windows FormsやWPFの画面は、基本的にUIスレッドで動作しています。 このUIスレッドが重い処理で占有されると、画面が再描画されず、ボタンやテキストボックスも反応しなくなります。 これがUIフリーズです。

UIフリーズが起きる例

Private Sub btnExecute_Click(sender As Object, e As EventArgs) Handles btnExecute.Click

    lblStatus.Text = "処理中です..."

    Threading.Thread.Sleep(5000)

    lblStatus.Text = "処理が完了しました。"

End Sub

このコードでは、Thread.SleepがUIスレッド上で実行されるため、 5秒間画面が固まります。 lblStatus.Textに「処理中です…」を設定しても、画面が再描画される前にスレッドが止まるため、 ユーザーには表示されないことがあります。

Task.Runを使った対策

Private Async Sub btnExecute_Click(sender As Object, e As EventArgs) Handles btnExecute.Click

    btnExecute.Enabled = False
    lblStatus.Text = "処理中です..."

    Try
        Await Task.Run(Sub()
                           Threading.Thread.Sleep(5000)
                       End Sub)

        lblStatus.Text = "処理が完了しました。"

    Finally
        btnExecute.Enabled = True
    End Try

End Sub

重い処理をTask.Runでバックグラウンド実行することで、 UIスレッドの停止を防ぎます。

処理中のUI制御

非同期処理中は、ユーザーが同じ処理を何度も実行できないように制御する必要があります。

  • 実行ボタンを無効化する
  • キャンセルボタンを有効化する
  • 処理中メッセージを表示する
  • 進捗バーを表示する
  • 処理完了後にUI状態を戻す

処理中状態を戻し忘れない例

Private Async Sub btnImport_Click(sender As Object, e As EventArgs) Handles btnImport.Click

    btnImport.Enabled = False
    lblStatus.Text = "取込中です..."

    Try
        Await _importService.ImportAsync()

        lblStatus.Text = "取込が完了しました。"

    Catch ex As Exception
        Logger.Error("取込処理中にエラーが発生しました。", ex)
        MessageBox.Show("取込処理中にエラーが発生しました。")

    Finally
        btnImport.Enabled = True
    End Try

End Sub

FinallyでUI状態を戻すことで、例外発生時にもボタンが無効化されたままになることを防げます。


4. BackgroundWorkerとTaskの違い

古いWindows Formsアプリケーションでは、BackgroundWorkerを使った非同期処理がよく使われていました。 一方、現在のVB.NETでは、TaskとAsync / Awaitを使う設計が一般的です。

BackgroundWorkerの例

Private WithEvents worker As New ComponentModel.BackgroundWorker()

Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load
    worker.WorkerReportsProgress = True
    worker.WorkerSupportsCancellation = True
End Sub

Private Sub btnStart_Click(sender As Object, e As EventArgs) Handles btnStart.Click
    worker.RunWorkerAsync()
End Sub

Private Sub worker_DoWork(sender As Object, e As ComponentModel.DoWorkEventArgs) Handles worker.DoWork
    For i As Integer = 1 To 100
        Threading.Thread.Sleep(50)
        worker.ReportProgress(i)
    Next
End Sub

Private Sub worker_ProgressChanged(sender As Object, e As ComponentModel.ProgressChangedEventArgs) Handles worker.ProgressChanged
    ProgressBar1.Value = e.ProgressPercentage
End Sub

Private Sub worker_RunWorkerCompleted(sender As Object, e As ComponentModel.RunWorkerCompletedEventArgs) Handles worker.RunWorkerCompleted
    MessageBox.Show("処理が完了しました。")
End Sub

BackgroundWorkerは、進捗通知や完了通知をイベントで扱えるため、 Windows Formsでは長く使われてきました。

TaskとAsync / Awaitの例

Private Async Sub btnStart_Click(sender As Object, e As EventArgs) Handles btnStart.Click

    ProgressBar1.Value = 0

    Await Task.Run(Sub()
                       For i As Integer = 1 To 100
                           Threading.Thread.Sleep(50)

                           Me.Invoke(Sub()
                                         ProgressBar1.Value = i
                                     End Sub)
                       Next
                   End Sub)

    MessageBox.Show("処理が完了しました。")

End Sub

Taskは、BackgroundWorkerよりも柔軟で、戻り値や例外処理、複数タスクの制御を扱いやすいです。 ただし、バックグラウンドスレッドからUIを直接操作しないよう注意が必要です。

BackgroundWorkerとTaskの比較

項目BackgroundWorkerTask / Async / Await
主な用途Windows Formsの旧来非同期処理現在の標準的な非同期処理
進捗通知ReportProgressで簡単IProgressやInvokeで対応
戻り値扱いにくいTask(Of T)で扱いやすい
例外処理RunWorkerCompletedで確認Try / Catchで扱いやすい
現代的な設計やや古い推奨されやすい

既存システムではBackgroundWorkerを理解する必要がありますが、 新規実装ではTaskとAsync / Awaitを中心に設計する方が扱いやすくなります。


5. 排他制御

マルチスレッド環境では、複数の処理が同時に同じデータへアクセスすることがあります。 このとき、適切な制御を行わないとデータ不整合が発生します。

排他制御とは、複数のスレッドが同時に共有データを変更しないように制御する仕組みです。

排他制御が必要な例

  • 複数スレッドから同じ変数を更新する
  • 複数処理で同じファイルへ書き込む
  • 複数タスクで同じListに要素を追加する
  • 同じ注文データを複数ユーザーが同時に更新する
  • バッチ処理と画面操作が同じデータを更新する

排他制御なしの危険な例

Private _count As Integer = 0

Public Sub Increment()
    _count += 1
End Sub

一見問題なさそうに見えますが、複数スレッドから同時にIncrementが呼ばれると、 更新が競合して正しい値にならない可能性があります。

SyncLockを使った排他制御

Private _count As Integer = 0
Private ReadOnly _lockObject As New Object()

Public Sub Increment()

    SyncLock _lockObject
        _count += 1
    End SyncLock

End Sub

SyncLockを使うことで、同時に1つのスレッドだけがブロック内の処理を実行できます。 これにより、共有データの不整合を防げます。

SyncLock利用時の注意点

  • ロックする範囲を必要最小限にする
  • ロック中に時間のかかる処理を行わない
  • UI操作をロック内で行わない
  • 複数のロックを組み合わせる場合はDeadlockに注意する
  • ロック用オブジェクトはPrivate ReadOnlyで用意する

排他制御はデータ整合性を守るために重要ですが、ロック範囲が広すぎると処理性能が低下します。 安全性と性能のバランスを考えて設計する必要があります。


6. Race Condition

Race Conditionとは、複数のスレッドやタスクが同じデータへ同時にアクセスすることで、 実行順序によって結果が変わってしまう問題です。

Race Conditionは、毎回発生するとは限りません。 タイミングによって発生したりしなかったりするため、再現が難しく、障害解析が非常に困難になります。

Race Conditionの例

Private _stock As Integer = 1

Public Function Purchase() As Boolean

    If _stock > 0 Then
        Threading.Thread.Sleep(100)
        _stock -= 1
        Return True
    End If

    Return False

End Function

このコードでは、在庫が1つしかないにもかかわらず、 複数スレッドが同時にPurchaseを実行すると、両方が購入成功になる可能性があります。

SyncLockで修正した例

Private _stock As Integer = 1
Private ReadOnly _lockObject As New Object()

Public Function Purchase() As Boolean

    SyncLock _lockObject

        If _stock > 0 Then
            _stock -= 1
            Return True
        End If

        Return False

    End SyncLock

End Function

在庫確認と在庫減算を同じロック内で実行することで、 同時購入による不整合を防げます。

Race Conditionが起きやすい処理

  • 在庫更新
  • 採番処理
  • 残高更新
  • ステータス更新
  • ファイル出力
  • 共有コレクションの更新
  • 複数ユーザーによる同時更新

業務システムでは、アプリケーション側の排他制御だけでなく、 DB側のトランザクション、ロック、楽観排他、悲観排他も考慮する必要があります。


7. Deadlock

Deadlockとは、複数の処理がお互いのロック解放を待ち続け、 処理が進まなくなる状態です。 日本語ではデッドロックと呼ばれます。

Deadlockが発生しやすい例

Private ReadOnly _lockA As New Object()
Private ReadOnly _lockB As New Object()

Public Sub Process1()
    SyncLock _lockA
        Threading.Thread.Sleep(100)

        SyncLock _lockB
            ' 処理
        End SyncLock
    End SyncLock
End Sub

Public Sub Process2()
    SyncLock _lockB
        Threading.Thread.Sleep(100)

        SyncLock _lockA
            ' 処理
        End SyncLock
    End SyncLock
End Sub

Process1はlockAを取得してからlockBを待ちます。 一方、Process2はlockBを取得してからlockAを待ちます。 このタイミングが重なると、互いに相手のロック解放を待ち続け、処理が止まります。

Deadlockを防ぐ方法

  • 複数ロックを取得する順序を統一する
  • ロック範囲を短くする
  • ロック中に外部APIやDBなど時間のかかる処理を行わない
  • UIスレッドでWaitやResultを不用意に使わない
  • 必要以上に共有状態を持たない

Async / Awaitで起きやすいDeadlock

Dim result = GetDataAsync().Result

非同期メソッドに対してResultやWaitを使うと、UIスレッドがブロックされ、 非同期処理の続きがUIスレッドに戻れずDeadlockになることがあります。

推奨される書き方

Dim result = Await GetDataAsync()

非同期メソッドは、可能な限りAwaitで待機します。 「Async all the way」という考え方があり、非同期処理を途中で同期的に待たない設計が重要です。


8. CancellationToken

長時間処理では、ユーザーが処理をキャンセルできる設計が重要です。 CancellationTokenを使うことで、非同期処理やバックグラウンド処理にキャンセル要求を伝えられます。

CancellationTokenSourceの基本

Private _cts As Threading.CancellationTokenSource

Private Async Sub btnStart_Click(sender As Object, e As EventArgs) Handles btnStart.Click

    _cts = New Threading.CancellationTokenSource()
    Dim token = _cts.Token

    btnStart.Enabled = False
    btnCancel.Enabled = True

    Try
        Await Task.Run(Sub()
                           ExecuteLongProcess(token)
                       End Sub, token)

        MessageBox.Show("処理が完了しました。")

    Catch ex As OperationCanceledException
        MessageBox.Show("処理をキャンセルしました。")

    Finally
        btnStart.Enabled = True
        btnCancel.Enabled = False
    End Try

End Sub

Private Sub btnCancel_Click(sender As Object, e As EventArgs) Handles btnCancel.Click
    _cts?.Cancel()
End Sub

キャンセル可能な処理

Private Sub ExecuteLongProcess(token As Threading.CancellationToken)

    For i As Integer = 1 To 100

        token.ThrowIfCancellationRequested()

        ' 時間のかかる処理
        Threading.Thread.Sleep(100)

    Next

End Sub

ThrowIfCancellationRequestedを呼び出すことで、 キャンセル要求があった場合にOperationCanceledExceptionを発生させます。

キャンセル設計のポイント

  • 長時間処理ではキャンセルボタンを用意する
  • 処理の途中で定期的にキャンセル要求を確認する
  • キャンセル時に中途半端なデータを残さない
  • DB更新を伴う場合はトランザクションと組み合わせる
  • キャンセルとエラーを区別してメッセージ表示する

キャンセルは、単に処理を止めればよいわけではありません。 途中まで処理されたデータやファイルをどう扱うかまで設計する必要があります。


9. 並列処理のパフォーマンス設計

並列処理とは、複数の処理を同時に実行して処理時間を短縮する方法です。 CPU負荷の高い処理や、大量データの独立した処理では効果があります。

ただし、並列化すれば必ず速くなるわけではありません。 DB、ファイル、ネットワークなどのI/O処理では、並列化によって逆に負荷が高まり、 全体の性能が悪化することもあります。

Parallel.ForEachの例

Dim items As List(Of Integer) = Enumerable.Range(1, 1000).ToList()

Parallel.ForEach(items, Sub(item)
                            ' 独立した重い処理
                            ProcessItem(item)
                        End Sub)

Parallel.ForEachを使うと、コレクション内の各要素に対する処理を並列実行できます。 ただし、各処理が共有データを更新する場合は排他制御が必要です。

共有Listへの追加に注意

Dim results As New List(Of Integer)()

Parallel.ForEach(items, Sub(item)
                            Dim result = ProcessItem(item)
                            results.Add(result) ' 危険
                        End Sub)

List(Of T)はスレッドセーフではありません。 複数スレッドから同時にAddすると、不整合や例外が発生する可能性があります。

SyncLockで保護する例

Dim results As New List(Of Integer)()
Dim lockObject As New Object()

Parallel.ForEach(items, Sub(item)
                            Dim result = ProcessItem(item)

                            SyncLock lockObject
                                results.Add(result)
                            End SyncLock
                        End Sub)

共有コレクションを更新する場合は、SyncLockで保護する必要があります。 ただし、ロックが多すぎると並列処理のメリットが小さくなります。

並列処理に向いている処理

  • 各データが独立して処理できる計算
  • 画像変換
  • 大量データの個別検証
  • CPU負荷の高い変換処理
  • ファイル単位で独立した処理

並列処理に注意が必要な処理

  • DB更新処理
  • 同じファイルへの書き込み
  • 外部APIへの大量アクセス
  • 順序が重要な処理
  • 共有データを頻繁に更新する処理

並列処理は強力ですが、業務システムではデータ整合性や外部システム負荷を優先すべき場面も多くあります。 速度だけでなく、安全性を含めて設計することが重要です。


10. Progressによる進捗通知

長時間処理では、ユーザーに進捗を表示することで安心感を与えられます。 VB.NETでは、IProgress(Of T)を使うことで、バックグラウンド処理からUIへ安全に進捗を通知できます。

IProgressを使った例

Private Async Sub btnStart_Click(sender As Object, e As EventArgs) Handles btnStart.Click

    ProgressBar1.Value = 0
    lblStatus.Text = "処理を開始しました。"

    Dim progress As New Progress(Of Integer)(
        Sub(value)
            ProgressBar1.Value = value
            lblStatus.Text = value & "% 完了"
        End Sub)

    Await Task.Run(Sub()
                       ExecuteProcess(progress)
                   End Sub)

    lblStatus.Text = "処理が完了しました。"

End Sub

進捗を通知する処理

Private Sub ExecuteProcess(progress As IProgress(Of Integer))

    For i As Integer = 1 To 100
        Threading.Thread.Sleep(50)
        progress.Report(i)
    Next

End Sub

Progress(Of T)を使うと、UIスレッドへの戻し処理を比較的安全に扱えます。 進捗バーやステータスメッセージを更新する場合に便利です。

進捗表示の設計ポイント

  • 全体件数が分かる処理ではパーセント表示する
  • 全体件数が分からない処理では処理中メッセージを表示する
  • 現在処理中のファイル名や件数を表示する
  • 処理完了時には明確に完了メッセージを出す
  • エラー時には進捗表示を適切に終了状態へ戻す

11. 実務でありがちな非同期処理の問題

Async Subを乱用している

Async Subは呼び出し元が完了を待てず、例外処理もしにくくなります。 イベントハンドラ以外では、Async Function As Taskを使うのが基本です。

UIスレッドから重い処理を実行している

DB検索、帳票出力、ファイル読込などをUIスレッド上で実行すると、画面が固まります。 重い処理はTask.Runや非同期APIを利用してバックグラウンドで実行します。

バックグラウンドスレッドから直接UIを操作している

バックグラウンドスレッドからTextBoxやLabel、DataGridViewを直接操作すると、 例外や不安定な動作の原因になります。 UI更新はInvoke、Await後のUIスレッド、IProgressなどを使って行います。

ResultやWaitでDeadlockを起こしている

非同期処理を同期的に待つResultやWaitは、UIアプリケーションでDeadlockの原因になることがあります。 可能な限りAwaitを使って待機します。

キャンセルできない長時間処理

長時間かかる処理にキャンセル手段がないと、ユーザーは処理完了まで待つしかありません。 CancellationTokenを導入し、安全に中断できる設計を検討します。

共有データをロックせずに更新している

複数スレッドから同じ変数やListを更新すると、Race Conditionが発生する可能性があります。 SyncLockやスレッドセーフなコレクションを利用する必要があります。


12. 演習課題

演習1:検索処理を非同期化する

検索ボタン押下時に3秒かかる疑似検索処理を作成し、 Async / AwaitとTask.Runを使ってUIが固まらないようにしてください。

演習2:処理中のUI制御を実装する

非同期処理中は実行ボタンを無効化し、処理完了またはエラー発生時に再度有効化してください。 また、処理中メッセージをLabelに表示してください。

演習3:CancellationTokenでキャンセル処理を実装する

100件のデータを順番に処理する疑似バッチを作成し、 キャンセルボタンで途中停止できるようにしてください。

演習4:Race Conditionを再現して修正する

複数タスクから同じカウンタを更新する処理を作成し、 SyncLockなしの場合とありの場合で結果がどう変わるか確認してください。

演習5:Progressで進捗表示を行う

長時間処理の進捗をProgressBarとLabelに表示してください。 IProgress(Of Integer)を利用し、1%ずつ進捗が更新されるようにしてください。


13. まとめ

本章では、VB.NETにおける非同期処理、マルチスレッド、並列処理について学習しました。

業務アプリケーションでは、DB検索、ファイル処理、帳票出力、外部API通信など、 時間のかかる処理が多く存在します。 これらをUIスレッド上でそのまま実行すると、画面がフリーズし、ユーザー体験が大きく低下します。

Async / AwaitとTaskを活用することで、重い処理をバックグラウンドで実行しながら、 画面の応答性を保つことができます。 ただし、Async Subの乱用、ResultやWaitによるDeadlock、バックグラウンドスレッドからのUI直接操作には注意が必要です。

マルチスレッド環境では、Race ConditionやDeadlockといった問題が発生する可能性があります。 共有データを扱う場合は、SyncLockなどによる排他制御が必要です。 また、複数ロックの取得順序やロック範囲にも注意しなければなりません。

長時間処理では、CancellationTokenを使ったキャンセル制御や、 IProgressを使った進捗通知を設計することで、ユーザーにとって扱いやすいアプリケーションになります。

並列処理は、CPU負荷の高い独立処理では効果を発揮しますが、 DB更新、ファイル書き込み、外部API通信などでは慎重に扱う必要があります。 速度だけを優先すると、データ不整合や外部システムへの過負荷を引き起こす可能性があります。

本章のゴールは、単に非同期処理を書けるようになることではなく、 安全性、応答性、保守性を考慮した並行処理を設計できるようになることです。 この知識は、業務アプリケーションの品質向上、本番運用、障害防止に直結します。