65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.57/5 (33投票s)

2009年11月27日

CPOL

5分钟阅读

viewsIcon

109506

downloadIcon

142

本文说明了为什么连 "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()
);

CheckedDisposePfz.Extensions.DisposeExtensions 命名空间中的一个扩展方法。它会在 Dispose 之前简单地检查变量是否不为 null。我这样做只是为了避免创建一个新的代码块来实现 "if" 逻辑。如你所见,这段代码比创建一个空的 try 来在 finally 块中编程要"不那么"丑陋。而且,它看起来不像一个错误,所以被那些不理解为什么代码写在 finally 子句中的人"纠正"的可能性也更小。

示例

在附加的 zip 文件中,有一个程序,它创建和中止线程,这些线程将创建和重新创建同一个文件,但允许你选择它将如何做到这一点。

  1. 使用 using 关键字
  2. 使用伪解决方案
  3. 使用 AbortSafe 解决方案

第一和第二个,在某个时候,都会导致 IO 异常,因为文件 "已经被打开",而第三个则不会引起此类异常。

© . All rights reserved.