AI Library · 知识库

# 确定性清理:关闭资源不是提交数据

学习目标:在成功、提前返回和业务失败时都释放资源,并分清主错误、清理错误以及持久化保证。

前置知识:函数返回、异常、文件读取;本篇的 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 语句协议。

# 错误与持久化

资源问题至少分四层:

  1. 关闭句柄:让操作系统资源不再被本进程占用;成功和失败都应做。
  2. 刷新用户态缓冲:如 Python 二进制缓冲流在关闭时刷出数据,刷新也可能失败。
  3. 请求同步到稳定存储:如 Go File.Sync、Java FileChannel.force、Python os.fsync;具体保证仍依赖平台、文件系统和硬件。
  4. 业务原子提交:临时文件、替换、目录同步或事务是另一套协议;关闭并不能补出它们。

本例只演示前两层,写入后拒绝仍可能留下完整或部分文件。不能因为用 defer、with 或析构就宣称“崩溃安全”。相关接口:Go Sync、Java force、Python fsync。

清理错误也不是可选日志:只有清理失败时,应报告清理失败;两者同时失败时,必须明确哪个是主错误以及如何访问另一个。不要在清理代码中 return 覆盖原结果。也不能依赖进程终止、垃圾回收或终结器实现及时关闭。

# 运行与验证

预期覆盖真实文件写入、精确换行字节、业务异常路径、作用域退出和本机 TCP 收发后异常退出。loopback 监听需要本机策略允许,设置三秒超时以避免无限等待,不依赖公网。没有诱发真实磁盘满、关闭失败、清理权限失败、强制进程终止;相应主次规则是文档依据,不冒充运行证据。本文未运行程序,统一执行记录见验证记录。

# 相关阅读

表达与传播失败 · 将报告写入不同目标 · Rust 所有权 · C++ RAII