-
Notifications
You must be signed in to change notification settings - Fork 137
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
2 changed files
with
138 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
# 文件写入与数据丢失:从用户态到内核缓冲 | ||
|
||
在进行开发时,许多程序需要将处理结果导出到文件中,以便用户查看、存档或进一步分析。比如,在数据分析、图像编辑、游戏等应用中,程序会生成一些结果数据或操作记录,需要导出成文件格式(如 CSV、TXT、JSON、图片文件等),以便用户保存或与他人共享。 | ||
|
||
我们一般也就是正常的使用标准库或者框架的接口进行文件的写入。比如使用 [`std::ofstream`](https://zh.cppreference.com/w/cpp/io/basic_ofstream)、`QFile`。 | ||
|
||
那么问题来了,当写入函数执行完毕后,文件真的被写入到硬件设备上了吗?这看起来是个简单的问题,但它涉及到了用户态中库实现与操作系统中文件系统的缓冲和刷新机制。 | ||
|
||
## 写入文件 | ||
|
||
先从简单的开始吧,我们读取写入一个文件会编写怎么样的代码呢? | ||
|
||
```cpp | ||
#include <iostream> | ||
#include <fstream> | ||
#include <thread> | ||
|
||
int main() { | ||
// 创建一个文件输出流对象,打开目标文件 | ||
std::ofstream file{"output.txt"}; | ||
|
||
if (!file) { | ||
std::cerr << "文件打开失败!" << std::endl; | ||
return 1; | ||
} | ||
|
||
// 写入数据到文件 | ||
file << "Hello, World!"; | ||
file << "这是一些需要写入文件的数据。"; | ||
|
||
// 关闭文件并刷新用户态缓冲区 | ||
file.close(); | ||
std::cout << "数据已成功写入到 output.txt" << std::endl; | ||
} | ||
``` | ||
|
||
`close()` 调用实际上是冗余的,因为当 file 对象析构时,会自动调用 close()。 | ||
|
||
在 C++ 标准库中,写入文件时,数据首先被写入到库内部的用户态缓冲区中。这样做的目的是提高性能,避免每次写入都直接与底层文件系统进行交互。这样做的目的是为了**减少系统调用次数和提高效率**,尤其是在频繁进行小规模写入时。通过使用缓冲区,多个写入操作可以**合并成一次批量写入**,减少 I/O 操作的开销。 | ||
|
||
几乎所有的库都会使用缓冲区来优化文件操作,这是一种非常常见的做法。在上述代码中,你可以在 `file.close()` 之前设置断点,或者 sleep,你会发现,只有在刷新了用户态缓冲区之后,数据才被写入了文件。 | ||
|
||
但事情真的是这样吗?刷新用户态缓冲区并不意味着数据立即写入到硬件设备中。 | ||
|
||
- **数据首先会从用户态缓冲区写入内核缓冲区**,之后才可能被实际写入磁盘。 | ||
|
||
操作系统的文件系统会在合适的时机将数据从内核缓冲区刷新到硬件设备。如果我们需要确保文件真的写入了,需要显式的使用 [`fsync`](https://pubs.opengroup.org/onlinepubs/9799919799/functions/fsync.html) 函数,或者`sync` 等命令,才能真正的确保文件被写入。 | ||
|
||
## 读取文件 | ||
|
||
假设你传递了一个磁盘路径进行对文件进行操作(读取,复制,查看大小),并且操作成功了,这时文件真的已经存在吗? | ||
|
||
在上一节我们清楚了:**当你调用了写入文件的函数,数据通常会首先存储在用户态缓冲区中,当我们刷新了缓冲区就会传输到内核缓冲区**。 | ||
|
||
```cpp | ||
write(file_path); | ||
flush; | ||
read(file_path); | ||
``` | ||
读取成功了,那么会不会出现一种情况:**这里写文件函数调用完成,也刷新了用户态缓冲到内核缓冲区,但是还没有真的写入磁盘?** | ||
这种情况是完全可能的。那么,为什么在这种情况下我们仍然能够**成功读取**文件呢? | ||
- **虽然此时数据可能还没有完全写入硬件设备(磁盘),操作系统文件系统会提供抽象,使得你在读取文件时,能从内核缓冲区中看到数据。** | ||
也就是让用户感觉好像真的写入了一样,“*如同写入*”,有点类似于一个映射。 | ||
通常情况下,作为开发者你无需过多关注这一点,因为内核会管理这些缓冲操作。然而,**如果发生了突然断电或磁盘被强制拔出,未能及时刷新到硬件的内核缓冲区中的数据就会丢失**。 | ||
## 测试 | ||
环境:`x86_64 Ubuntu22.04` | ||
你可以使用下面的代码进行测试。为了更方便地观察结果,推荐使用移动存储设备进行测试。尽管原理是相同的,这种方式更直观。 | ||
```cpp | ||
#include <iostream> | ||
#include <filesystem> | ||
#include <unistd.h> | ||
#include <fcntl.h> | ||
#include <string.h> | ||
void sync_file_with_fsync(const std::filesystem::path& dest_path) { | ||
int fd = open(dest_path.c_str(), O_WRONLY); | ||
if (fd == -1) { | ||
std::cerr << "无法打开文件进行 fsync: " << strerror(errno) << std::endl; | ||
return; | ||
} | ||
if (fsync(fd) == -1) { | ||
std::cerr << "fsync 失败: " << strerror(errno) << std::endl; | ||
} else { | ||
std::cout << "fsync 成功,数据已写入磁盘。" << std::endl; | ||
} | ||
close(fd); | ||
} | ||
int main(){ | ||
std::filesystem::path source_path = "/home/mq-b/test/test_fsync/1MB_file.dat"; | ||
std::filesystem::path dest_path = "/media/mq-b/MQ-B/1MB_file.dat"; | ||
std::filesystem::copy_file(source_path, dest_path, std::filesystem::copy_options::overwrite_existing); | ||
sync_file_with_fsync(dest_path); | ||
std::clog << dest_path << " 文件复制完成,大小:" << std::filesystem::file_size(dest_path) << " Byte" << std::endl; | ||
} | ||
``` | ||
|
||
用 `1MB` 大小的文件复制进行测试,[`filesystem::copy_file`](https://zh.cppreference.com/w/cpp/filesystem/copy_file) 会自动刷新用户态缓冲,无需手动处理。 | ||
|
||
通过注释与使用 `sync_file_with_fsync` 函数调用来进行观察。 | ||
|
||
1. 你可以使用 `VMware` 虚拟机,在虚拟机中的 Linux 系统编译并执行这段代码。 | ||
2. 插入一个移动存储设备(修改路径以匹配你的设备)。 | ||
3. 执行程序后,观察输出,程序会显示 `... 文件复制完成,大小:` 这样的信息。 | ||
4. 此时暂停虚拟机,切换到 Windows 系统。 | ||
5. 使用 windows 系统打开移动存储设备,你会发现,尽管复制函数已经执行完毕,且文件大小也显示正确,但**文件内容并没有被实际写入硬盘**。这是因为 [`file_size`](https://zh.cppreference.com/w/cpp/filesystem/file_size) 获取的是内核缓冲区中的信息,而非磁盘上的实际数据。 | ||
|
||
使用 `sync_file_with_fsync` 便**不会出现数据丢失**,因为其中 `fsync` 会将内核缓冲区的缓存都写入到物理存储中。 | ||
|
||
## 总结 | ||
|
||
在文件写入操作中,数据通常首先存储在用户态缓冲区(尽管某些接口,如 POSIX 的 write,没有用户态的缓冲)。我们可以选择手动刷新缓冲区,或者等待库在适当时机自动刷新,将数据写入到内核缓冲区。 | ||
|
||
此时,尽管文件尚未完全写入硬盘,我们仍然可以读取文件内容,但读取到的只是内核缓冲区中的数据。 | ||
|
||
在合适的时机,内核缓冲区会自动刷新,或者我们手动调用 `fsync` 等函数,数据才会最终写入物理存储设备。 | ||
|
||
一般情况下我们无需关心以上这些内容,但是如果要确保数据被硬件设备存储,就一定要注意刷新内核缓冲区。比如导出数据到 U盘,或者用户显式点击保存。 | ||
|
||
1. 数据导出到 U盘,但是你没注意刷新内核缓冲,程序流程继续执行,弹窗“**导出成功**”。用户直接拔出 U盘,然后发现**根本没有数据**,因为数据还存在内核缓冲中。 | ||
|
||
2. 用户显式 Ctrl + S 保存了编辑的文件,提示:“**已保存**”,此时停电了。用户在来电后打开电脑发现文件根本没保存。 | ||
|
||
以上两种不管哪种都不应该出现。 |