后台线程?让我数数有几种方式……






4.91/5 (117投票s)
在后台线程上执行操作的十种有趣方式

引言
本文(更像是一首小诗)演示了在 C# 中通过十种(以前是九种)绝妙的方式在后台线程上执行操作。除了在酒馆里无趣时提供谈资外,它几乎没有其他作用。
背景
我曾经在一次面试中被要求描述在后台线程上执行操作的不同方式。他们想要两个答案,但我给了他们三个。那是在 2008 年,当时可能只有四种方式——创建新线程并使用它,使用 ThreadPool
,通过委托使用异步编程模型(APM),或者使用计时器。我想 BackgroundWorker
也存在,所以说是五种吧,但自重的人是不会提那个的。
有人好心指出,我漏掉了一种方式,那就是生成第二个进程,该进程将有自己的线程并在那里完成工作。这在我看来不太符合后台线程的标准,但确实是一种笨拙地在后台执行操作的方式。所以,就算六种吧!
但时代已经发展,他们喜欢重新发明东西,所以我们现在有了任务并行库(TPL)、并行 LINQ(PLINQ),甚至反应式框架(RX),它们都可以被强制执行后台操作。(如果你想在示例源代码中运行 RX 方法,你需要从 NuGet 获取 RX 包。)
几乎所有方法最终都会将操作分配给 ThreadPool
,它们只是将其实现的不同方式。废话不多说,这里有十种在后台线程上执行操作的方式。我确信还有更多,所以请随时告知我我遗漏的,并称我为白痴。另外,请注意,我必须在我妻子和朋友喝咖啡回来之前写完代码和文章,因为我们得去 Blue Water(肯特郡的一个购物中心)买裤子,所以没有时间检查这里声称的任何内容是否正确。抱歉。
长期承诺
在后台执行操作最明显的方式是创建一个线程并在上面执行。但是,对于小型操作来说,这是不鼓励的,因为它是一个昂贵的过程。此外,一个线程通常配备 1MB 堆栈,所以随意创建这些东西会损害你的内存消耗。当后台操作需要长时间运行时,这种方法最适合。
public void LongTermCommitment() { // create a thread, execute the background method and block until it's done Thread thread = new Thread(_ => BackgroundMethod("Long Term Commitment")); thread.Start(); thread.Join(); }
绅士方法
因为线程创建开销大,.NET 提供了一个线程池,它就像线程的一个温馨家园。它们被锁起来,只有在执行操作时才被放出来,然后又被放回去,从而避免了每次大量的创建和销毁。
互联网上有很多关于 ThreadPool 的信息,所以我们不再赘述。我懂,你懂,我的狗也懂。
public void TheGentlemansApproach() { // straight onto the threadpool - what could be better? ThreadPool.QueueUserWorkItem(_ => BackgroundMethod("The Gentleman's Approach")); }
精神力量
啊,异步编程模型或 APM。它从早期就存在了,但在我见过的所有代码中都被大体忽略。这有什么奇怪的吗?它涉及一个名为 IAsyncResult
的东西,听起来很糟糕。要做到这一点,只需在委托上调用 BeginInvoke
,它就会在 ThreadPool 上启动。然后你需要调用 EndInvoke
,它会阻塞你当前的线程,直到委托返回。我似乎记得你必须这样做,否则会发生可怕的事情,但记不起是什么了。
public void TheMentalist() { // Use the Asyncronous Programming Model (APM) - a bit ugly in my eyes BackgroundMethodDelegate x = new BackgroundMethodDelegate(BackgroundMethod); IAsyncResult a = x.BeginInvoke("The Mentalist", null, null); x.EndInvoke(a); }
VB 娘娘腔
面对现实吧;任何用 VB 编程的人都是弱者。虽然我无法证明,但我怀疑当他们发明 BackgroundWorker
时,他们心里想的就是弱者。这是为那些需要在后台执行操作以免锁住 GUI 但又不真正理解线程的人设计的,所以它有很好的方法可以将更新回传给调用线程。这很像使用消息循环的旧式方法,所以当我们像这里一样从控制台应用程序调用它时,它将无法工作。
public void TheVbSissy() { // BackgroundWorkers are for wimps. Case closed. BackgroundWorker worker = new BackgroundWorker(); worker.DoWork += delegate { BackgroundMethod("The VB Sissy Approach"); }; worker.RunWorkerAsync(); }
有人在下面问,除了 VB 势利之外,有没有不喜欢 BackgroundWorker
的正当理由。这是一个合理的问题,答案是没有,真的没有。正如上面提到的,这适用于开发 GUI 时,以避免任何长时间运行或阻塞操作锁住用户界面。事实上,如果你需要执行一个长时间运行的操作,例如将大文件加载到内存中,它是一个相当不错的选择。它会在后台线程上执行工作,并内置支持报告进度和完成情况。
GUI 编程的黄金法则是,只有创建控件的线程才能更新它,因为所使用的消息循环本身不是线程安全的,而 BackgroundWorker
会为你处理线程切换。我本人从未使用过这个东西,而是倾向于在窗体上创建封装方法,这些方法 BeginInvoke
到 GUI 线程,因此是线程安全的。然后多个线程可以随心所欲地疯狂运行。
我就是这样做的,但俗话说,各人有各人的做法。如果你不想那样做,那就继续使用 BackgroundWorker
。(你这个娘娘腔)
疯狂的同事
我们都有这样的人,毕竟,既然可以直接在 ThreadPool
上执行操作,为什么还要创建一个计时器,让它立即触发一次并在其中执行操作呢?这样的事情确实会发生,当我看到它们时,通常不得不暂时离开大楼,边抽着万宝路淡烟边思考人类的失败。不要这样做。
public void TheInsaneCoWorker() { // this requires a certain level of teeth gritting Timer timer = new Timer(_ => BackgroundMethod("The Insane Coworker"), null, 0, Timeout.Infinite); }
为了清楚起见,这里我使用的是 System.Threading.Timer
。还有一个 System.Timers.Timer
也可以使用。WinForms 和 WPF 也提供了不同的计时器,但这些计时器会将消息发布回主线程而不是在后台线程上执行,因此无法使用。
手头的任务
接下来是 TPL,它在 ThreadPool 之上提供了一个抽象层。这种方法将操作封装在一个名为 Task
的事物中,它提供了更丰富的功能和控制,提供了诸如取消之类的出色特性。它很简单,看起来像这样
public void TheTaskAtHand() { // first of many TPL ways using (Task task = new Task(() => BackgroundMethod("The Task At Hand"))) { task.Start(); task.Wait(); } }
另一只手上的任务
或者,看起来像这样
public void TheTaskInOtherHand() { Task.Factory.StartNew(() => BackgroundMethod("The Task In The Other Hand")); }
现代方法
这确实非常巧妙。我们现在有一种非常直接的方法可以在后台调用一些东西。
public void TheContemporaryApproach() { // pretty neat - one has to admit Parallel.Invoke(() => BackgroundMethod("The Contemporary Approach")); }
炫耀
我最近一直在玩 RX,它本质上是 LINQ 经过了变性。以前被拉取的东西现在被推送,你可以设置订阅者,它们在后台线程上接收推送的项目。所以在这里我们将项目推送到一个处理它的订阅者,并且线程切换都是无缝完成的。如果你还没有玩过 RX,一旦你理解了核心思想,它就会非常酷。
public void TheShowOff() { // RX - push the item into a subscribed method Observable.Return("The Show Off", Scheduler.Default).Subscribe(BackgroundMethod); }
第二个进程
这是我没有想到但有人建议的一种在后台执行操作的方式,即使它不符合后台线程的定义。(根据定义,后台线程是进程中的一个线程,它在运行期间不会阻止进程终止。)
在本文中,我一直让后台线程直接将字符串输出到控制台。第二个进程也可以做到这一点,但会输出到它自己的控制台而不是我们的控制台。所以,这有点弱,但我们会让它这样做,并从第二个进程的标准输出流中收集字符串并显示它。因此,它将在主线程上输出,但将由单独进程中的第二个线程提供。
这确实是一种暴力方法,一个人不应该为了在后台做如此琐碎的事情而进入进程间通信的领域。话虽如此,将任务交给单独的辅助进程来完成并不罕见,但我认为将其定义为“后台进程”会更好。
为了实现这个功能,我在我们的应用程序中添加了一个开关,这样如果提供了命令行参数,它只会将其输出到控制台,否则它将像以前一样执行所有不同的方法。然后我们可以这样做
public void OutOfProcess() { // this is a bit of a kludge as we are writing to the console on the main application thread // even so, the operation (writing a string to output) is done in a second process and hence // thread, but we have to redirect the output here to see it.... ProcessStartInfo startInfo = new ProcessStartInfo("StartThreads.exe", "OutOfProcess"); startInfo.CreateNoWindow = false; startInfo.UseShellExecute = false; startInfo.RedirectStandardOutput = true; Process process = Process.Start(startInfo); Console.WriteLine("Approach \"{0}\" sent from second process", process.StandardOutput.ReadToEnd()); process.WaitForExit(); }
结论
如今,在如何做事方面你有很多选择,希望这能说明这一点。我个人认为选择有点太多了,这导致人们以不同的方式做同样的事情,这反过来又使开发周期有点复杂。我想我们应该服从并只对所有事情使用 TPL,并将旧方法视为过时和仅用于向后兼容。
关注点
嗯,可悲的是,我一个也看不到。
历史
- 2013年8月8日:初始版本
- 2013年8月13日:更新,包含第二个进程 - 感谢 thelazydogsback 的建议。
- 2013年8月14日:对
BackgroundWorker
的态度有所缓和 - 感谢 Mike Cattle 指出我的代码偏见。