-
Notifications
You must be signed in to change notification settings - Fork 983
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Introduce InvokeAsync, ShowAsync, ShowDialogAsync #4631
Comments
This comment has been minimized.
This comment has been minimized.
public Task InvokeAsync(
Func<Task> invokeDelegate,
TimeSpan timeOutSpan = default,
CancellationToken cancellationToken = default,
params object[] args) The Also do you really want If you divert from Also (without seeing how the private InvokeAsync is called) I suspect this is an overly complex and probably bad approach to implement InvokeAsync, but shouldn't derail the API discussion, implementation can be discussed separately if desired. (For example I'm missing unrolling and awaiting of the Cancellation also seems suspect
Thats not to say cancellation isn't useful to have in this API, if you do BeginInvoke behavior of posting you may want to cancel your posted callback before it runs. |
namespace System.Windows.Forms
{
public partial class Control
{
public Task InvokeAsync(
Func<Task> invokeDelegate,
TimeSpan timeOutSpan = default,
CancellationToken cancellationToken = default,
params object[] args
);
public async Task<T> InvokeAsync<T>(
Func<Task<T>> invokeDelegate,
TimeSpan timeOutSpan = default,
CancellationToken cancellationToken = default,
params object[] args
);
}
} |
Just skimming the post now... Am I understanding correctly that the timeout and CancellationToken don't actually impact the execution of the invokeDelegate, rather they cause the returned task to transition to a completed state even when the invokeDelegate may still be executing? If so, that's exactly what the new WaitAsync methods will do: await InvokeAsync(() => ...).WaitAsync(cancellationToken, timeout); That WaitAsync takes the task from the InvokeAsync and returns a task that also incorporates cancellation / timeout, e.g. if cancellation is requested, the task returned from WaitAsync will be canceled even though the task returned from InvokeAsync may still be running. |
Alternatively, I've been using the following version, modified to support public static async Task<T?> InvokeAsync<T>(
this Control @this,
Delegate invokeDelegate,
TimeSpan timeOutSpan = default,
CancellationToken = default,
params object?[]? args)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
if (timeOutSpan != default)
{
cts.CancelAfter(timeOutSpan);
}
var tcs = new TaskCompletionSource<T?>(TaskCreationOptions.RunContinuationsAsynchronously);
@this.BeginInvoke(new Action(() =>
{
try
{
// don't invoke the delegate if cancelled
cts.Token.ThrowIfCancellationRequested();
tcs.TrySetResult((T?)invokeDelegate.DynamicInvoke(args));
}
catch(Exception ex)
{
tcs.TrySetException(ex);
}
}), null);
using (cts.Token.Register(() => tcs.TrySetCanceled()))
{
return await tcs.Task;
}
} Edited: addressing the comments, here is an updated version that supports both regular and async delegates for |
Contrary to the original method (where I couldn't see the caller) this is public, thus definitely broken, because it doesn't unwrap and await the task returned by When passing an actual async function the In other words, your I believe you shouldn't be writing a catch-all method handling arbitrary delegates. While you could try inspecting the return type of the delegate and "guess" based on its type whether you have to await it, that only works for The IMHO correct solution is to only accept |
I guess I see what you mean. That version was for invoking synchronous delegates, and the real code accepts an static async Task<T?> InvokeAsync<T>(this Control @this, Func<T?> func, CancellationToken cancellationToken) As for async lambdas, I use a different override (similar to how public static async Task<T?> InvokeAsync<T>(
this Control @this,
Func<CancellationToken, Task<T?>> asyncFunc,
CancellationToken cancellationToken = default)
{
var tcs = new TaskCompletionSource<T?>(TaskCreationOptions.RunContinuationsAsynchronously);
@this.BeginInvoke(new Action(async () =>
{
// we use async void on purpose here
try
{
cancellationToken.ThrowIfCancellationRequested();
tcs.TrySetResult((T?)await asyncFunc(cancellationToken));
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
}), null);
using (cancellationToken.Register(() => tcs.TrySetCanceled()))
{
return await tcs.Task;
}
}
Totally agree and that's how I do it in my projects, following the pattern of many others existing APIs in .NET. Edited, as a matter of fact, @KlausLoeffelmann's version throws for the following code due to the same reason: private async void Form1_Load(object sender, EventArgs e)
{
// we're on UI thread
await Task.Run(async () =>
{
// we're on thread pool
var res = await this.InvokeAsync<bool>(new Func<Task<bool>>(async () =>
{
// we're on UI thread
await Task.Delay(5000);
return true;
}));
// we're on thread pool again
await Task.Delay(1000);
});
// back on UI thread
MessageBox.Show("Done");
}
|
The original method seems to be broken too for async lambdas, as I've shown above. However, it's still possible to make it work for async lambdas disguised as untyped Delegates. It's a questionable design, but it's also the legacy of Here's a take at that, it works for both regular and async delegates: public static async Task<T?> InvokeAsync<T>(
this Control @this,
Delegate invokeDelegate,
TimeSpan timeOutSpan = default,
CancellationToken cancellationToken = default,
params object?[]? args)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
if (timeOutSpan != default)
{
cts.CancelAfter(timeOutSpan);
}
var tcs = new TaskCompletionSource<T?>(TaskCreationOptions.RunContinuationsAsynchronously);
async void Invoker()
{
// async void makes sense here
try
{
cts.Token.ThrowIfCancellationRequested();
var result = invokeDelegate.DynamicInvoke(args);
// if the result is a Task, await it
if (result is Task task)
{
await task;
tcs.TrySetResult(((Task<T?>)task).GetAwaiter().GetResult());
}
else
{
tcs.TrySetResult((T?)result);
}
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
}
@this.BeginInvoke(new Action(Invoker));
using (cts.Token.Register(() => tcs.TrySetCanceled()))
{
return await tcs.Task;
}
} |
Absolutely. One of the features I use Invoke for, and which I find super important in scenarios, where I want dedicatedly a method call to be getting event characteristics through scheduling it on the message queue. Even if not fired off from a different thread. I’ll add a rational for that as well in the nearer future, and I am really interested in a discussion about alternative approaches for those scenarios. |
// simplified pseudocode for how you would implement one if you have the other
// if InvokeAsync behaves as BeginInvoke as proposed you can check InvokeRequired
public static async Task InvokeAsync_Inline(Func<Task> callback)
{
if (InvokeRequired)
await InvokeAsync_Yield(callback);
else
await callback();
}
// the currently proposed semantic could be reimplemented as extension method if InvokeAsync would inline
public static async Task InvokeAsync_Yield(Func<Task> callback)
{
if (!InvokeRequired)
{
await Task.Yield();
await callback(); // since InvokeAsync_Inline(callback) would inline it anyways
}
else
{
await InvokeAsync_Inline(callback);
}
} If |
I wonder if something like @kevingosse proposed in his article would allow us achieve the desired in a different way? |
The linked article is more like vs-threading using the pattern This pattern means you explicitly "change" threads by awaiting a custom awaiter, which will either be a no-op if you are on the right thread, or suspend execution and resume it on the right thread (by posting to the SynchronizationContext, which is equivalent to a BeginInvoke). Having explicit async-await thread switches can be nice for readability in some scenarios, but make it worse in others since there is a hidden implication of the "current thread type" you have to keep in your mind when reading the source. We've been using the vs-threading library ever since it was open sourced (works for both WinForms and WPF and also solves deadlock issues you'd have with naive implementation of such thread switches), but I believe this approach is orthogonal to a one-off |
That's a very interesting approach, thanks for the link. I personally like @kevingosse's initial idea, without the added private async Task UpdateControls()
{
await Dispatcher.SwitchToUi();
TextTime.Text = DateTime.Now.ToLongTimeString();
} This kind of tells the intention right away, in a very readable way, IMHO. A similar API has been already proposed for .NET 6.0. A potential problem I see with this is that we need to somehow remember the UI thread's task scheduler ( That said, a well-designed UI app should be updating the UI indirectly via a proper pattern (MVP/MVVM/MVU). So, we'd have a view model object, which could keep a references to the UI thread's task scheduler. That's where something like |
Only a problem if you want to build it as an external library, WinForms internally can have all the magic it wants, since it is providing the SynchronizationContext in the first place. That said However note that
Like said above, I believe both approaches are valid, have their own advantages and can live alongside each other. |
Git Extensions has been using Microsoft.VisualStudio.Threading as well (gitextensions/gitextensions#4601, thanks to @sharwell), but it required additional tweaks and plumbing (e.g. ControlThreadingExtensions and ThreadHelper), and then further more tweaks to work reliably in UI tests. Those aren't the most straight forward implementations, and likely a lot of (Windows Forms) developers will find those intimidating. |
My main concern with the I will say: once the plumbing for vs-threading is in place, the code using that approach is dramatically cleaner than code trying to use |
I think, the same concern applies to any I usually tackle this with the "main" cancellation token source which is triggered when the form is closing/disposing. With async Task WorkAsync(CancellationToken outerToken, SomeForm form) {
using var cts = CancellationTokenSource.CreateLinkedTokenSource(outerToken, form.MainToken);
// ...
await form.InvokeAsync(UpdateUiAsync, cts.Token);
// ...
}
async Task UpdateUiAsync(CancellationToken token) {
// e.g., we may need a delay for some debounce/throttle logic
await Task.Delay(100, token); // this should throw if the form is gone
UpdateSomeUI();
token.ThrowIfCancellationRequested(); // this should throw if the form is gone now
UpdateSomeOtherUI();
}
I believe it's a good practice to make cancellation as "all way down" propagating as async/await itself should be. |
So, after a short email-exchange with @Pilchie, I tried the following based on a question he asked and I am wondering: what about this approach? public async Task InvokeAsync(
Func<Task> invokeDelegate)
{
var asyncResult = BeginInvoke(invokeDelegate, args);
_ = await Task<object>.Factory.FromAsync(asyncResult, EndInvoke);
} And, apart from the fact that discoverability of this as an alternative is obviously a problem, I am wondering: is it an alternative? What problems do you see here? If using this is OK, would we still need InvokeAsync? |
@KlausLoeffelmann I don't think this is going to work well, for the same reason I brought up here.
await Task.Run(async () =>
{
// we're on thread pool
await control.InvokeAsync(new Func<Task<bool>>(async () => {
// we're on UI thread
await Task.Delay(1000);
Console.WriteLine("Updating");
this.Text = "Updated";
throw new Exception("Oh no");
}));
});
Console.WriteLine("Done"); The output will be:
I'd expect:
One other point. If I'm not mistaken, the I keep pitching the |
Just to be clear, for semantics I expect Furthermore, don't be lazy and try to offload the implementation of |
Will |
It doesn't look too probable currently, to be honest. |
This will also solve the issue with DownloadFile needing to call the new DownloadFileAsync and awaiting the results to be compatible with existing implementation. |
What do you think about an additional API pattern to cause the remainder of an async method to execute on the correct thread? It's very similar to how Instead of writing: // Maybe not on the UI thread now
await someControl.InvokeAsync(
async () =>
{
// Definitely on the UI thread now
await AbcAsync();
await DefAsync();
});
// Back to maybe not on the UI thread now It would look like this: // Maybe not on the UI thread now
await someControl.SwitchToThread();
// Definitely on the UI thread now
await AbcAsync();
await DefAsync();
// Still on the UI thread Here's an implementation that is very similar in theory, around SynchronizationContext.Post rather than Control.BeginInvoke, but it could be quickly adapted: https://gist.github.com/jnm2/c0ea860c69af1230ac3c0b2d6d010b2a There's a similar request for .NET itself for the "reverse" operation, moving to a background thread: dotnet/runtime#20025 Continuing to apply this pattern in another place, would you be open to adding |
That sounds interesting. Give me some time to get my mind behind this! |
|
@KlausLoeffelmann I believe #11854 resolves this. |
Rationale
[Scenario and API proposal updated to reflect the current eco system.]
A growing number of components in and for WinForms requires to asynchronously marshal an async method to run on the UI-Thread. These are for example APIs around WebView2, projected native Windows 10 and 11 APIs or async APIs from modern libraries for example for Semantic Kernel.
Other scenarios make it necessary to show a Form, a Popup or a MessageBox asynchronously, to be in alignment of other UI stacks like WinUI or .NET MAUI and share/adapt their ViewModel implementations for migration and modernization purposes.
One example where we need to use these APIs is when we need to implement a Login-Dialog in WinForms to authenticate a user on a WebAPI which represents the WinForms App's backend, and which uses Windows
WebView2
control and MSALsICustomWebUI
interface for acquiring an authorization code asynchronously.Since a modern architecture would it make necessary to call this Dialog via an UI-Service from a ViewModel, we would need to show this Form either with
ShowAsync
orShowDialogAsync
. Inside of the Form, when we would need to navigate to a specific URL and to honor the implementation ofICustomWebUI
, we would need to callNavigateAsyncTo
which would need to run a) asynchronously and b) on WinForms's UI Thread:The following is a demo which utilizes InvokeAsync with the Periodic Timer, demos parallelized rendering into the WinForms's GDI+ Graphics surface and also shows off the new Windows title bar customizing (which is part of the Dark Mode features):
API Suggestion:
Note: We suggest for .NET 9 to implement the new APIs under the
Experimental
attribute, so we can collect and react to respective feedback, and then introduce the new APIs finally in .NET 10.The text was updated successfully, but these errors were encountered: