固定長ページとpread/pwriteでファイルI/O層を作る——ストリームの共有シーク位置を避ける設計

オンメモリのKVストアの次に着手したのは、B-treeを永続化するための土台となるページ管理層です。LMDBやBoltDBのようなエンジンは、ファイルを固定長の「ページ」の並びとして扱い、B-treeの各ノードを1ページに対応させます。今回はB-tree自体にはまだ触れず、「ページを読み書きする」というその一つ下の層——PagePager——だけを切り出して実装しました。

PageとPageIdの型設計

// include/kvstore/page.hpp
struct PageId {
    std::uint64_t value;

    friend auto operator<=>(const PageId&, const PageId&) = default;
};

class Page {
public:
    static constexpr std::size_t kSize = 4096;

    Page() noexcept; // zero-filled

    [[nodiscard]] std::span<std::byte, kSize> data() noexcept;
    [[nodiscard]] std::span<const std::byte, kSize> data() const noexcept;

private:
    std::array<std::byte, kSize> buffer_{};
};

Pageはこの段階では「4096バイトの生バイト列」でしかなく、中身がB-treeのノードなのか何なのかは一切知りません。バイト列の解釈は上位のB-tree層に委ねる設計です。PageIdはただのstd::uint64_tのラッパーですが、ページ番号とバイトオフセットや件数のような無関係な整数を誤って混ぜないための強い型付けとして用意しました。実行時コストはゼロで、モダンC++でよく使われる安全性のイディオムです。

Pagerの設計

// include/kvstore/pager.hpp
enum class PagerError {
    file_open_failed,
    invalid_file_size,  // file size is not a multiple of Page::kSize
    io_error,
    invalid_page_id,    // id >= page_count()
};

class Pager {
public:
    [[nodiscard]] static std::expected<Pager, PagerError> open(const std::filesystem::path& path);

    Pager(Pager&&) noexcept;
    Pager& operator=(Pager&&) noexcept;
    Pager(const Pager&) = delete;
    Pager& operator=(const Pager&) = delete;
    ~Pager();

    [[nodiscard]] std::uint64_t page_count() const noexcept;
    [[nodiscard]] std::expected<PageId, PagerError> allocate_page();
    [[nodiscard]] std::expected<Page, PagerError> read_page(PageId id) const;
    std::expected<void, PagerError> write_page(PageId id, const Page& page);
    std::expected<void, PagerError> sync();

private:
    explicit Pager(int fd, std::uint64_t page_count) noexcept;

    int fd_;
    std::uint64_t page_count_;
};

ファイルは「PageId{n}はバイトオフセットn * Page::kSize」という素朴な対応関係だけを持つ、ページの単純な並びです。open()はファイルサイズをfstatで取得し、Page::kSizeの倍数でなければinvalid_file_sizeで弾きます——他のプログラムが作った無関係なファイルや壊れたファイルを、空のページ列として誤読しないためのガードです。

Pagerはファイルディスクリプタという生リソースを持つため、コピーを禁止しムーブのみ許可しています。コンストラクタはprivateにし、失敗しうる初期化処理(ファイルオープンとサイズ検証)をopen()という静的ファクトリ関数に閉じ込めることで、「不完全な状態のPager」が存在できないようにしています。

std::fstreamではなくPOSIXのpread/pwriteを使う理由

ファイルの決まった位置を読み書きするだけならstd::fstreamseekg/seekp + read/writeでも書けます。しかしストリームのシーク位置はオブジェクト内に1つしか持てない共有状態です。今はシングルスレッド前提なので実害はありませんが、Segment 3で並行アクセスに対応する際、複数スレッドが同じPager(正確には後のバッファプール)から異なるページを読もうとすると、シークしてから読むまでの間に他スレッドがシーク位置を動かしてしまう競合が発生します。

これに対してPOSIXのpread/pwriteはオフセットを引数として渡す位置指定I/Oで、呼び出し自体がアトミックにその位置を読み書きします。共有のシーク位置という状態を持たないため、後で読み取り側を並行化する際の設計を素直に保てます。BoltDBのReadAt/WriteAtやLMDBの内部実装も同じ理由でこの方式を採っており、今回は移植性よりも実際のエンジンに合わせた実装を優先しました(本プロジェクトはUbuntu + GCCのみをターゲットにしており、移植性上の制約はありません)。

// src/pager.cpp(抜粋)
std::expected<Page, PagerError> Pager::read_page(PageId id) const {
    if (id.value >= page_count_) {
        return std::unexpected(PagerError::invalid_page_id);
    }

    Page page;
    auto bytes = page.data();
    auto n = ::pread(fd_, bytes.data(), bytes.size(), static_cast<off_t>(id.value * Page::kSize));
    if (n != static_cast<ssize_t>(bytes.size())) {
        return std::unexpected(PagerError::io_error);
    }

    return page;
}

allocate_page()ftruncateでファイルを1ページ分伸ばすだけです。Linuxではftruncateによって拡張された領域は自動的にゼロ埋めされるため、明示的な初期化コードは不要でした。short writeやshort read(要求したバイト数より少なく読み書きされる状態)は、この段階ではリトライせず一律io_errorとして扱っています。通常ファイルに対するpread/pwriteでこれが起きることは稀なため、リトライループを今設けるのは過剰な複雑化だと判断しました。

テスト

tests/pager_test.cppでは、std::filesystem::temp_directory_path()配下に生成した一意な一時ファイルに対して、新規作成・連番のページID割り当て・書き込んだバイト列の往復・Pagerを破棄して再度openし直した後もデータが残っていること(再起動をエミュレート)・範囲外ページIDの拒否・不正なファイルサイズの拒否を確認しています。実際にファイルへの永続化が絡む最初のテストになるため、AddressSanitizer/UndefinedBehaviorSanitizerを有効にしたビルドでも実行し、pread/pwriteまわりのバッファ境界に問題がないことを確認しました。

参考リンク

  • man pread(2) —— オフセットを引数に取る位置指定read/writeのAPIリファレンスです
  • cppreference: std::span —— Page::data()が返す固定長ビューの型です