VB.NET Async/Await: Traps, Deadlocks and WhenAll

VB.NET Async/Await on .NET 10: an Async Sub crash Try/Catch can't stop, a reproduced .Result deadlock, BC36943, IProgress order, measured WhenAll gains.

Series: VB.NET lecture 5/6 (advanced). For VB developers who already write Async Function but have hit a hang, a crash with no stack in their own code, or "it isn't faster". After this lecture you can pick between Await, WhenAll, Task.Run and Parallel.For with numbers in hand, and you know which C# async features VB does not have.

If you maintain a VB.NET WinForms tool or a VB service that calls web APIs, async code is where the strangest bugs live: a Try/Catch that does not catch, a button click that freezes the window forever, a progress bar that jumps backwards. This lecture reproduces each of those on .NET 10 with real output, not descriptions. It also measures what Task.WhenAll buys over awaiting one call at a time, and why wrapping a loop in Task.Run alone does not make it faster.

Environment: .NET SDK 10.0.403, runtime 10.0.14, Visual Basic 17.13 (the SDK default), Apple Silicon Mac (M1 Pro, 8 cores). Timings are Release builds after a JIT warm-up. The HTTP test talks only to a local HttpListener on 127.0.0.1.

Setup: one file, one mode per section

Every demo below lives in one Program.vb. VB has no Async Sub Main (you will see the error in a moment), so Main picks a mode and blocks on it with GetAwaiter().GetResult():

dotnet new console -lang VB -o async1
cd async1
dotnet run -c Release -- basic      # or asyncsub, deadlock, cancel, progress, http, cpu, asyncenum
Option Strict On
Imports System.Collections.Concurrent
Imports System.Diagnostics
Imports System.Net
Imports System.Net.Http
Imports System.Threading

Module Program
    Sub Main(args As String())
        Dim mode = If(args.Length > 0, args(0), "basic")
        Select Case mode
            Case "basic" : BasicAsync().GetAwaiter().GetResult()
            Case "asyncsub" : AsyncSubDemo()
            Case "deadlock" : DeadlockDemo()
            Case "cancel" : CancelAsync().GetAwaiter().GetResult()
            Case "progress" : ProgressAsync().GetAwaiter().GetResult()
            Case "http" : HttpAsync().GetAwaiter().GetResult()
            Case "cpu" : CpuDemo()
            Case "asyncenum" : AsyncEnumDemo().GetAwaiter().GetResult()
        End Select
    End Sub
    ' … the functions from each section below go here …
End Module

GetAwaiter().GetResult() is preferred over .Result here because it rethrows the original exception instead of wrapping it in an AggregateException.

Async Function, Await and Task.WhenAll

An Async Function returns a Task or Task(Of T) immediately; Await suspends the method until that task finishes, without blocking the thread. Here, five fake calls of 200 ms each, first awaited one by one, then started together:

Async Function FetchAsync(id As Integer, ms As Integer) As Task(Of String)
    Await Task.Delay(ms)
    Return $"item{id}"
End Function

Async Function BasicAsync() As Task
    Dim sw = Stopwatch.StartNew()
    For i = 1 To 5
        Dim s = Await FetchAsync(i, 200)          ' one after another
    Next
    Console.WriteLine($"sequential Await x5 : {sw.ElapsedMilliseconds} ms")

    sw.Restart()
    Dim tasks = Enumerable.Range(1, 5).Select(Function(i) FetchAsync(i, 200)).ToArray()
    Dim results As String() = Await Task.WhenAll(tasks)   ' all started first
    Console.WriteLine($"Task.WhenAll x5     : {sw.ElapsedMilliseconds} ms -> {String.Join(",", results)}")
End Function
sequential Await x5 : 1009 ms
Task.WhenAll x5     : 202 ms -> item1,item2,item3,item4,item5

Two details matter. The .ToArray() is what starts the tasks: a LINQ Select is lazy, and WhenAll would start them anyway, but materializing makes the moment of starting explicit. And WhenAll returns results in the order of the input, not the order of completion.

What the compiler tells you when you get it wrong

These four mistakes are the ones we see most in VB code. Here is the real output of dotnet build (English UI):

Async Sub Main(args As String())                  ' (1)
Async Function NoAwaitAsync() As Task(Of Integer) ' (2) no Await inside
    Return 1
End Function
Try
    Await Task.Delay(1)
Catch ex As Exception
    Await LogAsync(ex.Message)                    ' (3)
Finally
    Await LogAsync("done")                        ' (4)
End Try
LogAsync("x")                                     ' (5) result ignored
Program.vb(5,15): error BC36934: The 'Main' method cannot be marked 'Async'.
Program.vb(9,20): warning BC42356: This async method lacks 'Await' operators and so will run synchronously. ...
Program.vb(21,13): error BC36943: 'Await' cannot be used inside a 'Catch' statement, a 'Finally' statement, or a 'SyncLock' statement.
Program.vb(23,13): error BC36943: 'Await' cannot be used inside a 'Catch' statement, a 'Finally' statement, or a 'SyncLock' statement.
Program.vb(28,9): warning BC42358: Because this call is not awaited, execution of the current method continues before the call is completed. ...

A sixth one is confusing: writing Await t inside a method that is not marked Async does not produce an "add Async" error. VB parses Await as an ordinary identifier there, so you get error BC30800: Method arguments must be enclosed in parentheses. Because it is a syntax error, it also hides every other error in the file until you fix it.

The Async Sub trap, with real output

Async Sub exists for event handlers (Button1.Click must be a Sub). Anywhere else it is a trap, because the caller gets nothing to wait on, and therefore nothing to catch on:

Async Sub FireAndForget()
    Await Task.Delay(50)
    Throw New InvalidOperationException("boom from Async Sub")
End Sub

Sub AsyncSubDemo()
    Try
        FireAndForget()
        Console.WriteLine("call returned, no exception seen")
    Catch ex As Exception
        Console.WriteLine("caught: " & ex.Message)   ' never runs
    End Try
    Thread.Sleep(500)
    Console.WriteLine("this line is never printed")
End Sub
call returned, no exception seen
Unhandled exception. System.InvalidOperationException: boom from Async Sub
   at async1.Program.FireAndForget() in ~/demo/async1/Program.vb:line 45
   at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__124_1(Object state)
   at System.Threading.ThreadPoolWorkQueue.Dispatch()
   at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()
   at System.Threading.Thread.StartCallback()

The process exited with code 134 (abort). FireAndForget() returned at its first Await, the Try block ended, and 50 ms later the exception had no caller left. With no SynchronizationContext, the runtime rethrows it on a thread-pool thread, which kills the process. On a WinForms UI thread the exception goes to the UI context instead, where it usually ends up in Application.ThreadException and the default crash dialog; we did not run that on this Mac.

Common mistake: List.ForEach with an Async lambda

The same trap hides in a lambda, because List(Of T).ForEach takes an Action(Of T), so the Async Sub(n) lambda becomes an Async Sub:

Dim sw = Stopwatch.StartNew()
Dim names As New List(Of String) From {"a", "b", "c"}
names.ForEach(Async Sub(n)
                  Await Task.Delay(100)
                  Console.WriteLine($"  saved {n} at {sw.ElapsedMilliseconds} ms")
              End Sub)
Console.WriteLine($"ForEach returned at {sw.ElapsedMilliseconds} ms")
ForEach returned at 2 ms
  saved c at 102 ms
  saved a at 102 ms
  saved b at 102 ms

What actually happens: ForEach returned after 2 ms, before anything was saved, and the order changed between runs (a-c-b, c-b-a, c-a-b in three runs). Any exception in the lambda would crash the process as above. Fix: make the lambda a Function that returns a Task, and await them: Await Task.WhenAll(names.Select(Async Function(n) …)), or a plain For Each with Await inside if order matters. The compiler gives no warning for this one.

.Result, .Wait() and the deadlock you cannot see in a console app

The famous deadlock needs two things: a thread that blocks on .Result or .Wait(), and a SynchronizationContext that wants to resume the awaiting method on that same thread. A UI thread has one. A console app does not, so to be honest, the console app below does not deadlock. To show the real thing without Windows, we install a ten-line context that queues continuations for one thread, like a UI message loop:

NotInheritable Class SingleThreadContext
    Inherits SynchronizationContext
    Public ReadOnly Queue As New BlockingCollection(Of (SendOrPostCallback, Object))
    Public Overrides Sub Post(d As SendOrPostCallback, state As Object)
        Queue.Add((d, state))
    End Sub
End Class

Async Function GetValueAsync(useConfigureAwait As Boolean) As Task(Of Integer)
    Await Task.Delay(100).ConfigureAwait(Not useConfigureAwait)
    Return 42
End Function

Sub DeadlockDemo()
    Console.WriteLine($"console: SynchronizationContext.Current is Nothing = {SynchronizationContext.Current Is Nothing}")
    Dim t0 = GetValueAsync(False)
    Console.WriteLine($"console: .Result = {t0.Result}  (no deadlock)")

    Dim ctx As New SingleThreadContext()
    SynchronizationContext.SetSynchronizationContext(ctx)   ' pretend this is a UI thread
    Dim t1 = GetValueAsync(False)
    Dim done1 = t1.Wait(2000)            ' blocks the only thread that could run the continuation
    Console.WriteLine($"UI-like context, plain Await : finished within 2 s = {done1}, queued continuations = {ctx.Queue.Count}")

    Dim t2 = GetValueAsync(True)
    Dim done2 = t2.Wait(2000)
    Console.WriteLine($"UI-like context, ConfigureAwait(False): finished = {done2}, value = {t2.Result}")
    SynchronizationContext.SetSynchronizationContext(Nothing)
End Sub
console: SynchronizationContext.Current is Nothing = True
console: .Result = 42  (no deadlock)
UI-like context, plain Await : finished within 2 s = False, queued continuations = 1
UI-like context, ConfigureAwait(False): finished = True, value = 42

The second line is why "it works in my console test" proves nothing about the WinForms app. The third line is the deadlock: the rest of GetValueAsync (the Return 42) is sitting in the queue, and the only thread that would run it is the one waiting in Wait. We used Wait(2000) so the demo finishes; with .Result it would wait forever. ConfigureAwait(False) lets the continuation run on a pool thread instead.

The real fix is "async all the way": make the click handler an Async Sub that Awaits, and never block on a task on the UI thread. ConfigureAwait(False) belongs in library code that never touches UI controls after the await.

Cancellation with a timeout, and IProgress

CancellationTokenSource takes a timeout directly. Pass the token down to every awaitable that accepts one:

Async Function SlowWorkAsync(ct As CancellationToken) As Task(Of Integer)
    For i = 1 To 10
        Await Task.Delay(100, ct)
    Next
    Return 10
End Function

Async Function CancelAsync() As Task
    Using cts As New CancellationTokenSource(TimeSpan.FromMilliseconds(350))
        Dim sw = Stopwatch.StartNew()
        Try
            Dim n = Await SlowWorkAsync(cts.Token)
            Console.WriteLine($"finished {n}")
        Catch ex As OperationCanceledException
            Console.WriteLine($"{ex.GetType().Name} after {sw.ElapsedMilliseconds} ms, IsCancellationRequested={cts.IsCancellationRequested}")
        End Try
    End Using
End Function
TaskCanceledException after 360 ms, IsCancellationRequested=True

Catch OperationCanceledException, not TaskCanceledException: the latter derives from the former, and ct.ThrowIfCancellationRequested() throws the base type.

IProgress(Of T) has a surprise in console apps and services. Progress(Of T) captures the current SynchronizationContext when it is created; when there is none, each Report is posted to the thread pool. Twenty quick reports:

Dim seen As New ConcurrentQueue(Of Integer)
Dim p As IProgress(Of Integer) = New Progress(Of Integer)(Sub(v) seen.Enqueue(v))
For i = 1 To 20
    p.Report(i)
Next
Thread.Sleep(200)
Console.WriteLine("Progress order: " & String.Join(" ", seen))
Progress order: 1 2 6 3 4 5 8 9 10 11 12 13 14 15 16 17 18 20 19 7
Progress order: 1 2 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 3
Progress order: 2 4 1 3 5 6 8 9 10 11 12 13 14 15 16 17 18 19 20 7

Four of five runs arrived out of order, so a log line could say "100%" before "7%". On a UI thread the callbacks are queued to that one thread and keep their order. If you need ordered, synchronous reports outside a UI, implement IProgress(Of T) yourself: a 10-line class whose Report simply calls the delegate.

Measured: sequential vs WhenAll over real HTTP

Task.Delay is a clean model, so we also measured real sockets. A local HttpListener on http://127.0.0.1:18080/ answers every request after 100 ms (it never leaves the machine). The client sends 10 GETs one by one, then 10 with WhenAll, three rounds after one warm-up request:

Using client As New HttpClient()
    Await client.GetStringAsync(prefix)   ' warm-up
    For round = 1 To 3
        Dim sw = Stopwatch.StartNew()
        For i = 1 To 10
            Await client.GetStringAsync(prefix & i)
        Next
        Dim seqMs = sw.ElapsedMilliseconds
        sw.Restart()
        Await Task.WhenAll(Enumerable.Range(1, 10).Select(Function(i) client.GetStringAsync(prefix & i)))
        Console.WriteLine($"round {round}: sequential {seqMs} ms, WhenAll {sw.ElapsedMilliseconds} ms")
    Next
End Using
round 1: sequential 1021 ms, WhenAll 103 ms
round 2: sequential 1013 ms, WhenAll 101 ms
round 3: sequential 1014 ms, WhenAll 101 ms
Bar chart. Five delays of 200 ms awaited one by one: 1009 ms. The same five with Task.WhenAll: 202 ms. Ten HTTP requests to a 100 ms loopback server one by one: 1014 ms. The same ten with Task.WhenAll: 101 ms.
Measured wall time, sequential Await vs Task.WhenAll. Five Task.Delay(200) calls, and ten GET requests to a local HttpListener on 127.0.0.1 that answers after 100 ms. .NET 10.0.14, Release, Apple M1 Pro.

WhenAll took the time of the slowest call, not the sum. That only holds while the other side can handle the calls in parallel. Against a real API with rate limits, cap the concurrency (for example with SemaphoreSlim.WaitAsync, which is also the VB replacement for Await inside SyncLock) instead of firing 1,000 requests at once.

CPU-bound work: Task.Run vs Parallel.For

Async does not make computation faster. We counted primes below 5,000,000 four ways (correct count: 348,513 in every case):

Dim a = CountRange(0, N)                                  ' plain loop
Dim b = Task.Run(Function() CountRange(0, N)).Result      ' one Task.Run
' 8 chunks (one per core), each in its own Task.Run, then WhenAll
Dim parts = Enumerable.Range(0, chunks).Select(
    Function(k) Task.Run(Function() CountRange(k * size, If(k = chunks - 1, N, (k + 1) * size)))).ToArray()
Dim c = Task.WhenAll(parts).Result.Sum()
' Parallel.For with a thread-local counter
Parallel.For(0, N, Function() 0,
             Function(i, st, local) If(IsPrime(i), local + 1, local),
             Sub(local) Interlocked.Add(total, local))
round 1: seq 587 ms | Task.Run x1 561 ms | Task.Run x8 187 ms | Parallel.For 144 ms | counts 348513 348513 348513 348513
round 2: seq 586 ms | Task.Run x1 582 ms | Task.Run x8 156 ms | Parallel.For 129 ms | counts 348513 348513 348513 348513
round 3: seq 558 ms | Task.Run x1 560 ms | Task.Run x8 173 ms | Parallel.For 149 ms | counts 348513 348513 348513 348513
Bar chart of time to count primes below five million. Plain loop on one thread: 586 ms. One Task.Run: 561 ms. Eight Task.Run chunks with Task.WhenAll: 173 ms. Parallel.For with thread-local counters: 144 ms.
Counting primes below 5,000,000 (median of 3 rounds after a JIT warm-up). A single Task.Run is not faster than the plain loop; splitting the work is. .NET 10.0.14, Release, Apple M1 Pro, 8 cores.

One Task.Run (561 ms median) is as slow as the plain loop (586 ms): it only moves the work to another thread, which is exactly what you want to keep a UI responsive, but nothing more. Eight equal chunks reached 173 ms, and Parallel.For 144 ms, because it balances the load: primes get rarer and more expensive to test as numbers grow, so equal-sized chunks finish at different times. That is about 4x on 8 cores, not 8x; the M1 Pro has 6 performance and 2 efficiency cores. The thread-local overload of Parallel.For matters: a shared counter with Interlocked.Increment per prime would serialize the threads on one memory location.

VB limitations: Await in Catch, and IAsyncEnumerable

Two C# async features are missing in VB as of the .NET 10 SDK, and we checked both with the compiler rather than from memory.

1. No Await in Catch/Finally (BC36943, shown above). C# allowed it in C# 6. The VB workaround is to capture and await afterwards:

Dim failure As Exception = Nothing
Try
    Await SaveAsync()
Catch ex As Exception
    failure = ex
End Try
If failure IsNot Nothing Then Await LogAsync(failure.Message)

2. No Await For Each. VB can neither write async iterators nor loop over IAsyncEnumerable(Of T). Both attempts fail:

Await For Each x In src
Program.vb(11,15): error BC30201: Expression expected.
Program.vb(13,9): error BC30092: 'Next' must be preceded by a matching 'For'.

For Each x In src
Program.vb(11,23): error BC32023: Expression is of type 'IAsyncEnumerable(Of Integer)', which is not a collection type.

The workaround is to drive the enumerator by hand. Note the trap: the natural place for Await e.DisposeAsync() is a Finally block, which BC36943 forbids, so we dispose after the Try and rethrow with the original stack trace:

Async Function AsyncEnumDemo() As Task
    Dim ch = Channels.Channel.CreateUnbounded(Of Integer)()
    Dim producer = Task.Run(Async Function()
                                For i = 1 To 3
                                    Await Task.Delay(20)
                                    Await ch.Writer.WriteAsync(i)
                                Next
                                ch.Writer.Complete()
                            End Function)
    Dim source As IAsyncEnumerable(Of Integer) = ch.Reader.ReadAllAsync()

    Dim e = source.GetAsyncEnumerator()
    Dim failure As Exception = Nothing
    Try
        While Await e.MoveNextAsync()
            Console.WriteLine($"got {e.Current}")
        End While
    Catch ex As Exception
        failure = ex                          ' no Await allowed in Catch/Finally
    End Try
    Await e.DisposeAsync()                    ' so dispose after the Try block
    If failure IsNot Nothing Then Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw()

    Dim list = Await AsyncEnumerable.Range(1, 5).Where(Function(x) x Mod 2 = 1).ToListAsync()
    Console.WriteLine("ToListAsync: " & String.Join(",", list))
End Function
got 1
got 2
got 3
ToListAsync: 1,3,5

Await works on the ValueTask(Of Boolean) that MoveNextAsync returns. The last two lines use System.Linq.AsyncEnumerable, which is part of the .NET 10 base library (no NuGet package needed, as our build confirmed). ToListAsync() is the simplest workaround when the sequence is finite and fits in memory; the manual loop is for streams you must process item by item.

Decision table

Situation Use Avoid
Waiting on HTTP, DB, files Async Function … As Task(Of T) + Await .Result/.Wait() on a UI thread
Several independent I/O calls Start all, then Await Task.WhenAll Awaiting one by one in a loop
Many calls to a rate-limited API WhenAll + SemaphoreSlim cap 1,000 requests at once
Event handler (Click, Load) Async Sub with Try/Catch inside Async Sub anywhere else
One CPU block, keep UI responsive Await Task.Run(...) Expecting it to be faster
Same CPU work over many items Parallel.For with thread-local state One Task.Run per item
Library code after Await .ConfigureAwait(False) Touching UI controls after it
Console Main MainAsync().GetAwaiter().GetResult() Async Sub Main (BC36934)
Timeouts New CancellationTokenSource(TimeSpan) + pass the token Thread.Sleep loops
IAsyncEnumerable in VB Manual MoveNextAsync loop or ToListAsync Await For Each (does not exist)
Decision diagram. Start with the work to do. If it is an event handler such as a button click, use Async Sub and put a Try/Catch inside it. If the work mostly waits for I/O such as HTTP, a database or a file, use an Async Function returning Task and Await it; for several independent calls, start them all and use Task.WhenAll. If the work is CPU-bound, use Await Task.Run for one block of work so the caller stays responsive, or Parallel.For when the same work runs over many items.
Which tool for which kind of work. Waiting (I/O) and computing (CPU) need different tools; Async Sub belongs only in event handlers.

Honest limits / version notes

  • Everything was compiled with the VB compiler in .NET SDK 10.0.403 (Roslyn 5.9.0, default language version 17.13) and run on runtime 10.0.14. We did not test older compilers; Async/Await itself dates from VB 11 (Visual Studio 2012).
  • The deadlock was reproduced with our own single-thread SynchronizationContext, not with WinForms or WPF, which cannot run on macOS. The mechanism is the same, but the UI-specific crash path for an Async Sub exception (crash dialog, Application.ThreadException) was not run here.
  • System.Linq.AsyncEnumerable in the base library is new in .NET 10; on older targets the same methods come from the System.Linq.Async NuGet package, which we did not install.
  • Timings are from one machine (Apple M1 Pro, 8 cores) and three rounds each; treat them as ratios, not absolutes. The HTTP numbers include almost no network cost because the server is on loopback.
  • The Progress(Of T) ordering result depends on the thread pool and will vary between runs; the point is that order is not guaranteed without a SynchronizationContext.

Previous: VB.NET lecture 4/6: LINQ and collections. Next: VB.NET lecture 6/6: VB in .NET 10 and C# interop. For the same ideas in other languages see Python threads, processes and asyncio and Java virtual threads; for exceptions in general, C# exception handling and C# delegates and events.

FAQ

Why does my Try/Catch not catch an exception thrown inside an Async Sub?

An Async Sub returns nothing the caller can wait on, so by the time the exception is thrown the caller's Try block has already finished. In a .NET 10 console app the exception was rethrown on a thread-pool thread and the process died with exit code 134. Return a Task (Async Function … As Task) and Await it, or put the Try/Catch inside the Async Sub.

Can I use Await inside a Catch or Finally block in VB.NET?

No. C# allows it since C# 6, but the VB compiler in the .NET 10 SDK (language version 17.13) still reports BC36943. Store the exception in a variable inside Catch and Await after End Try.

Does .Result or .Wait() deadlock in a VB console app?

Not by itself, because a console app has no SynchronizationContext. It deadlocks on a UI thread (WinForms, WPF) or any single-threaded context. We reproduced it with a 10-line UI-like context: Wait(2000) returned False with one continuation stuck in the queue; ConfigureAwait(False) made it return True.

  • #VB.NET
  • #.NET 10
  • #async
  • #await
  • #Task
  • #concurrency
  • #advanced

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑