leveldb 笔记四:系统环境Env

Posted by YuanBao on June 2, 2017

今天我们来分析 leveldb 对于运行环境的封装。前面已经介绍过,leveldb 实际上就是一个基于文件存储的 KV 数据库。为了能够提供平台无关的文件系统操作方式,leveldb 的作者设计了统一的系统相关的文件操作接口。这些接口定义在头文件 include/leveldb/env.h 中。当需要移植 leveldb 到不同的系统上,或者利用不同平台的特性优化 leveldb 的性能时,用户可以自行实现 env.h 中的接口,也就是自行定义一套运行环境。

Env 中大部分的接口都与文件操作相关。根据文件 include/leveldb/env.h 中的说明,要自行定义一个 Env 大致需要实现以下几个类:

namespace leveldb {
    class FileLock;
    class Logger;
    class RandomAccessFile;
    class SequentialFile;
    class Slice;
    class WritableFile;
};

当然,leveldb 提供了其默认的 Env 实现,也就是 env_posix。 env_posix 适用于任何符合 posix 标准的文件系统上,在文件 utils/env_posix.cc 中定义。接下来我们就仔细分析下 env_posix 的实现。

顺序读文件类和追加写文件类

继承于 SequentialFile 的类 PosixSequentialFile 负责实现顺序读的功能。顺序读文件类只提供两个对外的接口 readskip。其中,read 接口从当前位置顺序的读出 n 字节数据,而 skip 接口则顺序向后跳过 n 字节:

virtual Status Read(size_t n, Slice* result, char* scratch) {
    Status s;
    size_t r = fread_unlocked(scratch, 1, n, file_);
    *result = Slice(scratch, r);
    ... // 下省略
}

可以看到,顺序读 read 内部实现调用了 fread_unlocked 函数,该函数在读取文件时不会锁住文件流,因此外部的并发访问需要自行提供并发控制。需要注意的是,该函数是 GNU 的一个扩展,在 Unix 系统,例如 MacOS,FreeBSD,Soloris 中并无定义,因此作者使用了宏来控制 fread_unlocked 的定义。

PosixWritableFile 继承 WritableFile 实现了追加写文件类。所谓『追加写』,就是每次写入都是从文件末尾开始追加,也就是 append 操作。PosixWritableFile 定义了如下几个接口,我们逐个解析一下:

virtual Status Append(const Slice& data);
Status SyncDirIfManifest();
virtual Status Sync();
virtual Status Flush();
virtual Status Close();

函数 Append 负责在当前文件后面追加数据。其内部调用了函数 fwrite_unlocked 以提高操作效率。这里需要注意的是函数 FlushSync 的区别。

virtual Status Flush() {
    if (fflush_unlocked(file_) != 0) {
        return IOError(filename_, errno);
    }
    return Status::OK();
}

virtual Status Sync() {
    // Ensure new files referred to by the manifest are in the filesystem.
    Status s = SyncDirIfManifest();
    if (!s.ok()) {
        return s;
    }
    if (fflush_unlocked(file_) != 0 ||
            fdatasync(fileno(file_)) != 0) {
        s = Status::IOError(filename_, strerror(errno));
    }
    return s;
}

FlushSync 函数分别调用了 fflush_unlockedfdatasync 函数。这两个函数的主要区别就在于,fsyncfflush 工作在更底层。一般而言,应用程序持有的文件句柄 FILE* 具备一个内在的缓冲区,每次调用 fwrite 时,会将相应的数据写入到该句柄的缓冲区内。而 flush 的作用就是将 FILE* 内在缓冲区中的数据刷新到操作系统的内存缓冲区中(注意是内存中,而不是块设备上)。fsync 工作于文件描述符 fd 上,其作用就是将内存中的有关 fd 的内容修改全部同步到磁盘上(该操作会阻塞直到IO设备报告完成)。

注: 文件描述符(file descriptor)是系统层的概念, fd 对应于系统打开文件表里面的一个文件;FILE* 是应用层的概念,其包含了应用层操作文件的数据结构。

出于提高效率的原因,操作系统不能在每次用户修改文件之后都即时写磁盘,因此在内存中大量地缓存了用户对于文件的修改。用户每次调用 write 操作,更新的只是内存中的页缓存(page cache),其产生的脏页不会立即更新到硬盘中,而是由操作系统统一调度,例如由专门的内核线程在满足一定条件时(如一定时间间隔、脏页达到一定比例)将脏页面同步到硬盘上。这种优化带来的隐患是,一旦用户在调用 write 之后,操作系统同步之前,内核发生了崩溃,那么用户对于文件的写可能会丢失。尽管这段时间窗口非常小,但是对于提供事务性保证的程序仍然面临着一定的风险。因此操作系统提供了 fsync 函数来实现内存到块设备的显示同步。(说到这里,可能很多人联想到了 open 系统调用中的 O_DIRECT 和 O_SYNC 选项的作用,关于这个话题,可以参考这里的讨论)。

在 env_posix.c 中,leveldb 调用了 posix 标准中的 fdatasync,与 fsync 的区别在于,fdatasync 在同步时仅仅写入文件数据和文件大小而不写入其他的 metadata,这样能够减少 IO 操作从而提高写盘效率。然而现在的 Linux 系统并不严格区分 fdatasyncfsync (见Linux Sys Calls)。

PosixWritableFile 还提供了一个 SyncDirIfManifest 的操作。这个函数检测一下如果当前的文件被一个 MANIFEST 文件引用,就先同步这个 MANIFEST 文件。关于什么是 leveldb 的 MANIFEST 文件,后续会有介绍。

随机访问文件

env_posix.c 总定义了两种 random-access 文件,分别是 PosixRandomAccessFile 和 PosixMmapReadableFile。我们先来看一下前者随机访问的实现:

virtual Status Read(uint64_t offset, size_t n, Slice* result,
                    char* scratch) const {
    Status s;
    ssize_t r = pread(fd_, scratch, n, static_cast<off_t>(offset));
    *result = Slice(scratch, (r < 0) ? 0 : r);
    if (r < 0) {
        // An error: return a non-ok status
        s = IOError(filename_, errno);
    }
    return s;
}

可以看到的是,PosixRandomAccessFile 使用了 pread 来实现原子的定位加访问功能。常规的随机访问文件的过程可以分为两步,fseek (seek) 定位到访问点,调用 fread (read) 来从特定位置开始访问 FILE* (fd)。然而,这两个操作组合在一起并不是原子的,即 fseekfread 之间可能会插入其他线程的文件操作。相比之下 pread 由系统来保证实现原子的定位和读取组合功能。需要注意的是,pread 操作不会更新文件指针

PosixMmapReadableFile 使用了内存映射文件来实现对于文件的随机访问,在初始化该 class 时提供一块已经映射好的内存 mmapped_region 即可,之后的随机访问将如同操作内存中的字节数组一样简单:

virtual Status Read(uint64_t offset, size_t n, Slice* result, 
                    char* scratch) const {
    Status s;
    if (offset + n > length_) {
        *result = Slice();
        s = IOError(filename_, EINVAL);
    } else {
        *result = Slice(reinterpret_cast<char*>(mmapped_region_)
                        + offset, n);  //访问内存空间
    }
    return s;
}

既然提供了两种随机访问文件类,那么 posix_env 是怎么使他们的呢?事实上,posix_env 定义了一个成员变量 MmapLimiter mmap_limit_,用来限制打开的 PosixMmapReadableFile 的实例数量。当打开的内存映射文件达到指定数量的时候,后续的随机访问文件只能使用 PosixRandomAccessFile 打开。

class MmapLimiter {
    public:
    // 对于 64-bit 的系统,仅仅允许打开 1000 个内存映射文件.
    MmapLimiter() {
        SetAllowed(sizeof(void*) >= 8 ? 1000 : 0);
    }

    bool Acquire() {
        if (GetAllowed() <= 0) {
            return false;
        }
        MutexLock l(&mu_);
        intptr_t x = GetAllowed();
        if (x <= 0) {
            return false;
        } else {
            SetAllowed(x - 1);
            return true;
        }
    }
}

可以看到,初始化时,MmapLimiter 会通过测试指针大小来检测系统的字长,如果是 64-bit 的系统,那么允许打开 1000 个内存映射文件,否则不允许打开。这主要是因为 64-bit 拥有很大的虚拟内存,因此允许 mmap 系统调用来将文件映射到虚拟内存地址上。

Since mmapped pages can be stored back to their file when physical memory is low, it is possible to mmap files orders of magnitude larger than both the physical memory and swap space. The only limit is address space. The theoretical limit is 4GB on a 32-bit machine - however, the actual limit will be smaller since some areas will be reserved for other purposes. If the LFS interface is used the file size on 32-bit systems is not limited to 2GB (offsets are signed which reduces the addressable area of 4GB by half); the full 64-bit are available.

下面的代码展示了 env_posix 是如何根据 MmapLimiter 来产生两种不同的文件类:

virtual Status NewRandomAccessFile(const std::string& fname, 
                                    RandomAccessFile** result) {
    *result = NULL;
    Status s;
    int fd = open(fname.c_str(), O_RDONLY);
    if (fd < 0) {
        s = IOError(fname, errno);
    } else if (mmap_limit_.Acquire()) {
        uint64_t size;
        s = GetFileSize(fname, &size);
        if (s.ok()) {
            void* base = mmap(NULL, size, PROT_READ, MAP_SHARED, fd, 0);
            if (base != MAP_FAILED) {
                *result = new PosixMmapReadableFile(fname, 
                                base, size, &mmap_limit_);
            } else {
                s = IOError(fname, errno);
            }
        }
        close(fd);
        if (!s.ok()) {
            mmap_limit_.Release();
        }
    } else {
        *result = new PosixRandomAccessFile(fname, fd);
    }
    return s;
  }
  1. 每次 env_posix 中调用 NewRandomAccessFile 时,其会首先调用 mmap_limit_.Acquire() 来测试当前的系统是否还允许生成 PosixMmapReadableFile。
  2. 如果返回 true,那么调用 mmap 系统调用来将文件映射到一块连续的虚拟内存上。关于 mmap 系统调用对于虚拟内存的使用,可以看 mmap memory
  3. 如果 mmap 调用失败,或者 mmap_limit_.Acquire() 返回 false,那么系统将返回一个 PosixRandomAccessFile 用来实现对文件的随机访问。

FileLock 文件锁

env_posix 中定义了一个用来对文件进行加锁和解锁的函数 LockFileUnlockFileLockFile 主要执行如下几步:

  1. 打开文件,并且根据文件名检测当前的文件是否已经被加锁。事实上,所有的加过锁的文件都会被保存到一个列表 PosixLockTable 中。
  2. 如果当前的文件没有被加锁,那么调用函数 LockOrUnlock 来进行文件加锁。

其中,主要的加锁逻辑 LockOrUnlock 如下:

static int LockOrUnlock(int fd, bool lock) {
    errno = 0;
    struct flock f;
    memset(&f, 0, sizeof(f));
    f.l_type = (lock ? F_WRLCK : F_UNLCK);
    f.l_whence = SEEK_SET;
    f.l_start = 0;
    f.l_len = 0;        // Lock/unlock entire file
    return fcntl(fd, F_SETLK, &f);
}

这里调用了 posix 函数 fcntl 来完成对文件的加锁和解锁。leveldb 并没有对文件实现细粒度的锁控制,而是每次都对整个文件进行加锁。

leveldb 还封装了很多对于文件的相关操作,例如 GetFileSizeCreateDirRenameDir 等等。此外,leveldb 在 env_posix 中定义了一个用于调用后台线程的模块。这部分我们留到后面介绍 compaction 以及 leveldb 启动执行流程之后再回过头来学习。