Threads, Parallelism and Synchronization

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Visual Basic 17.13 on .NET 10 (SDK 10.0.402) and was checked on 2026-10-19. Every example marked "built and run" is a complete console program that starts with Option Strict On, was built with warnings treated as errors (except where the text says otherwise) and was run in the .NET 10 SDK container (Linux); the output shown is real output. The root namespace of the scratch projects was App. Concurrency output can depend on timing, so every program here prints only facts that do not (counts, sorted lists, booleans such as "lost updates"); where a number could vary it is not shown as a claim. The Windows Forms example is labelled "built, not run": it was compiled against the net10.0-windows targeting pack and nothing more is claimed.

A thread is an operating-system unit of execution; a program with several can do several things at once on a multi-core machine, or wait for I/O without freezing the user interface. The price is that data shared between threads can be read and written in overlapping ways that no single-threaded reading of the source predicts. This page covers the primitives (Thread, the thread pool, SyncLock, Monitor, Interlocked), the library layers above them (concurrent collections, Parallel, PLINQ) and the Visual Basic specifics. Everything below the language is shared .NET platform material, covered from the C# side in Threads and Synchronization (C#); for waiting without blocking a thread, use Async and Await, which should be your first choice for I/O-bound work.

Choosing a tool

Problem Use Notes

Wait for I/O (network, files, timers) without blocking

Async/Await

no thread is held while waiting

Run one CPU-bound job off the current thread

Await Task.Run(...)

pool thread; keep the UI thread free

Run many independent CPU-bound iterations

Parallel.For, Parallel.ForEach, PLINQ

only when each iteration is substantial

A long-lived dedicated worker

Thread, or Task.Factory.StartNew with TaskCreationOptions.LongRunning

rare; the pool is unsuited to threads that block for a long time

Protect shared state

SyncLock, Interlocked, a concurrent collection, or an immutable snapshot

prefer not sharing over locking

Hand work from producers to consumers

BlockingCollection(Of T), Channel(Of T)

bounded capacity gives back-pressure

Throttle concurrency

SemaphoreSlim, ParallelOptions.MaxDegreeOfParallelism

Legacy UI background work

BackgroundWorker (maintenance only)

replaced by Async/Await and IProgress(Of T)

Threads

A Thread is created with a delegate: ThreadStart (no parameters) or ParameterizedThreadStart (one Object). In Visual Basic you write New Thread(AddressOf Method) or New Thread(Sub() ...), and the compiler picks the delegate from the shape of the method. A thread does not run until Start(). Join() waits for it to finish. IsBackground decides whether the process may exit while the thread still runs.

Built and run: starting, naming and joining threads, and the thread states
Option Strict On

Imports System.Threading

Module Program
    Private ReadOnly Started As New ManualResetEventSlim(False)
    Private ReadOnly Release As New ManualResetEventSlim(False)

    ' ParameterizedThreadStart takes one Object; ThreadStart takes nothing.
    Sub ImportBatch(state As Object)
        Dim batch As String = CStr(state)
        Console.WriteLine("worker " & Thread.CurrentThread.Name & " imports " & batch &
                          " (background: " & Thread.CurrentThread.IsBackground.ToString() & ")")
    End Sub

    Sub Wait()
        Started.Set()
        Release.Wait()
    End Sub

    Sub Main()
        Dim t1 As New Thread(AddressOf ImportBatch) With {.Name = "importer-1"}
        Console.WriteLine("before Start: " & t1.ThreadState.ToString())
        t1.Start("batch A")
        t1.Join()
        Console.WriteLine("after Join:   " & t1.ThreadState.ToString())

        Dim result As Integer
        Dim t2 As New Thread(Sub() result = Enumerable.Range(1, 100).Sum()) With {.Name = "summer"}
        t2.Start()
        t2.Join()
        Console.WriteLine("t2 stored " & result.ToString() & " in a captured local")

        Dim t3 As New Thread(AddressOf Wait) With {.IsBackground = True, .Name = "waiter"}
        t3.Start()
        Started.Wait()
        Thread.Sleep(50)
        Console.WriteLine("while blocked: " & t3.ThreadState.ToString())
        Release.Set()
        t3.Join()
        Console.WriteLine("finished:      " & t3.ThreadState.ToString())
        Console.WriteLine("main thread id " & Environment.CurrentManagedThreadId.ToString() & ", t1 id differs: " & (t1.ManagedThreadId <> Environment.CurrentManagedThreadId).ToString())
    End Sub
End Module
before Start: Unstarted
worker importer-1 imports batch A (background: False)
after Join:   Stopped
t2 stored 5050 in a captured local
while blocked: Background, WaitSleepJoin
finished:      Stopped
main thread id 1, t1 id differs: True
  • ThreadState is a set of flags: Unstarted before Start, Stopped after the method returns, and WaitSleepJoin while the thread is blocked on a lock, a Sleep, a Join or a wait handle. Background is one of the flags. It is a snapshot: read it for diagnostics, never to make decisions, because it can change before you act on it.

  • A captured local (result) is how a thread hands a value back. Join() makes the write visible to the caller. For anything more elaborate return the value from a Task(Of T) instead.

  • AddressOf ImportBatch binds to ParameterizedThreadStart because ImportBatch takes one Object. A method with some other parameter type matches neither delegate, and the error is an overload-resolution failure on the Thread constructor (see the error section). Under Option Strict On a lambda or an Object parameter is the way out.

  • A foreground thread (the default) keeps the process alive after Main returns; a background thread does not.

  • An unhandled exception in a thread ends the process (the AppDomain.UnhandledException event fires first, but cannot stop it). Exceptions in `Task`s are different: they are stored in the task.

  • Thread priority and affinity exist (Priority, ProcessorAffinity on the process) but rarely help; the scheduler already does a better job.

Thread.Abort, Suspend and Resume are gone

Older material kills threads with Thread.Abort and pauses them with Suspend and Resume. On .NET 5 and later they are not supported: Abort compiles with a warning and throws at run time. The build prints the warning (an error when warnings are errors), the program prints the exception:

Option Strict On

Imports System.Threading

Module Program
    Sub Main()
        Dim worker As New Thread(Sub() Thread.Sleep(Timeout.Infinite))
        worker.IsBackground = True
        worker.Start()
        Try
            worker.Abort()
        Catch ex As PlatformNotSupportedException
            Console.WriteLine(ex.GetType().Name & ": " & ex.Message)
        End Try
    End Sub
End Module
PlatformNotSupportedException: Thread abort is not supported on this platform.

The replacement is cooperative cancellation: pass a CancellationToken, check it in loops, and let the thread end by itself, or signal a ManualResetEventSlim. Foreground threads need cooperative exit anyway.

The thread pool and Task.Run

Creating a thread costs roughly a megabyte of stack and an operating-system object, so short jobs run on the thread pool: a set of reusable background threads that grows and shrinks on demand. ThreadPool.QueueUserWorkItem, Task.Run and the continuations of Await all use it. Task.Run is the modern entry point: it returns a Task that carries the result and any exception.

Built and run: pool threads versus a dedicated thread
Option Strict On

Imports System.Threading

Module Program
    Function Describe() As String
        Dim t As Thread = Thread.CurrentThread
        Return If(t.IsThreadPoolThread, "pool", "dedicated") & ", background = " & t.IsBackground.ToString()
    End Function

    Sub Main()
        Dim done As New CountdownEvent(3)
        Dim lines As New Collections.Concurrent.ConcurrentBag(Of String)

        ThreadPool.QueueUserWorkItem(Sub(state)
                                         lines.Add("QueueUserWorkItem: " & Describe())
                                         done.Signal()
                                     End Sub)
        Task.Run(Sub()
                     lines.Add("Task.Run:           " & Describe())
                     done.Signal()
                 End Sub)
        Task.Factory.StartNew(Sub()
                                  lines.Add("LongRunning:        " & Describe())
                                  done.Signal()
                              End Sub, TaskCreationOptions.LongRunning)
        done.Wait()

        For Each line As String In lines.OrderBy(Function(l) l)
            Console.WriteLine(line)
        Next
        Console.WriteLine("main thread: " & Describe())
    End Sub
End Module
LongRunning:        dedicated, background = True
QueueUserWorkItem: pool, background = True
Task.Run:           pool, background = True
main thread: dedicated, background = False
  • Pool threads are background threads, so a program that only has pool work running exits when Main returns.

  • TaskCreationOptions.LongRunning asks for a dedicated thread, which is right for a job that blocks for minutes (a consumer loop, a listener). Do not park pool threads on blocking calls: the pool injects new threads slowly, and a pool full of blocked threads (thread-pool starvation) makes every Await continuation wait.

  • Thread.Sleep blocks a thread; in async code use Await Task.Delay.

  • The pool is shared by the whole process. Libraries should not call ThreadPool.SetMinThreads as a fix for their own blocking.

Shared state and data races

A data race occurs when two threads access the same memory and at least one writes, without synchronisation. _counter += 1 looks like one step but is three: read, add, write. Two threads can both read 5 and both write 6.

Built and run: four threads each add 500,000 to a counter, three different ways
Option Strict On

Imports System.Threading

Module Program
    Private Const Workers As Integer = 4
    Private Const PerWorker As Integer = 500000

    Private _unsafe As Integer
    Private _locked As Integer
    Private _atomic As Integer
    Private ReadOnly Gate As New Object()

    Sub RunWorkers(body As Action)
        Dim threads As New List(Of Thread)
        For i As Integer = 1 To Workers
            Dim t As New Thread(Sub() body())
            threads.Add(t)
            t.Start()
        Next
        For Each t As Thread In threads
            t.Join()
        Next
    End Sub

    Sub Main()
        Dim expected As Integer = Workers * PerWorker

        RunWorkers(Sub()
                       For i As Integer = 1 To PerWorker
                           _unsafe += 1                       ' read, add, write: three steps
                       Next
                   End Sub)

        RunWorkers(Sub()
                       For i As Integer = 1 To PerWorker
                           SyncLock Gate
                               _locked += 1
                           End SyncLock
                       Next
                   End Sub)

        RunWorkers(Sub()
                       For i As Integer = 1 To PerWorker
                           Interlocked.Increment(_atomic)
                       Next
                   End Sub)

        Console.WriteLine("expected:        " & expected.ToString())
        Console.WriteLine("unsynchronised:  lost updates = " & (_unsafe < expected).ToString())
        Console.WriteLine("SyncLock:        " & _locked.ToString())
        Console.WriteLine("Interlocked:     " & _atomic.ToString())
    End Sub
End Module
expected:        2000000
unsynchronised:  lost updates = True
SyncLock:        2000000
Interlocked:     2000000

The unsynchronised total is almost always short of 2,000,000; the program reports only the fact that updates were lost, because the exact figure differs on every run. SyncLock and Interlocked both give the exact answer; Interlocked is cheaper for a single variable, SyncLock is needed when several variables must change together. The deeper fix, in most programs, is to share less: let each worker accumulate into its own variable and combine the results at the end (see Parallel.For with localInit below).

SyncLock and what it locks on

SyncLock expression …​ End SyncLock acquires the monitor of an object, runs the block, and releases the monitor in a Finally, even when the block throws. It is Monitor.Enter and Monitor.Exit with the Try/Finally written for you.

Built and run: SyncLock, re-entry, literals, exceptions, Nothing and TryEnter
Option Strict On

Imports System.Threading

Module Program
    Private ReadOnly Gate As New Object()

    Sub Main()
        ' SyncLock is Monitor.Enter / Monitor.Exit inside Try/Finally.
        SyncLock Gate
            Console.WriteLine("inside SyncLock: Monitor.IsEntered = " & Monitor.IsEntered(Gate).ToString())
            SyncLock Gate                                  ' the same thread may enter again
                Console.WriteLine("re-entered, still held")
            End SyncLock
        End SyncLock
        Console.WriteLine("outside:         Monitor.IsEntered = " & Monitor.IsEntered(Gate).ToString())

        ' The lock is the object's identity, not the variable: two literals are the same object.
        Dim first As String = "shelf"
        Dim second As String = "sh" & "elf"
        SyncLock first
            Console.WriteLine("literal 'shelf' locked, a second literal is held too: " & Monitor.IsEntered(second).ToString())
        End SyncLock

        ' A lock released by an exception.
        Try
            SyncLock Gate
                Throw New InvalidOperationException("boom")
            End SyncLock
        Catch ex As InvalidOperationException
            Console.WriteLine("after the exception, still held: " & Monitor.IsEntered(Gate).ToString())
        End Try

        ' Nothing as the operand.
        Dim nothingLock As Object = Nothing
        Try
            SyncLock nothingLock
            End SyncLock
        Catch ex As ArgumentNullException
            Console.WriteLine("SyncLock Nothing: " & ex.GetType().Name)
        End Try

        ' Monitor.TryEnter with a time-out, called while another thread holds the lock.
        Dim holding As New ManualResetEventSlim(False)
        Dim leave As New ManualResetEventSlim(False)
        Dim holder As New Thread(Sub()
                                     SyncLock Gate
                                         holding.Set()
                                         leave.Wait()
                                     End SyncLock
                                 End Sub)
        holder.Start()
        holding.Wait()
        Console.WriteLine("TryEnter while another thread holds it: " & Monitor.TryEnter(Gate, 100).ToString())
        leave.Set()
        holder.Join()
        Console.WriteLine("TryEnter once it is free:               " & Monitor.TryEnter(Gate, 100).ToString())
        Monitor.Exit(Gate)
    End Sub
End Module
inside SyncLock: Monitor.IsEntered = True
re-entered, still held
outside:         Monitor.IsEntered = False
literal 'shelf' locked, a second literal is held too: True
after the exception, still held: False
SyncLock Nothing: ArgumentNullException
TryEnter while another thread holds it: False
TryEnter once it is free:               True

What each part of the output shows:

  • The monitor is re-entrant. The same thread may enter again; it must exit as many times as it entered. A different thread waits.

  • The lock is the object, not the variable. first and second are different variables holding the same interned String, so locking one holds the other. This is why you must never lock on a string, a Type object (GetType(Foo)), or Me of a public class: unrelated code can lock the same object and create contention or a deadlock you cannot see in your own source. Lock on a Private ReadOnly object created for the purpose, as Gate is.

  • An exception releases the lock. After the exception the monitor was free again.

  • SyncLock Nothing throws ArgumentNullException at run time.

  • A value type cannot be locked (BC30582, below): boxing would create a new object each time and nothing would be protected.

  • Monitor.TryEnter(object, milliseconds) returns False instead of waiting forever. Every successful TryEnter must be paired with Monitor.Exit.

  • Await is not allowed inside SyncLock (BC36943); the monitor belongs to a thread and an Await may resume on another. Use SemaphoreSlim.WaitAsync.

For waiting on a condition (the classic bounded buffer), Monitor.Wait(obj) releases the lock and sleeps until another thread calls Monitor.Pulse(obj) or PulseAll. Prefer BlockingCollection(Of T) or Channel(Of T), which package the pattern correctly.

The Lock type

NET 9 added System.Threading.Lock, a dedicated lock type with a faster implementation; C# 13 makes lock recognise it. Visual Basic

17.13 does not use it from SyncLock, and says so:

Option Strict On

Imports System.Threading

Module Program
    Sub Main()
        Dim gate As New Lock()
        SyncLock gate
            Console.WriteLine("held")
        End SyncLock
    End Sub
End Module
Program.vb(8,18): error BC37329: A value of type 'System.Threading.Lock' is not supported in SyncLock. Consider manually calling 'Enter' and 'Exit' methods in a Try/Finally block instead.

The compiler refuses SyncLock on a Lock (BC37329); a Lock converted to Object would silently fall back to a slower monitor lock, and that is the warning BC42508. The supported way to use a Lock is the explicit Enter/Exit pair in Try/Finally. The scoped form that C# uses, Using gate.EnterScope(), is also unavailable, because Lock.Scope is a ref struct and Visual Basic marks types with embedded references as unusable:

Option Strict On

Imports System.Threading

Module Program
    Sub Main()
        Dim gate As New Lock()
        Using scope As Lock.Scope = gate.EnterScope()
            Console.WriteLine("held")
        End Using
    End Sub
End Module
Program.vb(8,24): error BC30668: 'Lock.Scope' is obsolete: 'Types with embedded references are not supported in this version of your compiler.'.
Program.vb(8,15): error BC36010: 'Using' operand of type 'Lock.Scope' must implement 'System.IDisposable'.
Program.vb(8,15): warning BC42351: Local variable 'scope' is read-only and its type is a structure. Invoking its members or passing it ByRef does not change its content and might lead to unexpected results. Consider declaring this variable outside of the 'Using' block.
Built and run: Lock.Enter, TryEnter and Exit in Try/Finally
Option Strict On

Imports System.Threading

Module Program
    Sub Main()
        Dim gate As New Lock()

        gate.Enter()
        Try
            Console.WriteLine("Lock.Enter:  IsHeldByCurrentThread = " & gate.IsHeldByCurrentThread.ToString())
        Finally
            gate.Exit()
        End Try

        If gate.TryEnter(TimeSpan.FromMilliseconds(10)) Then
            Try
                Console.WriteLine("Lock.TryEnter succeeded, held = " & gate.IsHeldByCurrentThread.ToString())
            Finally
                gate.Exit()
            End Try
        End If
        Console.WriteLine("after Exit: IsHeldByCurrentThread = " & gate.IsHeldByCurrentThread.ToString())
    End Sub
End Module
Lock.Enter:  IsHeldByCurrentThread = True
Lock.TryEnter succeeded, held = True
after Exit: IsHeldByCurrentThread = False

Source: Lock

Use Lock only if you have measured a benefit. For most Visual Basic code a private Object and SyncLock is correct and clear.

Interlocked and Volatile

Interlocked provides atomic read-modify-write operations on a single variable: Increment, Decrement, Add, Exchange, CompareExchange. They take the variable ByRef, so they work on fields and on locals captured by lambdas, but not on properties. CompareExchange(location, newValue, comparand) stores newValue only if location still equals comparand, and always returns the previous value; looping on it gives lock-free "update if still current" logic.

Volatile.Read and Volatile.Write are the Visual Basic substitute for C#'s volatile keyword. Visual Basic has no Volatile modifier: declaring Private Volatile Stopped As Boolean is a syntax error (below). Without a barrier the compiler or the processor may keep a flag in a register, so a worker thread could loop forever on a stale False. Volatile.Write publishes the new value and Volatile.Read observes it.

Built and run: a stop flag, Interlocked counters and a CompareExchange loop
Option Strict On

Imports System.Threading

Module Program
    Private _stop As Boolean
    Private _count As Integer
    Private _best As Integer
    Private _total As Long

    Sub UpdateBest(candidate As Integer)
        Dim seen As Integer
        Do
            seen = Volatile.Read(_best)
            If candidate <= seen Then Return
        Loop While Interlocked.CompareExchange(_best, candidate, seen) <> seen
    End Sub

    Sub Main()
        ' Volatile.Read/Write: there is no Volatile keyword in Visual Basic.
        Dim spinner As New Thread(Sub()
                                      Dim spins As Long = 0
                                      Do Until Volatile.Read(_stop)
                                          spins += 1
                                      Loop
                                      Console.WriteLine("spinner saw the flag and stopped")
                                  End Sub)
        spinner.Start()
        Thread.Sleep(50)
        Volatile.Write(_stop, True)
        spinner.Join()

        Dim threads As New List(Of Thread)
        For worker As Integer = 1 To 4
            Dim id As Integer = worker
            Dim t As New Thread(Sub()
                                    For i As Integer = 1 To 1000
                                        Interlocked.Increment(_count)
                                        Interlocked.Add(_total, i)
                                        UpdateBest(id * 1000 + i)
                                    Next
                                End Sub)
            threads.Add(t)
            t.Start()
        Next
        For Each t As Thread In threads
            t.Join()
        Next
        Console.WriteLine("count = " & _count.ToString() & ", total = " & _total.ToString() & ", best = " & _best.ToString())

        Dim old As Integer = Interlocked.Exchange(_count, 0)
        Console.WriteLine("Exchange returned " & old.ToString() & ", now " & _count.ToString())
        Console.WriteLine("CompareExchange swaps only on a match: " & Interlocked.CompareExchange(_count, 5, 1).ToString() & " then " & Interlocked.CompareExchange(_count, 5, 0).ToString() & ", now " & _count.ToString())
    End Sub
End Module
spinner saw the flag and stopped
count = 4000, total = 2002000, best = 5000
Exchange returned 4000, now 0
CompareExchange swaps only on a match: 0 then 0, now 5

Source: Volatile

The last line shows the contract of CompareExchange: the first call compares against 1 but the value is 0, so nothing is stored and the old value (0) is returned; the second compares against 0, so it stores 5 and again returns the old value, 0. Check the return value, not the variable, to learn whether the swap happened.

Concurrent collections and producer/consumer pipelines

The ordinary collections (List(Of T), Dictionary(Of K, V)) are not safe for concurrent writes; a corrupted Dictionary can loop forever. The System.Collections.Concurrent namespace provides thread-safe versions: ConcurrentDictionary, ConcurrentQueue, ConcurrentStack, ConcurrentBag and BlockingCollection.

Built and run: ConcurrentDictionary.AddOrUpdate, BlockingCollection with a bound, and enumeration during modification
Option Strict On

Imports System.Collections.Concurrent
Imports System.Threading

Module Program
    Sub Main()
        Dim loansPerMember As New ConcurrentDictionary(Of String, Integer)
        Dim members As String() = {"ana", "ben", "ana", "carla", "ben", "ana", "carla", "ana"}

        Parallel.ForEach(members, Sub(member)
                                      loansPerMember.AddOrUpdate(member, 1, Function(key, current) current + 1)
                                  End Sub)
        For Each pair As KeyValuePair(Of String, Integer) In loansPerMember.OrderBy(Function(p) p.Key)
            Console.WriteLine(pair.Key & " = " & pair.Value.ToString())
        Next

        Dim titles As String = loansPerMember.GetOrAdd("dmitri", Function(key) key.Length).ToString()
        Console.WriteLine("GetOrAdd created dmitri = " & titles)
        Dim added As Boolean = loansPerMember.TryAdd("ana", 100)
        Console.WriteLine("TryAdd on an existing key: " & added.ToString())

        ' A bounded producer/consumer pipeline.
        Dim queue As New BlockingCollection(Of String)(boundedCapacity:=2)
        Dim consumed As New List(Of String)
        Dim consumer As Task = Task.Run(Sub()
                                            For Each isbn As String In queue.GetConsumingEnumerable()
                                                consumed.Add(isbn)
                                            Next
                                        End Sub)
        For i As Integer = 1 To 6
            queue.Add("978000000" & i.ToString("D4"))        ' blocks when 2 items are waiting
        Next
        queue.CompleteAdding()
        consumer.Wait()
        Console.WriteLine("consumed in order: " & String.Join(" ", consumed))

        Dim q As New ConcurrentQueue(Of Integer)
        Parallel.For(0, 100, Sub(i) q.Enqueue(i))
        Dim stack As New ConcurrentStack(Of Integer)
        stack.PushRange({1, 2, 3})
        Dim top As Integer
        Console.WriteLine("queue count " & q.Count.ToString() & ", stack pop " & If(stack.TryPop(top), top.ToString(), "none"))

        ' Unlike Dictionary(Of K, V), changing a concurrent dictionary while enumerating it is allowed.
        For Each pair As KeyValuePair(Of String, Integer) In loansPerMember
            If Not pair.Key.EndsWith("-copy", StringComparison.Ordinal) Then
                loansPerMember.TryAdd(pair.Key & "-copy", pair.Value)
            End If
        Next
        Console.WriteLine("modified while enumerating, no exception; keys now " & loansPerMember.Count.ToString())
    End Sub
End Module
ana = 4
ben = 2
carla = 2
GetOrAdd created dmitri = 6
TryAdd on an existing key: False
consumed in order: 9780000000001 9780000000002 9780000000003 9780000000004 9780000000005 9780000000006
queue count 100, stack pop 3
modified while enumerating, no exception; keys now 8
  • AddOrUpdate and GetOrAdd are atomic with respect to the dictionary, but the delegate you pass may run more than once (and its result be discarded) if threads race; keep it free of side effects, or store Lazy(Of T) values when creation is expensive.

  • BlockingCollection(boundedCapacity:=2) makes Add wait when two items are queued, so a fast producer cannot overrun a slow consumer. CompleteAdding() ends the consumer’s GetConsumingEnumerable() loop.

  • Enumerating a concurrent collection while it changes does not throw (unlike Dictionary), but the sequence you see is not a stable snapshot. The Count of a ConcurrentQueue or ConcurrentBag is exact only when no one is writing.

  • Channel(Of T) (namespace System.Threading.Channels) is the asynchronous producer/consumer queue; its use from Visual Basic, including reading with WaitToReadAsync or by driving ReadAllAsync() by hand, is on the async page.

See Collections for the single-threaded collections.

Parallel loops

Parallel.For, Parallel.ForEach and Parallel.Invoke split work across pool threads and return when all of it is done. They suit independent, CPU-bound iterations that each do enough work to repay the scheduling.

Built and run: Parallel.For, MaxDegreeOfParallelism, localInit/localFinally, Break and exceptions
Option Strict On

Imports System.Collections.Concurrent
Imports System.Threading

Module Program
    Sub Main()
        Dim squares As New ConcurrentBag(Of Integer)
        Parallel.For(1, 6, Sub(i) squares.Add(i * i))
        Console.WriteLine("Parallel.For squares: " & String.Join(" ", squares.OrderBy(Function(x) x)))

        Dim options As New ParallelOptions With {.MaxDegreeOfParallelism = 2}
        Dim peak As Integer = 0
        Dim active As Integer = 0
        Parallel.ForEach(Enumerable.Range(1, 8), options, Sub(i)
                                                              Dim now As Integer = Interlocked.Increment(active)
                                                              Dim seen As Integer
                                                              Do
                                                                  seen = Volatile.Read(peak)
                                                              Loop While now > seen AndAlso Interlocked.CompareExchange(peak, now, seen) <> seen
                                                              Thread.Sleep(20)
                                                              Interlocked.Decrement(active)
                                                          End Sub)
        Console.WriteLine("MaxDegreeOfParallelism = 2 respected: " & (peak <= 2).ToString())

        ' Thread-local partial sums: one Interlocked.Add per worker instead of one per element.
        Dim total As Long = 0
        Parallel.For(Of Long)(1, 100001, Function() 0L,
                              Function(i, state, local) local + i,
                              Sub(local) Interlocked.Add(total, local))
        Console.WriteLine("sum of 1..100000 with localInit/localFinally: " & total.ToString())

        ' Break stops scheduling new iterations beyond the index; Stop ends as soon as possible.
        Dim result As ParallelLoopResult = Parallel.For(0, 1000, Sub(i, state)
                                                                      If i = 10 Then state.Break()
                                                                  End Sub)
        Console.WriteLine("IsCompleted = " & result.IsCompleted.ToString() & ", LowestBreakIteration = " & result.LowestBreakIteration.ToString())

        ' Exceptions are collected into an AggregateException.
        Try
            Parallel.For(0, 4, Sub(i)
                                   If i Mod 2 = 1 Then Throw New InvalidOperationException("item " & i.ToString())
                               End Sub)
        Catch ex As AggregateException
            Dim messages As IEnumerable(Of String) = ex.InnerExceptions.Select(Function(e) e.Message).OrderBy(Function(m) m)
            Console.WriteLine("AggregateException with " & ex.InnerExceptions.Count.ToString() & " inner exceptions: " & String.Join(", ", messages))
        End Try

        Dim order As New ConcurrentBag(Of String)
        Parallel.Invoke(Sub() order.Add("loans"), Sub() order.Add("members"), Sub() order.Add("books"))
        Console.WriteLine("Parallel.Invoke ran " & order.Count.ToString() & " actions")
    End Sub
End Module
Parallel.For squares: 1 4 9 16 25
MaxDegreeOfParallelism = 2 respected: True
sum of 1..100000 with localInit/localFinally: 5000050000
IsCompleted = False, LowestBreakIteration = 10
AggregateException with 2 inner exceptions: item 1, item 3
Parallel.Invoke ran 3 actions
  • The loop body is a lambda: Sub(i) ... for Action(Of Integer), Sub(i, state) ... when you need ParallelLoopState. A lambda cannot leave a loop with Exit For (BC30096, below); use state.Break() or state.Stop().

  • ParallelOptions.MaxDegreeOfParallelism caps the concurrency (here 2), and CancellationToken makes the loop cancellable.

  • Parallel.For(Of Long)(from, to, localInit, body, localFinally) gives each worker its own accumulator: local is private to the worker, so there is no contention until the single Interlocked.Add at the end. The example states the type argument (Of Long) explicitly.

  • Break() lets iterations below the break index finish and stops new ones above it; Stop() asks everything to stop at once. LowestBreakIteration reports the index.

  • Exceptions from iterations are collected in an AggregateException, and after the first one the loop stops scheduling new iterations (the exact set of messages can vary; here both odd items ran).

  • The iterations run in no particular order. Writing results(i) into a pre-sized array is safe because each index is touched once; appending to a List is not.

  • Parallel.ForEachAsync (.NET 6) takes an asynchronous body and a degree of parallelism, for I/O-bound loops.

PLINQ

PLINQ is LINQ to Objects run in parallel. Add AsParallel() to the source; the Visual Basic query syntax (From ... Where ... Select) works on a ParallelQuery, as do Aggregate ... Into Sum() and the method forms. Results come back in any order unless you add AsOrdered().

Built and run: AsParallel, AsOrdered, WithDegreeOfParallelism, ForAll, exceptions and cancellation
Option Strict On

Imports System.Collections.Concurrent
Imports System.Threading

Module Program
    Sub Main()
        Dim numbers As IEnumerable(Of Integer) = Enumerable.Range(1, 20)

        Dim ordered As List(Of Integer) = (From n In numbers.AsParallel().AsOrdered()
                                           Where n Mod 2 = 0
                                           Select n * n).ToList()
        Console.WriteLine("AsOrdered query: " & String.Join(" ", ordered))

        Dim unordered As List(Of Integer) = numbers.AsParallel().Where(Function(n) n Mod 2 = 0).Select(Function(n) n * n).ToList()
        Console.WriteLine("unordered query has the same elements: " & unordered.OrderBy(Function(x) x).SequenceEqual(ordered).ToString())

        Dim sumOfAll As Integer = numbers.AsParallel().WithDegreeOfParallelism(2).Sum()
        Console.WriteLine("Sum = " & sumOfAll.ToString())

        Dim aggregated As Integer = Aggregate n In numbers.AsParallel() Into Sum(n)
        Console.WriteLine("Aggregate ... Into Sum = " & aggregated.ToString())

        Dim bag As New ConcurrentBag(Of Integer)
        numbers.AsParallel().Where(Function(n) n > 15).ForAll(Sub(n) bag.Add(n))
        Console.WriteLine("ForAll collected " & bag.Count.ToString() & " items")

        Try
            numbers.AsParallel().Select(Function(n) 10 \ (n - 5)).ToList()
        Catch ex As AggregateException
            Console.WriteLine("PLINQ exception: " & ex.InnerException.GetType().Name)
        End Try

        Using cts As New CancellationTokenSource()
            cts.Cancel()
            Try
                numbers.AsParallel().WithCancellation(cts.Token).Select(Function(n) n).ToList()
            Catch ex As OperationCanceledException
                Console.WriteLine("WithCancellation: " & ex.GetType().Name)
            End Try
        End Using
    End Sub
End Module
AsOrdered query: 4 16 36 64 100 144 196 256 324 400
unordered query has the same elements: True
Sum = 210
Aggregate ... Into Sum = 210
ForAll collected 5 items
PLINQ exception: DivideByZeroException
WithCancellation: OperationCanceledException

Notes: AsOrdered restores source order at some cost. ForAll runs a side effect on each result in parallel without merging them first (the target, a ConcurrentBag, must be thread-safe). Exceptions arrive as an AggregateException. PLINQ and Parallel are slower than the sequential version for small or cheap work: measure before using them. See LINQ Query Expressions for the query syntax.

Deadlocks

A deadlock is a cycle of waiting: thread 1 holds lock A and wants B while thread 2 holds B and wants A. Neither can proceed and the program hangs without an error. The programs below would hang with plain SyncLock; Monitor.TryEnter with a timeout lets the example report it instead.

Built and run: opposite lock order versus a global lock order
Option Strict On

Imports System.Threading

Module Program
    Private ReadOnly Catalog As New Object()
    Private ReadOnly Loans As New Object()

    ' Two threads take the same two locks in opposite order. A Barrier makes sure each holds its first lock
    ' before asking for the second, and Monitor.TryEnter turns the permanent hang into a report.
    Function Try2(first As Object, second As Object, barrier As Barrier) As String
        SyncLock first
            barrier.SignalAndWait()
            If Monitor.TryEnter(second, 200) Then
                Monitor.Exit(second)
                Return "ok"
            End If
            Return "gave up waiting"
        End SyncLock
    End Function

    Sub Main()
        Dim barrier As New Barrier(2)
        Dim a As String = ""
        Dim b As String = ""
        Dim t1 As New Thread(Sub() a = Try2(Catalog, Loans, barrier))
        Dim t2 As New Thread(Sub() b = Try2(Loans, Catalog, barrier))
        t1.Start() : t2.Start()
        t1.Join() : t2.Join()
        Console.WriteLine("opposite order:  thread 1 " & a & ", thread 2 " & b)

        ' The fix: every thread takes the locks in the same global order.
        Dim barrier2 As New Barrier(1)
        Dim c As String = ""
        Dim d As String = ""
        Dim t3 As New Thread(Sub() c = Try2(Catalog, Loans, barrier2))
        t3.Start() : t3.Join()
        Dim t4 As New Thread(Sub() d = Try2(Catalog, Loans, barrier2))
        t4.Start() : t4.Join()
        Console.WriteLine("same order:      thread 3 " & c & ", thread 4 " & d)
    End Sub
End Module
opposite order:  thread 1 ok, thread 2 gave up waiting
same order:      thread 3 ok, thread 4 ok

How to avoid them:

  • Always take multiple locks in the same global order (document it).

  • Hold locks for as short a time as possible, and never call unknown code (an event handler, a virtual method, a callback) while holding one.

  • Prefer one lock per invariant, or no lock: immutable data, Interlocked, a single-consumer queue.

  • Use Monitor.TryEnter with a timeout where you can recover; log and give up instead of hanging.

  • For the async version of the same bug (blocking on a task from a thread with a synchronization context) see Async and Await.

  • A thread dump from the debugger (Parallel Stacks and Threads windows) or dotnet-stack shows which thread waits for which lock.

Immutability and lazy initialisation

Data that never changes needs no lock. System.Collections.Immutable collections create a new collection on each change and leave the old one intact, so a reader holding a reference always sees a complete, consistent snapshot. ImmutableInterlocked.Update replaces the shared reference atomically, retrying the transformation if another thread changed it first.

Built and run: copy-on-write with ImmutableInterlocked, Volatile.Write publication, and Lazy(Of T)
Option Strict On

Imports System.Collections.Immutable
Imports System.Threading

Module Program
    Private _loans As ImmutableList(Of String) = ImmutableList(Of String).Empty
    Private _published As String() = {"v1"}

    Sub Main()
        ' Readers always see a complete snapshot; writers replace it atomically.
        Dim writers As New List(Of Thread)
        For w As Integer = 1 To 4
            Dim id As Integer = w
            Dim t As New Thread(Sub()
                                    For i As Integer = 1 To 250
                                        Dim entry As String = "w" & id.ToString() & "-" & i.ToString()
                                        ImmutableInterlocked.Update(_loans, Function(current) current.Add(entry))
                                    Next
                                End Sub)
            writers.Add(t)
            t.Start()
        Next
        For Each t As Thread In writers
            t.Join()
        Next
        Console.WriteLine("ImmutableInterlocked.Update: " & _loans.Count.ToString() & " entries")

        Dim before As ImmutableList(Of String) = _loans
        Dim after As ImmutableList(Of String) = before.Add("extra")
        Console.WriteLine("the old snapshot is unchanged: " & before.Count.ToString() & " versus " & after.Count.ToString())

        ' Publishing a new reference safely: build it fully, then Volatile.Write.
        Dim fresh As String() = {"v1", "v2"}
        Volatile.Write(_published, fresh)
        Console.WriteLine("published: " & String.Join(",", Volatile.Read(_published)))

        ' Lazy initialisation that is safe on first use.
        Dim index As New Lazy(Of Dictionary(Of String, Integer))(Function()
                                                                      Console.WriteLine("building the index once")
                                                                      Return New Dictionary(Of String, Integer) From {{"a", 1}}
                                                                  End Function, LazyThreadSafetyMode.ExecutionAndPublication)
        Dim readers As Task() = Enumerable.Range(0, 4).Select(Function(i) Task.Run(Function() index.Value.Count)).ToArray()
        Task.WaitAll(readers)
        Console.WriteLine("index count = " & index.Value.Count.ToString())
    End Sub
End Module
ImmutableInterlocked.Update: 1000 entries
the old snapshot is unchanged: 1000 versus 1001
published: v1,v2
building the index once
index count = 1
  • The transformation function passed to Update may run more than once. It must be a pure function of its argument, and must not capture the loop variable of a For (BC42324); the example copies entry into a local first.

  • Publish a fully constructed object by writing the reference with Volatile.Write; readers use Volatile.Read.

  • Lazy(Of T) with LazyThreadSafetyMode.ExecutionAndPublication (the default) runs the factory exactly once even if four threads ask at the same time, as the single "building the index once" line shows.

Thread-local and async-local state

  • <ThreadStatic> on a Shared field gives each thread its own copy. The initialiser (= 10) runs only once, for the thread that triggers the type’s static initialisation; other threads see the default (0). Initialise lazily in the accessor, or use ThreadLocal(Of T).

  • ThreadLocal(Of T) takes a factory that runs once per thread, and (with trackAllValues:=True) lets you read every thread’s value via Values.

  • AsyncLocal(Of T) flows with the logical execution context: it survives Await, and is copied to Task.Run and to new threads. Changes made in a child are not seen by the parent.

Built and run: ThreadStatic, ThreadLocal and AsyncLocal
Option Strict On

Imports System.Threading

Module Program
    <ThreadStatic>
    Private _perThread As Integer = 10               ' the initialiser runs only for the thread that first loads the type

    Private ReadOnly Local As New ThreadLocal(Of Integer)(Function() 10, trackAllValues:=True)
    Private ReadOnly Ambient As New AsyncLocal(Of String)

    Sub Bump()
        _perThread += 1
        Local.Value += 1
    End Sub

    Async Function ChildAsync() As Task
        Console.WriteLine("child sees Ambient = " & Ambient.Value)
        Ambient.Value = "changed in the child"
        Await Task.Delay(1)
    End Function

    Sub Main()
        Console.WriteLine("main thread first sees ThreadStatic = " & _perThread.ToString())
        Dim t As New Thread(AddressOf Bump)
        t.Start()
        t.Join()
        Bump()
        Console.WriteLine("main thread:   ThreadStatic = " & _perThread.ToString() & ", ThreadLocal = " & Local.Value.ToString())
        Console.WriteLine("ThreadLocal values across threads: " & String.Join(",", Local.Values.OrderBy(Function(v) v)))

        Dim worker As New Thread(Sub() Console.WriteLine("worker thread: ThreadStatic starts at " & _perThread.ToString() & " (initialiser did not run)"))
        worker.Start()
        worker.Join()

        Ambient.Value = "request 42"
        ChildAsync().GetAwaiter().GetResult()
        Console.WriteLine("parent still sees Ambient = " & Ambient.Value)
        Dim viaThread As String = ""
        Dim t2 As New Thread(Sub() viaThread = Ambient.Value)
        t2.Start()
        t2.Join()
        Console.WriteLine("a new Thread also receives the ExecutionContext: " & viaThread)
    End Sub
End Module
main thread first sees ThreadStatic = 10
main thread:   ThreadStatic = 11, ThreadLocal = 11
ThreadLocal values across threads: 11,11
worker thread: ThreadStatic starts at 0 (initialiser did not run)
child sees Ambient = request 42
parent still sees Ambient = request 42
a new Thread also receives the ExecutionContext: request 42

Notice that the worker thread saw 0 for the ThreadStatic field: its = 10 initialiser had already run, on the main thread, before the worker started. The main thread’s own value went from 10 to 11 in Bump. ThreadLocal ran its factory once per thread (both threads show 11). AsyncLocal carried "request 42" into the async child and into a new Thread, and the child’s assignment did not leak back.

Other synchronization primitives

SemaphoreSlim limits concurrency, ReaderWriterLockSlim allows many readers or one writer, Barrier lets a fixed group of workers advance phase by phase, CountdownEvent waits for N signals, ManualResetEventSlim and AutoResetEvent signal between threads, Mutex locks across processes. All are ordinary classes used the same way from Visual Basic, with Try/Finally around every acquire/release pair.

Built and run: ReaderWriterLockSlim, Barrier phases and SemaphoreSlim
Option Strict On

Imports System.Threading

Module Program
    Sub Main()
        ' ReaderWriterLockSlim: many readers or one writer.
        Dim rw As New ReaderWriterLockSlim()
        Dim index As New Dictionary(Of String, Integer)
        Dim done As New CountdownEvent(5)

        For w As Integer = 1 To 2
            Dim id As Integer = w
            ThreadPool.QueueUserWorkItem(Sub(state)
                                             rw.EnterWriteLock()
                                             Try
                                                 index("writer" & id.ToString()) = id
                                             Finally
                                                 rw.ExitWriteLock()
                                             End Try
                                             done.Signal()
                                         End Sub)
        Next
        Dim seen As Integer = 0
        For r As Integer = 1 To 3
            ThreadPool.QueueUserWorkItem(Sub(state)
                                             rw.EnterReadLock()
                                             Try
                                                 Interlocked.Add(seen, index.Count)
                                             Finally
                                                 rw.ExitReadLock()
                                             End Try
                                             done.Signal()
                                         End Sub)
        Next
        done.Wait()
        Console.WriteLine("ReaderWriterLockSlim: " & index.Count.ToString() & " entries written, readers finished: " & done.IsSet.ToString())

        ' Barrier: three workers finish phase 1 before any starts phase 2.
        Dim log As New Collections.Concurrent.ConcurrentQueue(Of String)
        Using barrier As New Barrier(3, Sub(b) log.Enqueue("--- phase " & b.CurrentPhaseNumber.ToString() & " complete ---"))
            Dim workers As Task() = Enumerable.Range(1, 3).Select(Function(n) Task.Run(Sub()
                                                                                          log.Enqueue("phase 0 by worker")
                                                                                          barrier.SignalAndWait()
                                                                                          log.Enqueue("phase 1 by worker")
                                                                                          barrier.SignalAndWait()
                                                                                      End Sub)).ToArray()
            Task.WaitAll(workers)
        End Using
        Console.WriteLine(String.Join(" | ", log.ToArray().Select(Function(l) l.Replace(" by worker", "")).GroupBy(Function(l) l).Select(Function(g) g.Key & " x" & g.Count().ToString())))

        ' SemaphoreSlim as a counting gate.
        Using gate As New SemaphoreSlim(2, 2)
            gate.Wait()
            gate.Wait()
            Console.WriteLine("third Wait(0) while both slots are taken: " & gate.Wait(0).ToString())
            gate.Release(2)
            Console.WriteLine("CurrentCount after releasing: " & gate.CurrentCount.ToString())
        End Using
    End Sub
End Module
ReaderWriterLockSlim: 2 entries written, readers finished: True
phase 0 x3 | --- phase 0 complete --- x1 | phase 1 x3 | --- phase 1 complete --- x1
third Wait(0) while both slots are taken: False
CurrentCount after releasing: 2

Pick the simplest primitive that fits: SyncLock or Interlocked for mutual exclusion, SemaphoreSlim for counting, a BlockingCollection for hand-off. ReaderWriterLockSlim pays off only for read-heavy data with long read sections; a plain SyncLock is often faster.

Timers

System.Threading.Timer calls a callback on a pool thread at an interval (callbacks can overlap if one runs long; keep them short and thread-safe). PeriodicTimer is the modern async form: a loop awaits the next tick, so ticks never overlap and cancellation is a token.

Built and run: Threading.Timer and PeriodicTimer
Option Strict On

Imports System.Threading

Module Program
    Async Function TicksAsync() As Task
        Dim ticks As Integer = 0
        Using timer As New PeriodicTimer(TimeSpan.FromMilliseconds(30))
            Using cts As New CancellationTokenSource(TimeSpan.FromMilliseconds(130))
                Try
                    Do While Await timer.WaitForNextTickAsync(cts.Token)
                        ticks += 1
                    Loop
                Catch ex As OperationCanceledException
                    Console.WriteLine("PeriodicTimer stopped by the token after " & ticks.ToString() & " ticks (3 or 4 expected: " & (ticks >= 3 AndAlso ticks <= 4).ToString() & ")")
                End Try
            End Using
        End Using
    End Function

    Sub Main()
        Dim fired As Integer = 0
        Dim second As New ManualResetEventSlim(False)
        Using timer As New Timer(Sub(state)
                                     If Interlocked.Increment(fired) = 2 Then second.Set()
                                 End Sub, Nothing, 20, 20)
            second.Wait()
            Console.WriteLine("System.Threading.Timer fired on a pool thread, at least twice: " & (fired >= 2).ToString())
        End Using

        TicksAsync().GetAwaiter().GetResult()
    End Sub
End Module
System.Threading.Timer fired on a pool thread, at least twice: True
PeriodicTimer stopped by the token after 4 ticks (3 or 4 expected: True)

Source: PeriodicTimer

(System.Timers.Timer and the Windows Forms and WPF timers also exist; the Windows Forms timer ticks on the UI thread, the others on pool threads.)

BackgroundWorker versus Async/Await

BackgroundWorker is the pre-Async way to run work off the UI thread: DoWork runs on a pool thread, ProgressChanged and RunWorkerCompleted are raised on the thread that called RunWorkerAsync (through its SynchronizationContext). It still works on .NET, and Windows Forms code from the Framework era is full of it. New code replaces it with Await Task.Run(...) and IProgress(Of T):

Built and run: the same import with BackgroundWorker and with Task.Run plus IProgress
Option Strict On

Imports System.ComponentModel
Imports System.Threading

Module Program
    Sub OldWay()
        Dim worker As New BackgroundWorker With {.WorkerReportsProgress = True, .WorkerSupportsCancellation = True}
        Dim reports As New List(Of String)
        Dim finished As New ManualResetEventSlim(False)

        AddHandler worker.DoWork, Sub(sender As Object, e As DoWorkEventArgs)
                                      For i As Integer = 1 To 4
                                          Thread.Sleep(20)
                                          DirectCast(sender, BackgroundWorker).ReportProgress(i * 25)
                                      Next
                                      e.Result = "imported 4 batches"
                                  End Sub
        AddHandler worker.ProgressChanged, Sub(sender As Object, e As ProgressChangedEventArgs)
                                               SyncLock reports
                                                   reports.Add(e.ProgressPercentage.ToString())
                                               End SyncLock
                                           End Sub
        AddHandler worker.RunWorkerCompleted, Sub(sender As Object, e As RunWorkerCompletedEventArgs)
                                                  Console.WriteLine("BackgroundWorker completed: " & CStr(e.Result) & ", cancelled = " & e.Cancelled.ToString())
                                                  finished.Set()
                                              End Sub
        worker.RunWorkerAsync()
        finished.Wait()
        Thread.Sleep(100)
        SyncLock reports
            Console.WriteLine("progress reports (sorted): " & String.Join(" ", reports.OrderBy(Function(r) CInt(r))))
        End SyncLock
    End Sub

    Async Function NewWay() As Task
        Dim reports As New List(Of Integer)
        Dim progress As New Progress(Of Integer)(Sub(p)
                                                    SyncLock reports
                                                        reports.Add(p)
                                                    End SyncLock
                                                End Sub)
        Dim result As String = Await Task.Run(Function()
                                                  For i As Integer = 1 To 4
                                                      Thread.Sleep(20)
                                                      DirectCast(progress, IProgress(Of Integer)).Report(i * 25)
                                                  Next
                                                  Return "imported 4 batches"
                                              End Function)
        Await Task.Delay(100)
        Console.WriteLine("Task.Run + IProgress completed: " & result)
        SyncLock reports
            Console.WriteLine("progress reports (sorted): " & String.Join(" ", reports.OrderBy(Function(r) r)))
        End SyncLock
    End Function

    Sub Main()
        OldWay()
        NewWay().GetAwaiter().GetResult()
    End Sub
End Module
BackgroundWorker completed: imported 4 batches, cancelled = False
progress reports (sorted): 25 50 75 100
Task.Run + IProgress completed: imported 4 batches
progress reports (sorted): 25 50 75 100

In a console program there is no context, so the events run on pool threads in no fixed order (the output is sorted for that reason); in a Windows Forms form they run on the UI thread, in order. The async form is shorter, composes with Try/Catch (exceptions arrive at the Await instead of in e.Error) and supports cancellation with a token instead of CancelAsync plus polling.

Built, not run: a BackgroundWorker form, and Invoke for other threads
Option Strict On

Imports System.ComponentModel
Imports System.Windows.Forms

Public Class ImportForm
    Inherits Form

    Private ReadOnly _status As New Label() With {.Dock = DockStyle.Top}
    Private ReadOnly _start As New Button() With {.Text = "Import", .Dock = DockStyle.Bottom}
    Private WithEvents Worker As New BackgroundWorker() With {.WorkerReportsProgress = True}

    Public Sub New()
        Controls.Add(_status)
        Controls.Add(_start)
        AddHandler _start.Click, Sub(sender, e) Worker.RunWorkerAsync()
    End Sub

    ' DoWork runs on a pool thread: it must not touch controls.
    Private Sub Worker_DoWork(sender As Object, e As DoWorkEventArgs) Handles Worker.DoWork
        For i As Integer = 1 To 4
            Threading.Thread.Sleep(100)
            Worker.ReportProgress(i * 25)
        Next
    End Sub

    ' ProgressChanged and RunWorkerCompleted are raised on the UI thread.
    Private Sub Worker_ProgressChanged(sender As Object, e As ProgressChangedEventArgs) Handles Worker.ProgressChanged
        _status.Text = e.ProgressPercentage.ToString() & "%"
    End Sub

    Private Sub Worker_RunWorkerCompleted(sender As Object, e As RunWorkerCompletedEventArgs) Handles Worker.RunWorkerCompleted
        _status.Text = "done"
    End Sub

    ' From any other thread: marshal to the UI thread explicitly.
    Public Sub SetStatusFromAnyThread(text As String)
        If InvokeRequired Then
            Invoke(Sub() _status.Text = text)
        Else
            _status.Text = text
        End If
    End Sub
End Class

Module Program
    <STAThread>
    Sub Main()
        Application.EnableVisualStyles()
        Application.Run(New ImportForm())
    End Sub
End Module

This compiles on net10.0-windows with UseWindowsForms; it was not run. The Handles clauses on WithEvents Worker wire the three events (see Events and Handlers); InvokeRequired/Invoke is the way to touch a control from any other thread when you are not using Await. In WPF the equivalent is Dispatcher.Invoke or Dispatcher.InvokeAsync. A control must only be touched on the thread that created it, and Control.CheckForIllegalCrossThreadCalls = False hides the symptom without removing the bug.

Errors and what they mean

Option Strict On

Imports System.Threading

Module Program
    Private Counter As Integer

    Sub Work(batch As Integer)
    End Sub

    Sub Main()
        SyncLock Counter                           ' a value type: boxed each time, so it would never lock
        End SyncLock

        Dim t As New Thread(AddressOf Work)        ' Work(Integer) matches neither thread delegate
    End Sub
End Module
Program.vb(12,18): error BC30582: 'SyncLock' operand cannot be of type 'Integer' because 'Integer' is not a reference type.
Program.vb(15,22): error BC30518: Overload resolution failed because no accessible 'New' can be called with these arguments:
Option Strict On

Module Program
    Private Volatile Stopped As Boolean            ' there is no Volatile keyword
    Sub Main()
    End Sub
End Module
Program.vb(4,22): error BC30205: End of statement expected.
Option Strict On

Imports System.Threading

Module Program
    Sub Main()
        Dim x As Integer = 0
        Parallel.For(0, 10, Sub(i)
                                x += 1
                                Exit For
                            End Sub)
    End Sub
End Module
Program.vb(10,33): error BC30096: 'Exit For' can only appear inside a 'For' statement.
Code Message (shortened) Typical cause and fix

BC30582

'SyncLock' operand cannot be of type 'Integer' because 'Integer' is not a reference type.

Locking a value-type field. Lock a private Object.

BC30518

Overload resolution failed because no accessible 'New' can be called with these arguments.

New Thread(AddressOf Work) where Work takes an Integer; take an Object or use a lambda.

BC30205

End of statement expected.

Private Volatile Stopped As Boolean: there is no Volatile keyword; use Volatile.Read and Volatile.Write.

BC30096

'Exit For' can only appear inside a 'For' statement.

Exit For in a Parallel.For lambda; use state.Break() or state.Stop().

BC37329

A value of type 'System.Threading.Lock' is not supported in SyncLock.

Use Enter/Exit in Try/Finally.

BC42508 (warning)

A value of type 'System.Threading.Lock' converted to a different type will use likely unintended monitor-based locking.

A Lock passed as Object to SyncLock.

BC36943

'Await' cannot be used inside a 'Catch' statement, a 'Finally' statement, or a 'SyncLock' statement.

See the async page.

BC42324 (warning)

Using the iteration variable in a lambda expression may have unexpected results.

Copy the For variable to a local before the lambda.

SYSLIB0006 (warning)

Thread.Abort is not supported and throws PlatformNotSupportedException.

Use cooperative cancellation.

Visual Basic versus C#

Topic Visual Basic C#

Mutual exclusion

SyncLock obj …​ End SyncLock

lock (obj) { }

Lock type

not usable in SyncLock (BC37329); explicit Enter/Exit in Try/Finally; EnterScope unusable

lock recognises Lock (C# 13)

Volatile fields

no keyword; Volatile.Read/Volatile.Write

volatile modifier

Value-type lock operand

BC30582

CS0185

Await inside the lock

BC36943

CS1996

Thread entry

New Thread(AddressOf M) or Sub() ...

new Thread(M) or a lambda

Thread-static

<ThreadStatic> Private Shared x

[ThreadStatic] static

Parallel loop body

Sub(i) ...; Exit For is BC30096

lambda; return

Locals captured by lambdas

can be passed ByRef to Interlocked

same (ref)

Query syntax on ParallelQuery

From ... In x.AsParallel()

from ... in x.AsParallel()

Thread.Abort

warning SYSLIB0006, PlatformNotSupportedException

same

Older material you may meet

  • Visual Basic 6 had no real threading, and tutorials that start Thread with ThreadStart/Abort pairs date from the Framework era. Abort, Suspend and Resume do not work on .NET.

  • Books that show the thread pool through ThreadPool.QueueUserWorkItem and WaitCallback are correct but have been superseded by Task.Run. BackgroundWorker chapters remain valid for existing Windows Forms code.

  • SyncLock Me and SyncLock GetType(Foo) appear in older samples; both are discouraged today because the lock object is visible to outsiders.

  • Samples that rely on Application.DoEvents() instead of a background thread are a workaround for a frozen UI; use Async/Await.

  • A lock on a value-type counter is a compile error (BC30582); old examples that "lock" a boxed Integer never worked.

  • Material that shows Delegate.BeginInvoke for background work fails on .NET with PlatformNotSupportedException.

  • The Volatile.Read and Volatile.Write methods, and Interlocked, are the supported memory-ordering tools; there is no Visual Basic Volatile keyword in any version.

Pitfalls checklist

Pitfall What to do

Unsynchronised += on a shared field

Interlocked.Increment/Add, or SyncLock.

Locking on Me, a Type or a String

Lock on a private ReadOnly Object.

Taking two locks in different orders

Define one global order; use TryEnter with a timeout.

Calling out (events, callbacks) while holding a lock

Copy what you need, release, then call.

Await inside SyncLock

SemaphoreSlim(1, 1) and WaitAsync.

Blocking pool threads (Thread.Sleep, .Result)

Use Await and Task.Delay; dedicated threads for long blocking work.

Sharing a List or Dictionary between threads

Concurrent collections, immutable snapshots, or one lock for every access.

Side effects in AddOrUpdate/GetOrAdd delegates

Keep them pure; they can run more than once.

<ThreadStatic> initialisers

They run once; initialise on first use.

Parallel.For on tiny bodies

Measure; sequential is often faster.

Updating controls from a non-UI thread

Await, Invoke, Dispatcher.

Assuming Volatile exists as a keyword

It does not; use Volatile.Read/Volatile.Write.

Using Thread.Abort

Cancel cooperatively.