计算机原理—如何高效的操作文件

一、文件操作的层次

在普通的文件处理中,其中主要是读和写操作。而针对mmap或Direct IO则有着特殊的情况,后面再分析。正常的情况下,可以从三个层面来看对文件操作的情况:

  1. 应用层
    应用层主要是业务代码和库,特别是相关的缓存处理。当然系统调用也是从这里进行的,有可能会出现用户态和内核态的切换
  2. 系统层
    这一块相对复杂,一般开发人员只是使用相关的库或系统接口。但这其中包括了很多细节,如VFS系统、页缓存、预读、回写以及各种IO及BLK的处理,直到到相关的硬件的驱动如NVMe等
  3. 硬件
    它主要涉及到相关的总和,对于开发者来说,侧重点应该是缓存。毕竟其它都已经

通过上面的分析可以看出,要想实现文件的高效操作,需要注意以下几点:

  1. 缩短访问乱离和访问次数即减少系统调用、拷贝、元数据操作等
  2. 文件I/O操作尽量按规律进行如文件数据的顺序、对齐、大小及并发度等
  3. 在相关位置做好操作边界和是否延迟如批量、缓存、异步等

二、硬件层

  1. 不同硬件存储的效率
    这一点非常好理解,对于不同的存储硬件,其访问速度区别很大,特别是机械硬盘的固态硬盘更是如此。一般来说,机械硬盘的的反应都是毫秒级,传输速度也大约在百兆左右(每秒),但对随机访问支持不如对顺序访问好;而SSD硬盘也根据不同的接口和版本,区别也很大。它们的反应速度都是在微秒级别,速度差异很大,从SATA的几百兆到NVME的十吉左右(每秒)。对于文件操作来说,一般内存不是瓶颈
  2. 硬件的特点
    机械盘的特点是顺序访问支持的好,所以尽量使用追加的方式写文件的;SSD则由于自身特点可能出现小块随机写的写放大,由于其是按块操作,所以无法实现原地覆盖等特殊要求。,NVME可以支持并行队列,从而实现更快的文件操作行为

这里有一些需要明确的问题。首先,写入数据有可能只是写入到了硬件的缓存中,这在掉电后仍然会丢失数据。所以必须使用各种方式保证数据的落盘(如flush等)。虽然说普通的文件操作内存一般不会成为瓶颈,但在高吞吐场景下,还是有可能的。所以可以考虑零拷贝或DMA技术。也就是说,针对硬件层,需要考虑存储的硬件是何种类型,然后有针对性的进行选择操作方式。

三、操作系统层

对于文件操作来说,操作系统和相关的库是最主要的处理层,需要重点关注:

  1. Page Cache即页缓存
    普通的文件读写如write和read本质上都是在操作内存中的缓冲页。有过内核经验的可以知道,其实就是对脏页标志的处理。另外一个需要注意的是"预读"技术,内核会根据情况对后续数据自动的计入。这也是为什么有些文件一开始慢后来变得快的原因。可以对访问提供一些接口或参数用来显式的支持这种操作如readhead()或posix_fadvise函数的SEQUENTIAL等
  2. 系统调用开销
    其实就是看能不能以最小的交互来产生最大收益。尽量减少系统调用的开销。给一个大缓冲或者合并调用等等
  3. 常见的文件操作IO方式
    一般常见的就是write或read接口,它会经过页缓存,适合大多数的应用场景,简单方便。其它还有mmap,用来处理大文件的操作,速度快。但应用较为复杂,应用也有不少的限制。前面还提到的零拷贝的sendfile、splice等等。这种机制效率要高很多,但一般对库或系统的版本要求比较高。其它还有一些如写时复制clonefile、reflink等,它们比较适合于大文件的复制,在新的文件系统如Btrfs中
  4. 异步IO
    这个一定要明白是真异步还是假异步。一般现在都是使用liburing来进行操作;AIO库只在设定参数为O_DIRECT下才是真异步
  5. 持久化状态结果
    这一点非常容易忽视,如前面提到的write成功未必真正持久化成功,C++ 的ofstream::flush()只是把数据交给内核(页缓存),不等于真正落盘。STL库没有提供类似fsync的操作,如果需要必须通过POSIX fd进行处理
  6. 文件系统及相关
    在实际的操作过程中,可以进行空间的预分配和相关参数的配置,达到最大的读写操作效率。对于小文件可以采用一些业务的逻辑处理如压缩打包等来进行控制。同样,不同的文件系统也有各自的特点如:ext4的日志模式对设置data=ordered/journal/writeback的不同,都会影响到性能和效率

四、应用层

其实上面的两层,对于开发者来说,不到性能追求到极致一般不会操作它们或者说操作的很少。大多还是在应用层上做文章:

  1. 缓冲设置和批处理
    要减少高频调用,STL中提供的ifstream等本身支持缓冲处理,可以调用pubsetbuf等进行设置(不过一般来说,大多数的开发者用不到);同样write和read都可以自行处理缓存,具体的大小就要根据实际情况来确定。一般来说,顺序读大文件时,可以尽量选择大一些的缓存,写入时尽量在内存中缓存一批后再批量写入。尽量不要混用各种平台或语言的文件操作
  2. 访问模式
    可以根据实际场景通过设计不同的访问模式来提高性能,如文件数据库的levelDB以及数据库中的WAL等。都可以将随机写转成顺序写。如果是读多写少的情况,可以使用内存映射的机制更有优势;另外数据库中的殘存储也在某些场景下非常用有优势,特别是在压缩比较大的情况下更是如此
  3. 控制fsync频率
    其实仍然是减少文件操作的次数,可以使用批量提交、周期性提交或在特定情况下取消同步持久化
  4. 并发处理
    如果硬件支持并发,如NVME,则可以使用pread或pwrite分片读写,或者用io_uring提高队列深度。前者还可以利用偏移量操作实现不同线程对同一文件的操作
  5. 拷贝处理
    其实仍然是减少数据内存需要的分配,尽可能复用缓冲区或者零拷贝。对大文件处理尽量使用流式操作,不要一次加载

上面的这些点,往往需要在实际应用中不断的进行测试,找出一个平衡点。一般来说,不同的平台都提供了很多的相关的工具如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大块读写自行设置缓冲区以及用户态批量处理写入。

六、应用场景

一般来说,应用场景的常见处理方式为:

  1. 顺序处理大日志或CSV,可以从大缓冲区(如1 MB)及流式处理开始测量,可显式的提示进行顺序处理
  2. 随机读大文件时,可以根据情况考虑比较mmap与pread,看哪个更优。可对随机访问的MADV_RANDOM和顺序访问的MADV_SEQUENTIAL进行测试
  3. 高频小数据写入文件,可使用追加、用户态缓冲、组提交或定时fsync一起应对
  4. 数据库存储引擎的写入,可按缓存与持久化需求对O_DIRECT、自管缓存、io_uring和WAL进行整体的协调使用
  5. 文件下载或静态文件服务,可使用零拷贝如sendfile或splice等
  6. 文件复制特别是大文件复制,可考虑reflink或clonefile及copy_file_range
  7. 海量小文件的读写,可考虑合并打包成大文件、批量处理元数据、以及并发执行
  8. 配置或状态文件,可使用原子写即临时文件和fsync及rename、目录fsync等一起应对

七、总结

通过上面的分析可以知道,想要高效的操作文件,没有一条显式的直接确定的方式。必须先测量真实瓶颈,再根据情况选择块大小、访问顺序、缓存与并发策略等具体的操作设置。只有把具体的操作行为和具体的场景(应用+系统+硬件)匹配到最佳,才可能获得最优的文件操作结果。

相关推荐
Rabitebla1 小时前
【系统性学习笔记】Linux 权限篇(二):文件属性、权限修改与目录权限的底层逻辑
linux·c++·笔记·学习·算法
system_error_x1 小时前
C++ 编程基础入门教程
开发语言·c++
布莱克6053 小时前
A* 算法详解:从原理到实现
开发语言·c++·算法
多弗朗皮卡丘3 小时前
C++ 智能指针
开发语言·c++·c++11·智能指针
进化中的刀盾兽3 小时前
从零实现 C++ AI 大模型接入 SDK(十):SessionManager 会话管理与多轮对话实现
c++
小成202303202653 小时前
C++~02运算符重载
开发语言·c++
郑同学的笔记4 小时前
【c++随笔30】C++ 注册回调函数
开发语言·c++
lengxuenong5 小时前
树状数组 · 信息学学习手册2-离散化与逆序对
c++·学习·算法
十二5 小时前
【洛谷题解】P1115 最大子段和(前缀和 + 维护最小前缀)
数据结构·c++·算法·蓝桥杯