7

我有一个 C# 扩展方法,可以与任务一起使用,以确保所引发的任何异常都被观察到最低限度,以免使托管进程崩溃。在 .NET4.5 中,行为略有改变,因此不会发生这种情况,但仍会触发未观察到的异常事件。我在这里的挑战是编写一个测试来证明扩展方法有效。我正在使用 NUnit 测试框架,而 ReSharper 是测试运行器。

我努力了:

var wasUnobservedException = false;
TaskScheduler.UnobservedTaskException += (s, args) => wasUnobservedException = true;
var res = TaskEx.Run(() =>
                          {
                              throw new NaiveTimeoutException();
                              return new DateTime?();
                          });
GC.Collect();
GC.WaitForPendingFinalizers();
Assert.IsTrue(wasUnobservedException);

测试总是在Assert.IsTrue. 当我在 LINQPad 之类的东西中手动运行这个测试时,我得到了wasUnobservedExceptiontrue.

我猜测试框架正在捕获异常并观察它,以便TaskScheduler.UnobservedTaskException永远不会触发。

我尝试修改代码如下:

var wasUnobservedException = false;
TaskScheduler.UnobservedTaskException += (s, args) => wasUnobservedException = true;
var res = TaskEx.Run(async () =>
                          {
                              await TaskEx.Delay(5000).WithTimeout(1000).Wait();
                              return new DateTime?();
                          });
GC.Collect();
GC.WaitForPendingFinalizers();
Assert.IsTrue(wasUnobservedException);

我在这段代码中所做的尝试是让任务在抛出异常之前获得 GC,这样终结器就会看到一个未捕获的、未观察到的异常。然而,这导致了上述相同的失败。

事实上,测试框架是否连接了某种异常处理程序?如果是这样,有没有办法解决它?还是我只是完全搞砸了一些事情,并且有更好/更容易/更清洁的方法来做到这一点?

4

2 回答 2

10

我看到这种方法存在一些问题。

首先,有一个明确的竞争条件。当TaskEx.Run返回一个任务时,它只是将一个请求排队到线程池中;该任务不一定尚未完成。

其次,您遇到了一些垃圾收集器的详细信息。当在调试中编译时——实际上,无论何时编译器都喜欢它——局部变量(即,res)的生命周期被延长到方法的末尾。

考虑到这两个问题,我能够通过以下代码:

var wasUnobservedException = false;
TaskScheduler.UnobservedTaskException += (s, args) => wasUnobservedException = true;
var res = Task.Run(() =>
{
    throw new NotImplementedException();
    return new DateTime?();
});
((IAsyncResult)res).AsyncWaitHandle.WaitOne(); // Wait for the task to complete
res = null; // Allow the task to be GC'ed
GC.Collect();
GC.WaitForPendingFinalizers();
Assert.IsTrue(wasUnobservedException);

但是,仍然存在两个问题:

仍然存在(技术上)竞争条件。尽管UnobservedTaskException由于任务终结器而引发,但不能保证 AFAIK 它是任务终结器引发的。目前,它似乎是,但在我看来这是一个非常不稳定的解决方案(考虑到终结器应该是如何受限的)。UnobservedTaskException因此,在该框架的未来版本中,如果知道终结器只是将一个队列放入线程池而不是直接执行它,我不会感到太惊讶。在这种情况下,您不能再依赖于在任务完成时事件已被处理的事实(上面代码的隐含假设)。

还有一个关于UnobservedTaskException在单元测试中修改全局状态()的问题。

考虑到这两个问题,我最终得到:

var mre = new ManualResetEvent(initialState: false);
EventHandler<UnobservedTaskExceptionEventArgs> subscription = (s, args) => mre.Set();
TaskScheduler.UnobservedTaskException += subscription;
try
{
    var res = Task.Run(() =>
    {
        throw new NotImplementedException();
        return new DateTime?();
    });
    ((IAsyncResult)res).AsyncWaitHandle.WaitOne(); // Wait for the task to complete
    res = null; // Allow the task to be GC'ed
    GC.Collect();
    GC.WaitForPendingFinalizers();
    if (!mre.WaitOne(10000))
        Assert.Fail();
}
finally
{
    TaskScheduler.UnobservedTaskException -= subscription;
}

这也通过了,但考虑到它的复杂性,它的价值相当可疑。

于 2014-01-21T21:09:43.503 回答
5

只需为@Stephen Cleary 的解决方案添加一个补充(请不要支持我的答案)。

正如他之前提到的,在“Debug”模式下编译时,局部变量的生命周期会延长到方法的末尾,因此建议的解决方案仅在“Release”模式下编译代码时才有效(未附加调试器)。

如果您确实需要对此行为进行单元测试(或使其在“调试”模式下工作),您可以“欺骗”GC,将启动 Task 的代码放入 Action(或本地函数)中。这样做,在调用 action 后,Task 将可供 GC 收集。

var mre = new ManualResetEvent(initialState: false);
EventHandler<UnobservedTaskExceptionEventArgs> subscription = (s, args) => mre.Set();
TaskScheduler.UnobservedTaskException += subscription;
try
{
    Action runTask = () =>
    {
        var res = Task.Run(() =>
        {
            throw new NotImplementedException();
            return new DateTime?();
        });
        ((IAsyncResult)res).AsyncWaitHandle.WaitOne(); // Wait for the task to complete
    };

    runTask.Invoke();

    GC.Collect();
    GC.WaitForPendingFinalizers();

    if (!mre.WaitOne(10000))
        Assert.Fail();
}
finally
{
    TaskScheduler.UnobservedTaskException -= subscription;
}
于 2018-06-28T22:59:39.990 回答