C++ 文件系统与 IO:std::filesystem、流与内存映射

文件 IO 看似简单,性能与可移植性却常被低估。本文从 std::filesystem 的 path 与目录遍历讲起,对比 fstream、POSIX read/write 与 mmap 三层的开销差异,剖析缓冲、系统调用次数与页缓存的关系,并给出内存映射的适用边界与常见陷阱。

「读一个文件」在教科书里是 ifstream in("a.txt"),在生产环境里却涉及路径规范化、字符编码、系统调用次数、页缓存命中率和磁盘寻道。同一个 1GB 文件,用 ifstream 逐行读、用 read 大块读、用 mmap 映射,吞吐量可能差一个数量级。而文件系统 API 的跨平台差异(Windows 的宽字符路径、大小写不敏感、文件锁语义)又让「一次编写处处运行」变成奢望。

C++17 的 std::filesystem 统一了路径与目录操作,但它只负责「元数据」,不负责「内容 IO」——读写内容仍要落到 fstream、系统调用或第三方库。本文按这条界线展开:先用 std::filesystem 把路径与目录打理干净,再深入内容 IO 的三层实现与性能取舍。

path 模型:跨平台路径的正确姿势

std::filesystem::path 内部保存的是「原生格式」的字符串:POSIX 上是 char,Windows 上是 wchar_t。它提供了可移植的分隔符与拼接语义。

#include <filesystem>
namespace fs = std::filesystem;

fs::path p = "/var/log/app";
p /= "2026";            // 追加目录分量,自动处理分隔符
p += ".log";            // 注意:+= 是字符串拼接,/= 是路径拼接
// 结果:/var/log/app/2026.log

std::cout << p.filename()      << "\n";  // 2026.log
std::cout << p.stem()          << "\n";  // 2026
std::cout << p.extension()     << "\n";  // .log
std::cout << p.parent_path()   << "\n";  // /var/log/app

几个必须记住的语义:

操作含义陷阱
p /= x追加路径分量若 x 是绝对路径,结果是 x(替换整个路径)
p += x字符串拼接不做分隔符处理,p += "x" 会拼成 appx
p.string()原生窄字符串Windows 上可能因编码转换抛异常
p.u8string()UTF-8C++20 起返回 std::u8string
fs::absolute(p)转绝对路径不改动磁盘,纯字符串运算
fs::canonical(p)解析符号链接、.、..要求路径存在,否则抛异常
fs::weakly_canonical(p)部分规范化允许不存在的尾部,更安全

canonical 与 weakly_canonical 的区别是高频踩坑点:判断「两个路径是否指向同一文件」时,canonical 会因为文件不存在而抛异常,而实际场景里目标文件常常还没创建。稳妥做法是用 weakly_canonical 规范化父目录,再拼接文件名。

另一个高频问题是路径的编码。Linux 上文件名是「字节串」,不保证是合法 UTF-8;Windows 上是 UTF-16。用 p.string() 在 Windows 上遇到非当前代码页字符会抛 filesystem_error。可移植的写法是全程用 fs::path 传递,只在真正需要字节串时(如传给 POSIX 系统调用)才调 .string(),且只在 POSIX 平台上这么做。

#ifdef _WIN32
    std::wstring native = p.wstring();
#else
    std::string native = p.string();   // POSIX 上就是字节串,安全
#endif

目录遍历、文件属性与错误处理

directory_iterator 与 recursive_directory_iterator 是遍历的两把工具。前者只扫一层,后者递归。

for (const auto& entry : fs::recursive_directory_iterator(
         "/data", fs::directory_options::skip_permission_denied)) {
    if (!entry.is_regular_file()) continue;
    if (entry.path().extension() != ".log") continue;
    std::cout << entry.path() << " " << entry.file_size() << "\n";
}

遍历的性能要点:

  • entry.status() 会触发一次 stat 系统调用。在热循环里反复调 is_regular_file() + file_size() 会各自 stat 一次,应缓存 entry.status() 的结果。
  • directory_entry 在 C++17 里会缓存状态,但只有当目录迭代器能直接从 readdir 拿到 d_type 时才不额外 stat,很多文件系统(如某些网络文件系统)不提供 d_type,此时仍会 stat。
  • 遍历时不要修改目录内容,recursive_directory_iterator 的行为在并发增删下未定义。

文件属性方面,fs::status() 与 fs::symlink_status() 的区别很重要:前者跟随符号链接,后者不跟随。

fs::file_status st = fs::status(p);
if (fs::exists(st)) {
    auto sz  = fs::file_size(p);        // 字节数
    auto t   = fs::last_write_time(p);  // 文件时间戳
    bool ro  = (st.permissions() & fs::perms::owner_write) == fs::perms::none;
}

last_write_time 返回的是 file_time_type,它的纪元与 system_clock 不同(通常对应文件系统的原生时间戳),C++20 之前无法直接与 system_clock 互转。C++20 引入了 std::chrono::clock_cast 才能安全转换:

auto ft = fs::last_write_time(p);
auto sys = std::chrono::clock_cast<std::chrono::system_clock>(ft);

C++20 之前只能靠实现定义的偏移量硬转,跨平台会错。这也是 https://plumephp.com/cpp-modern-17-20-23/ 里讨论过的「标准库补全历史欠账」的一个典型例子。

错误处理:异常还是 error_code

std::filesystem 的每个函数都有两个重载:抛异常版与 std::error_code 版。

// 抛异常版
try { fs::create_directories("/data/2026/10"); }
catch (const fs::filesystem_error& e) { /* e.what(), e.code() */ }

// error_code 版,不抛异常
std::error_code ec;
fs::create_directories("/data/2026/10", ec);
if (ec) { /* 处理 */ }

选择原则:在「正常流程」里用 error_code,在「不可恢复的契约违反」里用异常。比如「配置文件不存在」是正常分支,用 error_code;「临时目录创建失败且无路可退」才抛异常。批处理脚本式的代码应该全程 error_code,避免在循环里被异常打断控制流。

注意 fs::exists(p) 在权限不足时可能返回 false 而不是抛异常,这会让「文件不存在」与「无法访问」混为一谈。需要区分时用 fs::status(p, ec) 并检查 ec。

内容 IO 的三层:fstream、POSIX 与 mmap

std::filesystem 到此为止。真正读写字节有三条路径,抽象层次与开销递减。

第一层:iostream / fstream

std::ofstream out("out.bin", std::ios::binary);
out.write(data.data(), data.size());
out.flush();          // 把用户态缓冲刷到内核
// out.close() 析构时也会 flush

ofstream 有用户态缓冲(streambuf),默认约 8KB。它的优点是类型安全、支持格式化;缺点是每层抽象都有成本:operator<< 的格式化、sentry 构造、locale 检查、虚函数分发。

逐字节读的经典写法是灾难:

// 慢:每个字符一次 sentry + 一次虚调用
char c;
while (in.get(c)) { /* ... */ }

正确做法是 read() 大块读进缓冲:

std::vector<char> buf(1 << 20);
while (in.read(buf.data(), buf.size()) || in.gcount() > 0) {
    process(buf.data(), in.gcount());
}

gcount() 返回上一次非格式化读取实际拿到的字节数,循环条件里 || in.gcount() > 0 是为了处理「读到最后一块但没到 eof 标志」的情况——只看 in.read() 的返回值会漏掉最后一段不满缓冲的数据。

逐行读的真实开销

按行读文本是常见需求,但 std::getline 每次都会扫描到分隔符、可能扩容字符串,并且每次调用都有一次 sentry 构造。一个可测量的对比(1GB 文本文件,约 2000 万行):

写法相对耗时说明
in.get(c) 逐字符100x每字符一次虚调用,最慢
std::getline(in, line)12x每行一次 sentry + 可能扩容
std::getline(in, line) + 预留 buffer8x复用 std::string 避免反复分配
大块 read + 手写扫描1x一次系统调用读 MB 级,内存内切行
mmap + 手写扫描0.8x省掉一次拷贝,但缺页有代价

结论很明确:在内存里做「切行」比让流做「读行」快得多。手写扫描的核心是找到 \n 的位置:

size_t start = 0;
for (size_t i = 0; i < n; ++i) {
    if (buf[i] == '\n') {
        std::string_view line(buf.data() + start, i - start);
        process(line);
        start = i + 1;
    }
}

用 std::string_view 而不是 std::string 是关键:切行时不拷贝,只在真正需要时构造字符串。这个思路与 https://plumephp.com/cpp-ranges-views/ 中「视图代替容器」的惰性求值哲学一致。

换行符与文本模式的陷阱

Windows 上文本文件用 CRLF(\r\n),POSIX 用 LF(\n)。fstream 的文本模式在 Windows 上会自动把 \r\n 转成 \n,二进制模式则不转。问题在于:

std::ifstream in("data.txt");                        // 文本模式,Windows 会转换
std::ifstream bin("data.bin", std::ios::binary);     // 二进制模式,原样读

如果在 Linux 上按文本模式读一个 Windows 生成的 CRLF 文件,行尾会残留 \r,导致字符串比较、正则匹配、数值解析全部失败。稳妥做法是读文件一律用 std::ios::binary,在应用层自己处理换行符,这样行为在所有平台上一致。

同理,写文件也用二进制模式,需要 CRLF 时显式写 "\r\n"。可移植的文件 IO 里,「让库替你猜」通常是错误来源。

第二层:POSIX read/write

绕过 iostream,直接调系统调用,省掉 C++ 流的所有开销:

#include <fcntl.h>
#include <unistd.h>

int fd = ::open("out.bin", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) { /* errno */ }

size_t off = 0;
while (off < size) {
    ssize_t n = ::write(fd, data.data() + off, size - off);
    if (n < 0) { if (errno == EINTR) continue; break; }  // EINTR 必须重试
    off += static_cast<size_t>(n);
}
::close(fd);

两个必须处理的细节:短写(partial write) 与 EINTR。write 可能只写一部分,循环必须推进偏移;被信号中断时要重试而不是当成错误。close 也可能返回错误(数据未能落盘),对可靠性有要求的场景要检查。

Windows 上对应的 API 是 CreateFile / ReadFile / WriteFile,语义与 POSIX 差异很大(HANDLE、OVERLAPPED、FILE_FLAG_NO_BUFFERING)。跨平台代码通常用一层薄封装或直接用 std::fstream 换可移植性。

第三层:mmap 内存映射

把文件映射进进程地址空间,读写内存即读写文件:

#include <sys/mman.h>
#include <sys/stat.h>

int fd = ::open("big.dat", O_RDONLY);
struct stat sb;
::fstat(fd, &sb);
void* addr = ::mmap(nullptr, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
// 像访问数组一样访问 addr
::munmap(addr, sb.st_size);
::close(fd);

mmap 省掉了「内核缓冲区 → 用户缓冲区」的一次拷贝:页缓存直接映射进进程地址空间,访问时按需触发缺页(page fault)从磁盘载入。对于「随机访问大文件」和「多进程共享只读数据」,这是最优解。

缓冲、系统调用次数与页缓存

理解性能差异的关键是三层缓存:

  1. 用户态缓冲(streambuf、自定义 buffer):减少系统调用次数。
  2. 页缓存(page cache):内核缓存文件内容,命中则无需磁盘 IO。
  3. 磁盘缓存:设备自带,对 OS 透明。

一次 read 的开销主要在系统调用本身(用户态/内核态切换,约数百纳秒到微秒)。因此吞吐量的第一优化目标是减少系统调用次数:把 1KB 的读换成 1MB 的读,系统调用次数降 1000 倍。

// 用 strace 观察系统调用
// strace -c -e trace=read,write,openat ./app

strace -c 会给出每个系统调用的次数与耗时占比,这是定位 IO 瓶颈最直接的手段。常见结论是:小 buffer 的 fstream 在大文件上表现为 read 次数极多,换成大 buffer 后 read 次数骤降,耗时随之下降。

但页缓存不是越多越好:它占用物理内存,且 mmap 的脏页回写由内核决定时机,msync 才能显式落盘。数据库之所以常常自己实现缓冲池(buffer pool)而不是依赖页缓存,就是为了精确控制替换策略与刷盘时机——这一点在 https://plumephp.com/cpp-storage-engine-implementation/ 里有完整的工程化讨论。

方式系统调用次数拷贝次数适用场景
fstream 小块极多2(内核→用户 + 用户态内部)小文件、格式化文本
fstream 大块少2通用
POSIX read 大块少1(内核→用户)高性能顺序读写
mmap1(建立映射)+ 缺页0(页缓存直接映射)随机访问大文件、只读共享
sendfile / splice少0(内核内部)文件→socket 转发

mmap 的适用边界与陷阱

mmap 强大但有一串陷阱,用错会得到「看似随机」的崩溃。

陷阱一:文件被截断导致 SIGBUS。 映射后若另一个进程把文件截短,访问超出新长度的页会触发 SIGBUS(不是 SIGSEGV),且无法用异常捕获。因此映射只读文件时也要防止文件被外部修改。

陷阱二:mmap 返回的指针可能为 MAP_FAILED(值为 (void*)-1),不是 nullptr:

void* addr = ::mmap(...);
if (addr == MAP_FAILED) { /* 处理,别写成 if (!addr) */ }

陷阱三:零长度文件。 mmap 长度传 0 会失败,必须先判 sb.st_size == 0。

陷阱四:写入是延迟的。 对 MAP_SHARED 的写会反映到文件,但刷盘时机由内核决定;MAP_PRIVATE 的写是写时复制(COW),不会落盘。要保证持久化必须 msync(addr, len, MS_SYNC)。

陷阱五:对齐。 offset 必须是页大小(通常 4096 字节)的整数倍,否则 mmap 失败。

long page = sysconf(_SC_PAGESIZE);
off_t aligned = (offset / page) * page;
void* base = ::mmap(nullptr, len + offset % page, PROT_READ, MAP_PRIVATE, fd, aligned);
char* p = static_cast<char*>(base) + (offset % page);

对于「读一遍就丢」的顺序扫描,mmap 未必比大块 read 快:缺页中断的代价可能超过一次大块拷贝。判断标准是访问模式:随机访问、多次访问、多进程共享 → mmap 占优;一次性顺序扫描 → 大块 read 更稳。系统编程里对这套机制的系统性梳理可以参看 Rust 系统编程中的 mmap 与共享内存 ,其原理与 C++ 完全相通。

大文件、零拷贝与异步 IO 的边界

当文件大到「读进内存」不现实时,需要跳出「一次读全部」的框架。

分块处理。 固定大小的窗口滑动,配合 pread/pwrite 支持并发读不同偏移(pread 不改变文件偏移,天然线程安全):

ssize_t n = ::pread(fd, buf, chunk, offset);

零拷贝转发。 文件内容要发给网络时,sendfile 让内核直接在页缓存与 socket 之间搬运,完全跳过用户态:

::sendfile(sock_fd, file_fd, &offset, count);

这比「read 到缓冲再 write 到 socket」少两次拷贝与两次上下文切换。Web 服务器静态文件服务几乎都走这条路。

异步 IO。 Linux 的 io_uring 提供真正的异步接口:提交一批读请求到环形队列,完成后从完成队列取结果,无需线程池。它比 epoll + 线程池的模式更省资源,但 API 复杂、需要较新的内核(5.1+)。对于「海量小文件随机读」的场景收益最大。

跨平台抽象上,std::fstream 保证可移植但慢;要性能就得用平台 API。这与文件系统本身的差异叠加在一起,构成了 C++ IO 代码里最费心的部分。相关主题可以对照 C# 文件系统与 IO 看托管语言如何处理同样的抽象层次,以及 PHP 流封装与文件系统 里「流包装器」这种统一抽象的设计思路。

文件锁、原子替换与并发安全

多进程同时读写同一文件时,仅靠 open/read/write 没有任何互斥保证。POSIX 提供两套锁:

#include <sys/file.h>
// 建议锁(advisory),进程自愿遵守
::flock(fd, LOCK_EX);      // 排他锁
::flock(fd, LOCK_SH);      // 共享锁
::flock(fd, LOCK_UN);

flock 是「建议锁」:不调用它的进程照样能读写,它只在协作进程之间起作用。fcntl 的 F_SETLK 支持字节范围锁,粒度更细但也更容易死锁(不同进程以不同顺序锁区间)。两者在 NFS 上的行为都不完全可靠,跨网络文件系统应改用应用层协调。

原子替换是「安全更新配置文件」的标准手法:先写临时文件,fsync 后再 rename 覆盖目标。rename 在同一文件系统内是原子的,读者要么看到旧文件、要么看到新文件,不会看到半个。

fs::path tmp = target; tmp += ".tmp";
{
    std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
    out << new_content;
    out.flush();
}
// 让数据真正落盘后再替换,否则崩溃可能丢内容
int fd = ::open(tmp.c_str(), O_RDONLY);
::fsync(fd); ::close(fd);
fs::rename(tmp, target);   // 原子替换

注意 rename 只保证「目录项替换」原子,不保证数据已落盘。顺序必须是 写 → fsync → rename,漏掉中间的 fsync 在掉电时可能得到「新文件名 + 空内容」。这是数据库与配置管理系统的通用模式。

并发读写的另一条经验是尽量做到「一次写入、多次读取」(single writer):多个读者共享只读映射,只有一个写者通过原子替换发布新版本,读者重新 open 即可拿到新内容。这避免了锁,也避免了读者读到中间状态。日志文件的「轮转(rotation)」正是这个模型:写者写满后 rename 成带时间戳的归档,新开一个文件继续写,读者按文件名顺序消费。

实践建议

  1. 路径一律用 fs::path,不要在 std::string 与路径之间来回转换;需要字节串时确认平台。
  2. 判断同一文件用 weakly_canonical,不要用会抛异常的 canonical。
  3. 遍历目录时缓存 directory_entry 的状态,避免重复 stat。
  4. IO 缓冲至少 64KB 起,大文件顺序读写用 MB 级缓冲;用 strace -c 验证系统调用次数。
  5. 正确处理短读短写与 EINTR,这是 POSIX IO 的必答题。
  6. mmap 只用于随机访问或多进程共享,顺序扫描优先大块 read;务必处理 MAP_FAILED、零长度与对齐。
  7. 文件内容转发用 sendfile,别自己 read 再 write。
  8. 区分「数据落盘」与「写入完成」:需要持久化时用 fsync/msync,普通日志可以依赖内核回写。
  9. 更新文件用「写临时文件 + fsync + rename」,绝不要原地截断重写;并发场景尽量收敛为单写者模型。
  10. 文本处理统一用二进制模式打开,自己处理换行符,避免平台差异污染解析逻辑。

把这十条压缩成一句话:元数据交给 std::filesystem,内容 IO 按访问模式选层,持久化语义自己负责。 文件 IO 的复杂度从来不在于「怎么读写字节」,而在于路径语义、缓冲层次与崩溃一致性这三件事,它们各自对应本文的一节,也各自对应一类线上事故。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cpp」更多文章

  1. C++ Unicode 与文本处理:编码转换与高性能字符串
  2. C++ 数值计算与线性代数:Eigen 与表达式模板
  3. C++ 静态分析与代码质量工具链:clang-tidy 与 Clang Static Analyzer