使用async的“Fire-and-forget”与“旧异步委托”的区别

77

我正在尝试使用一种新的语法来替换我的旧的"fire-and-forget"调用,希望能够更简单易懂,但似乎一直不成功。以下是一个例子:

class Program
{
    static void DoIt(string entry) 
    { 
        Console.WriteLine("Message: " + entry);
    }

    static async void DoIt2(string entry)
    {
        await Task.Yield();
        Console.WriteLine("Message2: " + entry);
    }

    static void Main(string[] args)
    {
        // old way
        Action<string> async = DoIt;
        async.BeginInvoke("Test", ar => { async.EndInvoke(ar); ar.AsyncWaitHandle.Close(); }, null);
        Console.WriteLine("old-way main thread invoker finished");
        // new way
        DoIt2("Test2");   
        Console.WriteLine("new-way main thread invoker finished");
        Console.ReadLine();
    }
}

这两种方法实现的功能相同,但是我通过新方法得到了一些东西(不需要结束调用并关闭句柄,尽管这仍然有点值得商榷),但是通过必须等待 Task.Yield() 的新方式失去了它,实际上还需要重新编写所有现有的异步 F&F 方法,只为添加一个单行代码。在性能/清理方面是否有一些看不见的收益?

如果我无法修改后台方法,该如何应用异步?对我来说似乎没有直接的方法,我需要创建一个包装的异步方法来等待 Task.Run() 吗?

编辑:我现在看到自己可能缺少一个真正的问题。问题是:已知一个同步方法 A(),如何使用 async/await 异步地调用它以“忘记和放手”方式调用,而不会得到比“旧方法”更复杂的解决方案。


1
async/await并不是为了将同步工作负载转移到另一个线程而设计的。我在一些非常庞大的项目中使用了async/await,没有看到任何Thread.Yield。我认为这段代码是对async/await哲学的滥用。如果没有异步IO,那么async/await可能不是正确的解决方案。 - spender
1
我不同意,尤其是在我的情况下;没有充分的理由强制HTTP请求者等待完整的过程结束才能接收一开始就可用的响应。其余部分可以安全地卸载。真正的问题只是异步/等待是否有帮助,会使情况变得更糟还是在这种情况下根本无法使用。我必须承认我对它的想法不同。 - mmix
2
我并不反对工作可能需要卸载。我是说,使用async/await结合Task.Yield的做法有一种不好的味道。在这里,使用ThreadPool.QueueUserWorkItem会更合适。毕竟,这才是你真正想要做的... 将工作发送到线程池,并以相当小的代码占用空间,对吧? - spender
1
哦,好的。公正的评论,我误解了你的说法。我想我只是认为使用异步方法,我只需调用一个方法,它就会神奇地在另一个线程上启动 :). 谈到不同的方法,有人知道三者之间的比较吗?ThreadPool.QueueUserWorkItem vs Task.Factory.StartNew vs delegate.BeginInvoke?如果我要进行更改,我最好以最佳可用方式进行。 - mmix
这个回答解决了你的问题吗?在ASP.NET MVC中使用Fire and forget异步方法 - Michael Freidgeim
5个回答

124
避免使用 async void,由于其在错误处理上存在棘手的语义;虽然有人称之为“烧掉并忘记”,但我通常用“烧掉并崩溃”这个词组来形容它。
问题是:给定一个同步的方法 A(),如何使用 async/await 在“烧掉并忘记”的方式下异步地调用它,而不会比“旧方法”更复杂呢?
你不需要使用 async/await。只需像这样调用即可:
Task.Run(A);

12
如果 A() 函数内含有异步方法调用,会怎么样? - Anthony Johnston
7
我的意思是,你如何避免关于不等待任务的警告? - Anthony Johnston
46
这个警告是因为在一个async方法中使用fire-and-forget几乎肯定是一个错误。如果你确信这就是你想做的事情,你可以像这样将结果分配给一个未使用的局部变量: var _ = Task.Run(A); - Stephen Cleary
7
@AnthonyJohnston说:“我指的是从一个async方法中调用一个fire-and-forget方法几乎肯定是一个错误。在你的情况下,由于你总是在方法内处理异常,因此在async Taskasync void之间几乎没有区别。我仍然更倾向于选择async Task,因为对我来说,async void意味着“事件处理程序”。” - Stephen Cleary
1
但是如果您想在UI线程上运行方法A()而不是在线程池上运行呢?并且您不能等待A(),因为它将在UI线程上异步运行更长的时间(非阻塞)。例如,单击处理程序必须触发和忘记调用A(),然后执行一些其他操作。但是,否则不关心A()。只需启动A()即可。 - Welcor
显示剩余2条评论

79

正如其他答案和这篇优秀的博客文章所指出的那样,您应该避免在UI事件处理程序之外使用async void。如果您想要一个安全的"fire and forget" async方法,请考虑使用以下模式(感谢@ReedCopsey;这是他在聊天对话中给我的方法):

  1. Create an extension method for Task. It runs the passed Task and catches/logs any exceptions:

    static async void FireAndForget(this Task task)
    {
       try
       {
            await task;
       }
       catch (Exception e)
       {
           // log errors
       }
    }
    
  2. Always use Task style async methods when creating them, never async void.

  3. Invoke those methods this way:

    MyTaskAsyncMethod().FireAndForget();
    
您不需要等待它(也不会生成await警告)。它还会正确处理任何错误,由于这是您唯一放置async void的地方,因此您不必记住在每个地方放置try/catch块。
如果您确实想要正常await它,这也为您提供了不使用async方法作为“fire and forget”方法的选项。

2
如果我有一个任务,那我只需要运行它,对吧? - mmix
2
@mmix 这取决于情况,你可以使用一个Task对象并运行它,但这不是使用await/async的方式。这就是如何使用await/async进行“fire and forget”。请注意,当您调用Async框架方法并希望以“fire and forget”的方式使用它们时,这种方法会更加有用。 - BradleyDotNET
2
嗨,这是一篇旧文章,但通常的想法是使用语言“流”元素来实现“点火并忘记”,而不会隐式地使用Task对象。我们得出结论,由于调用async不会引发新线程直到它等待,因此这是不可能的。如果我有Task对象,那么我只需Run()-它,它就会点火并忘记。 - mmix
我不理解async/await关键字的迷恋 - await的重点是简化fire-but-DON'T-forget场景吗? - Chris F Carroll
1
由于ASP.NET在请求后管理对象的生命周期,因此有整个库来管理它(例如Hangfire)。我不建议在这种情况下仅发送任务。 - BradleyDotNET
显示剩余6条评论

24

在我看来,“等待”某事和“启动并忘记”是两个正交的概念。您可以异步地启动方法而不关心结果,或者在操作完成后想要恢复在原始上下文中执行(可能使用返回值),这正是await所做的。如果你只想在线程池线程上执行一个方法(以便你的UI不被阻塞),就选择...

Task.Factory.StartNew(() => DoIt2("Test2"))

然后你就没问题了。


2
越是我尝试使用它,它似乎就越是如此。异步仅适用于具有异步任务结果的有意义继续的进程。没有继续需要,也没有支持(除了Task.Yield())。我想我又被营销忽悠了... - mmix
1
在这个话题上,delegate.BeginInvokeTask.Factory.StartNew之间有任何实质性的区别吗? - mmix
1
@mmix,使用Task的最大区别是,如果Task中出现异常,它将在Task对象的终结器中被抛出,因为没有观察Task故障状态的内容。如果您不注册TaskScheduler.UnobservedTaskException事件,则可能会导致nasty crash而不触发常规的last-resort logging方法。这也不幸地导致只有在GC引起终结器运行后才会崩溃,而调用的委托将在异常之后立即使应用程序崩溃。 - Dan Bryant
10
在.NET 4.5中有所变化,如果不处理UnobservedTaskException异常,进程将不会崩溃,而是会默默地忽略这些异常。请参考这篇文章 - Stephen Cleary
3
起初,我也有同样的感受;我花费了很长时间才开始欣赏那个设计。未来基于任务的代码将是基于异步的;在这个新的世界里,一个未被观察的任务就是一个"fire-and-forget"(发送后不作任何处理)的任务。这种做法并没有违反快速失败的原则,与旧的行为一样。旧的行为默认会崩溃,因为某些错误发生在 "某个不确定的时间之前",所以旧的行为其实也不是"快速失败"。 - Stephen Cleary
显示剩余2条评论

1

这是我根据Ben Adams推文构建的一个类。HTH https://twitter.com/ben_a_adams/status/1045060828700037125

using Microsoft.Extensions.Logging;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;

// ReSharper disable CheckNamespace
namespace System.Threading.Tasks
{
    public static class TaskExtensions
    {
        [SuppressMessage("ReSharper", "VariableHidesOuterVariable", Justification = "Pass params explicitly to async local function or it will allocate to pass them")]
        public static void Forget(this Task task, ILogger logger = null, [CallerMemberName] string callingMethodName = "")
        {
            if (task == null) throw new ArgumentNullException(nameof(task));

            // Allocate the async/await state machine only when needed for performance reasons.
            // More info about the state machine: https://blogs.msdn.microsoft.com/seteplia/2017/11/30/dissecting-the-async-methods-in-c/?WT.mc_id=DT-MVP-5003978
            // Pass params explicitly to async local function or it will allocate to pass them
            static async Task ForgetAwaited(Task task, ILogger logger = null, string callingMethodName = "")
            {
                try
                {
                    await task;
                }
                catch (TaskCanceledException tce)
                {
                    // log a message if we were given a logger to use
                    logger?.LogError(tce, $"Fire and forget task was canceled for calling method: {callingMethodName}");
                }
                catch (Exception e)
                {
                    // log a message if we were given a logger to use
                    logger?.LogError(e, $"Fire and forget task failed for calling method: {callingMethodName}");
                }
            }

            // note: this code is inspired by a tweet from Ben Adams: https://twitter.com/ben_a_adams/status/1045060828700037125
            // Only care about tasks that may fault (not completed) or are faulted,
            // so fast-path for SuccessfullyCompleted and Canceled tasks.
            if (!task.IsCanceled && (!task.IsCompleted || task.IsFaulted))
            {
                // use "_" (Discard operation) to remove the warning IDE0058: Because this call is not awaited, execution of the
                // current method continues before the call is completed - https://learn.microsoft.com/en-us/dotnet/csharp/discards#a-standalone-discard
                _ = ForgetAwaited(task, logger, callingMethodName);
            }
        }
    }
}

1
我的感觉是,这些“fire and forget”方法主要是为了需要一种清洁的方法来交错UI和后台代码,以便仍然可以将逻辑编写为顺序指令序列。由于async/await负责通过SynchronizationContext进行编组,因此这变得不那么重要。长序列中的内联代码有效地成为您以前从后台线程例程启动的“fire and forget”块。这实际上是模式的反转。
主要区别在于,等待之间的块更类似于Invoke而不是BeginInvoke。如果需要更像BeginInvoke的行为,则可以调用下一个异步方法(返回任务),然后直到您想要的“BeginInvoke”代码之后再实际等待返回的任务。
    public async void Method()
    {
        //Do UI stuff
        await SomeTaskAsync();
        //Do more UI stuff (as if called via Invoke from a thread)
        var nextTask = NextTaskAsync();
        //Do UI stuff while task is running (as if called via BeginInvoke from a thread)
        await nextTask;
    }

4
实际上,我们使用 F&F 来避免阻塞 HTTP 调用者,这更多是与调用方的限制有关,而不是与我们自己有关。这个逻辑是正确的,因为调用方不希望除了接收到消息(实际处理响应将通过另一个不相关的频道或 HTTP 发布)以外得到其他响应。 - mmix

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