在.NET C#中,等待异步事件完成并仍然具有“同步”流程的最佳方法是什么?

9
我的一般性问题是:如何编写异步代码,使其仍然清晰易懂,就像同步解决方案一样?
我的经验是,如果你需要将一些同步代码变成异步代码,使用类似BackgroundWorker的东西,你将不再有一系列易于遵循的程序语句,表达你的整体意图和活动顺序,相反,你最终会得到一堆“完成”事件处理程序,每个事件处理程序都会启动下一个BackgroundWorker,从而产生非常难以理解的代码。
我知道这不是很清楚;以下更具体:
假设我的WinForms应用程序中的一个函数需要启动一些amazon EC2实例,等待它们变成运行状态,然后等待它们全部接受SSH连接。一个同步解决方案的伪代码可能看起来像这样:
instances StartNewInstances() {
    instances = StartInstances()
    WaitForInstancesToBecomeRunning(instances)
    WaitForInstancesToAcceptSSHConnection(instances).
    return (instances)
    }

很好。发生的事情很清晰,程序操作的顺序也很明确。没有白噪声干扰你理解代码和流程。我真的希望最终的代码看起来是这样的。
但实际上,我不能使用同步解决方案,因为每个函数都可能运行很长时间,并且每个函数都需要做诸如:更新UI、监视超时是否超过以及定期重试操作直到成功或超时等操作。简而言之,每个操作都需要在后台进行,以便前台UI线程可以继续进行。
但是,如果我使用BackgroundWorker之类的解决方案,似乎就无法像上面那样拥有易于跟踪的程序逻辑了。相反,我可能会从我的UI线程启动一个背景工作线程来执行第一个函数,然后我的UI线程回到UI,而工作线程在运行。当它完成时,它的“完成”事件处理程序可能会启动下一个Background Worker。当它完成时,它的“完成”事件处理程序可能会启动最后一个Background Worker,以此类推。这意味着您必须“跟随”Done事件处理程序才能了解整个程序流程。
一定有更好的方法,使得a)让我的UI线程响应,b)让我的异步操作能够更新UI,最重要的是c)能够将我的程序表达为一系列连续的步骤(如上所示),以便其他人可以理解生成的代码。
任何建议都会非常感激! 迈克尔
5个回答

11
我的一般性问题是:如何编写异步代码,使其仍然清晰易懂,就像同步解决方案一样?
你可以等待C#5。它不久就会到来。{async}/{await}太棒了。你已经在上面的句子中描述了这个特性...请查看Visual Studio async homepage以获取教程、语言规范、下载等。
目前,实际上没有一个非常干净的方法——这就是为什么首先需要这个特性的原因。异步代码很自然地变得混乱,特别是考虑到错误处理等方面。
您的代码将被表达为:
async Task<List<Instance>> StartNewInstances() {
    List<Instance> instances = await StartInstancesAsync();
    await instances.ForEachAsync(x => await instance.WaitUntilRunningAsync());
    await instances.ForEachAsync(x => await instance.WaitToAcceptSSHConnectionAsync());
    return instances;
}

假设需要进行一点额外的工作,例如在 IEnumerable<T> 上使用扩展方法,并具有以下形式。
public static Task ForEachAsync<T>(this IEnumerable<T> source,
                                   Func<T, Task> taskStarter)
{
    // Stuff. It's not terribly tricky :(
}

3
Async/await 是最好的选择。但是,如果您不想等待,可以尝试Continuation-passing-style或CPS。为此,您需要将委托传递到异步方法中,在处理完成时调用该委托。在我看来,这比拥有所有额外事件更加清晰。

这将改变此方法的签名。

Foo GetFoo(Bar bar)
{
    return new Foo(bar);
}

To

void GetFooAsync(Bar bar, Action<Foo> callback)
{
    Foo foo = new Foo(bar);
    callback(foo);
}

然后,为了使用它,您需要:
Bar bar = new Bar();
GetFooAsync(bar, GetFooAsyncCallback);
....
public void GetFooAsyncCallback(Foo foo)
{
    //work with foo
}

GetFoo可能抛出异常时,情况会变得有点棘手。我更喜欢的方法是改变GetFooAsync的签名。

void GetFooAsync(Bar bar, Action<Func<Foo>> callback)
{
    Foo foo;
    try
    {
        foo = new Foo(bar);
    }
    catch(Exception ex)
    {
        callback(() => {throw ex;});
        return;
    }

    callback(() => foo);
}

您的回调方法将如下所示:
public void GetFooAsyncCallback(Func<Foo> getFoo)
{
    try
    {
        Foo foo = getFoo();
        //work with foo
    }
    catch(Exception ex)
    {
        //handle exception
    }
}

其他方法涉及给回调函数传递两个参数:实际结果和异常。

void GetFooAsync(Bar bar, Action<Foo, Exception> callback);

这依赖于回调函数检查异常,但这可能会导致异常被忽略。其他方法有两个回调函数,一个用于成功,另一个用于失败。

void GetFooAsync(Bar bar, Action<Foo> callback, Action<Exception> error);

对我来说,这会使流程变得更加复杂,但仍然允许忽略异常。

然而,为回调提供必须调用以获取结果的方法会强制回调处理异常。


3

如果你像Jon所建议的那样等不了5秒钟,我建议你看一下.NET 4中的Task Parallel Library。它提供了许多关于“异步执行某项任务,当其完成后再执行另一项任务”的基础结构,并且在异步任务本身的错误处理方面提供了可靠的支持。


我已经花了一些时间研究TPL,它在很多方面都非常好。但有一件事还不清楚,那就是它是否能够让任务与主UI线程进行通信(??)。也就是说,使用BackgroundWorker时,有一些管道可以让工作线程报告进度,最终将其推送到主UI队列中。不确定TPL中是否有这样的管道? - Michael Ray Lovett
很遗憾,大多数情况下答案是否定的。你仍然通常会受限于InvokeRequired/Invoke调用。可能的一个选择是在UI线程上使用TaskScheduler.FromCurrentSynchronizationContext来捕获一个“特殊情况”的TaskScheduler,在这个TaskScheduler中安排会触及UI的任务,但我还没有尝试过。 - Chris Shain
事实上,Reed Copsey在他关于TPL的优秀系列文章中建议这样做:http://reedcopsey.com/2010/03/18/parallelism-in-net-part-15-making-tasks-run-the-taskscheduler/。 - Chris Shain
你可能会对我的博客文章感兴趣,其中包括后台任务的各种实现方式,以及我早期关于使用FromCurrentSynchronizationContext从任务中报告进度的文章。不过要提醒一下,相比这些方法,async/await更胜一筹! - Stephen Cleary
我昨天和今天花时间阅读了关于C# 5的文档,并下载了VS RC 2012,正在尝试使用async/await。我同意,到目前为止,这些方法在解决这类问题上胜过了我所见过的一切。当然,并不是说它们不会让人有些头疼,特别是当你看一些更复杂的例子时,但相比使用回调函数和延续来处理这些事情,它们简直就是完胜!此外,与UI的交互至少有两种方法可以处理(在"await"之后在UI线程中执行代码,和/或使用IProgress)。太棒了! - Michael Ray Lovett
我们可以自动地(或者使用属性)将所有代码都在后台线程中运行,并调用所有UI操作,而不是使用后台工作器。这样流程将是同步的,也不会使UI卡住。听起来像是一个有趣的基础设施。 - Yaniv

1
当它完成时,它的“完成”事件处理程序可能会启动下一个后台工作者。
这是我一直在努力解决的问题。基本上等待进程完成而不锁定用户界面。
然而,你可以只使用一个backgroundWorker来执行所有任务,而不是使用backgroundWorker来启动backgroundWorker。在backgroundWorker.DoWork函数内部,它在该线程上同步运行。因此,您可以有一个DoWork函数来处理所有3个项目。
然后,您只需要等待一个BackgroundWorker.Completed,并拥有“更干净”的代码。
所以你最终可以得到:
BackgroundWorker_DoWork
  returnValue = LongFunction1
  returnValue2 = LongFunction2(returnValue)
  LongFunction3

BackgroundWorker_ProgressReported
  Common Update UI code for any of the 3 LongFunctions

BackgroundWorker_Completed
  Notify user long process is done

0
在某些情况下(稍后会解释),您可以像以下伪代码一样将异步调用包装到方法中:
byte[] ReadTheFile() {
  var buf = new byte[1000000];
  var signal = new AutoResetEvent(false);
  proxy.BeginReadAsync(..., data => {
    data.FillBuffer(buf);
    signal.Set();
  });
  signal.WaitOne();
  return buf;
}

为了使上述代码正常工作,回调需要从不同的线程中调用。因此,这取决于您正在处理什么。根据我的经验,至少 Silverlight Web 服务调用是在 UI 线程中处理的,这意味着上述模式不能使用 - 如果 UI 线程被阻塞,则甚至无法执行先前的开始调用。如果您正在使用此类框架,则处理多个异步调用的另一种方法是将您的高级逻辑移动到后台线程并使用 UI 线程进行通信。然而,在大多数情况下,这种方法有点过度杀伤,因为它需要一些样板代码来启动和停止后台线程。

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