Visual Basic 初級講座 [改訂版] |
Visual Basic 中学校 > 初級講座[改訂版] >
2021/8/30
この記事が対象とする製品・バージョン
Visual Basic 2019 | ◎ | 対象です。 | |
Visual Basic 2017 | ◎ | 対象です。 | |
Visual Basic 2015 | ◎ | 対象です。 | |
Visual Basic 2013 | ◎ | 対象です。 | |
Visual Basic 2012 | ◎ | 対象です。 | |
Visual Basic 2010 | × | 対象外です。 | |
Visual Basic 2008 | × | 対象外です。 | |
Visual Basic 2005 | × | 対象外です。 | |
Visual Basic.NET 2003 | × | 対象外です。 | |
Visual Basic.NET (2002) | × | 対象外です。 | |
Visual Basic 6.0 | × | 対象外です。 |
目次
インテリセンスに<待機可能>と表示される処理は、非同期で実行できるということはこれまでに説明しました。そして、Async/Awaitを使うことで、この非同期処理を簡単に扱えるようになることも説明しました。
今回は、この非同期処理を自分で作成する方法を説明します。まず簡単に概要を紹介しましょう。
自分で、<待機可能>な非同期処理を作成する方法は3つあります。
方法1は、Asyncを使った非同期メソッドの戻り値を Task または Task(Of T)にするということです。
次の例は、非同期でテキストファイルを検索する非同期メソッドです。
Private Async Function SeachFileAsync(fileName As String, searchKeyword As String) As Task(Of Integer)
Dim allText As String = Await IO.File.ReadAllTextAsync(fileName)
Dim position As Integer = allText.IndexOf(searchKeyword)
Return position
End Function
この方法は、何かの既にある 待機可能な処理を使って、自分自身も待機可能にするという方法です。何か既存の待機可能な処理を利用することが前提になります。
方法2は、Async/Awaitに依存せずに、Task または Task(Of T)を返すメソッドを自作することです。戻り値が Task または Task(Of T)であればそれだけで待機可能になります。これはAwaitできる処理をゼロから自作する最も簡単な方法です。
方法3は、一定の要件を満たすクラスを戻り値にすることです。GetAwaiterメソッドの実装などいくつかの要件を満たすクラスを戻り値にするメソッドは待機可能になります。
この後、方法1と方法2についてもう少し詳しく説明します。方法3は一般的ではなく、使いこなすのは難しく、しかも、使用する必要はまずないため説明は割愛します。
Asyncで付きで定義した非同期メソッドはSubで定義するか、戻り値がTask または Task(Of T)のFunctionで定義する必要があります。
戻り値がない場合、 Sub で定義することは意味がなく、使いにくくなるので推奨されていません。Sub で良いと感じたら Sub ではなく Task を返す Function にしてください。。
Sub で定義した非同期メソッドを Task を返すFunctionに書き換えるのはとても簡単です。
次の例はシンプルな(説明用でしか使い道のない)非同期メソッドです。1秒待機した後にメッセージを非同期で表示します。
非同期メソッドなので名前の末尾に Async をつけています。
Private Async Sub TestAsync()
Await Task.Delay(1000)
Debug.WriteLine("1秒経ちました。")
End Sub
この例は特に戻り値がないので Sub で宣言しています。動作はしますがこれは非推奨です。
これをTaskを返すFunctionに書き換えるには次のようにするだけです。
Private Async Function TestAsync() As Task
Await Task.Delay(1000)
Debug.WriteLine("1秒経ちました。")
End Function
文字通り Sub を Function に変更し、戻り値として As Task を付けています。
何も Return していないのですが、Async/Await の効果で 自動的に Task が戻り値に設定されます。
戻り値に設定されるタスクはAwaitしている処理ではなく、Awaitより下にある処理です。この例では Debug.WriteLine がタスクとして戻り値に設定されます。
この処理は戻り値が Task なのでこれ自体が <待機可能> になります。ですから、呼び出し側ではAwaitを使って次のように呼び出すことができます。
Await TestAsync()
Awaitが付いているので、この呼び出しはAsyncで定義されたメソッド内にしか記述できない点に注意してください。
戻り値がTaskのメソッドをAwaitを付けて呼び出すと戻り値を受け取ることはできないので、次のようにTask型の戻り値が返ってくる前提のプログラムはエラーになります。
'この例はエラーです。
Dim t = Await TestAsync()
Awaitを使わないで呼び出せば本来の戻り値である Task を受け取ることができます。戻り値のTaskを使って状態を確認したり、待ち合わせをしたりという処理を記述することもできます。
たとえば、次の例では呼び出し側はこの処理の完了を待ちます。
Dim t As Task = TestAsync()
t.Wait()
もっと簡単に次のように書くこともできます。
TestAsync().Wait()
ところが、非同期メソッドが Sub で定義されている場合は、呼び出し側はタスクを受け取れないので、タスクを使って待ち合せたり、何かタスクの制御をするということが一切できなくなります。
これが Sub が非推奨である理由です。
本文中にさらっと書きましたが、Async/Await の効果でReturnしていないのに戻り値が設定されるんです。普段は戻り値を設定するには Return だということが頭に染みついているプログラマーも多いので、戸惑うかもしれません。ご注意ください。 |
非同期メソッドが実行する非同期処理自体に戻り値がある場合は、その非同期メソッドの戻り値をTask(Of T)で宣言します。
T は 非同期処理自体の戻り値の型です。
たとえば、次の非同期メソッドは、テキストファイル内で文字列を検索し、ヒットした位置を数値で返します。
Private Async Function SeachFileAsync(fileName As String, searchKeyword As String) As Task(Of Integer)
Dim allText As String = Await IO.File.ReadAllTextAsync(fileName)
Dim position As Integer = allText.IndexOf(searchKeyword)
Return position
End Function
この例では、デフォルトの文字コードを使用するので、正常に検索するには対象のテキストファイルがUTF-8で保存されている必要があります。
メソッドの定義は As Task(Of Integer) となっていますが、Return しているの値は Integer です。Async/Awaitの効果で自動的に Task(Of Integer)が戻り値になるわけです。
このメソッドの宣言を As Integer に書き換えると、Visual Studio は自動的に As Task(Of Integer)に変更します。
ここで戻り値となるタスクは、やはりAwait より下にある部分です。つまり、Dim Position As Integer の行と Return position の行がタスク化されて戻り値になります。
Task(Of Integer)が戻っていくので、この非同期メソッドは<待機可能>であり、呼び出し元は次のように Await 付きでこの処理を呼び出すことができます。
Dim position As Integer = Await SeachFileAsync("C:\temp\sample.html", "レムス")
If position > 0 Then
Debug.WriteLine($"レムスは{position}文字目にあります。")
'Debug.WriteLine("レムスは" & position & "文字目にあります。") '←VB2013以前の場合
Else
Debug.WriteLine("レムスはありませんでした。")
End If
このプログラム中には Task や Task(Of T)がまったく記述されていないことに注意してください。仕組みとしてはもちろんタスクを使って実現される機能ですが、プログラムでは完全に隠蔽されています。
Await を使用しているので、この呼び出し元のメソッドにも Async を付ける必要があります。
Await を付けないで Task(Of T)の戻り値をそのまま受け取ることもできます。
Dim t As Task(Of Integer) = SeachFileAsync("C:\temp\sample.html", "レムス")
t.ContinueWith(Sub(baseTask As Task(Of Integer))
Dim position As Integer = baseTask.Result
If position > 0 Then
Debug.WriteLine($"レムスは{position}文字目にあります。")
'Debug.WriteLine("レムスは" & position & "文字目にあります。") '←VB2013以前の場合
Else
Debug.WriteLine("レムスはありませんでした。")
End If
End Sub)
見比べてみると Await を付けて呼び出す方が簡潔に書けて、タスクのことを何も記述しないで済みます。
Awaitを使わないで真の戻り値である Task(Of Integer) を受け取る方は、少し小難しいプログラムになってしまいますが、タスクを直接操作できるので、タスクを使った処理を行いたい場合には優れています。
非同期メソッドを使えば、どちらでもお好みの方で呼び出すことができるというわけです。
さて、1つ問題です。少し考えてみてください。
次のプログラムで DoSomethingAsyncメソッドの戻り値の値は何でしょうか?
Private Async Function DoSomethingAsync() As Task(Of Integer)
Await Task.Run(Function() 123)
End Function
どのように呼び出しても答えは同じですが、たとえば、次のように呼び出す場合を考えてみてください。
Dim result = Await DoSomethingAsync
Debug.WriteLine(result)
DoSomethingAsyncメソッドは、123を返す処理をAwaitしているだけの短いプログラムです。
このメソッド自体の戻り値は Task(Of Integer)となっています。
前述したように、 この場合、戻り値となるタスクは、Await より下にある部分です。ところが、Awaitより下には何も記述されていませんから、既定の値を返すだけの何の処理もないタスクが返されることになります。既定のというのは Integer の場合 0 のことですから、つまり「0を返すタスク」が返されることになります。
そうすると、Await DoSomethingAsync は、DoSomethingAsync の戻り値である「0を返すタスク」から戻り値の 0 をを取り出すので、result は 0 になるというわけです。
では、この場合、123 の方を戻り値にするにはどうしたらよいでしょうか?
次のようにします。
Private Function DoSomethingAsync() As Integer
Return 123
End Function
(もはや非同期メソッドではありませんが、説明の連続性を重視して名前の末尾の Async は付けたままにしておきました)
タスクとして返したいのなら次の通りです。
Private Function DoSomethingAsync() As Task(Of Integer)
Return Task.Run(Function() 123)
End Function
単純な値を返すタスクは FromResult メソッドで作ることもできます。次のプログラムでも同じ意味です。
Private Function DoSomethingAsync() As Task(Of Integer)
Return Task.FromResult(123)
End Function
Async/Awaitで記述するには?
この処理は本質的にAsync/Awaitでの記述になじみません。Async/Awaitは、何かAwaitして処理することがあって、それの後続タスクがReturnされる仕組みです。単純に123を返すという処理は、何の後続タスクもないし、その前処理となるタスクもないのでAsync/Awaitになじまないというわけです。
非同期実行したい処理を実行するTaskを作成して、そのTaskを戻り値とするだけで、そのメソッドは<待機可能>になります。
たとえば、次の処理はデバッグ出力にメッセージを非同期で出力します。
Private Function OutputMessage(message As String) As Task
Dim t = Task.Run(Sub() Debug.WriteLine(message))
Return t
End Function
これだけでインテリセンスのヒントにもちゃんと<待機可能>と表示されます。
ポイントは実施したい処理に戻り値がない場合でも、メソッドはそれを実行する Task を戻り値にする必要があるという点です。
Await はここで戻ってきた Task を自動的に処理してくれる仕組みなのです。
メッセージを表示するだけだと処理がすぐ終わってしまうので、指定されたフォルダーの中から、サイズが大きいファイルを順に10個取り出す処理を非同期で実行するようにしてみましょう。ここでは取り出されたファイルをデバッグ出力します。
このサンプルでは、使用している EnumerationOptionsクラスが .NET Core 2.1以上から導入されたクラスのため、Visual Studio 2017以上でフレームワークを.NET Core 2.1以上または.NET Core 5以上にしないと動作しません。
Private Function Find10LargestFiles(folderPath As String) As Task
Dim t = Task.Run(Sub()
Dim folder As New IO.DirectoryInfo(folderPath)
Dim options As New IO.EnumerationOptions()
options.IgnoreInaccessible = True 'アクセスできないファイルは無視します。
options.RecurseSubdirectories = True 'サブフォルダーも検索対象にします。
Dim largestFiles = From file In folder.EnumerateFiles("*", options)
Order By file.Length Descending
Take 10
Debug.WriteLine(largestFiles.Count & " 個見つかりました。")
For Each largeFile In largestFiles
Debug.WriteLine($"{largeFile.FullName} {largeFile.Length / 1024 / 1024:0.0} MB")
Next
End Sub)
Return t
End Function
このプログラムではDirectoryInfoクラスのEnumerateFilesメソッドを使ってファイルを検索します。EnumerateFilesメソッドは戻り値としてFileInfoのコレクションを返します。第1引数に "*" を指定しているのですべてのファイルが検索対象となります。
第2引数にEnumerationOptionsを設定することで、検索の動作を細かく設定します。具体的に設定している項目は2つで、IgnoreInaccessibleプロパティをTrueに設定することでアクセスできないファイルを無視するようにします。この設定がないと、検索範囲内にアクセスできないファイルが存在した場合に例外が発生します。また、RecurseSubdirectoriesプロパティもTrueにすることでサブディレクトリーも検索対象に含めるようにします。
Taskを返しているので、このメソッドは<待機可能>になります。次のようにAwait を使って呼び出すことができます。
Private Async Sub TestAsync()
Await Find10LargestFiles("C:\Program Files")
End Sub
この処理は指定するフォルダーによっては少し時間がかかると思うので、コンソールアプリケーションで試してみる場合は、非同期処理が終わる前にアプリケーションが終了してと思います。そうならないように、実際に動作させる場合は、Console.ReadLine などでプログラムが終わらないように間を持たせてください。
たとえば、次のような感じです。
Sub Main(args As String())
TestAsync()
Console.WriteLine("出力ウィンドウのデバッグ出力を確認して下さい。")
Console.ReadLine()
End Sub
非同期処理が何らかの戻り値を返す場合、その値の型を型パラメーターにしたTask(Of T)を作成して、それを戻り値とするメソッドを作ると<待機可能>になります。たとえば、Integer型の値を返す非同期処理であれば、Task(Of Integer)を使用します。
単純なたし算の例を紹介します。
Private Function Compute(x As Integer, y As Integer) As Task(Of Integer)
Dim t = Task.Run(Function() x + y)
Return t
End Function
下記はポイントを図で示したものです。
呼び出し側は、Await を使っている場合、Integer を直接戻り値として受け取ることができます。
Private Async Sub AsyncTest()
Dim result As Integer = Await Compute(2, 3)
Debug.WriteLine($"計算結果は{result}です。")
'Debug.WriteLine("計算結果は" & result & "です。") '←VB2013以前の場合
End Sub
Await を使わない場合は、本来の戻り値であるTask(Of Integer)を受け取ります。
Awaitの力で極力 Task の存在が隠蔽されるというわけです。
さきほど紹介したサイズの大きい10個のファイルを検索する処理を書き換えて、見つけた10個のファイルを戻り値で返すようにすると次のようになります。
Private Function Find10LargestFiles(folderPath As String) As Task(Of IEnumerable(Of IO.FileInfo))
Dim t = Task.Run(Function()
Dim folder As New IO.DirectoryInfo(folderPath)
Dim options As New IO.EnumerationOptions()
options.IgnoreInaccessible = True 'アクセスできないファイルは無視します。
options.RecurseSubdirectories = True 'サブフォルダーも検索対象にします。
Dim largestFiles = From file In folder.EnumerateFiles("*", options)
Order By file.Length Descending
Take 10
Return largestFiles
End Function)
Return t
End Function
呼び出し側は、Awaitと組み合わせることで戻りを IEnumerable(Of IO.FileInfo)として受け取ることができます。
つまり、FileInfoのコレクションということなので、戻りをそのままFor Eachして検索で見つかったファイルを取り出すことができます。
Private Async Sub TestAsync()
Dim files = Await Find10LargestFiles("C:\Program Files")
For Each file In files
Debug.WriteLine($"{file.FullName} {file.Length / 1024 / 1024:0.0} MB")
Next
End Sub
ここまでAsync/Awaitを使った非同期処理について説明してきました。どれも簡単なサンプルで紹介しているので、非同期処理がごく簡単に扱えるように思えたかもしれません。それは間違いです。
非同期で処理が実行されるということはそれ自体が難しい問題を秘めています。第42回のマルチスレッドの説明で、マルチスレッドの難しいポイントして下記の4つを取り上げました。
Async/Awaitを使うとどうなるか見てみましょう。
非同期で処理が実行されるのに同じ変数を使うということは本質的にプログラムの難易度を挙げます。
次のプログラムで CountUpAsyncメソッドは変数 count に1を10000回 たします。
AsyncTestメソッドはこれを2回呼び出していますが、結果として 変数 count の値はいくつになるか考えてみてください。
Private Sub AsyncTest()
Dim t1 = CountUpAsync()
Dim t2 = CountUpAsync()
't1 と t2 が終わるまで待機します。
Task.WaitAll(t1, t2)
Debug.WriteLine("結果=" & count)
End Sub
Private count As Integer = 0
Private Async Function CountUpAsync() As Task
Await Task.Run(Sub()
'count に +1 を 10000回実行します。
For i As Integer = 0 To 9999
count += 1
Next
End Sub)
End Function
20000 になる場合もありますが、16895などの値になることもあります。2つの非同期処理が同時に変数にアクセスするので、値の取得と更新のタイミングが重なるとうまく更新できない場合があるということです。これはAsync/Awaitを使わないタスクベースの非同期処理でも同様で、第42回で詳しく説明しています。
手軽な解決方法は 第42回で説明したのと同様 SyncLock を使うことです。
Private Sub AsyncTest()
Dim t1 = CountUpAsync()
Dim t2 = CountUpAsync()
't1 と t2 が終わるまで待機します。
Task.WaitAll(t1, t2)
Debug.WriteLine("結果=" & count)
End Sub
Private count As Integer = 0
Private countUpLocker As New Object
Private Async Function CountUpAsync() As Task
Await Task.Run(Sub()
'count に +1 を 10000回実行します。
For i As Integer = 0 To 9999
SyncLock countUpLocker
count += 1
End SyncLock
Next
End Sub)
End Function
コレクションの列挙中にコレクションの内容を変更することはできません。Async/Await を使った処理でもこの原則が変わることはありません。次の例では高確率で例外が発生します。
Private Sub AsyncTest()
Dim items As New List(Of String)
'Windowsフォルダー配下の全ファイルのフルパスをコレクションに追加します。
items.AddRange(IO.Directory.GetFiles("C:\Windows"))
'Program Filesフォルダー配下の全ファイルのフルパスをコレクションに非同期で追加します。
FindFilesAsync(items, "C:\Program Files")
For Each item In items
Debug.WriteLine(item)
Next '←高確率で InvalidOperationException が発生します。
End Sub
Private Async Function FindFilesAsync(items As List(Of String), path As String) As Task
Await Task.Run(Sub()
'pathのフォルダー配下の全ファイルのフルパスをコレクションに追加します。
items.AddRange(IO.Directory.GetFiles(path))
End Sub)
End Function
ただし、VBのコンパイラーは賢いので、このプログラムが何かおかしいぞということを感じ取って警告を表示してくれます。
FindFilesAsync(items, "C:\Program Files") の部分に緑の波線が表示され、次のように警告されます。
BC42358 この呼び出しは待機されなかったため、現在のメソッドの実行は呼び出しの完了を待たずに続行されます。呼び出しの結果に Await 演算子を適用することを検討してください。
警告に書かれているようにずばり Await を適用するか、または、戻り値に Wait メソッドを適用するなどすれば、列挙中にアイテムが追加されることはなくなります。
初級講座第42回で、タスクベースの非同期処理でこの問題を説明した時は、コレクションのコピー機能を利用して回避する方法やSyncLockについても説明しており、Async/Awaitを使っている上記の場合でもそれらの回避方法は同じです。
Await しているタスクで例外が発生した場合、Await はその例外を呼び出し元に再スローしてくれます。そのため、呼び出し元のTry~Catchで例外の発生を検出することができます。Awaitなしでタスクを実行している場合は、単にTry ~ Catch しただけでは例外を捕捉できないので、この点は大きな違いです。
下記のプログラムを使って、Try ~ Catch で囲んだ部分で中の処理で例外が発生した場合にどうなるか確認してみましょう。
このプログラムは「通常のプログラム」「通常のラムダ式」「Awaitなしのタスクを実行」「Await付きのタスクを実行」の4パターンで例外が発生する型変換を実行します。同時には試せないで1つずつコメントを解除して実行してみます。(見やすいように色はコメント化されていないときの色にしておきました。)
Try
'通常のプログラム
'Dim result = CInt("ABC")
'通常のラムダ式 ※ () を付けて即座に呼び出しています。
'Dim result = (Function() CInt("ABC"))()
'Awaitなしでタスクを実行
'Dim t = Task.Run(Function() CInt("ABC"))
'Await付きでタスクを実行
'Dim result = Await Task.Run(Function() CInt("ABC"))
Catch ex As Exception
Console.WriteLine("例外発生" & ex.GetType.FullName)
End Try
どうなるかは Visual Studio で通常の実行(デバッグ実行)する場合と、デバッグなしで実行する場合とで変わります。
また、Try ~ Catch がない場合の反応も合わせて結果を表にまとめました。
通常のプログラム /通常のラムダ式 |
Awaitなしで タスクを実行 |
Await付きで タスクを実行 |
|
---|---|---|---|
Visual Studio で デバッグ実行 |
Catchの処理が実行される | エラー発生個所で停止 | エラー発生個所で停止 |
デバッグなしで実行 | Catchの処理が実行される | 例外を捕捉できない | Catchの処理が実行される |
Try~Catch なしで Visual Studio で デバッグ実行 |
エラー発生個所で停止 | エラー発生個所で停止 | エラー発生個所で停止 |
Try~Catchなしで デバッグなしで実行 |
アプリケーションはエラーで終了 | 例外を捕捉できない | アプリケーションはエラーで終了 |
この表で Catchの処理が実行される場合、捕捉される例外はすべて System.InvalidCastException です。
Visual Studioのデバッグ実行時に例外をどうするかはオプションで変更できるので、この表ではデフォルトの設定の場合を扱っています。なお、動作確認は .NET 6.0 プレビューで作成したコンソールアプリケーションをVisual Studio 2022 Preview 2.1 で実行することで行いました。
ポイントは2つあります。
1つ目のポイントは、通常のプログラムと Await を付けた場合の反応はほぼ同じということです。Visual Studio のデバッグ実行時の反応に違いがあることだけ押さえておけば、通常のルールを覚えておくだけでよさそうです。これはさすが言語機能として導入されたAwaitの効果だと思います。
2つ目のポイントは、Awaitなしの場合、例外を全く捕捉できていないという点です。通常はアプリケーションがエラー終了するケースでも、何事もなかったかのように振る舞うので、Awaitなしでタスクを扱う場合は、例外発生時に何が起こるか注意深く実装/テストする必要があります。
タスクから例外を検出する方法は 初級講座第42回 で説明しています。
プログラムの過程ではまず通常の機能を作ってから、例外発生時の処理を考えるとことがよくありますが、Awaitなしでタスクを扱っている場合は、はじめから例外を意識したほうが良さそうです。
Async/Await を使ったマルチスレッドではスレッドアフィニティの面倒を見てくれるため、GUIの操作を通常のプログラムのように書くことができます。
念のためAwaitを使わないで、GUIを操作して問題が発生する例から確認しておきましょう。
次のWindowsフォームアプリケーションのプログラムでは、Buttonクリックすると3秒後にTextBox1に"OK"を表示しようとするものですが、実行すると3秒後にエラーになります。エラーになる理由はGUIを生成したスレッドと、GUIを操作するスレッドが異なるからです。
Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
Task.Delay(3000).ContinueWith(Sub()
TextBox1.Text = "OK"
End Sub)
End Sub
Async/Awaitを使った非同期メソッドはVBがうまい具体に面倒を見てくれるので、このエラーは発生しません。
次のように書きます。
Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
Dim t = UpdateUiAsync()
End Sub
Private Async Function UpdateUiAsync() As Task
Await Task.Delay(3000)
TextBox1.Text = "OK"
End Function