为什么 System.Timers.Timer 能够在垃圾回收时存活,而 System.Threading.Timer 却不能?

83

看起来 System.Timers.Timer 实例会被某种机制保持活动状态,但是 System.Threading.Timer 实例不会。

一个示例程序,其中包含一个周期性的 System.Threading.Timer 和自动重置的 System.Timers.Timer

class Program
{
  static void Main(string[] args)
  {
    var timer1 = new System.Threading.Timer(
      _ => Console.WriteLine("Stayin alive (1)..."),
      null,
      0,
      400);

    var timer2 = new System.Timers.Timer
    {
      Interval = 400,
      AutoReset = true
    };
    timer2.Elapsed += (_, __) => Console.WriteLine("Stayin alive (2)...");
    timer2.Enabled = true;

    System.Threading.Thread.Sleep(2000);

    Console.WriteLine("Invoking GC.Collect...");
    GC.Collect();

    Console.ReadKey();
  }
}
当我运行这个程序(.NET 4.0客户端,发布版,在调试器之外),只有System.Threading.Timer被垃圾回收:
Stayin alive (1)...
Stayin alive (1)...
Stayin alive (2)...
Stayin alive (1)...
Stayin alive (2)...
Stayin alive (1)...
Stayin alive (2)...
Stayin alive (1)...
Stayin alive (2)...
Invoking GC.Collect...
Stayin alive (2)...
Stayin alive (2)...
Stayin alive (2)...
Stayin alive (2)...
Stayin alive (2)...
Stayin alive (2)...
Stayin alive (2)...
Stayin alive (2)...
Stayin alive (2)...

编辑:我已经接受了下面John的答案,但是我想进一步阐述一下。

运行上面的示例程序(在Sleep处设置断点),以下是相关对象及GCHandle表的状态:

!dso
OS Thread Id: 0x838 (2104)
ESP/REG  Object   Name
0012F03C 00c2bee4 System.Object[]    (System.String[])
0012F040 00c2bfb0 System.Timers.Timer
0012F17C 00c2bee4 System.Object[]    (System.String[])
0012F184 00c2c034 System.Threading.Timer
0012F3A8 00c2bf30 System.Threading.TimerCallback
0012F3AC 00c2c008 System.Timers.ElapsedEventHandler
0012F3BC 00c2bfb0 System.Timers.Timer
0012F3C0 00c2bfb0 System.Timers.Timer
0012F3C4 00c2bfb0 System.Timers.Timer
0012F3C8 00c2bf50 System.Threading.Timer
0012F3CC 00c2bfb0 System.Timers.Timer
0012F3D0 00c2bfb0 System.Timers.Timer
0012F3D4 00c2bf50 System.Threading.Timer
0012F3D8 00c2bee4 System.Object[]    (System.String[])
0012F4C4 00c2bee4 System.Object[]    (System.String[])
0012F66C 00c2bee4 System.Object[]    (System.String[])
0012F6A0 00c2bee4 System.Object[]    (System.String[])

!gcroot -nostacks 00c2bf50

!gcroot -nostacks 00c2c034
DOMAIN(0015DC38):HANDLE(Strong):9911c0:Root:  00c2c05c(System.Threading._TimerCallback)->
  00c2bfe8(System.Threading.TimerCallback)->
  00c2bfb0(System.Timers.Timer)->
  00c2c034(System.Threading.Timer)

!gchandles
GC Handle Statistics:
Strong Handles:       22
Pinned Handles:       5
Async Pinned Handles: 0
Ref Count Handles:    0
Weak Long Handles:    0
Weak Short Handles:   0
Other Handles:        0
Statistics:
      MT    Count    TotalSize Class Name
7aa132b4        1           12 System.Diagnostics.TraceListenerCollection
79b9f720        1           12 System.Object
79ba1c50        1           28 System.SharedStatics
79ba37a8        1           36 System.Security.PermissionSet
79baa940        2           40 System.Threading._TimerCallback
79b9ff20        1           84 System.ExecutionEngineException
79b9fed4        1           84 System.StackOverflowException
79b9fe88        1           84 System.OutOfMemoryException
79b9fd44        1           84 System.Exception
7aa131b0        2           96 System.Diagnostics.DefaultTraceListener
79ba1000        1          112 System.AppDomain
79ba0104        3          144 System.Threading.Thread
79b9ff6c        2          168 System.Threading.ThreadAbortException
79b56d60        9        17128 System.Object[]
Total 27 objects

正如John在他的答案中指出的那样,两个计时器都会将它们的回调函数 (System.Threading._TimerCallback) 注册到 GCHandle 表中。就像Hans在评论中指出的,当这样做时,state 参数也会被保留。

正如John所指出的那样,System.Timers.Timer 之所以保持活动状态是因为其被回调引用(作为内部的 System.Threading.Timerstate 参数传递);同样,我们的 System.Threading.Timer 之所以被GC清除是因为其回调没有引用它。

timer1 的回调函数加入一个显式引用(例如 Console.WriteLine("Stayin alive (" + timer1.GetType().FullName + ")"))就足以防止 GC 清除它。

使用 System.Threading.Timer 的单参数构造函数也可以,因为此时计时器会将自身作为 state 参数进行引用。下面的代码在 GC 后仍然保持两个计时器处于活动状态,因为它们分别由其回调从 GCHandle 表中引用:

class Program
{
  static void Main(string[] args)
  {
    System.Threading.Timer timer1 = null;
    timer1 = new System.Threading.Timer(_ => Console.WriteLine("Stayin alive (1)..."));
    timer1.Change(0, 400);

    var timer2 = new System.Timers.Timer
    {
      Interval = 400,
      AutoReset = true
    };
    timer2.Elapsed += (_, __) => Console.WriteLine("Stayin alive (2)...");
    timer2.Enabled = true;

    System.Threading.Thread.Sleep(2000);

    Console.WriteLine("Invoking GC.Collect...");
    GC.Collect();

    Console.ReadKey();
  }
}

1
为什么timer1会被垃圾回收?它不是还在作用域内吗? - Jeff Sternal
3
Jeff说:作用域并不是很重要。这基本上就是GC.KeepAlive方法的存在理由。如果你对琐碎的细节感兴趣,请参阅http://blogs.msdn.com/b/cbrumme/archive/2003/04/19/51365.aspx。 - Nicole Calinoiu
7
请使用Reflector查看Timer.Enabled属性的设置器。请注意它使用“cookie”技巧,为系统计时器提供状态对象以在回调中使用。CLR已经意识到它,在clr/src/vm/comthreadpool.cpp和SSCLI20源代码中的CorCreateTimer()函数中。MakeDelegateInfo()变得复杂了。 - Hans Passant
2
@StephenCleary 哇,太神了。我刚发现一个应用程序中的错误,涉及到 System.Timers.Timer 在我期望它死亡后仍然保持活动状态并发布更新。感谢您节省了很多时间! - Dr. Andrew Burnett-Thompson
2
因此,如果您在构造函数之后始终自己调用timer1.Change(dueTime, period),那么您就不会意外地被GC回收。 - fastmultiplication
显示剩余7条评论
4个回答

35

你可以使用windbg、sos和!gcroot来回答这个问题以及类似的问题。

0:008> !gcroot -nostacks 0000000002354160
DOMAIN(00000000002FE6A0):HANDLE(Strong):241320:Root:00000000023541a8(System.Thre
ading._TimerCallback)->
00000000023540c8(System.Threading.TimerCallback)->
0000000002354050(System.Timers.Timer)->
0000000002354160(System.Threading.Timer)
0:008>

在这两种情况下,本地计时器都必须通过 GCHandle 来防止回调对象被 GC 回收。区别在于,在 System.Timers.Timer 的情况下,回调引用了System.Timers.Timer 对象(该对象在内部使用 System.Threading.Timer 实现)。


13

我最近在查找一些Task.Delay的示例实现并进行了一些实验后发现了这个问题。

事实证明,System.Threading.Timer是否被GC取决于你如何构建它!!!

如果只使用回调函数构建,则状态对象将是定时器本身,这将防止其被GC。这似乎没有在任何地方记录,但如果没有它,创建“fire and forget”定时器将变得极为困难。

我从此处的代码中发现了这一点

该代码中的注释还指出,如果回调引用由new返回的定时器对象,则始终最好只使用回调-ctor,否则可能会存在竞态漏洞。


实际上,这符合文档。如果您在回调中保留对System.Threading.Timer的引用(通过闭包或构造函数),那么它就不会像任何托管对象一样被收集。 - joe
1
@joe 你在文档中有参考资料吗?因为我似乎在主计时器或计时器回调页面上找不到这个提到的内容。 - user3797758
@user3797758 文档没有直接提到,但它与这个答案是一致的。请查看“备注”部分:“只要使用计时器,就必须保留对它的引用。与任何托管对象一样,当没有对其的引用时,计时器会受到垃圾回收的影响。计时器仍然处于活动状态并不会防止其被回收。”在这种情况下,静态的TimerQueue保留了对计时器实例的引用,因此它不会被回收。 - joe
1
是的,我也读了那部分,但那与任何其他C#对象没有区别,我以为你在评论中指的是其他东西... - user3797758

1
在timer1中,你给它一个回调函数。在timer2中,你连接了一个事件处理程序;这会设置对你的Program类的引用,这意味着计时器不会被GC回收。由于你再也没有使用timer1的值(基本上相当于删除了var timer1 =),编译器足够聪明,可以优化掉该变量。当你调用GC时,没有任何东西再引用timer1,因此它被回收了。
在你的GC调用之后添加一个Console.Writeline来输出timer1的属性之一,你会注意到它不再被回收了。

3
事件处理程序没有对 Program 类的引用,即使有引用也不能防止计时器被垃圾回收。 - Stephen Cleary
3
是的,编译以上代码,然后使用.Net反编译器查看。+= lambda被转换为Program类中的一个方法。是的,链接事件处理程序确实会防止垃圾回收。参考博客链接:http://blogs.msdn.com/b/abhinaba/archive/2009/05/05/memory-leak-via-event-handlers.aspx - Andy
通过Andy的工作链接修复上述无效链接:通过事件处理程序导致的内存泄漏,Abhinaba Basu - Theodor Zoulias

0

顺便提一下,从.NET 4.6开始(如果不是更早),这似乎不再正确。今天运行您的测试程序时,不会导致任何计时器被垃圾回收。

Stayin alive (1)...
Stayin alive (2)...
Stayin alive (1)...
Stayin alive (2)...
Stayin alive (1)...
Stayin alive (2)...
Stayin alive (1)...
Stayin alive (2)...
Stayin alive (1)...
Invoking GC.Collect...
Stayin alive (2)...
Stayin alive (1)...
Stayin alive (2)...
Stayin alive (1)...
Stayin alive (2)...
Stayin alive (1)...
Stayin alive (2)...
Stayin alive (1)...
Stayin alive (2)...
Stayin alive (1)...
Stayin alive (2)...
Stayin alive (1)...

当我查看System.Threading.Timer的实现时,这似乎是有道理的,因为当前版本的.NET使用活动计时器对象的链接列表,并且该链接列表由TimerQueue内部的成员变量持有(它是一个单例对象,由TimerQueue中的静态成员变量保持活动状态)。因此,只要计时器实例处于活动状态,它们就会一直保持活动状态。

3
在.NET 4.6中,我仍然看到System.Threading.Timer实例被收集。请确保使用启用了优化的发布模式编译代码。你提到的链表包含帮助程序TimerQueueTimer对象; 它不会防止原始的System.Threading.Timer实例被GC收集。(每个System.Threading.Timer实例引用自己的TimerQueueTimer对象,但反过来则不行。当GC收集System.Threading.Timer时,它的TimerQueueTimer对象将通过~TimerHolder finalizer从队列中删除。) - Antosha
尝试在发布模式下运行。 - Ievgen
同样的问题,我在.NET Core 3.0上运行代码。无论是Debug还是Release版本,这两个计时器都没有被垃圾回收。为了重现这个问题,我必须将计时器的创建移动到Main方法之外。 - Theodor Zoulias

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