使用 using 关键字并非安全中止






4.57/5 (33投票s)
本文说明了为什么连 "using" 关键字都不是一个万无一失的机制
引言
C# 是一种安全且托管的语言。所谓安全,我们可以理解为它能帮助开发者避免常见的错误,例如导致内存泄漏或访问无效内存。事实上,.NET 在这方面做得非常好,但垃圾回收并不会立即发生,所以当我们确实需要立即释放资源时,我们必须调用某种类型的 "释放" 方法,例如文件和数据库连接的 Close 方法,事务的 Commit 或 Rollback,或者通常是 Dispose()
方法,该方法也由文件、数据库对象和事务实现。
Dispose()
方法会立即释放关联的资源,因此正在写入的文件可以立即被另一个进程读取,数据库连接可以返回到连接池,其他非托管资源(如窗口句柄)也会被立即释放,从而释放内存。
但是,我们如何调用 Dispose()
呢?
在 C# 中,我们有 using
关键字,必须如下使用:
using(var disposable = new DisposableType())
{
... do what's needed with the disposable variable here ...
}
在代码块结束时,将调用 Dispose
。这样的代码将被编译成:
{
var disposable = new DisposableType();
try
{
... do what's needed with the disposable variable here ...
}
finally
{
if (disposable != null)
disposable.Dispose();
}
}
甚至 new
关键字也不会返回 null
,"模式" 包括 if (disposable != null)
,但我真的认为 JIT 会优化并移除这种不必要的 if
。
那么,这段代码是安全的,对吧?在 disposable 对象创建之后的任何异常都会被 finally
子句保护,并会调用 Dispose
。
好吧,不是。对于同步异常,这是正确的,但也有异步异常,特别是 ThreadAbortException
。
想象一下,在设置 disposable 值之后、进入 try
块之前,抛出了一个 ThreadAbortException
,这是由另一个线程发出的 Abort
请求引起的。我们还没有进入 try
块,所以 finally 不会被调用。这是一个问题。它不会导致内存泄漏,因为 GC 最终会回收对象,但该资源将被长时间占用。如果这是一个数据库连接,它将无法返回到连接池。如果这是一个文件,它可能会被锁定,阻止其他人使用。
那么,我们如何解决这个问题呢?我稍后会展示解决方案,但首先我会展示一个**看起来**像解决方案的东西。为什么?因为我认为不知道这个伪解决方案的问题,会让一些人试图使用它,特别是因为有些地方已经将这种结构用作"正确"的方式。
代码
DisposableType disposable = null;
try
{
disposable = new DisposableType();
... use the disposable object here ...
}
finally
{
if (disposable != null)
disposable.Dispose();
}
在这个解决方案中,disposable 初始化为 null
。因此,代码块被一个 try
/finally
块保护,该块在 disposable 对象创建之前。如果 ThreadAbortException
在对象创建之前发生,finally
中的 if
将使其正常工作。如果 ThreadAbortException
在对象刚刚创建后发生,它也会正常工作。但是,仍然存在一个问题。
Abort 可以在任何汇编指令处发生。即使我们的一行代码看起来是 disposable = new DisposableType()
,在汇编层面,我们首先分配类型,然后将结果存储到 disposable 变量中。更糟糕的是,构造函数也可以在中间被中断(我自己进行了大量测试,即使没有例子确切地展示异常发生在哪里)。
那么,有没有可能解决这个问题?是的。但我们必须谨慎使用。正如前面所示,当抛出异常时,finally
块会执行。如果我们已经进入了 finally
块,它会继续正常执行,所以即使调用 Abort
来中断一个已在 finally
块中的线程,也不会强制其退出到另一个 finally
块,从而错过一些步骤。因此,我们可以利用这一点,将所有不应被阻塞的代码放在 finally
块中。
但是,请记住,要谨慎使用。如果你在此块中使用任何阻塞操作,即使你需要,也无法 Abort
该线程。这可能导致非常糟糕的用户体验。
所以,让我们看看代码
DisposableType disposable = null;
try
{
try
{
}
finally
{
disposable = new DisposableType();
}
... use the disposable object here ...
}
finally
{
if (disposable != null)
disposable.Dispose();
}
有了这个解决方案,Abort
要么发生在 DisposableType
被分配之前,要么发生在它完全分配并且变量被设置之后。不会发生 "中间" 中止。
那么,就这样了吗?嗯,对于 ThreadAbortExceptions
,是的。对于其他异步异常,不是。如果你查看 CERs (Constrained Execution Regions) 的文档,准备好应对 ThreadAbortException
只是其中一个需要注意的事项。系统在需要编译方法时可能会耗尽内存,或者应用程序可能会被要求突然关闭,从而跳过正常的 finally
子句。但是,不要认为这会让这种技术过时。ThreadAbortExceptions
比其他异常更常见,特别是当应用程序突然关闭时,未关闭的文件或数据库连接无论如何都会被操作系统回收。
改进
所展示的技术有效,但它很丑陋。所以,我决定创建一些类和辅助方法。其中最重要的一个在 AbortSafe
类中,就是接收三个参数的 Run
方法。让我们看看这个方法
public static void Run(Action allocationBlock, Action codeBlock, Action finallyBlock)
{
if (allocationBlock == null)
throw new ArgumentNullException("allocationBlock");
if (codeBlock == null)
throw new ArgumentNullException("codeBlock");
if (finallyBlock == null)
throw new ArgumentNullException("finallyBlock");
try
{
try
{
}
finally
{
allocationBlock();
}
codeBlock();
}
finally
{
finallyBlock();
}
}
它只接收三个操作。如果开始分配,即使在中间发生中止,也保证会完成。无论分配成功与否,都会运行最终化。唯一可能被中止的代码块是代码块本身。
让我们看一个简单的使用示例
DisposableType disposable = null;
AbortSafe.Run
(
() => disposable = new DisposableType(),
() =>
{
... do what you need with the disposable object...
},
() => disposable.CheckedDispose()
);
CheckedDispose
是 Pfz.Extensions.DisposeExtensions
命名空间中的一个扩展方法。它会在 Dispose 之前简单地检查变量是否不为 null
。我这样做只是为了避免创建一个新的代码块来实现 "if" 逻辑。如你所见,这段代码比创建一个空的 try
来在 finally
块中编程要"不那么"丑陋。而且,它看起来不像一个错误,所以被那些不理解为什么代码写在 finally
子句中的人"纠正"的可能性也更小。
示例
在附加的 zip 文件中,有一个程序,它创建和中止线程,这些线程将创建和重新创建同一个文件,但允许你选择它将如何做到这一点。
- 使用
using
关键字 - 使用伪解决方案
- 使用
AbortSafe
解决方案
第一和第二个,在某个时候,都会导致 IO 异常,因为文件 "已经被打开",而第三个则不会引起此类异常。