不调用Dispose()方法对于TPL任务对象来说是否被认为是可以接受的?

134

我希望在后台线程上触发一个任务,并且不需要等待任务完成。

在 .net 3.5 中,我会这样做:

ThreadPool.QueueUserWorkItem(d => { DoSomething(); });

在 .net 4 中,TPL 是推荐的方式。我看到被推荐的常见模式是:

Task.Factory.StartNew(() => { DoSomething(); });
然而,StartNew() 方法返回一个实现 IDisposable 接口的Task 对象。这个模式被推荐的人似乎忽略了这一点。在Task.Dispose() 方法的 MSDN 文档中说:

"在释放对 Task 的最后引用之前始终调用 Dispose。"

在任务完成之前无法调用任务的处理程序,因此,在主线程等待并调用处理程序将首先在后台线程上执行其目的。也没有任何完成/结束事件可用于清理。
任务类的 MSDN 页面没有对此进行评论,《Pro C#2010...》一书推荐相同的模式,并没有对任务处理进行评论。
我知道如果我只是把它留下,最终器将在最后捕捉到它,但是当我执行许多这样的fire & forget任务时,最终器线程是否会遇到问题?
所以我的问题是:
  • 在这种情况下不调用Task类的Dispose()方法是否可行?如果可以,为什么?会有什么风险/后果吗?
  • 是否存在讨论此内容的任何文档?
  • 是否有适当的方法来处理我错过的Task对象?
  • 或者,是否有其他使用TPL进行fire & forget任务的方式?

1
相关:正确的Fire-and-Forget方式(请参见答案 - Simon P Stevens
3个回答

118

有一场关于此问题的讨论在MSDN论坛上。

微软PFX团队成员Stephen Toub表示:

Task.Dispose存在是因为Task在等待完成时可能会包装一个事件句柄,在等待线程实际上需要阻塞 (而不是旋转或者潜在地执行它正在等待的任务) 的情况下。如果你只是使用continuations,那么该事件句柄将永远不会被分配
...
更好的方法是依赖终结来处理事情。

更新(2012年10月)
Stephen Toub发布了一篇名为“我需要处理Tasks吗?”的博客文章,其中提供了更多细节,并解释了.NET 4.5中的改进。

总之:99%的情况下您不需要处理Task对象。

有两个主要原因去销毁一个对象:及时、有序地释放非托管资源和避免运行对象的终结器的成本。但是这些情况大部分时间都不适用于Task

  1. 自.NET 4.5起,只有在您明确使用TaskIAsyncResult.AsyncWaitHandle时,Task才会分配内部等待句柄(Task对象中仅有的非托管资源)。
  2. Task对象本身没有终结器;该句柄本身被包装在一个带有终结器的对象中,因此除非它被分配,否则没有终结器可以运行。

3
谢谢,有趣。但这与MSDN文档相矛盾。微软或.NET团队是否有官方声明认可这是可接受的代码?该讨论还提出了一个观点:“如果实现在未来版本中更改会怎样?” - Simon P Stevens
实际上,我刚刚注意到那个帖子中的回答者确实在微软工作,似乎是PFX团队的,所以我想这算是某种官方答案。但底部有建议说它并不适用于所有情况。如果存在潜在泄漏,我最好还是回归到我知道是安全的ThreadPool.QueueUserWorkItem吗? - Simon P Stevens
是的,有一个Dispose可能不会被调用,这非常奇怪。如果您查看此处的示例 http://msdn.microsoft.com/en-us/library/dd537610.aspx 和这里 http://msdn.microsoft.com/en-us/library/dd537609.aspx 它们没有处理任务的释放。但是MSDN中的代码示例有时展示非常糟糕的技术。 此外,回答该问题的人是微软公司的员工。 - Insomniac
3
@Simon:(1)你引用的MSDN文档是通用建议,具体情况有更具体的建议(例如,在使用BeginInvoke在UI线程上运行代码时,在WinForms中不需要使用EndInvoke)。 (2)Stephen Toub以有效使用PFX而闻名(例如,在http://channel9.msdn.com/上),因此如果有人能够提供良好的指导,那么他就是这个人。请注意他的第二段话:有时将事情留给终结器更好。 - Richard

14

这与Thread类的问题类似。它使用了5个操作系统句柄,但没有实现IDisposable接口。原始设计者做出了明智的决定,当然有几种合理的方法来调用Dispose()方法。你必须首先调用Join()。

Task类增加了一个句柄,即内部手动重置事件。这是最便宜的操作系统资源。当然,它的Dispose()方法只能释放这一个事件句柄,而不能释放Thread使用的5个句柄。所以不必费心

请注意,您应该对任务的IsFaulted属性感兴趣。这是一个相当棘手的话题,您可以在MSDN Library article中阅读更多内容。一旦您正确处理了这个问题,还应该在代码中找到一个好的位置来释放任务。


7
大多数情况下,一个任务不会创建一个Thread,而是使用线程池(ThreadPool)。 - svick

-1
我很想看到有人在这篇文章中谈论所展示的技术:C# 中类型安全的“fire-and-forget”异步委托调用
看起来,一个简单的扩展方法将处理所有与任务交互的琐碎情况,并能够在完成后调用 dispose。
public static void FireAndForget<T>(this Action<T> act,T arg1)
{
    var tsk = Task.Factory.StartNew( ()=> act(arg1),
                                     TaskCreationOptions.LongRunning);
    tsk.ContinueWith(cnt => cnt.Dispose());
}

3
当然,这并不能处理通过ContinueWith返回的Task实例,但请参考Stephen Toub所说的话:如果没有任何东西执行阻塞等待任务,则没有任何需要处理的内容。 - Richard
1
正如Richard所提到的,ContinueWith(...)还返回了第二个Task对象,然后该对象并没有被处理。 - Simon P Stevens
2
因此,就目前而言,ContinueWith代码实际上比冗余更糟糕,因为它会导致创建另一个任务来处理旧任务的处置。按照这种方式,除非传递给它的操作委托也试图操作Tasks本身,否则基本上不可能在此代码块中引入阻塞等待,对吗? - Chris Marisic
1
你可以巧妙地利用Lambda表达式来捕获变量,以处理第二个任务。Task disper = null; disper = tsk.ContinueWith(cnt => { cnt.Dispose(); disper.Dispose(); }); - Gideon Engelberth
@ChrisMarisic 只有当TPL保留其自己的正在运行任务缓存时,它才可能这样做。否则,除了它本身之外,没有对disper的引用,因此GC可以收集disper。 - Gideon Engelberth
显示剩余2条评论

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