有些对象持有垃圾回收器不管理的资源:打开的文件、网络套接字、数据库连接、操作系统句柄。它们实现了 IDisposable,你通过调用 Dispose() 来释放资源。using 语句会在一个确定的位置替你调用它,即使抛出了异常也一样。
本页讲的是 using 语句。文件顶部的 using System; 这几行是另一个功能,叫作 using 指令,它的作用是导入命名空间。
using 代码块
using (declaration) { body } 会创建对象、运行代码块主体,然后调用 Dispose。用一个在 Dispose 中打印内容的小类,就能清楚地看到它何时发生:
输出:
open db
db sends SELECT 1
close db
open api
api sends GET /orders
close api
error handled
第二个代码块抛出了异常,“close api” 仍然会在 catch 处理异常之前打印出来。正是这种保证,让我们使用 using,而不是自己在最后一行调用 Dispose():在手动调用之前抛出的任何异常都会让它被跳过。
using 会被编译成什么
编译器会把 using 代码块展开成一个 try/finally。下面两种写法是等价的:
using (var writer = new StreamWriter("log.txt"))
{
writer.WriteLine("started");
}
// is compiled as:
{
var writer = new StreamWriter("log.txt");
try
{
writer.WriteLine("started");
}
finally
{
if (writer != null) ((IDisposable)writer).Dispose();
}
}
从这种展开方式可以得出三个细节。变量的作用域仅限于代码块,而且在代码块内是只读的(你不能给 writer 重新赋值)。对象必须实现 IDisposable,否则代码无法编译。另外,值为 null 是允许的:这时只是跳过 Dispose,当工厂方法可能什么都不返回时,这一点很方便。
文件与流
文件和流的类是最常见的场景。StreamWriter 会把文本缓冲在内存中,而 Dispose 会把缓冲区刷新到磁盘并关闭文件句柄:
输出:
id,total
1001,59.90
1002,12.50
如果不调用 Dispose,写入器的最后几行可能还留在缓冲区里,而且在 Windows 上,打开的句柄会让其他程序(以及你自己接下来的 File.Delete)无法操作这个文件。File.WriteAllText 和 File.ReadAllLines 这类辅助方法会在内部打开并释放文件,所以不需要 using。更多内容请参阅 文件。
多个资源:释放顺序
把多个 using 语句叠在一起、中间不加大括号,就能在一个代码块中打开多个资源。它们会按创建顺序的相反顺序释放:
输出:
acquire file
acquire buffer
acquire writer
working
release writer
release buffer
release file
acquire a
acquire b
both open
release b
release a
层层包装的资源需要的正是相反的顺序:写入器包装了缓冲区,缓冲区又包装了文件,所以写入器必须先刷新到缓冲区,缓冲区再写入文件,最后文件才能关闭。
using 声明(C# 8)
C# 8 新增了一种不带大括号的形式。变量会在外层作用域结束时被释放,通常是方法的末尾:
static void ExportReport(string path, IEnumerable<string> rows)
{
using var writer = new StreamWriter(path);
writer.WriteLine("Report");
foreach (string row in rows)
{
writer.WriteLine(row);
}
} // writer.Dispose() runs here, at the end of the method
它少了一层缩进,当一个方法用到两三个资源时,这一点就很可观了。释放顺序的规则相同:同一作用域中的声明按相反的顺序释放。代价是资源会一直保持打开,直到作用域结束;所以如果文件应该在方法继续做其他工作之前关闭,就保留代码块的写法,或者把声明放进单独的 { } 代码块中。
对于清理过程是异步的对象(IAsyncDisposable,比如许多数据库连接和流),C# 8 还提供了 await using,它会在同样的位置 await DisposeAsync()。
在你自己的类中实现 IDisposable
当你的类拥有某个可释放的东西(它创建了一个流、一个定时器、一个连接),因此也必须负责释放它时,就实现 IDisposable。对于常见的情况,只需要一个简短的方法:
输出:
1. user ana logged in
2. order 1001 created
AuditLog disposed
Caught ObjectDisposedException
这里遵循的规则是:Dispose 会释放对象拥有的所有东西;多次调用时,第二次什么也不做;释放之后再使用这个对象会抛出 ObjectDisposedException。using (log) 还说明了代码块可以接收一个已有的变量,而不一定是一个声明。
只有当一个类直接持有非托管句柄(来自本机代码的原始指针)时,才需要更长的“Dispose 模式”,也就是带有 protected virtual void Dispose(bool disposing) 方法和终结器的写法。在现代 .NET 中,请改为把这类句柄包装在 SafeHandle 的子类中,这样你的类只需要上面这种简单的写法就够了。
常见错误
- 返回在
using代码块中创建的对象。 它会在代码块退出时被释放,所以调用方拿到的是一个已关闭的流。请返回数据,或者让调用方来负责using。 - 流和写入器忘了用
using。 缓冲的数据可能丢失,文件也会一直被锁定。 - 释放共享的对象。 只释放你拥有的东西。比如一个长期存在的
HttpClient,本来就是要共享和复用的,而不是每个请求都创建再释放。 - 在代码块之后使用对象。 调用已释放对象的方法会抛出
ObjectDisposedException。 - 依赖垃圾回收器。 它释放的是内存,而不是文件或套接字,而且从不调用
Dispose。
常见问题
C# 中的 using 语句有什么作用?
using (var x = ...) { ... } 会在代码块结束时调用 x.Dispose(),无论代码块是正常结束、通过 return 结束,还是因为抛出了异常而结束。编译器会把它转换成一个 try/finally,并把 Dispose 调用放在 finally 中,这样资源就会在一个确定的时间点释放,而不是等到垃圾回收器某次运行时。
C# 中的 IDisposable 是什么?
IDisposable 是一个只有一个方法 void Dispose() 的接口。当一个类持有必须显式释放的东西时,就会实现它:文件句柄、网络套接字、数据库连接、定时器。任何类型实现了 IDisposable 的对象,用完之后都应该释放,通常用 using 来完成。
using 和 using var 有什么区别?
using (var x = ...) { } 在它的代码块结束时释放。using 声明 using var x = ...;(C# 8 及更高版本)没有自己的代码块:它在外层作用域结束时释放,通常是方法的末尾。当资源需要在方法剩余部分一直存在时,它可以减少一层嵌套。
多个 using 对象按什么顺序释放?
按创建顺序的相反顺序释放。对于 using (var a = ...) using (var b = ...) { },先释放 b,再释放 a。当 b 依赖于 a 时,比如 StreamWriter 包装了一个 FileStream,这正是你想要的顺序。
垃圾回收器会调用 Dispose 吗?
不会。垃圾回收器负责释放内存,它也可能在之后某个无法预测的时间运行终结器,但它从不调用 Dispose。没有释放的文件可能会一直被锁定,其中缓冲的数据也可能永远写不进去。这就是 using 存在的原因。