一、文件操作的层次
在普通的文件处理中,其中主要是读和写操作。而针对mmap或Direct IO则有着特殊的情况,后面再分析。正常的情况下,可以从三个层面来看对文件操作的情况:
- 应用层
应用层主要是业务代码和库,特别是相关的缓存处理。当然系统调用也是从这里进行的,有可能会出现用户态和内核态的切换 - 系统层
这一块相对复杂,一般开发人员只是使用相关的库或系统接口。但这其中包括了很多细节,如VFS系统、页缓存、预读、回写以及各种IO及BLK的处理,直到到相关的硬件的驱动如NVMe等 - 硬件
它主要涉及到相关的总和,对于开发者来说,侧重点应该是缓存。毕竟其它都已经
通过上面的分析可以看出,要想实现文件的高效操作,需要注意以下几点:
- 缩短访问乱离和访问次数即减少系统调用、拷贝、元数据操作等
- 文件I/O操作尽量按规律进行如文件数据的顺序、对齐、大小及并发度等
- 在相关位置做好操作边界和是否延迟如批量、缓存、异步等
二、硬件层
- 不同硬件存储的效率
这一点非常好理解,对于不同的存储硬件,其访问速度区别很大,特别是机械硬盘的固态硬盘更是如此。一般来说,机械硬盘的的反应都是毫秒级,传输速度也大约在百兆左右(每秒),但对随机访问支持不如对顺序访问好;而SSD硬盘也根据不同的接口和版本,区别也很大。它们的反应速度都是在微秒级别,速度差异很大,从SATA的几百兆到NVME的十吉左右(每秒)。对于文件操作来说,一般内存不是瓶颈 - 硬件的特点
机械盘的特点是顺序访问支持的好,所以尽量使用追加的方式写文件的;SSD则由于自身特点可能出现小块随机写的写放大,由于其是按块操作,所以无法实现原地覆盖等特殊要求。,NVME可以支持并行队列,从而实现更快的文件操作行为
这里有一些需要明确的问题。首先,写入数据有可能只是写入到了硬件的缓存中,这在掉电后仍然会丢失数据。所以必须使用各种方式保证数据的落盘(如flush等)。虽然说普通的文件操作内存一般不会成为瓶颈,但在高吞吐场景下,还是有可能的。所以可以考虑零拷贝或DMA技术。也就是说,针对硬件层,需要考虑存储的硬件是何种类型,然后有针对性的进行选择操作方式。
三、操作系统层
对于文件操作来说,操作系统和相关的库是最主要的处理层,需要重点关注:
- Page Cache即页缓存
普通的文件读写如write和read本质上都是在操作内存中的缓冲页。有过内核经验的可以知道,其实就是对脏页标志的处理。另外一个需要注意的是"预读"技术,内核会根据情况对后续数据自动的计入。这也是为什么有些文件一开始慢后来变得快的原因。可以对访问提供一些接口或参数用来显式的支持这种操作如readhead()或posix_fadvise函数的SEQUENTIAL等 - 系统调用开销
其实就是看能不能以最小的交互来产生最大收益。尽量减少系统调用的开销。给一个大缓冲或者合并调用等等 - 常见的文件操作IO方式
一般常见的就是write或read接口,它会经过页缓存,适合大多数的应用场景,简单方便。其它还有mmap,用来处理大文件的操作,速度快。但应用较为复杂,应用也有不少的限制。前面还提到的零拷贝的sendfile、splice等等。这种机制效率要高很多,但一般对库或系统的版本要求比较高。其它还有一些如写时复制clonefile、reflink等,它们比较适合于大文件的复制,在新的文件系统如Btrfs中 - 异步IO
这个一定要明白是真异步还是假异步。一般现在都是使用liburing来进行操作;AIO库只在设定参数为O_DIRECT下才是真异步 - 持久化状态结果
这一点非常容易忽视,如前面提到的write成功未必真正持久化成功,C++ 的ofstream::flush()只是把数据交给内核(页缓存),不等于真正落盘。STL库没有提供类似fsync的操作,如果需要必须通过POSIX fd进行处理 - 文件系统及相关
在实际的操作过程中,可以进行空间的预分配和相关参数的配置,达到最大的读写操作效率。对于小文件可以采用一些业务的逻辑处理如压缩打包等来进行控制。同样,不同的文件系统也有各自的特点如:ext4的日志模式对设置data=ordered/journal/writeback的不同,都会影响到性能和效率
四、应用层
其实上面的两层,对于开发者来说,不到性能追求到极致一般不会操作它们或者说操作的很少。大多还是在应用层上做文章:
- 缓冲设置和批处理
要减少高频调用,STL中提供的ifstream等本身支持缓冲处理,可以调用pubsetbuf等进行设置(不过一般来说,大多数的开发者用不到);同样write和read都可以自行处理缓存,具体的大小就要根据实际情况来确定。一般来说,顺序读大文件时,可以尽量选择大一些的缓存,写入时尽量在内存中缓存一批后再批量写入。尽量不要混用各种平台或语言的文件操作 - 访问模式
可以根据实际场景通过设计不同的访问模式来提高性能,如文件数据库的levelDB以及数据库中的WAL等。都可以将随机写转成顺序写。如果是读多写少的情况,可以使用内存映射的机制更有优势;另外数据库中的殘存储也在某些场景下非常用有优势,特别是在压缩比较大的情况下更是如此 - 控制fsync频率
其实仍然是减少文件操作的次数,可以使用批量提交、周期性提交或在特定情况下取消同步持久化 - 并发处理
如果硬件支持并发,如NVME,则可以使用pread或pwrite分片读写,或者用io_uring提高队列深度。前者还可以利用偏移量操作实现不同线程对同一文件的操作 - 拷贝处理
其实仍然是减少数据内存需要的分配,尽可能复用缓冲区或者零拷贝。对大文件处理尽量使用流式操作,不要一次加载
上面的这些点,往往需要在实际应用中不断的进行测试,找出一个平衡点。一般来说,不同的平台都提供了很多的相关的工具如iostat -x、strace -c、blktrace等等。在这个过程中,对IOPS、吞吐、p99 延迟、队列深度、页缓存命中率要作全面的分析,并对冷、热恋缓存以及设备及具体的设置都要进行测试、监控。
五、C++ 示例
下面给出一些常见的用法的例子:
cpp
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <unistd.h>
#include <algorithm>
#include <cerrno>
#include <cstdint>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <stdexcept>
#include <string>
#include <system_error>
#include <vector>
namespace fs = std::filesystem;
[[noreturn]] static void throwErrno(const std::string& operation) {
throw std::system_error(errno, std::generic_category(), operation);
}
class Fd {
public:
explicit Fd(int fd) : fdValue(fd) {}
Fd(Fd&& other) noexcept : fdValue(other.fdValue) { other.fdValue = -1; }
Fd(const Fd&) = delete;
Fd& operator=(const Fd&) = delete;
~Fd() {
if (fdValue >= 0) {
::close(fdValue);
}
}
int get() const { return fdValue; }
private:
int fdValue;
};
static Fd openOrThrow(const fs::path& filePath, int flags, mode_t mode = 0644) {
int fd = ::open(filePath.c_str(), flags | O_CLOEXEC, mode);
if (fd < 0) {
throwErrno("open " + filePath.string());
}
return Fd(fd);
}
static void writeAll(int fd, const char* data, size_t length) {
while (length > 0) {
ssize_t bytesWritten = ::write(fd, data, length);
if (bytesWritten < 0) {
if (errno == EINTR) {
continue;
}
throwErrno("write");
}
if (bytesWritten == 0) {
throw std::runtime_error("write returned zero bytes");
}
data += bytesWritten;
length -= static_cast<size_t>(bytesWritten);
}
}
static void adviseSequential(int fd) {
::posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL);
}
static void syncData(int fd) {
if (::fdatasync(fd) != 0) {
throwErrno("fdatasync");
}
}
static void syncAll(int fd) {
if (::fsync(fd) != 0) {
throwErrno("fsync");
}
}
static void syncDirectory(const fs::path& filePath) {
fs::path directoryPath = filePath.has_parent_path() ? filePath.parent_path() : fs::path(".");
Fd directoryFd = openOrThrow(directoryPath, O_RDONLY | O_DIRECTORY);
syncAll(directoryFd.get());
}
size_t countLines(const fs::path& filePath) {
std::vector<char> buffer(1 << 20);
std::ifstream input;
input.rdbuf()->pubsetbuf(buffer.data(), static_cast<std::streamsize>(buffer.size()));
input.open(filePath, std::ios::binary);
if (!input) throw std::runtime_error("open " + filePath.string());
std::string line{};
size_t lineCount = 0;
while (std::getline(input, line)) {
++lineCount;
}
if (!input.eof()) {
throw std::runtime_error("read " + filePath.string());
}
return lineCount;
}
uint64_t checksum(const fs::path& filePath) {
Fd fd = openOrThrow(filePath, O_RDONLY);
adviseSequential(fd.get());
std::vector<unsigned char> buffer(1 << 20);
uint64_t sum = 0;
for (;;) {
ssize_t bytesRead = ::read(fd.get(), buffer.data(), buffer.size());
if (bytesRead < 0) {
if (errno == EINTR) continue;
throwErrno("read");
}
if (bytesRead == 0) {
break;
}
for (ssize_t index = 0; index < bytesRead; ++index) {
sum += buffer[index];
}
}
return sum;
}
void writeRecords(const fs::path& filePath, const std::vector<std::string>& records) {
constexpr size_t bufferSize = 1 << 20;
int rawFd = ::open(filePath.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_APPEND | O_CLOEXEC, 0644);
bool created = rawFd >= 0;
if (rawFd < 0) {
if (errno != EEXIST) {
throwErrno("open " + filePath.string());
}
rawFd = ::open(filePath.c_str(), O_WRONLY | O_APPEND | O_CLOEXEC);
if (rawFd < 0) {
throwErrno("open " + filePath.string());
}
}
Fd fd(rawFd);
std::string buffer{};
buffer.reserve(bufferSize);
for (const auto& record : records) {
buffer += record;
buffer += '\n';
if (buffer.size() >= bufferSize) {
writeAll(fd.get(), buffer.data(), buffer.size());
buffer.clear();
}
}
if (!buffer.empty()) {
writeAll(fd.get(), buffer.data(), buffer.size());
}
syncData(fd.get());
if (created) {
syncDirectory(filePath);
}
}
int main() {
try {
std::string directoryPattern = (fs::temp_directory_path() / "test.XXXXXX").string();
std::vector<char> directoryName(directoryPattern.begin(), directoryPattern.end());
directoryName.push_back('\0');
if (::mkdtemp(directoryName.data()) == nullptr) {
throwErrno("mkdtemp");
}
fs::path directoryPath(directoryName.data());
fs::path logPath = directoryPath/"records.log";
std::vector<std::string> records;
records.reserve(1'000'000);
for (int index = 0; index < 1'000'000; ++index){
records.push_back("record " + std::to_string(index));
}
writeRecords(logPath, records);
std::printf("lines = %zu\n", countLines(logPath));
std::printf("checksum = %llu\n", static_cast<unsigned long long>(checksum(logPath)));
} catch (const std::exception& error) {
std::fprintf(stderr, "error: %s\n", error.what());
return 1;
}
return 0;
}
需要说明的是,pubsetbuf必须在open之前调用,否则在libstdc++上不生效。缓冲区的生命周期必须长于流对象。几种操作机制分别为标准库设置缓冲、POSIX大块读写自行设置缓冲区以及用户态批量处理写入。
六、应用场景
一般来说,应用场景的常见处理方式为:
- 顺序处理大日志或CSV,可以从大缓冲区(如1 MB)及流式处理开始测量,可显式的提示进行顺序处理
- 随机读大文件时,可以根据情况考虑比较mmap与pread,看哪个更优。可对随机访问的MADV_RANDOM和顺序访问的MADV_SEQUENTIAL进行测试
- 高频小数据写入文件,可使用追加、用户态缓冲、组提交或定时fsync一起应对
- 数据库存储引擎的写入,可按缓存与持久化需求对O_DIRECT、自管缓存、io_uring和WAL进行整体的协调使用
- 文件下载或静态文件服务,可使用零拷贝如sendfile或splice等
- 文件复制特别是大文件复制,可考虑reflink或clonefile及copy_file_range
- 海量小文件的读写,可考虑合并打包成大文件、批量处理元数据、以及并发执行
- 配置或状态文件,可使用原子写即临时文件和fsync及rename、目录fsync等一起应对
七、总结
通过上面的分析可以知道,想要高效的操作文件,没有一条显式的直接确定的方式。必须先测量真实瓶颈,再根据情况选择块大小、访问顺序、缓存与并发策略等具体的操作设置。只有把具体的操作行为和具体的场景(应用+系统+硬件)匹配到最佳,才可能获得最优的文件操作结果。