winforms Winform事件处理程序中的“void async”问题--也许是一个解决方案?

rhfm7lfc  于 2022-11-17  发布在  其他
关注(0)|答案(1)|浏览(208)

据我所知,当一个“async void”方法(比如一个事件处理程序)被调用时,调用者永远不知道它什么时候完成(因为它不能等待Task完成)。
下面的代码演示了这一点(我已经将一个Button和TabControl放到了一个窗体上,并连接了这两个事件)。当单击按钮时,它会更改选项卡,这会引发SelectedIndexChanged事件,这是异步的。

private void button1_Click(object sender, EventArgs e)
    {
        Debug.WriteLine("Started button1_Click");
        tabControl1.SelectedTab = tabControl1.SelectedIndex == 0 ? tabPage2 : tabPage1;
        Debug.WriteLine("Ended button1_Click");
    }

    private async void tabControl1_SelectedIndexChanged(object sender, EventArgs e)
    {
        Debug.WriteLine("Started tabControl1_SelectedIndexChanged");
        await Task.Delay(1000);
        Debug.WriteLine("Ended tabControl1_SelectedIndexChanged");
    }

结果输出为

Started button1_Click
Started tabControl1_SelectedIndexChanged
Ended button1_Click
Ended tabControl1_SelectedIndexChanged

正如您所看到的,SelectedIndexChanged事件处理程序被激发,但调用者没有等待它完成(它不能等待,因为它没有要等待的Task)。

我建议的解决方案

事件处理程序不使用async,而是等待它使用的任何Async方法,然后一切似乎都工作了...它通过轮询Task.IsCompleted属性来等待,同时调用DoEvents,以保持异步任务的活动和处理(在本例中为Task.Delay)。

private void button1_Click(object sender, EventArgs e)
    {
        Debug.WriteLine("Started button1_Click");
        tabControl1.SelectedTab = tabControl1.SelectedIndex == 0 ? tabPage2 : tabPage1;
        Debug.WriteLine("Ended button1_Click");
    }

    private void tabControl1_SelectedIndexChanged(object sender, EventArgs e)
    {
        Debug.WriteLine("Started tabControl1_SelectedIndexChanged");
        Await(Task.Delay(1000));
        Debug.WriteLine("Ended tabControl1_SelectedIndexChanged");
    }

    public static void Await(Task task)
    {
        while (task.IsCompleted == false)
        {
            System.Windows.Forms.Application.DoEvents();
        }

        if (task.IsFaulted && task.Exception != null)
            throw task.Exception;
        else
            return;
    }

这现在给出了预期结果

Started button1_Click
Started tabControl1_SelectedIndexChanged
Ended tabControl1_SelectedIndexChanged
Ended button1_Click

有人能看到采取这种方法有什么问题吗?

2w3kk1z5

2w3kk1z51#

它在调用DoEvents时通过轮询Task.IsCompleted属性来等待
这是异步代码上的一种阻塞形式,我称之为嵌套消息循环攻击。
有人认为采取这种方法有什么问题吗?
是的。这种解决方案会遭受我所说的“意外重入”。
从历史上看,意外的重入是导致许多错误的原因,因为某些代码最终不可避免地会被其他代码调用(在相同的堆栈上)。在您的范例中,tabControl1_SelectedIndexChanged(或者更具体地说,Await)可以直接执行任何其他UI代码。包括对tabControl1_SelectedIndexChanged的另一次调用。一旦您的代码变得重要,这就会导致问题,甚至更糟,它与时间有关所以你会得到“海森堡”。
有一句流传已久的话:“doEvents是邪恶的”。这值得仔细考虑。
为什么与DoEvent关联的所有问题都不与await关联呢?
问得好许多开发人员最初对await持怀疑态度,因为他们已经被DoEvents严重烧伤。await * 没有 * 落入这个陷阱的原因是,只有一个消息循环。await * 返回 * 到那个单一的消息循环。没有嵌套的消息循环,因此没有意外的重入。
我绝对不会推荐DoEvents作为解决方案,总有更好的解决方案。
处理的顺序不被保留。例如,我以编程方式更改了选项卡,并期望事件处理程序加载选项卡数据,然后我想对加载的数据执行一些操作,当前我设置了选项卡,事件触发-它开始加载选项卡数据并立即返回,事件处理程序调用返回,然后我尝试与部分加载的选项卡交互。在您无意中触发异步事件处理程序的任何地方都会发生这种问题
所以,实际的问题是:C#事件(至少是void-返回事件,这是绝大多数事件)的功能不足以充当通知以外的任何其他功能。在设计方面,C#事件允许您实现Observer pattern,但 * 是实现Strategy pattern的错误选择 *。
为了应用到您的示例中,tabControl1_SelectedIndexChanged只是一个通知(Observer模式),用于通知您的代码制表符索引发生了变化。它不是一个提供数据加载的钩子(Strategy模式),试图以这种方式使用它才是导致此处实际问题的原因。
解决方案是 * 不 * 依赖于事件处理程序来驱动您的逻辑。看看一些Model-View-ViewModel(MVVM)概念,从中获得一些启发。使用ViewModel类的方法可能会在这种情况下有所帮助。这样,您的代码就根本不会更新选项卡控件;相反,它将更新ViewModel,并且您的VM可以异步地完成这项工作(根本没有async void),然后您的代码可以在工作完成时更新UI。
一种简单的方法(没有数据绑定和显式VM)如下所示:

private async void button1_Click(object sender, EventArgs e)
{
  Debug.WriteLine("Started button1_Click");
  var newTab = tabControl1.SelectedIndex == 0 ? tabPage2 : tabPage1;
  ShowLoadingState(newTab);
  tabControl1.SelectedTab = newTab;
  await LoadDataForTab(newTab); 
  Debug.WriteLine("Ended button1_Click");
}

private async Task LoadDataForTab(TabPage tab)
{
  await Task.Delay(1000);
  // load data into tab
}

相关问题