受 Android 启发的 C# AsyncTask
受 Android 异步回调机制的启发,用于短暂的后台任务。
动机和背景
最近,.NET Framework 中固有的、尤其是 WinForms 中繁琐的回调模式让我感到恼火。虽然我不是什么专家,但我熟悉 Android Java SDK,发现那里的异步回调机制很有启发性。它简单、表达力强,并且对于将执行与 GUI 线程解耦的任务非常强大。我自然受到了启发,并且在再次从事 WinForms 工作时,我坚决要用 C# 来制作一个自己的山寨版本。从官方的 文档中,我们可以看到描述
“AsyncTask 能够正确且轻松地使用 UI 线程。此类允许在后台执行操作并在 UI 线程上发布结果,而无需操作线程和/或 Handlers。AsyncTask
被设计为 Thread 和 Handler 的辅助类,并不构成通用的线程框架。AsyncTask
s 最好用于短暂的操作(最多几秒钟)。如果您需要让线程长时间运行,强烈建议您使用 java.util.concurrent 包提供的各种 API,例如 Executor
、ThreadPoolExecutor
和 FutureTask
。异步任务由在后台线程上运行的计算以及在 UI 线程上发布结果的计算定义。异步任务由 3 个泛型类型(称为 Params、Progress 和 Result)和 4 个步骤(称为 onPreExecute
、doInBackground
、onProgressUpdate
和 onPostExecute
)定义。”
从同一页面,我们可以看到一个用于异步文件下载的简短示例,该示例 intended to be used directly from a GUI event handler。我将展示相同的代码片段,但以匿名类的形式使用,这在 Android 开发人员的使用中更为常见。
new AsyncTask<URL, Integer, Long> {
protected Long doInBackground(URL... urls) {
int count = urls.length;
long totalSize = 0;
for (int i = 0; i < count; i++) {
totalSize += Downloader.downloadFile(urls[i]);
publishProgress((int) ((i / (float) count) * 100));
// Escape early if cancel() is called
if (isCancelled()) break;
}
return totalSize;
}
protected void onProgressUpdate(Integer... progress) {
setProgressPercent(progress[0]);
}
protected void onPostExecute(Long result) {
showDialog("Downloaded " + result + " bytes");
}
}.Execute("www.fileserver.com/filename.ext");
挑战 C# 的极限
在尝试完全复制 Java 实现的语义时,我将 C# 技能发挥到了极致。起初,非常天真地,我希望能够以 IAsyncTask
泛型接口的形式提供一个漂亮的抽象。然而,我很快意识到 C# 中并没有真正意义上的匿名类,而是以对象初始化器的形式出现的匿名类型。微妙的区别在于,您只能初始化属性和字段,而无法覆盖/重新定义函数。
以下代码片段演示了这个问题
class MyClass<T>
{
public T MyProp { get; set; },
public T GetMyProp() { return MyProp; }
}
var myvar = new MyClass<int>()
{
MyProp = 1,
int GetMyProp() { return -666 }
}
在我看来,另一个限制是缺乏对 void 类型作为有效泛型类型的支持。理想情况下,我想这样做
class MyClass<T1,T2> : MyBaseClass<T1,void,T3>
{}
//then i can check for typeof(T2) is void ?
在这里,我删除了一段文字,原因很简单,我不想把文章变成一篇抱怨,而是决定只分享一个我发现最有趣的链接:(为什么 void 不允许作为 C# 中的泛型类型)
总之,结果是我不得不重复代码。这并不是一个障碍,但这是我最终希望摆脱的。
最终领悟
如果我没有偶然发现 DaveyM69 的这篇文章:GenericBackgroundWorker
。我可能都没有勇气写一整篇文章了。然而,经过一个周末的时间,我投入了精力构建了一些可用的代码和 WinForms 演示项目,我感到有义务分享。
由于 C# 允许我们动态地初始化除字段成员和属性之外的任何内容,我能想到的唯一明智的选择是通过属性暴露的委托。
我的初始实现是一个简单的 AsyncTask
,具有三个回调函数:Pre
、Do
和 Post
,其中 Tin
是 Do
的参数,Tout
是 Do
的返回值和 Post
的输入参数。
public class AsyncTask<Tin, Tout>{
private BackgroundWorker<Tin, int, Tout> bw;//cannot pass void :(
public delegate bool PreFunc();
public delegate Tout DoFunc(Tin input);
public delegate void PostFunc(Tout output, Exception err);
public PreFunc Pre
{
get;
set;
}
public DoFunc Do
{
get;
set;
}
public PostFunc Post
{
get;
set;
}
public AsyncTask()
{
this.Pre = delegate { return true; };
this.bw = new BackgroundWorker<Tin, int, Tout>();
this.bw.DoWork += new EventHandler<DoWorkEventArgs<Tin, Tout>>(OnDoWork);
this.bw.RunWorkerCompleted +=
new EventHandler<RunWorkerCompletedEventArgs<Tout>>(OnRunWorkerCompleted);
}
public void Execute(Tin input)
{
if (Pre())
{
this.bw.RunWorkerAsync(input);
}
}
private void OnDoWork(object sender, DoWorkEventArgs<Tin, Tout> e)
{
Tout r = Do(e.Argument);
e.Result = r;
}
private void OnRunWorkerCompleted(object sender, RunWorkerCompletedEventArgs<Tout> e)
{
Post(e.Result, e.Error);
}
}
关于此示例代码,有几点需要注意。Do
和 Post
委托都需要定义,因此是必需的,而 Pre
完全是可选的。我通过创建一个返回 true 的匿名函数来实现这一点。Pre
函数旨在作为先决条件,仅在允许进一步执行时返回 true。
其余的实现非常直接,使用 GenericBackgroundWorker
,就像您通常使用本机 BackgroundWorker
一样。
对于我后来的实现,我想要支持进度报告。这需要从匿名 Do
函数中引用 AsyncTask
,以便我可以将进度报告给底层的 BackgroundWorker
成员。这并不是一个小小的间接包装器无法解决的问题。
用法和目的
AsyncTask
的唯一预期目的是在后台线程中运行任何 GUI 的长期进程。虽然用例不限于 GUI,但适用于任何需要使用 IoC 在两个线程之间实现无缝解耦的地方。在我看来,这是一种比某些著名模式(如发布-订阅模式)更简洁、更简单的解决方案。通过避免使用任何 C# 风格的事件,我们节省了大量形式主义和代码量。但我认为此解决方案最引人注目的地方在于它展示了清晰的使用意图,同时又能优雅地为我们维护关注点的分离。
如果您不熟悉 C# lambda 函数表达式,最好先查看 MSDN。有各种各样的语法糖可供您使用。起初,lambda 语法对我来说令人生畏。与我认为的经典 C# 脱节,但您最终会发现它给您的强大功能。
new AsyncTask<int, int, string>(){
Pre = delegate
{
return true;
},
Go = progress =>
{
this.progressBar1.Value = progress;
},
Do = (input, progress) =>
{
progrss.Report(1);
Thread.Sleep(1000);
progrss.Report(100);
return “it took us 1 sec to finish”;
},
Post = (output, err) =>
{
//handle err
//print output
}
}.Execute(Tin);
您可以使用任何类型作为泛型参数,但我建议使用不可变的值类型。您可能会发现最糟糕的情况是共享后台线程之间的引用并遇到众所周知的线程问题。作为经验法则,您应该将内存访问限制在 Do
函数的本地范围内。
最后的建议是考虑 Android 文档中提到的相同限制在我们当前实现中也适用。也就是说,您应该仅将 AsyncTask
用于短暂的异步操作。这是因为 GenericBackgroundWorker
辅助类使用系统线程池,这与原始的 BackroundWorker
一致。否则,您将面临线程池饥饿的风险,从而导致推迟进程中任何进一步 GUI 操作的执行。
WinForms 演示
附带的演示应用程序包含一个简单的 WinForms 演示窗体,具有:一个用于启动线程的按钮、一个用于指定线程数的下拉列表和一个用于报告杂项输出的文本框。您可以在本文开头看到窗体的概述。
请注意 TestForm.cs 开头的定义语句 #define ADVANCED
。通过简单地注释掉它,您可以切换使用 AsynTask
的简单示例AsyncTask
#define ADVANCED
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Text;
using System.Windows.Forms;
using System.Threading;
namespace AsyncTaskCallback
{
public partial class TestForm : Form
{
public TestForm()
{
InitializeComponent();
this.progressBar1.Value = 0;
this.progressBar1.Maximum = 0;
}
private void OnGoClick(object sender, EventArgs e)
{
int count = int.Parse((string)(this.comboBox1.SelectedItem ?? "1"));
this.progressBar1.Maximum += count;
int i;
for (i = 1; i <= count; i++)
{
#if !ADVANCED
new AsyncTask<int, string>()
{
Do = input => {
int timeout = RandomRoulette();
Thread.Sleep(timeout);
return string.Format("T{0} returned after {1} secs",input,timeout/1000);
},
Post = (output, err) => {
PrintInTheGUI(output); }
}.Execute(i);//TODO send something meaningful
#endif
#if ADVANCED
new AsyncTask<int, int, string>()
{
//pre-condition, first function to e called
Pre = delegate
{
return true;
},
//here you have the chance to report progress back to the GUI
Go = progress =>
{
this.progressBar1.Value += 1;//usually = progress;
},
//this function is executed asynchronously in another thread
Do = (input, progress) =>
{
int timeout = RandomRoulette();
Thread.Sleep(timeout);
progress.Report(100, input);
return string.Format("T{0} returned after {1} secs", input, timeout / 1000);
},
//the result of the asynchronous task or exception during its execution
Post = (output, err) =>
{
PrintInTheGUI(output);
}
}.Execute(i);//invoke the task and pass input parameter
#endif
}
//this will be the first thing you will see after pressing the button
this.PrintInTheGUI("Threads spawned :" + (i - 1).ToString());
}
private static int RandomRoulette()
{
int timeout = new Random().Next(1, 13) * 1000;
return timeout;
}
private void PrintInTheGUI(string text)
{
this.textBox1.Text = text + System.Environment.NewLine + this.textBox1.Text;
}
}
}
仅出于实验目的,您可以尝试在 Do
函数中使用“PrintOnGUIThread
”函数,看看会产生什么效果。当您尝试从主 GUI 线程外部访问 GUI 元素时,您将收到一个 IllegalOperationException
。本文旨在解决这个问题。
另外,请注意其他函数的可见性,否则我们可以安全地从单独的线程调用它们。即,静态函数:RandomRoulette()
。任何静态函数都保证在 CLR 上是线程安全的。该函数本身会随机返回以毫秒为单位的睡眠超时时间,范围在 1 到 13 秒之间。
在输出文本框中,按下 Start 后首先会打印出我们请求的线程数。出于某种原因,我使用了“请求”而不是“创建”,因为线程首先在线程池中调度,并且一次最多执行 X 个。其中 X 实际上取决于您请求的异步请求数量,留给操作系统来处理。如果您好奇,可以在调试器中暂停执行,然后在 Visual Studio 10 的 Threads 窗口窗格中查看。在那里,您还可以享受出色的并行堆栈。做得好 VS
我可以轻松地在您调用单个异步请求批次后禁用 Start 按钮,但我做了一个小的调整,使其可以安全地请求后续批次,请随意进行一些实验。如果您遇到任何运行时错误,请告诉我。
我想这就是全部了。代码本身很容易理解。
基于 TPL 的实现
应广大用户要求 这是使用 TPL 实现的基于契约的相同
AsynTask
概念。Task Parallel Library 是一个通用的线程库,位于 System.Threading.Tasks
命名空间下。它的目标是简化并发编程,同时处理调度、状态管理等底层问题。您可以在 MSDN 找到一份优秀文档(TAP.docx),其中对 TPL 的内容进行了全面概述并提供了使用示例。如果您确实想深入了解其内部实现,可以在以下 博客文章 中找到更多信息。
我建议在深入研究下面的新 AsyncTask
实现之前,先了解一些背景知识。
public class AsyncTask<Tin, Tgo, Tout> where Tgo : struct
{
public class AsyncTaskCallBack : IProgress<Tgo>
{
private readonly AsyncTask<Tin, Tgo, Tout> asyncTask;
public AsyncTaskCallBack(AsyncTask<Tin, Tgo, Tout> at)
{
asyncTask = at;
}
public void ThrowIfCancel()
{
this.asyncTask.cancelToken.ThrowIfCancellationRequested();
}
public bool IsCancellationRequested
{
get
{
return this.asyncTask.cancelToken.IsCancellationRequested;
}
}
public void Report(Tgo value)
{
//make sure we are on the caller thread
this.asyncTask.syncContext.Post(o => this.asyncTask.Go(value), null);
}
}
private readonly SynchronizationContext syncContext;
private readonly TaskCreationOptions taskCreateOptions;
private readonly CancellationToken cancelToken;
private readonly AsyncTaskCallBack callback;
private Task<Tout> task;
/// <summary>
/// This function is executed before anything.
/// </summary>
public Func<bool> Pre;
/// <summary>
/// The code inside this function is executed on ananother thread.
/// </summary>
public Func<Tin, AsyncTaskCallBack, Tout> Do;
/// <summary>
/// Do your GUI progress control manipulatio here
/// </summary>
public Action<Tgo> Go;
/// <summary>
/// Post processing any result
/// </summary>
public Action<Tout> Post;
/// <summary>
/// Called upon an unhandled exception. If return true, exception is propagted up the stack.
/// </summary>
public Func<Exception, bool> Err;
public AsyncTask(CancellationToken ct)
: this()
{
cancelToken = ct;
}
public AsyncTask(TaskCreationOptions tco = TaskCreationOptions.None)
{
//make it optional
this.Pre = delegate { return true; };
this.Err = delegate(Exception e) { return true; };
this.callback = new AsyncTaskCallBack(this);
this.taskCreateOptions = tco;
this.syncContext = SynchronizationContext.Current ?? new SynchronizationContext();
}
public async void Execute(Tin input)
{
if (Pre())
{
Tout result = default(Tout);
try
{
result = await RunTaskAsync(input);
Post(result);
}
catch (OperationCanceledException oce)
{
if (Err(oce))
throw;
}
catch (AggregateException aex)
{
if (Err(aex.Flatten()))
throw;
}
catch (Exception ex)
{
if (Err(ex))
throw;
}
}
}
private Task<Tout> RunTaskAsync(Tin input)
{
this.task = Task.Factory.StartNew(() => Do(input, callback),
cancelToken,
taskCreateOptions,
TaskScheduler.Default);
return task;
}
/// <summary>
/// Winforms you need to use Control.Invoke Method (Delegate)
/// to make sure that control is updated in the UI thread.
/// </summary>
/// <param name="ctrl"></param>
/// <param name="action"></param>
private static void PerformInvoke(Control ctrl, Action action)
{
if (ctrl.InvokeRequired)
ctrl.Invoke(action);
else
action();
}
}
我将省略研究 C# 中的异步解决方案需要什么,而是专注于它如何服务于我们的目的。如前所述,TPL 是一个通用库,而本文提出的 AsyncTask
则非常专业,只有一个目标:将 GUI 线程与应用程序的其余部分解耦。同时,它提供了一个清晰、安全、无副作用的基于契约的实现。
首先要注意的是我提出的非常紧凑的解决方案。我引入了预定义的泛型委托 Action
和 Func
,它们是最新 .NET 的一部分。Execute
方法被标记为 async
,它 awaits
(等待) RunTaskAsync
方法。这两个关键字 async 和 await 是构成基本 TPL 解决方案的核心。另一个细微的区别是可选的 CancelationToken
,用于取消 TPL 任务,有关取消我们的 AsyncTask
的更多信息,请参见下一节。您可以在 此处找到有关取消和报告的优秀读物。
除了取消标记,我们还有一个重要角色,即 SynchronizationContext
。我们的 AsyncTask
的关键职责之一是依次将来自任何独立工作线程的调用委托给主 GUI 线程,这就是 syncContext
发挥作用的地方。我仍然保留实用静态方法 PerformInvoke(Control ctrl, Action action)
作为参考,以说明您需要什么来从不同的线程上下文调用 GUI 线程。
错误处理和报告是我非常感兴趣的主题。因此,我将尽量不深入探讨该主题,而是仅指向使用 try-catch 表达式处理异常的传统方法。长话短说,我发现这是对我来说唯一有效的方法。然而,就异步任务而言,存在一种替代方法,请参阅 异常处理 (Task Parallel Library) ,使用 continuations。然而,我的异常堆栈跟踪丢失了,这是我必须具备的。值得研究的地方。在结论中阅读更多关于该主题的内容。
如果抛出 CancelationException
(这似乎是推荐的方法),我会处理它并将其传播到 Err
函数委托。这可能不完全是您想要的。
TPL DemoForm
public partial class TestForm : Form { private CancellationTokenSource cancelationTokenSource; public TestForm() { InitializeComponent(); this.progressBar1.Value = 0; this.progressBar1.Maximum = 0; this.cancelationTokenSource = new CancellationTokenSource(); } private void OnGoClick(object sender, EventArgs e) { int count = int.Parse((string)(this.comboBox1.SelectedItem ?? "1")); this.progressBar1.Maximum += count; this.cancelationTokenSource = new CancellationTokenSource(); int i; for (i = 1; i <= count; i++) { new AsyncTask<int, int, string>(cancelationTokenSource.Token) { Go = progress => { this.progressBar1.Value += 1;//usually = progress; }, Do = (input, progress) => { int timeout = RandomRoulette(); if (timeout % 2000 == 0) { throw new InvalidOperationException("Muuuuuu..."); } Thread.Sleep(timeout); if (progress.IsCancellationRequested) { //clean up here ... progress.ThrowIfCancel(); } progress.Report(100); return string.Format("T{0} returned after {1} secs", input, timeout / 1000); }, Post = (output) => { PrintInTheGUI(output); }, Err = (exception) => { this.progressBar1.Value += 1; PrintInTheGUI("Holly C0W! An Exception: " + exception.Message); return false; } }.Execute(i); } //this will be the first thing you will see after pressing the execute button this.PrintInTheGUI("Threads spawned :" + (i - 1).ToString()); } private static int RandomRoulette() { int timeout = new Random().Next(1, 13) * 1000; return timeout; } private void PrintInTheGUI(string text) { this.textBox1.Text = text + System.Environment.NewLine + this.textBox1.Text; } private void OnCancel(object sender, EventArgs e) { this.cancelationTokenSource.CancelAfter(666); PrintInTheGUI("Canceling......"); } }
查看上面的用法示例,它与之前非常相似。但仍有一些我认为值得提及的要点。
改进的契约接口
首先,接口已扩展了一个新的 Err 函数委托。以前,遵循原始 Android SDK 设计,错误被作为参数传递给 Post 函数委托。现在,如果在您的 Do
函数中抛出异常,它将被传播到您的 Err
函数,您可以在那里向用户报告。
请注意,Err
返回一个布尔值,表示是重新抛出异常还是将其消耗掉。最佳实践规定您应该让异常沿着堆栈向上传播。
TPL 提供了一个合适的 IProgress<T> 接口
尽管如此,我们仍然需要一种方法来进行取消操作以及报告进度。所以我们仍然需要这个回调引用到新匿名类内的基类 AsyncTask
。AsyncTaskCallBack
类可用于此目的。它作为第二个参数传递给我们的 Do
函数。它被命名为 progress
,您可以考虑起一个更合适的名字来兼顾报告和取消的双重目的。
随机异常
为了演示正确的错误处理,一些 AsyncTask
s 将从其 Do
方法抛出异常。随机效果取决于睡眠时间,是否为偶数秒。所以偶尔您应该会遇到 Muu 异常。您可以考虑将 VS 调试器设置为不自动中断异常。
取消标记是外部共享的
取消标记作为外部依赖项在 AsyncTask
的构造函数中提供。因此,您需要牢记一些预防措施。首先,如果您触发取消标记,您尝试运行的任何未来任务在创建时都处于 fault 状态,因此永远不会实际执行。第二,由于上述原因和影响,我需要每次调度新批次时重新创建 token 对象的新实例,否则取消一次后,您将永远无法再运行另一批次。
还有最后一件事:this.cancelationTokenSource.CancelAfter(666)
。666 没什么特别的,只是为了逗弄宗教人士,我经常使用它。这个想法是在方法返回后才延迟调用取消机制,并为 GUI 留出一些时间来刷新。考虑一下,自己尝试一下,当您调用 AsyncTask
1000 次或更多次,然后才尝试取消时会发生什么。
未来改进
旧的
一种非常直观的改进方式是摆脱对GenericBackgroundWorker
的依赖。虽然它是一个非常好的实现,但它有很多不必要的开销。首先,它依赖于 C# 中存在的事件模式,而我最初就想避免。支持取消异步任务。GenericBackgroundWorker
已经支持了这一点,所以我将留给您来实现额外的执行策略,例如(SERIAL_EXECUTOR
、THREAD_EXECUTOR
)的原始 AndroidAsyncTask
。当前实现已经为您提供了足够的空间在 GUI 线程上执行一些小型逻辑。例如,限制创建的AsynTask
实例的最大数量可以很容易且正确地通过使用计数变量来实现。您可以安全地从任何地方(但不能在 Do 函数内部)操作它。在这种情况下,一个好的选择是 Pre 函数。当然,欢迎您设计另一个自定义AsyncTask
来实现这种行为。
所以,在最新的 C#5.0 语言中使用异步任务,我设法大大缩短了实现。在此基础上,我引入了取消功能,已经改进了我之前的实现。
如果我使用了 TPL 中的 continuation 概念,我可能会得到一个更简洁的 AsyncTask
实现。考虑以下替代方案
this.task = Task.Factory.StartNew(() => Do(input, this),
cancelToken,
taskCreateOptions,
TaskScheduler.Default)
.ContinueWith<Tout>((t) => { return t.Result; }, cancelToken,
TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.FromCurrentSynchronizationContext())
.ContinueWith<Tout>((t) => { Err(t.Exception); return default(Tout); },
cancelToken, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.FromCurrentSynchronizationContext());
.ContinueWith<Tout>((t) => { return t.Result; }, TaskContinuationOptions.OnlyOnRanToCompletion)
.ContinueWith<Tout>((t) => { Err(t.Exception); return default(Tout); }, TaskContinuationOptions.OnlyOnFaulted);
如果计划运行多个不相关的 AsyncTask
,则我们的取消标记是外部共享的这一事实可能不理想。一个明显的解决方案是将 token 保留在内部,为每个实例专用。然而,这将需要将每个 AsyncTask
的实际实例保存在您需要自行管理的队列中。这超出了本文的范围。
结论
在本文添加了最新的 TPL 后,我们现在有了两个具体实现的相同契约驱动设计解决方案,用于解决如何将执行与 GUI 分离到应用程序其余部分的常见问题。原始版本虽然更冗长,但提供了更经典的 C# 解决方案,并且可以在任何 CLR 2.0 或更高版本的环境中轻松部署。另一方面,新的 TPL AsyncTask
实现非常紧凑,但它会带来更多复杂性。虽然 C# 中的新异步模型乍一看非常简单明了,但我发现表面之下的一些事情变得更加复杂和晦涩。在我看来,异常处理需要格外注意。取消异步任务还有更多需要改进的地方。首先,取消不是逻辑/流程异常,不应该通过异常来处理。这并不完全正确,因为最终采用的是老套的轮询概念,这让我有了最后的评论。您实际上无法停止/中止异步任务。没错,如果您用测试窗体进行实验,就可以体验到我所说的效果。即使在按下取消按钮后,任何已在执行的任务仍会继续执行,直到其 Thread.Sleep
函数结束。这不是 C#/CLR 特有的问题,而是一个普遍问题,通常通过在运行长时间计算算法时每隔几个步骤轮询取消请求来解决。然而,最大的区别在于异步任务的抽象,在这种情况下是以牺牲控制为代价的。如果我直接使用线程,我仍然可以选择提前中止(杀死)一个线程。这无疑是一个好解决方案,但据我所知,只要您不弄乱一些共享内存/状态,它就能很好地工作。哦……而且考虑到上一节的最后一段,这确实让我相信我们需要 AsyncCancel
,因为当您尝试同时取消许多任务时,GUI 会冻结/无响应。
最后的 remarks
请自行承担使用风险。本文中的代码完全是实验性的,**不是** 生产级别的。如果您最终在生产环境中使用它,我将非常感谢您分享您的经验。
[添加于 2015 年] 反对使用 TLP 的原因是其复杂的间接模式:
祝您一切顺利!