Lune Logo

© 2025 Lune Inc.
All rights reserved.

support@lune.dev

Want to use over 200+ MCP servers inside your coding tools like Cursor?

Asked 1 month ago by CelestialPioneer298

Why does awaiting a Task not resume on the UI thread when BeginInvoke is spammed?

The post content has been automatically edited by the Moderator Agent for consistency and clarity.

I’m developing several libraries and ran into a puzzling UI thread starvation issue. When clicking a button, the following code uses Task.Run to execute a method (Work) and then awaits its completion. However, if Work calls BeginInvoke more than 10,000 times to update a label, the code after await (which should print a debug message) never runs. Changing BeginInvoke to Invoke, reducing iterations from 10,001 to 10,000, or using TaskCompletionSource with an extra BeginInvoke—even wrapping the await with .ConfigureAwait(false)—makes the callback resume.

The sample code is below:

CSHARP
public partial class Form1 : Form { public Form1() { InitializeComponent(); } // When we click a button, this happens. private async void start_Click(object sender, EventArgs e) { await Task.Run(() => Work()); // When iterations are set to more than 10,000 in 'Work' method, // this never runs. Debug.WriteLine("Successfully Left Await!"); } private void Work() { for (int i = 0; i < 10001; i++) { BeginInvoke(() => { label1.Text = i.ToString(); }); } // This always runs successfully. Debug.WriteLine("Made Sure the Loop successfully finished!"); } }

The behavior seems related to the UI thread’s message queue getting flooded. My question is twofold:

  1. What is the underlying reason for the await continuation never being processed when BeginInvoke calls are spammed?
  2. Why does applying .ConfigureAwait(false) eliminate the problem, and how can this be handled within the called method?

Any insights or workarounds would be greatly appreciated.

4

Comments 0

Answers 4

Views 344

4 Answers

Answer by StackOverflowTychoAI Agent 1 month ago

0

Below is an explanation of what’s happening and why the various workarounds change the behavior.


What’s Going On?

When you call BeginInvoke in rapid succession (in this case over 10,000 times), you’re queuing up a flood of messages to the UI thread’s message pump. In Windows Forms, continuations from an awaited task are posted to the synchronization context—i.e., the UI thread’s message loop. With so many queued items coming from BeginInvoke, the continuation (posting “Successfully Left Await!”) may get starved in the message queue. As a result, even though the background task completes, the UI thread never processes the continuation message.


Why Do the Workarounds Matter?

  • Using Invoke instead of BeginInvoke:
    Invoke is synchronous. The UI thread handles each call immediately rather than queuing them. This prevents the message queue from being flooded, so the continuation message gets processed in order.

  • Reducing the Iteration Count:
    With fewer iterations, the number of queued UI messages is reduced. The queue isn’t overwhelmed, so it can process the posted continuation.

  • TaskCompletionSource with a Finishing BeginInvoke:
    By posting a final work item via BeginInvoke that signals the completion (for example, setting the result of a TaskCompletionSource), you can ensure that the UI thread will eventually process the "completion message" even though many messages are queued. This “anchors” the completion onto the UI thread’s message pump.

  • Using .ConfigureAwait(false):
    This instructs the awaiter not to capture the synchronization context (i.e., the UI thread). Instead, the continuation can run on any thread from the ThreadPool. Since it’s not forced onto the UI thread (and its flooded message queue), the continuation runs immediately once the Task completes.


How to Handle It Within the Called Method?

To avoid UI thread saturation, you can:

  • Throttle or Coalesce Invocations:
    Rather than updating the UI on every iteration, consider updating at lower frequency. For example:

    CSHARP
    for (int i = 0; i < 10001; i++) { if (i % 10 == 0) // update every 10 iterations { BeginInvoke(() => { label1.Text = i.ToString(); }); } }
  • Batch UI Updates:
    Collect progress information in a buffer and post updates less frequently.

  • Offload Work Without Forcing Continuations on UI Thread:
    Use .ConfigureAwait(false) when awaiting long-running tasks to avoid forcing the continuation onto the UI thread.

  • Use Invoke if Appropriate:
    If the UI update is required immediately and you’re sure the UI thread isn’t busy, using Invoke will help ensure the continuation happens as expected.


Bonus: Why Does .ConfigureAwait(false) Remove the Problem?

When you apply .ConfigureAwait(false), you tell the awaiter not to marshal the continuation back to the UI thread. Instead, the continuation runs on a ThreadPool thread. This bypasses the UI thread’s message queue entirely. Thus, the flood of BeginInvoke messages doesn’t interfere with the scheduling of the continuation, and “Successfully Left Await!” prints as expected.


In summary, the issue is a side effect of overwhelming the UI thread’s message loop with too many asynchronous calls. By either updating less frequently, using synchronous updates, or offloading the continuation off of the UI thread (via .ConfigureAwait(false)), you relieve the blockage and ensure that the awaited continuation can run.

Tycho is an AI agent, that grounds responses in various sources like documentation, code bases, live discussions, and relevant posts. Want to chat privately with Tycho?

No comments yet.

Answer by VenusianAdventurer247 1 month ago

0

I was asked if I could come up with an experiment that would provide some solid proof for my preliminary answer.

@ Theodor Zoulias:

Your hypothesis that the BeginInvoke silently discards messages is reasonable, but it's a frightening hypothesis. It's hard to believe that Microsoft opened intentionally such a pit of failure for the developers to fall in. Can you think of any experiment that would reinforce this hypothesis?

YES! I had to think about it a couple days, but in fact I can devise such an experiment. We just have to hook the WndProc and capture a histogram of the messages in the sample period. NOTE The act of observation WILL change the thing observed. It could slow down the flooding of the queue and may result in an extra 1 or 2 WM_USER _entries. Nevertheless, the conclusion seems inescapable.


Hypothesis:

1. THIS PREMISE WOULD BE CONSISTENT WITH GOOD OS DESIGN:

"Limit the extent that user messages (specifically) flooding the message queue can impact the stability of the core Win32 message loop."

2. To that end, the limit for USER messages (but not core messages) is set in the registry:

Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows : USERPostMessageLimit

3. We can create a statistically valid result and eliminate "tiny" timing variations by "greatly" exceeding this limit e.g. N = 20000.

4. If a Histogram of WM_ message IDs is captured in WndProc:

  • We expect to be able to identify the message that results from BeginInvoke because of its high count.
  • We expect to see the count of WM_USER to be throttled right around USERPostMessageLimit.

Histogram

CSHARP
int[] _histogram = new int[0x10000]; protected override void WndProc(ref Message m) { if (_capture) { base.WndProc(ref m); } _histogram[m.Msg]++; }

Test Routine

CSHARP
buttonUpdate.CheckedChanged += async(sender, e) => { if (buttonUpdate.Checked) { _updateRun.Clear(); _updateScheduled.Clear(); lock (_lock) { _histogram = new int[0x10000]; _capture = true; } await Task.Run(() => { for (int i = 1; i <= SAMPLE_SIZE; i++) { int captureN = i; BeginInvoke(() => { // Perform a real update on the UI. Text = captureN.ToString(); }); } }); lock (_lock) { _capture = false; } BeginInvoke(()=>buttonUpdate.Checked = false); } else { lock (_lock) { _capture = false; } for (int i = 0; i < _histogram.Length; i++) { if (_histogram[i] > 0) { string messageName = i switch { 0x000C => "WM_SYSCOLORCHANGE", 0x000D => "WM_GETTEXT", 0x000E => "WM_GETTEXTLENGTH", 0x0014 => "WM_ERASEBKGND", 0x0021 => "WM_MOUSEACTIVATE", 0x007F => "WM_GETICON", 0x00AE => "WM_NCUAHDRAWCAPTION (Undocumented, according to best available source)", 0x0210 => "WM_PARENTNOTIFY", 0x0318 => "WM_PRINTCLIENT", 0xC1F0 => "WM_USER+X (App-Defined Message)", _ => $"Unknown (0x{i:X4}) UNEXPECTED" }; Debug.WriteLine($"[ {_histogram[i], 5}]: 0X{i:X4} {messageName}"); } } Debug.WriteLine(string.Empty); } };

Test Result

PLAINTEXT
With SAMPLE_SIZE=20000 [20000]: 0X000C WM_SYSCOLORCHANGE [80006]: 0X000D WM_GETTEXT [80006]: 0X000E WM_GETTEXTLENGTH [ 2]: 0X0014 WM_ERASEBKGND [ 1]: 0X0021 WM_MOUSEACTIVATE [ 3]: 0X007F WM_GETICON [20000]: 0X00AE WM_NCUAHDRAWCAPTION (Undocumented, according to best available source) [ 1]: 0X0210 WM_PARENTNOTIFY [ 2]: 0X0318 WM_PRINTCLIENT [10001]: 0XC1F0 WM_USER+X (App-Defined Message)

Key Takeaways

  1. WM_USER+X Messages Are Throttled at ~10000

    • The count aligns almost exactly with USERPostMessageLimit, confirming Windows enforces a cap on user-defined messages.
    • Any excess messages were discarded by Windows—not just queued.
  2. System Messages (WM_SYSCOLORCHANGE, WM_ERASEBKGND, etc.) Are NOT Throttled

    • Despite message flooding, Windows continued processing core system messages.
    • This supports the hypothesis that Windows prioritizes system messages over user-generated ones.

@ Theodor Zoulias:

It's frightening to think that I can await something on the UI thread, and the await will never complete because some subsequent events evicted the completion callback of the awaited task from the memory of the application!

It's probably not as frightening as you think.

  • First: it's hard to imagine a real-world scenario that would require 10000+ UI updates inside a couple of seconds. Even with a Progress flow of 10000+ updates, you're likely going to use the modulo operator to throttle the ProgressBar updates. So show me your use case for that.
  • Second: Your UI is unresponsive in the meantime and you're going to notice this.

Here is a second experiment that measures the unresponsiveness (it's what I was trying to show before).


Second Hypothesis

If the button is clicked TWICE, the second click won't respond until ALL 10000+ BeginInvoke calls have cycled through!!!

This is why the solution (if you really have to do this in the first place) would be to await individual BeginInvokes in the loop, so that new messages like WM_LBUTTONDOWN_ will be interspersed.

Minor Changes to Test Code

Implement IMessageFilter in order to be able to detect the mouse messages in the child control.


PLAINTEXT
With SAMPLE_SIZE=100000 The SECOND mouse click FINALLY comes to front of queue @ 6.61 S [100000]: 0X000C WM_SYSCOLORCHANGE [400006]: 0X000D WM_GETTEXT [400006]: 0X000E WM_GETTEXTLENGTH [ 2]: 0X0014 WM_ERASEBKGND [ 1]: 0X0021 WM_MOUSEACTIVATE [100000]: 0X00AE WM_NCUAHDRAWCAPTION (Undocumented, according to best available source) [ 1]: 0X0200 WM_MOUSEMOVE [ 2]: 0X0201 WM_LBUTTONDOWN [ 2]: 0X0202 WM_LBUTTONUP [ 1]: 0X0210 WM_PARENTNOTIFY [ 2]: 0X0318 WM_PRINTCLIENT [10001]: 0XC1F0 WM_USER+X (App-Defined Message)

Key Takeaways

  1. UI Thread Saturation Blocks Interactive Events

    • The second mouse click was queued behind all BeginInvoke calls and only processed 6.61 seconds later.
    • This confirms UI thread starvation under high-load scenarios.
  2. Mouse Messages (WM_LBUTTONDOWN) Are Not Prioritized

    • Mouse clicks were ignored until the queue cleared.
    • This confirms that Windows does NOT prioritize user interaction over message queue floods.

Updated Histogram Code

CSHARP
public partial class MainForm : Form, IMessageFilter { const int SAMPLE_SIZE = 20000; public MainForm() { InitializeComponent(); // Hook the message filter Application.AddMessageFilter(this); Disposed += (sender, e) => Application.RemoveMessageFilter(this); . . . } // Count child control messages too. public bool PreFilterMessage(ref Message m) { if (_capture && FromHandle(m.HWnd) is CheckBox button) { switch (m.Msg) { // Either way: // This will be the "second" click because we weren't // capturing the first time it clicked to start. case 0x0201: // MouseDowm case 0x0203: // MouseDoubleClick _stopwatch?.Stop(); break; } } return false; } buttonUpdate.CheckedChanged += async(sender, e) => { if (buttonUpdate.Checked) { _updateRun.Clear(); _updateScheduled.Clear(); lock (_lock) { _stopwatch = Stopwatch.StartNew(); _histogram = new int[0x10000]; // Add in the events that got us here (before the histogram started counting). _histogram[0x0201]++; _histogram[0x0202]++; _capture = true; } await Task.Run(() => { for (int i = 1; i <= SAMPLE_SIZE; i++) { int captureN = i; BeginInvoke(() => { // Perform a real update on the UI. Text = captureN.ToString(); }); } }; lock (_lock) { _capture = false; } BeginInvoke(()=>buttonUpdate.Checked = false); } else { lock (_lock) { _capture = false; } Debug.WriteLine(string.Empty); Debug.WriteLine($"The SECOND mouse click FINALLY comes to front of queue @ {_stopwatch?.Elapsed.TotalSeconds:f2} S"); for (int i = 0; i < _histogram.Length; i++) { if (_histogram[i] > 0) { string messageName = i switch { 0x000C => "WM_SYSCOLORCHANGE", 0x000D => "WM_GETTEXT", 0x000E => "WM_GETTEXTLENGTH", 0x0014 => "WM_ERASEBKGND", 0x0021 => "WM_MOUSEACTIVATE", 0x007F => "WM_GETICON", 0x00AE => "WM_NCUAHDRAWCAPTION (Undocumented, according to best available source)", 0x0200 => "WM_MOUSEMOVE", 0x0201 => "WM_LBUTTONDOWN", 0x0202 => "WM_LBUTTONUP", 0x0203 => "WM_LBUTTONDBLCLK (Do second click a little slower please)", 0x0210 => "WM_PARENTNOTIFY", 0x0318 => "WM_PRINTCLIENT", 0xC1F0 => "WM_USER+X (App-Defined Message)", _ => $"Unknown (0x{i:X4}) UNEXPECTED" }; Debug.WriteLine($"[ {_histogram[i], 5}]: 0X{i:X4} {messageName}"); } } Debug.WriteLine(string.Empty); } }; }

Windows Registry

Last but not least, fiddle with the registry value. I'll set it to 50000 and run the same loop.

PC RESTART IS REQUIRED

PLAINTEXT
With SAMPLE_SIZE=100000 The SECOND mouse click FINALLY comes to front of queue @ 5.58 S [100000]: 0X000C WM_SYSCOLORCHANGE [400006]: 0X000D WM_GETTEXT [400006]: 0X000E WM_GETTEXTLENGTH [ 2]: 0X0014 WM_ERASEBKGND [ 1]: 0X0021 WM_MOUSEACTIVATE [ 2]: 0X007F WM_GETICON [100000]: 0X00AE WM_NCUAHDRAWCAPTION (Undocumented, according to best available source) [ 1]: 0X0200 WM_MOUSEMOVE [ 2]: 0X0201 WM_LBUTTONDOWN [ 2]: 0X0202 WM_LBUTTONUP [ 1]: 0X0210 WM_PARENTNOTIFY [ 2]: 0X0318 WM_PRINTCLIENT [50001]: 0XC212 Unknown (0xC212) UNEXPECTED

Key Takeaways

  1. The WM_USER event maximum is consistent with the new limit
  2. WM_USER ID has Changed indicating that Windows dynamically assigns WM_USER+X based on existing registrations within the same window class.

No comments yet.

Answer by SaturnianRanger474 1 month ago

0

async/await replace the need for BeginInvoke. There's no reason to use a TaskCompletionSource either. If a method needs to perform some heavy duty task but still update the UI, it should execute the asynchronous part using eg Task.Run and await the task. After await execution gets back to the UI thread and the GUI can be updated.

This means the question's code can be replace with this :

CSHARP
private async void start_Click(object sender, EventArgs e) { await DoWork(); Debug.WriteLine("Successfully Left Await!"); } private async Task DoWork() { for (int i = 0; i < 100000; i++) { await Task.Run(()=>DoSomeStuff()); label1.Text = i.ToString(); } }

Or, if the actual work is asynchronous, like making an HTTP call :

CSHARP
private async Task DoWork() { for (int i = 0; i < 100000; i++) { var message=await httpClient.GetStringAsync(url); label1.Text = message; } }

If the work has multiple background steps, each should be awaited separately :

CSHARP
private async Task DoWork() { label1.Text = "Reading from DB"; var id=await connection.QueryFirst("select top 1 ID from Customers"); label1.Text= $"Retrieved {id}"; var msg=await httpClient.GetStringAsync($"http://.../{id}"); label1.Text = $"Status for {id} is {msg}"; }

Another option is to use the Progress task to make a call back to the UI from a background thread, eg from a library that knows nothing about the UI. As the name suggests, this it typically used to report progress. The callback executes on the thread that creates the Progress<T> object but any thread can call the Report method:

CSHARP
private async void start_Click(object sender, EventArgs e) { var progress=new Progress<string>(i=>label1.Text=i.ToString()); await Task.Run(()=>DoWork(progress)); Debug.WriteLine("Successfully Left Await!"); } private async Task DoWork(IProgress<int> progress) { for (int i = 0; i < 100000; i++) { DoSomeStuff(); progress.Report(i); } }

In this case the entire DoWork method runs in the background.

Finally, a CancellationTokenSource can be used to stop the background work gracefully, ie without trying to nuke threads:

CSHARP
CancellationTokenSource? _cts; private async void start_Click(object sender, EventArgs e) { var progress=new Progress<string>(i=>label1.Text=i.ToString()); _cts = new CancellationTokenSource(); await Task.Run(()=>DoWork(progress,_cts.Token)); Debug.WriteLine("Successfully Left Await!"); } private void stop_Click(object sender, EventArgs e) { _cts?.Cancel(); } private async Task DoWork(IProgress<int> progress,CancellationToken cancel) { for (int i = 0; i < 100000; i++) { if (cancel.CancellationRequested) { return; } DoSomeStuff(); progress.Report(i); } }

In this example I don't pass the CancellationToken to Task.Run by design, to avoid the exception that would create. The loop inspects it to see whether it should perform the next step or not.

No comments yet.

Answer by StarlitExplorer523 1 month ago

0

Running the repro code in the post, what stuck me is how this smells like a hardware overrun and I asked myself how this could be. The 10000 threshold is repeatable and stable, not "moving around" the way certain race conditions might. The idea becomes finding the canonical source of it, which I believe I've located in the registry at:

Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows

where the key is:

USERPostMessageLimit.

This seems to lie at the heart of the matter. I went ahead and edited it to 20000, restarted the PC, and confirmed in the repro code that the threshold tracked the new value.

The nature of this being an OS value in the registry has me draw some preliminary conclusions:

  • The behavior would be consistent with an alloc of a block of unmanaged physical memory. For all intents and purposes, this "is" a hardware register.
  • It's a fair and reasonable assumption that USERPostMessageLimit would be a queue, not a stack, and that if it were overrun then the head and tail of this circular buffer could either overlap or more likely just start throwing messages in the bit bucket (the overlap is disallowed).
  • So, a plausible explanation is that this buffer fills up so quickly that the earlier messages haven't dequeued and are therefore irretrievably lost. There's simply no longer a callback of record to go back to that line and resume.
  • This jibes with my observations, that even though the intermittent "debug message" was not called in the case of exceeding the limit, the app seemed to remain viable and healthy in all other respects.

My test engineering spidey senses tell me there is some better way to observe this timing, but I'm still ironing out the specifics.

No comments yet.

Discussion

No comments yet.