# 确定性清理:关闭资源不是提交数据
学习目标:在成功、提前返回和业务失败时都释放资源,并分清主错误、清理错误以及持久化保证。
前置知识:函数返回、异常、文件读取;本篇的 socket 仅是本机字节连接,不需要网络协议知识。版本见主题基线。
导航:问题与边界 · 关键差异 · Go · Java · Python · 错误与持久化 · 运行与验证
# 问题与边界
在自己的临时目录创建报告,写出精确 UTF-8/ASCII 字节 items: 3\n,随后可能发生业务拒绝。成功与拒绝均必须关闭文件。拒绝不承诺删除已写字节,也不承诺回滚;调用方报告拒绝,不能把“文件存在”当作报告已被业务接受。
文件程序成功输出 written,失败输出 rejected and closed。Python 再用仅绑定 127.0.0.1 的真实 TCP 连接展示同一清理机制;连接只传一个字节,不依赖公网,不实现生产协议。关闭本地句柄不等于远端收到或提交业务数据。
# 关键差异
| 语言 | 释放时机 | 主体与关闭同时失败 | 边界 |
|---|---|---|---|
| Go | defer 在函数退出时执行 |
显式 errors.Join 保留两者 |
不是循环体或任意大括号退出 |
| Java | try-with-resources 块退出,逆序关闭 | 主体异常优先,关闭异常 suppressed | 只管理成功初始化的资源 |
| Python | with 调用退出协议 |
退出异常可成为当前异常,原异常成为上下文 | 管理器可以选择抑制异常,须看契约 |
选择这三种展示延迟函数、语言级资源块和上下文协议。Rust 的 Drop 与 C++ 析构另见所有权专题和 RAII 专题,不把析构错误策略伪装成同一套规则。
# 各语言实现
# Go
预期两行 written、rejected and closed。
Gopackage main
import (
"errors"
"fmt"
"os"
"path/filepath"
)
var ErrRejected = errors.New("report rejected")
func report(path string, reject bool) (err error) {
file, err := os.Create(path)
if err != nil { return err }
defer func() {
if closeErr := file.Close(); closeErr != nil {
err = errors.Join(err, fmt.Errorf("close report: %w", closeErr))
}
}()
if _, err = file.WriteString("items: 3\n"); err != nil { return err }
if reject { return ErrRejected }
return nil
}
func run() (err error) {
dir, err := os.MkdirTemp("", "report-")
if err != nil { return err }
defer func() { err = errors.Join(err, os.RemoveAll(dir)) }()
path := filepath.Join(dir, "report.txt")
if err := report(path, false); err != nil { return err }
data, err := os.ReadFile(path)
if err != nil { return err }
if string(data) != "items: 3\n" { return errors.New("wrong bytes") }
fmt.Println("written")
err = report(path, true)
if !errors.Is(err, ErrRejected) { return fmt.Errorf("expected rejection: %v", err) }
// 本例不把可能附带的关闭错误默认为成功。
joined, ok := err.(interface{ Unwrap() []error })
if ok && len(joined.Unwrap()) > 1 { return err }
err = nil
if err := os.Remove(path); err != nil { return err }
fmt.Println("rejected and closed")
return nil
}
func main() { if err := run(); err != nil { panic(err) } }
命名返回变量让延迟函数能补充关闭失败;defer file.Close() 单独一行会丢掉返回的错误。这里主错误与关闭错误按顺序聚合,错误匹配能找到业务拒绝,但调用方仍必须决定附带清理错误是否导致整体失败。本例选择继续传播,不能只看到 ErrRejected 就忽略其余错误。参见 Go defer、File.Close 和 errors.Join。
删除成功在某些系统上不能证明此前句柄已关闭;实际关闭保证来自函数退出执行的 defer,不是来自“能删除文件”的跨平台猜测。
# Java
预期同 Go。
Javaimport java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
public class Main {
static final byte[] REPORT = "items: 3\n".getBytes(StandardCharsets.UTF_8);
static class Rejected extends Exception {}
static void report(Path path, boolean reject) throws IOException, Rejected {
try (OutputStream out = Files.newOutputStream(path)) {
out.write(REPORT);
if (reject) throw new Rejected();
}
}
public static void main(String[] args) throws Exception {
Path dir = Files.createTempDirectory("report-");
Path path = dir.resolve("report.txt");
try (AutoCloseable cleanup = () -> {
Files.deleteIfExists(path);
Files.delete(dir);
}) {
report(path, false);
if (!Arrays.equals(Files.readAllBytes(path), REPORT)) {
throw new AssertionError("wrong bytes");
}
System.out.println("written");
try {
report(path, true);
throw new AssertionError("rejection lost");
} catch (Rejected e) {
// 若附带关闭失败,不把它当作普通、已处理的业务拒绝。
if (e.getSuppressed().length != 0) throw e;
Files.delete(path);
System.out.println("rejected and closed");
}
}
}
}
主体正常但 close() 失败时,该关闭异常会向外传播;主体已经抛异常时,关闭异常加入主异常的 suppressed 列表。不要把 finally { close(); } 当成完全等价替换,它可能让关闭异常取代主异常。规则来自 JLS try-with-resources。本例最外层目录清理也是资源块的一部分;它失败时仍使程序失败。
# Python
预期三行 written、rejected and closed、connection closed。
Pythonfrom contextlib import ExitStack
from pathlib import Path
from tempfile import TemporaryDirectory
import socket
class Rejected(Exception):
pass
def report(path: Path, reject: bool) -> None:
with path.open("wb") as out:
out.write(b"items: 3\n")
if reject:
raise Rejected("report rejected")
assert out.closed
with TemporaryDirectory(prefix="report-") as directory:
path = Path(directory) / "report.txt"
report(path, False)
assert path.read_bytes() == b"items: 3\n"
print("written")
try:
report(path, True)
except Rejected:
# with 在异常到达这里以前已经执行退出协议。
assert path.read_bytes() == b"items: 3\n"
path.unlink()
print("rejected and closed")
else:
raise AssertionError("rejection lost")
try:
with ExitStack() as resources:
listener = resources.enter_context(socket.socket())
listener.settimeout(3)
listener.bind(("127.0.0.1", 0))
listener.listen(1)
client = resources.enter_context(
socket.create_connection(listener.getsockname(), timeout=3)
)
server, _ = listener.accept()
resources.enter_context(server)
server.settimeout(3)
client.sendall(b"x")
assert server.recv(1) == b"x"
raise Rejected("request rejected")
except Rejected:
assert listener.fileno() == client.fileno() == server.fileno() == -1
print("connection closed")
ExitStack 按逆序退出已经注册的资源;连接创建失败时,先前注册的监听器也会关闭。必须在获得资源后立即登记,不能等一批资源全部创建完才登记。标准 socket 的上下文退出关闭套接字,见 socket 对象。
这里捕获的只有业务 Rejected。如果文件或 socket 退出时抛出 OSError,它不会被误判为正常拒绝,将传播并保留原业务异常的上下文;本例没有声称 Python 默认将业务异常保持为顶层。需要两者平等可检查时,可在自定义管理边界构造 ExceptionGroup,但这不是内置 with 自动提供的主次策略。参见 with 语句协议。
# 错误与持久化
资源问题至少分四层:
- 关闭句柄:让操作系统资源不再被本进程占用;成功和失败都应做。
- 刷新用户态缓冲:如 Python 二进制缓冲流在关闭时刷出数据,刷新也可能失败。
- 请求同步到稳定存储:如 Go
File.Sync、JavaFileChannel.force、Pythonos.fsync;具体保证仍依赖平台、文件系统和硬件。 - 业务原子提交:临时文件、替换、目录同步或事务是另一套协议;关闭并不能补出它们。
本例只演示前两层,写入后拒绝仍可能留下完整或部分文件。不能因为用 defer、with 或析构就宣称“崩溃安全”。相关接口:Go Sync、Java force、Python fsync。
清理错误也不是可选日志:只有清理失败时,应报告清理失败;两者同时失败时,必须明确哪个是主错误以及如何访问另一个。不要在清理代码中 return 覆盖原结果。也不能依赖进程终止、垃圾回收或终结器实现及时关闭。
# 运行与验证
预期覆盖真实文件写入、精确换行字节、业务异常路径、作用域退出和本机 TCP 收发后异常退出。loopback 监听需要本机策略允许,设置三秒超时以避免无限等待,不依赖公网。没有诱发真实磁盘满、关闭失败、清理权限失败、强制进程终止;相应主次规则是文档依据,不冒充运行证据。本文未运行程序,统一执行记录见验证记录。