登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go os.OpenFile O_CREATE 没有 O_EXCL 时为什么可能覆盖竞态

来源:17golang原创

时间:2026-09-11 13:33:19 433浏览 收藏

两个 Go worker 同时生成 daily.json,日志却显示它们都“打开成功”,最后文件内容时好时坏。问题通常不在 Go 的 File 并发安全,而在于把 O_CREATE 误当成了“只允许一个创建者”。

需要竞争同一个文件名时,使用 os.O_CREATE|os.O_EXCL。第一个成功打开的调用者获得创建资格;其他调用者应把 os.ErrExist 当作“已有生产者”的结果,而不是继续写同一路径。
要点速览
  • O_CREATE 是“没有就创建,有就打开”,单独使用不会拒绝第二个调用者。
  • O_EXCL 必须和 O_CREATE 一起用,才能让文件名争抢成为一次性判定。
  • O_CREATE 本身不截断文件;真正的覆盖风险来自后续写入、O_TRUNC 或多个写者的时序。

O_CREATE 只是兜底创建,不是独占创建

官方 os.OpenFile 文档把 O_CREATE 定义为:目标不存在时创建;如果目标已经存在,调用仍然可以成功打开它。O_EXCL 的含义则是与 O_CREATE 联用,文件必须不存在。三者边界可以这样记:

标志目标不存在目标已存在是否独占
O_CREATE创建并打开打开已有文件
O_CREATE|O_EXCL创建并打开返回 os.ErrExist
O_CREATE|O_TRUNC创建并打开打开后截断
Go os.OpenFile O_CREATE 连接目标路径、已有文件、文件描述符和写入偏移的静态语义框图
图1:看清 O_CREATE 的“不存在才创建、存在仍可打开”关系,避免把它当作独占锁。

所以,O_CREATE 单独出现时并不等于“覆盖已有文件”。它只是把两个调用者导向同一个已存在对象;如果后面使用普通 Write、显式 Seek(0, 0),或者叠加 O_TRUNC,才可能出现内容互相覆盖、交错或被截断。

没有 O_EXCL 时,竞态发生在打开之后

常见的生产代码是:

f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0o600)
if err != nil {
    return err
}
defer f.Close()
// 中文注释:多个调用者都可能拿到同一路径的描述符,下面的写入没有“赢家”概念。
_, err = f.Write(payload)
return err

当路径还不存在时,调用 A 负责创建;调用 B 紧接着发现它已经存在,于是也成功返回。两个描述符的当前偏移都可能从文件开头开始,写入顺序由调度和系统时序决定。若其中一方先写入较长内容,另一方再从开头写入较短内容,还可能残留旧尾部;若代码用了 O_TRUNC,后打开者会直接把前一个写者的内容清空。

这也是为什么“给每个 goroutine 加互斥锁”不一定够:锁只覆盖同一个进程,无法约束另一个进程、另一个容器或重启后恢复的任务。真正要保护的是文件名的创建资格。

用 O_CREATE|O_EXCL 让一个调用者成为赢家

把判断交给一次打开操作,不要先 Stat 再决定是否创建。后者把检查和创建拆成两次系统调用,中间始终存在窗口。

package main

import (
    "errors"
    "fmt"
    "os"
)

func createOnce(path string, payload []byte) error {
    // 中文注释:O_EXCL 与 O_CREATE 联用,要求路径在打开瞬间仍不存在。
    f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
    if err != nil {
        // 中文注释:ErrExist 表示别的 worker 已占用文件名,调用方不应继续写入。
        if errors.Is(err, os.ErrExist) {
            return fmt.Errorf("文件已由其他任务创建: %w", err)
        }
        return fmt.Errorf("独占创建文件失败: %w", err)
    }

    // 中文注释:写入失败时关闭并删除半成品,避免下一次任务误把它当成完整文件。
    if _, err := f.Write(payload); err != nil {
        _ = f.Close()
        _ = os.Remove(path)
        return fmt.Errorf("写入新文件失败: %w", err)
    }
    // 中文注释:关闭失败也要报告,调用方可决定是否重试或清理文件。
    if err := f.Close(); err != nil {
        _ = os.Remove(path)
        return fmt.Errorf("关闭新文件失败: %w", err)
    }
    return nil
}

这个函数的关键不是“写得更快”,而是把“谁有资格创建”与“谁负责写入”绑定到同一个成功结果。失败者收到 os.ErrExist 后,可以转去读取已完成文件、等待 ready 标记,或记录“本次任务已被其他 worker 处理”。

Go O_CREATE O_EXCL 将两个 worker 连接到同一路径并分成赢家文件描述符和 ErrExist 结果的静态关系框图
图2:O_CREATE|O_EXCL 的结果分成“唯一文件描述符”和“文件已存在错误”,后者不再进入写入路径。

修复后还要处理半成品和读取时机

O_EXCL 解决的是“名称只能被一个创建者拿到”,不是“读者永远看不到半成品”。赢家打开成功后到写完关闭前,其他读者仍可能看到一个正在增长的文件。因此要先明确业务约束:

  • 读者允许等待:写入成功后再创建 daily.json.ready,读者只处理存在 ready 标记的文件。
  • 读者必须只见完整内容:先在同目录用唯一临时名独占创建,写完并关闭,再采用符合目标系统语义的原子发布方案;不要跨文件系统移动。
  • 文件是追加日志:使用 O_APPEND,并为每条记录设计可恢复的边界;它不能替代“只创建一次”的 O_EXCL

另外,os.OpenFile 返回成功后要确保每条错误路径都关闭描述符。写入半途失败时删除文件是否安全,取决于是否可能有读者已经拿到它;有读者时更适合把文件标记为失败并异步清理。

并发创建文件的检查清单

  1. 目标是“没有就创建”还是“只能有一个创建者”?后者必须包含 O_EXCL
  2. 是否先 Stat、再 OpenFile?如果是,应改为一次 O_CREATE|O_EXCL
  3. 是否误加了 O_TRUNC?它会把已有普通文件截断,和独占创建是两个问题。
  4. 失败者收到 os.ErrExist 后是否停止写入?继续写就是竞态重新出现。
  5. 读者如何判断文件写完?必要时增加 ready 标记、临时文件发布或任务状态。

常见问题

O_CREATE 不加 O_TRUNC 会自动清空旧文件吗?

不会。O_CREATE 只控制不存在时创建;已有文件通常会被打开,是否清空取决于 O_TRUNC 或后续写入方式。

同一进程的多个 goroutine 只用 O_EXCL 就够了吗?

它足以保护文件名的创建资格,但不能自动保证读者看不到半成品,也不能替你处理写入失败后的清理。

为什么不先 os.Stat 判断文件是否存在?

因为 Stat 和后续创建不是一个不可分割的动作,另一个调用者可以在两者之间抢先创建。把判断放进 O_CREATE|O_EXCL 的打开操作中,才不会留下这个窗口。

参考资料:https://pkg.go.dev/os#OpenFilehttps://go.dev/src/os/file.go。它们分别说明 OpenFile 的创建语义以及 O_CREATE、O_EXCL、O_TRUNC 等标志的定义。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>