异步等待(Async Await)以保持事件触发

5
我有一个关于C# .NET应用程序中异步\等待的问题。实际上,我正在尝试在基于Kinect的应用程序中解决此问题,但为了帮助我说明,我已经构建了这个类比例子:
假设我们有一个名为timer1的计时器,它设置了Timer1_Tick事件。现在,我对该事件采取的唯一操作是使用当前日期时间更新UI。
 private void Timer1_Tick(object sender, EventArgs e)
    {
        txtTimerValue.Text = DateTime.Now.ToString("hh:mm:ss.FFF", CultureInfo.InvariantCulture);
    }

这很简单,我的用户界面每隔几百分之一秒更新一次,我可以愉快地观察时间的流逝。
现在想象一下,我还想在同样的方法中计算前500个素数:
 private void Timer1_Tick(object sender, EventArgs e)
    {
        txtTimerValue.Text = DateTime.Now.ToString("hh:mm:ss.FFF", CultureInfo.InvariantCulture);
        List<int> primeNumbersList = WorkOutFirstNPrimeNumbers(500);
        PrintPrimeNumbersToScreen(primeNumbersList);
    }

    private List<int> WorkOutFirstNPrimeNumbers(int n)
    {
        List<int> primeNumbersList = new List<int>();
        txtPrimeAnswers.Clear();
        int counter = 1;
        while (primeNumbersList.Count < n)
        {
            if (DetermineIfPrime(counter))
            {
                primeNumbersList.Add(counter);

            }
            counter++;
        }

        return primeNumbersList;
    }

    private bool DetermineIfPrime(int n)
    {
        for (int i = 2; i < n; i++)
        {
            if (n % i == 0)
            {
                return false;
            }
        }
        return true;
    }
    private void PrintPrimeNumbersToScreen(List<int> primeNumbersList)
    {
        foreach (int primeNumber in primeNumbersList)
        {
            txtPrimeAnswers.Text += String.Format("The value {0} is prime \r\n", primeNumber);
        }
    }

这是我遇到的问题。计算质数的密集方法阻止了事件处理程序的运行 - 因此我的计时器文本框现在每30秒才更新一次。
我的问题是,如何在遵守以下规则的同时解决这个问题:
  • 我需要我的UI计时器文本框像以前一样平滑,可能通过将密集的质数计算推送到不同的线程来实现。我猜,这将使事件处理程序像以前一样频繁运行,因为阻塞语句不再存在。
  • 每次质数计算函数完成后,都会将其结果写入屏幕(使用我的PrintPrimeNumbersToScreen()函数),并且立即重新启动,以防万一这些质数发生变化。
我尝试过使用async/await和使我的质数计算函数返回Task>来解决问题,但没有成功解决我的问题。 Timer1_Tick事件中的await调用仍然似乎会阻塞,从而防止进一步执行处理程序。
任何帮助都将不胜感激 - 我非常愿意接受正确的答案 :)

更新:我非常感谢@sstan能够提供一个简洁的解决方案来解决这个问题。然而,我在将其应用于我的真实基于Kinect的情况时遇到了困难。由于我有点担心让这个问题变得太具体,所以我已经在这里发布了后续问题: Kinect Frame Arrived Asynchronous


你可以通过将图形和计算分为两个独立的线程来解决这个问题。更多信息请参考:http://www.dreamincode.net/forums/topic/246911-c%23-multi-threading-in-a-gui-environment/ - BgrWorker
我可能误解了,但是如果计算质数确实需要30秒左右的时间,无论事件处理程序是否阻塞,您如何期望比每30秒更新一次文本框更频繁地更新它呢? - sstan
@sstan - 我有两个文本框 - 一个用于计时器,另一个较大的用于素数。我希望我的计时器文本框在几毫秒内更新一次(在timer1_tick上),而素数文本框在计算出素数时更新。 - Benjamin Biggs
3个回答

2
你应该避免使用async/await(尽管它们很好),因为微软的反应式框架(Rx)- NuGet中的“Rx-WinForms”或“Rx-WPF”-是一种更好的方法。
这是Windows Forms解决方案所需的代码:
private void Form1_Load(object sender, EventArgs e)
{
    Observable
        .Interval(TimeSpan.FromSeconds(0.2))
        .Select(x => DateTime.Now.ToString("hh:mm:ss.FFF", CultureInfo.InvariantCulture))
        .ObserveOn(this)
        .Subscribe(x => txtTimerValue.Text = x);

    txtPrimeAnswers.Text = "";

    Observable
        .Interval(TimeSpan.FromSeconds(0.2))
        .Select(n => (int)n + 1)
        .Where(n => DetermineIfPrime(n))
        .Select(n => String.Format("The value {0} is prime\r\n", n))
        .Take(500)
        .ObserveOn(this)
        .Subscribe(x => txtPrimeAnswers.Text += x);
}

就这样。非常简单。所有操作都在后台线程上完成,然后再被传回到用户界面。

以上内容应该很容易理解,如果需要进一步解释,请告诉我。


哇...这是一个非常有趣的答案,我不确定是否能将其应用到我的现实情况中。如果您有时间,请帮忙查看一下我上面链接的补充问题,我将不胜感激。 - Benjamin Biggs
@BenjaminBiggs - 我不明白为什么这个不能转移 - 我的代码与您现有的代码完全相同,而且没有阻塞用户界面。在发布之前我进行了充分的测试。 - Enigmativity
我同意你的代码完全有效并解决了我的问题。然而,如果你查看我发布的补充问题,我不确定如何使用你所写的东西来帮助解决那个真正的问题,而不仅是我为了理解而虚构出来的这个问题。 - Benjamin Biggs
1
@BenjaminBiggs - 这个问题非常好,因为我们可以相对容易地运行您的代码,看看发生了什么,然后提供一个有效的解决方案。您的新问题很难编译,因此很难提供解决方案-这可能就是为什么您还没有任何答案的原因。您应该尝试发布一个具有可编译代码的问题,以代表您的实际问题-然后我们可能可以提供答案。在Stack Overflow上,您的工作是让我们的生活变得更轻松,而不是相反。 - Enigmativity

2

这可能不是最好的解决方案,但它可以工作。您可以创建两个单独的计时器。您第一个计时器的 Tick 事件处理程序只需要处理您的 txtTimerValue 文本框。它可以保持原样:

private void Timer1_Tick(object sender, EventArgs e)
{
    txtTimerValue.Text = DateTime.Now.ToString("hh:mm:ss.FFF", CultureInfo.InvariantCulture);
}

对于第二个计时器的 Tick 事件处理程序,可以像这样定义 Tick 事件处理程序:

private async void Timer2_Tick(object sender, EventArgs e)
{
    timer2.Stop(); // this is needed so the timer stops raising Tick events while this one is being awaited.

    txtPrimeAnswers.Text = await Task.Run(() => {
        List<int> primeNumbersList = WorkOutFirstNPrimeNumbers(500);
        return ConvertPrimeNumbersToString(primeNumbersList);
    });

    timer2.Start(); // ok, now we can keep ticking.
}

private string ConvertPrimeNumbersToString(List<int> primeNumbersList)
{
    var primeNumberString = new StringBuilder();
    foreach (int primeNumber in primeNumbersList)
    {
        primeNumberString.AppendFormat("The value {0} is prime \r\n", primeNumber);
    }
    return primeNumberString.ToString();
}

// the rest of your methods stay the same...

您会注意到我将您的PrintPrimeNumbersToScreen()方法更改为ConvertPrimeNumbersToString()(其余部分保持不变)。更改的原因是您真的希望最小化在UI线程上执行的工作量。因此最好从后台线程准备字符串,然后只需在UI线程上对txtPrimeAnswers文本框进行简单的赋值即可。 编辑:另一种可以使用单个计时器的替代方案 这里有另一个想法,但只用了一个计时器。这里的想法是,您的Tick事件处理程序将定期执行并每次更新计时器值文本框。但是,如果质数计算已经在后台进行,事件处理程序将跳过该部分。否则,它将开始质数计算,并在完成时更新文本框。
// global variable that is only read/written from UI thread, so no locking is necessary.
private bool isCalculatingPrimeNumbers = false; 

private async void Timer1_Tick(object sender, EventArgs e)
{
    txtTimerValue.Text = DateTime.Now.ToString("hh:mm:ss.FFF", CultureInfo.InvariantCulture);

    if (!this.isCalculatingPrimeNumbers)
    {
        this.isCalculatingPrimeNumbers = true;
        try
        {
            txtPrimeAnswers.Text = await Task.Run(() => {
                List<int> primeNumbersList = WorkOutFirstNPrimeNumbers(500);
                return ConvertPrimeNumbersToString(primeNumbersList);
            });
        }
        finally
        {
            this.isCalculatingPrimeNumbers = false;
        }
    }
}

private string ConvertPrimeNumbersToString(List<int> primeNumbersList)
{
    var primeNumberString = new StringBuilder();
    foreach (int primeNumber in primeNumbersList)
    {
        primeNumberString.AppendFormat("The value {0} is prime \r\n", primeNumber);
    }
    return primeNumberString.ToString();
}

// the rest of your methods stay the same...

你好。你写的东西绝对棒极了,第二个版本正好解决了我的问题。然而,我在尝试解决真实情况时遇到了麻烦。希望你能够找出这里的问题 - 我已经更新了信息。 - Benjamin Biggs
请在这里找到问题:Kinect Frame Arrived Asynchronous - Benjamin Biggs
@Benjamin:我看了你的另一个问题。不幸的是,我对Kinect一窍不通。我不知道其他与Kinect相关的因素会如何影响我的答案是否适用于你的实际情况。希望你能得到更有经验的人的关注。祝你好运。 - sstan
感谢您的关注。我不确定我的问题是否真的是与Kinect有关的。表面上看,它看起来就像是类似于Timer1_Tick被触发了一个事件,并且有一个foreach循环迭代遍历我创建的自定义类。我已经尽力在项目中的要点解释给那些具有有限Kinect经验的人,所以我认为您应该有一定的机会——当然前提是您有时间 :) - Benjamin Biggs

1

您希望在不等待结果的情况下启动任务。当任务完成计算后,它应该更新UI。

首先了解一些有关async-await的内容,然后回答

您的UI在长时间操作期间无响应的原因是因为您没有声明事件处理程序为async。最简单的方法是为按钮创建一个事件处理程序来查看其结果:

同步-执行期间UI被阻塞:

private void Button1_clicked(object sender, EventArgs e)
{
    List<int> primeNumbersList = WorkOutFirstNPrimeNumbers(500);
    PrintPrimeNumbersToScreen(primeNumbersList);
}

异步 - 在执行过程中UI响应灵敏:
private async void Button1_clicked(object sender, EventArgs e)
{
    List<int> primeNumbersList = await Task.Run( () => WorkOutFirstNPrimeNumbers(500));
    PrintPrimeNumbersToScreen(primeNumbersList);
}

请注意以下区别:
  • 函数声明为async
  • 使用Task.Run启动任务,而不是调用函数
  • await语句确保您的UI线程返回并继续处理所有UI请求。
  • 一旦任务完成,UI线程将继续进行await的下一部分。
  • await的值是WorkOutFirstNPrimeNumber函数的返回值

注意:

  • 通常情况下,您会看到异步函数返回一个Task而不是void,返回Task<TResult>而不是TResult。
  • 等待Task是void,而等待Task<TResult>是TResult。
  • 要将函数作为单独的任务启动,请使用Task.Run(() => MyFunction(...))
  • Task.Run的返回值是可等待的任务。
  • 每当您想要使用await时,都必须声明函数为async,并因此返回Task或Task<TResult>。
  • 所以您的调用者也必须是异步的,依此类推。
  • 唯一可能返回void的异步函数是事件处理程序。

您的问题:计算仍在忙碌时定时器滴答声报告

问题在于您的计时器比您的计算速度更快。如果在上一次计算尚未完成时报告了新的滴答声,您希望发生什么?
  1. 无论如何开始新的计算。这可能会导致许多线程同时进行计算。
  2. 忽略滴答声,直到没有计算正在进行。
  3. 您还可以选择只让一个任务进行计算,并在完成后立即启动它们。在这种情况下,计算将持续运行。

(1) 启动任务,但不要等待它。

private void Button1_clicked(object sender, EventArgs e)
{
    Task.Run ( () =>
    {    List<int> primeNumbersList = WorkOutFirstNPrimeNumbers(500);
        PrintPrimeNumbersToScreen(primeNumbersList);
    }); 
}

(2) 如果任务仍在忙碌中,请忽略该勾选:

Task primeCalculationTask = null;
private void Button1_clicked(object sender, EventArgs e)
{
    if (primeCalculationTask == null || primeCalculationTask.IsCompleted)
    {   // previous task finished. Stat a new one
        Task.Run ( () =>
        {    List<int> primeNumbersList = WorkOutFirstNPrimeNumbers(500);
            PrintPrimeNumbersToScreen(primeNumbersList);
        }); 
    }
}

开始一个连续计算的任务。
private void StartTask(CancellationToken token)
{
    Task.Run( () =>
    {
        while (!token.IsCancelRequested)
        {
            List<int> primeNumbersList = WorkOutFirstNPrimeNumbers(500);
            PrintPrimeNumbersToScreen(primeNumbersList);
        }
     })
 }
   

这是一份非常好的解释,我非常感激您花费时间和精力解释您的答案。我已经实现了您提供的三种选项,并且开始真正理解这个异步机制是如何工作的。如果您有时间,我非常希望您能够帮助解决这个相关的现实生活中的补充问题,链接在此处:现实生活中的问题 - Benjamin Biggs

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接