【问题标题】:How to make Sequential Processing as simple as Parallel Processing如何让顺序处理像并行处理一样简单
【发布时间】:2013-08-05 14:24:42
【问题描述】:

我有两个 .net Task 对象,我可能希望它们并行或按顺序运行。无论哪种情况,我都不想阻止线程等待它们。事实证明,Reactive Extensions 让平行故事变得非常漂亮。但是当我尝试按顺序排列任务时,代码可以工作,但感觉很尴尬。

我想知道是否有人可以展示如何使顺序版本更简洁或像并行版本一样轻松编码。没有必要使用响应式扩展来回答这个问题。

作为参考,这是我的两个并行和顺序处理解决方案。

并行处理版本

这是纯粹的快乐:

    public Task<string> DoWorkInParallel()
    {
        var result = new TaskCompletionSource<string>();

        Task<int> AlphaTask = Task.Factory.StartNew(() => 4);
        Task<bool> BravoTask = Task.Factory.StartNew(() => true);

        //Prepare for Rx, and set filters to allow 'Zip' to terminate early
        //in some cases.
        IObservable<int> AsyncAlpha = AlphaTask.ToObservable().TakeWhile(x => x != 5);
        IObservable<bool> AsyncBravo = BravoTask.ToObservable().TakeWhile(y => y);

        Observable
            .Zip(
                AsyncAlpha,
                AsyncBravo,
                (x, y) => y.ToString() + x.ToString())
            .Timeout(TimeSpan.FromMilliseconds(200)).Subscribe(
                (x) => { result.TrySetResult(x); },
                (x) => { result.TrySetException(x.GetBaseException()); },
                () => { result.TrySetResult("Nothing"); });

        return result.Task;
    }

顺序/流水线处理版本

这可行,但很笨拙:

    public Task<string> DoWorkInSequence()
    {
        var result = new TaskCompletionSource<string>();

        Task<int> AlphaTask = Task.Factory.StartNew(() => 4);

        AlphaTask.ContinueWith(x =>
        {
            if (x.IsFaulted)
            {
                result.TrySetException(x.Exception.GetBaseException());
            }
            else
            {
                if (x.Result != 5)
                {
                    Task<bool> BravoTask = Task.Factory.StartNew(() => true);
                    BravoTask.ContinueWith(y =>
                    {
                        if (y.IsFaulted)
                        {
                            result.TrySetException(y.Exception.GetBaseException());
                        }
                        else
                        {
                            if (y.Result)
                            {
                                result.TrySetResult(x.Result.ToString() + y.Result.ToString());
                            }
                            else
                            {
                                result.TrySetResult("Nothing");
                            }
                        }
                    });
                }
                else
                {
                    result.TrySetResult("Nothing");
                }
            }
        }
        );

        return result.Task;
    }

在上面的顺序代码中,它变得一团糟,我什至没有添加timeout capability来匹配并行版本!

要求(8/6 更新)

对于那些回答,请注意:

  1. 顺序方案应允许第一个任务的输出馈送第二个任务的输入的安排。我上面的示例“尴尬”代码很容易被安排来实现这一点。

  2. 我对 .net 4.5 答案感兴趣 - 但 .net 4.0 答案对我来说同样重要或更重要。

  3. 任务“Alpha”和“Bravo”的总时限为 200 毫秒;他们每个人都没有 200 毫秒。在顺序情况下也是如此。

  4. 如果任一任务返回无效结果,SourceCompletionTask 必须在两个任务完成之前提前完成。如示例代码中的显式测试所示,无效结果是 [AlphaTask:5] 或 [BravoTask:false]。
    更新 8/8:澄清 - 在顺序情况下,如果 AlphaTask 不成功或超时已经发生,则 BravoTask 根本不应该执行。

  5. 假设 AlphaTask 和 BravoTask 都无法阻止。这并不重要,但在我的真实场景中,它们实际上是异步 WCF 服务调用。

也许我可以利用 Rx 的某个方面来清理顺序版本。但即使只是任务编程本身也应该有一个更好的故事,我想。我们拭目以待。

ERRATA 在两个代码示例中,我将返回类型更改为 Task,因为海报的答案非常正确,我不应该返回 TaskCompletionSource。

【问题讨论】:

    标签: c# asynchronous .net-4.0 .net-4.5 system.reactive


    【解决方案1】:

    如果您可以使用 async/await,Brandon 有一个不错的答案。如果您仍在使用 VS2010,那么清理顺序版本的第一件事是获取扩展方法,例如 blog post 中描述的 Then 方法 Stephen Toub。如果您不使用 .NET 4.5,我还将实现 Task.FromResult 方法。有了这些,您可以获得:

    public Task<string> DoWorkInSequence()
    {
        return Task.FromResult(4)
               .Then(x => 
                     { if (x != 5)
                       {
                           return Task.FromResult(true)
                                  .Then(y => 
                                        { if (y)
                                          {
                                              return Task.FromResult(x.ToString() + y.ToString());
                                          }
                                          else
                                          {
                                              return Task.FromResult("Nothing");
                                          }
                                        });
                        }
                        else
                        {
                            return Task.FromResult("Nothing");
                        }
                     });
    }
    

    此外,您通常应该返回 Task 而不是 TaskCompletionSource(您可以通过在 TaskCompletionSource 上调用 .Task 来获得),因为您不希望调用者为您返回给他们的任务设置结果.

    Brandon 的回答还提供了一种实现超时功能的好方法(针对缺少 async/await 关键字进行调整)。

    编辑 为了减少箭头代码,我们可以实现更多的 LINQ 方法。先前链接的博客文章中提供了 SelectMany 实现。 LINQ 需要的其他方法是 Select 和 Where。完成 Then 和 SelectMany 后,这些应该相当简单,但它们在这里:

    public static Task<T> Where<T>(this Task<T> task, Func<T, bool> predicate)
    {
        if (task == null) throw new ArgumentNullException("task");
        if (predicate == null) throw new ArgumentNullException("predicate");
    
        var tcs = new TaskCompletionSource<T>();
        task.ContinueWith((completed) =>
            {
                if (completed.IsFaulted) tcs.TrySetException(completed.Exception.InnerExceptions);
                else if (completed.IsCanceled) tcs.TrySetCanceled();
                else
                {
                    try
                    {
                        if (predicate(completed.Result))
                            tcs.TrySetResult(completed.Result);
                        else
                            tcs.TrySetCanceled();
                    }
                    catch (Exception ex)
                    {
                        tcs.TrySetException(ex);
                    }
                }
            });
        return tcs.Task;
    }
    
    public static Task<TResult> Select<T, TResult>(this Task<T> task, Func<T, TResult> selector)
    {
        if (task == null) throw new ArgumentNullException("task");
        if (selector == null) throw new ArgumentNullException("selector");
    
        var tcs = new TaskCompletionSource<TResult>();
        task.ContinueWith((completed) =>
        {
            if (completed.IsFaulted) tcs.TrySetException(completed.Exception.InnerExceptions);
            else if (completed.IsCanceled) tcs.TrySetCanceled();
            else
            {
                try
                {
                    tcs.TrySetResult(selector(completed.Result));
                }
                catch (Exception ex)
                {
                    tcs.TrySetException(ex);
                }
            }
        });
        return tcs.Task;
    }
    

    之后,最后一种非 LINQ 扩展方法允许在取消时返回默认值:

    public static Task<T> IfCanceled<T>(this Task<T> task, T defaultValue)
    {
        if (task == null) throw new ArgumentNullException("task");
    
        var tcs = new TaskCompletionSource<T>();
        task.ContinueWith((completed) =>
        {
            if (completed.IsFaulted) tcs.TrySetException(completed.Exception.InnerExceptions);
            else if (completed.IsCanceled) tcs.TrySetResult(defaultValue);
            else tcs.TrySetResult(completed.Result);
        });
        return tcs.Task;
    }
    

    以及新的和改进的 DoWork(无超时):

    public static Task<string> DoWorkInSequence()
    {
        return (from x in Task_FromResult(5)
                where x != 5
                from y in Task_FromResult(true)
                where y
                select x.ToString() + y.ToString()
               ).IfCanceled("Nothing");
    }
    

    Brandon 的回答中的 Timeout 方法(一旦重写,如果需要不使用 async/await)可能会卡在链的末尾以实现整体超时和/或在链中的每个步骤之后,如果您想保留进一步的步骤达到整体超时后运行。链中断的另一种可能性是使所有单个步骤都采用取消令牌并修改 Timeout 方法以采用 CancellationTokenSource 并在发生超时时取消它,并抛出超时异常。

    编辑(布伦特阿里亚斯)

    从您所提供的内容中汲取奇妙的想法,我设计了我认为是我的 POV 的最终答案。它基于ParallelExtensionsExtras 的nuget 包中的.net 4.0 扩展方法。下面的示例添加了第三个任务,以帮助说明针对顺序任务进行编程的“感觉”,考虑到我提出的要求:

    public Task<string> DoWorkInSequence()
    {
        var cts = new CancellationTokenSource();
    
        Task timer = Task.Factory.StartNewDelayed(200, () => { cts.Cancel(); });
    
        Task<int> AlphaTask = Task.Factory
            .StartNew(() => 4 )
            .Where(x => x != 5 && !cts.IsCancellationRequested);
    
        Task<bool> BravoTask = AlphaTask
            .Then(x => true)
            .Where(x => x && !cts.IsCancellationRequested);
    
        Task<int> DeltaTask = BravoTask
            .Then(x => 7)
            .Where(x => x != 8);
    
        Task<string> final = Task.Factory
            .WhenAny(DeltaTask, timer)
            .ContinueWith(x => !DeltaTask.IsCanceled && DeltaTask.Status == TaskStatus.RanToCompletion
                ? AlphaTask.Result.ToString() + BravoTask.Result.ToString() + DeltaTask.Result.ToString(): "Nothing");
    
        //This is here just for experimentation.  Placing it at different points
        //above will have varying effects on what tasks were cancelled at a given point in time.
        cts.Cancel();
    
        return final;
    }
    

    在这次讨论和共同努力中,我提出了一些关键意见:

    • 在琐碎的情况下使用“Then”扩展名很好,但值得注意的是,它的适用性有限。对于更复杂的情况,有必要将其替换为例如.ContinueWith(x =&gt; true, cts.Token, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default)。在我陈述的场景中将“Then”替换为“ContinueWith”时,添加OnlyOnRanToCompletion 选项至关重要。
    • 使用超时扩展最终在我的场景中不起作用。这是因为它只会导致它立即附加到的 Task 的取消,而不是取消序列中所有先前的 Task 实例。这就是为什么我改用StartNewDelayed(...) 策略并在每个Where 子句中添加一个快速取消检查的原因。
    • 虽然 ParallelExtensionsExtras 库定义了您使用的 LINQ to Tasks,但我得出的结论是,最好远离 LINQ 式的任务外观。这是因为使用 LINQ 的任务是 highly esoteric;它可能会让普通开发人员感到困惑。让他们理解异步编码已经够难的了。甚至 LINQ to Tasks 的作者也说“How useful this LINQ implementation is in practice is arguable,但至少它提供了一个有趣的思考练习。”是的,同意,一个有趣的思考练习。当然,我必须至少承认“Where”LINQ to Tasks 方法,因为它在我上面列出的解决方案中发挥了关键作用。

    【讨论】:

    • +1 表示Then。如果您正在编写延续并且不能使用 async/await,则必须保持清醒。
    • 该博客文章中显示的“Then”扩展似乎有一个错误。充其量,它总是在同步执行时有next 任务t 提示,这可能是不可取的——例如当first 任务是一个I/O 线程时。在最坏的情况下,它不会做任何事情来确保随后启动var t = next(first.Result) 的任务t。 “下一个”任务t 可能根本不会执行。但即使除了所谓的错误之外,代码也没有比我开始的更简洁。例如,它必须在形成“箭头代码”时处理两次“无”结果。也没有超时
    • @BrentArias 同步提示很容易删除。如果我正确理解您的另一点,您注意到从next 返回的任务在返回时无法启动。虽然这是可能的,但它会违反任务返回功能的规范。处理箭头代码,我们只需要……更多的扩展方法! (见更新)
    • @Gideon:我会让我的版本暂停一下,看看你是否想进行编辑。之后我会假设将您的帖子标记为答案。
    • @BrentArias 这看起来就像我期望的超时和取消。在这种情况下,所有 LINQ 语法将为您做的将是创建中间变量(在 from 子句中),因此对于必须处理代码的人来说更容易理解的就是那个。跨度>
    【解决方案2】:
    public Task<string> DoWorkInSequence()
    {
        Task<int> AlphaTask = Task.Factory.StartNew(() => 4);
        Func<int> BravoFunc = x => 2 * x;
    
        //Prepare for Rx, and set filters to allow 'Zip' to terminate early
        //in some cases.
        IObservable<int> AsyncAlpha = AlphaTask.ToObservable().TakeWhile(x => x != 5);
    
        return AsyncAlpha
            .Do(x => Console.WriteLine(x))  //This is how you "Do WORK in sequence"
            .Select(BravoFunc)              //This is how you map results from Alpha
                                            //via a second method.
            .Timeout(TimeSpan.FromMilliseconds(200)).Subscribe(
                (x) => { result.TrySetResult(x); },
                (x) => { result.TrySetException(x.GetBaseException()); },
                () => { result.TrySetResult("Nothing"); }).ToTask();
    }
    

    不过,如果你想要任务,我实际上只是在 TPL 中完成所有这些操作,或者使用 Observable.ToTask(this IObservable&lt;T&gt; observable) 而不是使用 TaskCompletionSource

    【讨论】:

    • AlphaTaskBravoTask 都需要异步。在我的真实场景中,它们代表我不想阻止的 Web 服务调用。你能调整你的样本以反映这一点吗?
    • 只需将 Func BravoFunc 切换回问题中的 Task 并改用 selectMany。 Aron 基本上是对的,这种模式在 Rx 代码中非常常见,用于网络请求。
    • 哦,还有 Aron,您忘记删除 .Subscribe( 直到 .ToTask() 的所有代码。我已经“更正”并在下面扩展了您的答案,但对该答案投了赞成票。
    【解决方案3】:

    首先,我没有返回TaskCompletionSource。这是达到目的的一种手段……应该对公共 API 隐藏的方法的实现细节。你的方法应该返回一个Task(它应该只返回result.Task)。

    无论如何,如果你只是在处理任务,你应该只使用 TPL 而不是使用 Rx。仅当您确实需要将任务与其他 rx 代码集成时才使用 Rx。如果你不混入 Rx 的东西,即使你的 DoWorkInParallel 也可以变得更简单。 Rx 可以出色地处理复杂的任务内容。但是你描述的场景比较简单,用TPL就可以轻松解决。

    以下是如何在 TPL 中执行并行和顺序版本:

    /// <summary>Extension methods for timing out tasks</summary>
    public static class TaskExtensions
    {
        /// <summary> throws an error if task does not complete before the timer.</summary>
        public static async Task Timeout(this Task t, Task timer)
        {
            var any = await Task.WhenAny(t, timer);
            if (any != t)
            {
               throw new TimeoutException("task timed out");
            }
        }
    
        /// <summary> throws an error if task does not complete before the timer.</summary>
        public static async Task<T> Timeout<T>(this Task<T> t, Task timer)
        {
            await Timeout((Task)t, timer);
            return t.Result;
        }
    
        /// <summary> throws an error if task does not complete in time.</summary>
        public static Task Timeout(this Task t, TimeSpan delay)
        {
            return t.IsCompleted ? t : Timeout(t, Task.Delay(delay));
        }
    
        /// <summary> throws an error if task does not complete in time.</summary>
        public static Task<T> Timeout<T>(this Task<T> t, TimeSpan delay)
        {
            return Timeout((Task)t, delay);
        }
    }
    
    // .. elsewhere ..
    public async Task<string> DoWorkInParallel()
    {
        var timer = Task.Delay(TimeSpan.FromMilliseconds(200));
        var alphaTask = Task.Run(() => 4);
        var betaTask = Task.Run(() => true);
    
        // wait for one of the tasks to complete
        var t = await Task.WhenAny(alphaTask, betaTask).Timeout(timer);
    
        // exit early if the task produced an invalid result
        if ((t == alphaTask && alphaTask.Result != 5) ||
            (t == betaTask && !betaTask.Result)) return "Nothing";
    
        // wait for the other task to complete
        // could also just write: await Task.WhenAll(alphaTask, betaTask).Timeout(timer);
        await ((t == alphaTask) ? (Task)betaTask : (Task)alphaTask).Timeout(timer);
    
        // unfortunately need to repeat the validation logic here.
        // this logic could be moved to a helper method that is just called in both places.
        var alpha = alphaTask.Result;
        var beta = betaTask.Result;
        return (alpha != 5 && beta) ? (alpha.ToString() + beta.ToString()) : "Nothing";
    }
    
    public async Task<string> DoWorkInSequence()
    {
        var timer = Task.Delay(TimeSpan.FromMilliseconds(200));
        var alpha = await Task.Run(() => 4).Timeout(timer);
        if (alpha != 5)
        {
            var beta = await Task.Run(() => true).Timeout(timer);
            if (beta)
            {
                return alpha.ToString() + beta.ToString();
            }
        }
    
        return "Nothing";
    }
    

    如果您需要在 .Net 4.0 中工作,那么您可以使用 Microsoft.Bcl.Async nuget 包,它允许您使用 VS2012 编译器来定位 .Net 4.0并且仍然使用 async/await。看到这个问题:Using async-await on .net 4

    编辑:如果任务产生无效值,我已修改代码以提前退出并行和顺序版本,并且我已修改超时以组合而不是每个任务。尽管在顺序情况下,此计时器也将计算两个任务之间的时间

    【讨论】:

    • +1 用于 TaskExtensions 助手。但是,您可以从扩展方法中删除异步并直接返回 Task
    • @Aron 我不知道怎么做。它需要等待 WhenAny 解决,以便它可以返回失败的任务或带有结果的任务。
    • 非常接近我的答案。我已经用明确的要求更新了我的问题。可以稍作调整吗?
    • @Brandon 我的意思是我公开的功能。
    • @Aron 是的,我知道您指的是哪个功能。但我认为你的提议行不通。如果它只是返回原始任务,那么它就不会等待超时。
    【解决方案4】:

    Aron 几乎看准了

    public Task<string> DoWorkSequentially()
    {
       Task<int> AlphaTask = Task.Run(() => 4);    //Some work;
       Task<bool> BravoTask = Task.Run(() => true);//Some other work;
    
       //Prepare for Rx, and set filters to allow 'Zip' to terminate early
       //in some cases.
       IObservable<int> AsyncAlpha = AlphaTask.ToObservable().TakeWhile(x => x != 5);
       IObservable<bool> AsyncBravo = BravoTask.ToObservable().TakeWhile(y => y);
    
        return (from alpha in AsyncAlpha
               from bravo in AsyncBravo
               select bravo.ToString() + alpha.ToString())
           .Timeout(TimeSpan.FromMilliseconds(200))
           .Concat(Observable.Return("Nothing"))   //Return Nothing if no result
           .Take(1)
           .ToTask();
    }
    

    在这里,我刚刚将BravoFunc 放回BravoTask。我已经删除了TaskCompletionSource(就像 Aron 所做的那样)。最后,您使用 ToTask() 运算符将 Rx 延续转回 Task&lt;string&gt;

    注意

        from alpha in AsyncAlpha
        from bravo in AsyncBravo
        select bravo.ToString() + alpha.ToString()
    

    也可以写成

        AsyncAlpha.SelectMany(a=>AsyncBravo.Select(b=> b.ToString() + a.ToString()))
    

    SelectMany 运算符对于这些类型的延续非常方便。在查询理解语法中更加方便,因为您仍然可以在最后的 select 子句中访问 bravoalpha

    正如您所见,一旦您有许多延续,这将变得非常有用。例如,考虑一个需要 3 或 4 个延续的示例

        from a in Alpha
        from b in Bravo
        from c in Charlie
        from d in Delta
        select a+b+c+d
    

    这也有现实世界的应用程序。我认为这是一种常见的模式。一些例子包括; 等待服务器连接,然后获取会话令牌以传递给服务客户端。

        from isConnected in _server.ConnectionState.Where(c=>c)
        from session in _server.GetSession()
        from customer in _customerServiceClient.GetCustomers(session)
        select customer;
    

    或者可能在我们需要进行身份验证的社交媒体源中,找到联系人,获取他们的电子邮件列表,然后拉下这些电子邮件的前 20 个标题。

        from accessToken in _oauth.Authenticate()
        from contact in _contactServiceClient.GetContact(emailAddress, accessToken)
        from imapMessageId in _mailServiceClient.Search(contact).Take(20)
        from email in _mailServiceClient.GetEmailHeaders(imapMessageId)
        select email;
    

    【讨论】:

    • 您的答案是并行执行任务,而不是按顺序执行。如果任一任务产生“无效”值,它也不会返回 "Nothing"
    • 按顺序执行。 from bravo in AsyncBravo 行仅在 from alpha in AsyncAlpha 产生值时有效运行,即以串行或顺序方式。这就是 SelectMany 所做的。
    • 更新为“无”功能。
    • 您的代码在方法的开头启动这两个任务 (Task.Factory.StartNew)。它们现在并行运行。您的 linq 表达式只是按顺序等待它们的结果。
    • 在将它们包装在 Observable.FromAsync() 之前,请查看 OP 的要求 #1。在已知 alpha 任务的结果满足该要求之后,需要有效地定义 bravo 任务。
    猜你喜欢
    • 2023-03-31
    • 2010-10-04
    • 1970-01-01
    • 2021-11-08
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多