LeavaTailの日記

LeavaTailの日記

Linuxエンジニアを目指した技術者の備忘録

Linuxカーネルのファイルアクセスの処理を追いかける (8) work Queue

関連記事

概要

QEMUの vexpress-a9 (arm) で Linux 5.15を起動させながら、ファイル書き込みのカーネル処理を確認していく。

本章では、writebackワークキューの作成から、ワーカーの追加・取り出しに関係する次の関数を確認した。

  • wb_wakeup_delayed関数
  • wb_wakeup関数
  • wb_queue_work関数

はじめに

ユーザプロセスはファイルシステムという機構によって記憶装置上のデータをファイルという形式で書き込み・読み込みすることができる。
本調査では、ユーザプロセスがファイルに書き込み要求を実行したときにLinuxカーネルではどのような処理が実行されるかを読み解いていく。

調査対象や環境などはPart 1: 環境セットアップを参照。

調査対象(シーケンス図)

本記事では、writebackカーネルスレッドが起床するために用いる機構work Queueについて確認する。

writeback kthreadの概要

メインメモリから記憶装置に書き込む手法は大きく分けて「write back」と「write through」の二通り存在する。

itmanabi.com

PCのようなシステムでは、記憶装置の書き込み上限や速度の観点から「write back」方式を利用することが多い。
ここでは、write back方式におけるデータの書き込み方法を確認する。

wiki.bit-hive.com

write back方式では、ユーザプロセスからファイル書き込みをすると、該当するページキャッシュやinodeキャッシュに対して、Dirtyのフラグを立て処理を終了する。
その後、write back用のカーネルスレッドがDirtyになっているキャッシュの記憶装置への書き込みを実施する。

Linuxでは、主に次のようなタイミングでライトバック処理が実行される。

  • sync(1)fsync(2)が実行されたタイミング
  • ページ回収のタイミング
  • ファイル操作などによる遅延実行のタイミング

writeback用のワーカスレッドの作成

Linux v5.15では、ライトバックを実現するためにWork Queueと呼ばれる機構を用いている。

writebackワークキュー概要

Work Queueでは、指定した処理を指定した時間経過後に呼び出すことのできる仕組みとなっている。(詳細な説明は下記を参照)

www.coins.tsukuba.ac.jp

writeback用のWork Queueは、Linuxカーネルの起動時に下記の関数によって生成される。

// 234:
static int __init default_bdi_init(void)
{
    int err;

    bdi_wq = alloc_workqueue("writeback", WQ_MEM_RECLAIM | WQ_UNBOUND |
                 WQ_SYSFS, 0);
    if (!bdi_wq)
        return -ENOMEM;

    err = bdi_init(&noop_backing_dev_info);

    return err;
}
subsys_initcall(default_bdi_init);

ここで、write back用のWork Queue(bdi_wq)はグローバル変数である。

Work Queueの生成には、alloc_workqueue関数を利用する。

lwn.net

writeback用のWork Queueには下記のフラグを指定している。

  • WQ_MEM_RECLAIM: ページフレームの回収に利用されることがある
  • WQ_UNBOUND: Work Queueを一つのCPUに割り付けない
  • WQ_SYSFS: sysfs (devices/virtual/workqueue/writeback)を生成する

その後、NFSといったblock deviceの実態が存在しないファイルシステムのためにbdi_init関数によって、noop_backing_bdi_initを初期化する。
noop_backing_bdi_initは、backing_dev_info型の変数であり、SDカードなど含めた周辺機器に対する情報を保持する。

default_bdi_init関数で作成されたwriteback用のWork Queueは、下記の3つの関数にて使用される。

  1. wb_wakeup_delayed: dirty_writeback_centisecsで指定されたセンチ秒後に、bdi_writebackに紐づいている関数を実行する。
  2. wb_wakeup: bdi_writebackに紐づいている関数を実行する。
  3. wb_queue_work: Writeback用のキューのリストを末尾に追加して、bdi_writebackに紐づいている関数を実行する。

それぞれの関数は共通して、bdi_writeback型の変数を引数としている。
bdi_writeback型は、それぞれのブロックデバイスにおけるwritebackに関連するパラメータを保持した構造体となっている。 今回の環境では、対象デバイスがSDカードであるのでカーネルがSDカードを認識したタイミング(mmc_rescan)でSDカード用のbdi_writeback型のデータを生成する。

wb_init関数が、**指定時間経過後にwb_workfn関数を呼び出すようにbdi_writebackを初期化する。

// 287:
static int wb_init(struct bdi_writeback *wb, struct backing_dev_info *bdi,
           gfp_t gfp)
{
    int i, err;

    memset(wb, 0, sizeof(*wb));

    if (wb != &bdi->wb)
        bdi_get(bdi);
    wb->bdi = bdi;
    wb->last_old_flush = jiffies;
    INIT_LIST_HEAD(&wb->b_dirty);
    INIT_LIST_HEAD(&wb->b_io);
    INIT_LIST_HEAD(&wb->b_more_io);
    INIT_LIST_HEAD(&wb->b_dirty_time);
    spin_lock_init(&wb->list_lock);

    atomic_set(&wb->writeback_inodes, 0);
    wb->bw_time_stamp = jiffies;
    wb->balanced_dirty_ratelimit = INIT_BW;
    wb->dirty_ratelimit = INIT_BW;
    wb->write_bandwidth = INIT_BW;
    wb->avg_write_bandwidth = INIT_BW;

    spin_lock_init(&wb->work_lock);
    INIT_LIST_HEAD(&wb->work_list);
    INIT_DELAYED_WORK(&wb->dwork, wb_workfn);
    INIT_DELAYED_WORK(&wb->bw_dwork, wb_update_bandwidth_workfn);
    wb->dirty_sleep = jiffies;

    err = fprop_local_init_percpu(&wb->completions, gfp);
    if (err)
        goto out_put_bdi;

    for (i = 0; i < NR_WB_STAT_ITEMS; i++) {
        err = percpu_counter_init(&wb->stat[i], 0, gfp);
        if (err)
            goto out_destroy_stat;
    }

    return 0;

out_destroy_stat:
    while (i--)
        percpu_counter_destroy(&wb->stat[i]);
    fprop_local_destroy_percpu(&wb->completions);
out_put_bdi:
    if (wb != &bdi->wb)
        bdi_put(bdi);
    return err;
}

writebackワークキューにキューを追加

writeback用のWork Queueは以下の3つの関数で使用される。

  • wb_wakeup_delayed
  • wb_wakeup
  • wb_queue_work

wb_wakeup_delayed関数

elixir.bootlin.com

  • wb_workfn: dirty_writeback_intervalが経過した場合に、呼び出す。
  • __mark_inode_dirty: 対象のbdi_writeback型のデータに対して、初めての書き込みの場合のみ呼び出す。

wb_wakeup関数

elixir.bootlin.com

  • inode_switch_wbs_work_fn関数
  • wb_start_writeback関数
    • wakeup_flusher_threads_bdi関数: 下記の関数から呼ばれる
      • laptop_mode_timer_fn関数: Laptop Modeによるタイマのコールバック関数。
    • wakeup_flusher_threads 下記の3つの関数から呼ばれる。
      • ksys_sync関数: syncシステムコールなどから呼ばれる。
      • dirty_writeback_centisecs_handler関数: /proc/sys/vm/dirty_writeback_centisecsを書き換えた場合かつ、経過時間が過ぎている場合に呼ばれる。
      • shrink_inactive_list: 今回は調査を省略。
  • wb_start_background_writeback関数
    • balance_dirty_pages関数: Dirtyの閾値に応じて、カーネルスレッドに (または、現在のプロセス) でwritebackを実施する。
  • wb_workfn関数: 後述するwb_queue_workで追加されたキューがある場合に呼び出す。
  • wakeup_dirtytime_writeback関数: dirty_expire_centisecsミリ秒経過毎に呼び出される。

wb_queue_work関数

elixir.bootlin.com

  • bdi_split_work_to_wbs関数: backing_dev_infoのノードをキューに分割する。
  • cgroup_writeback_by_id関数: 今回は調査を省略。

writebackワークキューからキューを取り出す

ext2ファイルシステムのwrite処理内の__mark_inode_dirtyでは、前述で紹介したwb_wakeup_delayed関数を呼び出す。

leavatail.hatenablog.com

// 263:
void wb_wakeup_delayed(struct bdi_writeback *wb)
{
    unsigned long timeout;

    timeout = msecs_to_jiffies(dirty_writeback_interval * 10);
    spin_lock_bh(&wb->work_lock);
    if (test_bit(WB_registered, &wb->state))
        queue_delayed_work(bdi_wq, &wb->dwork, timeout);
    spin_unlock_bh(&wb->work_lock);
}

おわりに

本記事では、Linux v5.15におけるwriteback用のWorkqueueを解説した。

変更履歴

  • 2022/1/1: 記事公開
  • 2022/09/19: カーネルバージョンを5.15に変更

参考

Ubuntu-base-20.04 を QEMU から NFSRootで起動する

概要

QEMUでエミュレートした armhfプロセッサ に Linuxを起動させ 、Ubuntu-base-20.04イメージをNFS mountする。

はじめに

Ubuntu baseは、最低限のUbuntuのrootfsである。 そのため、LXCやDockerのコンテナイメージで使われることがある。

一方でrootfsをNFS server上に置き、rootfsをNFS経由でmountする手法は、(主に組込み機器の)開発において有効である。*1

そこで、本記事では、QEMU上でarmhfプロセッサをエミュレートさせて、Ubuntu-base-20.04をrootfsとしてNFS mountする方法を記述する。

QEMUでarmhf用 Linuxカーネルを起動させる方法は、下記の記事を流用する。

leavatail.hatenablog.com

leavatail.hatenablog.com

実行環境

Host PCは下記のような環境で実験する。

項目 概要
Host PC (OS) Ubuntu 20.04.02
Architecture x86_64
QEMU qemu v5.1.0
nfs-kernlel-server 1:1.3.4-2.5ubuntu3.4
buildroot buildroot-2020.11

上記の環境で、QEMUを実行しゲストOSとしてLinux v5.10.1を起動させる。

動作環境の全体像

手順

  1. Host PCに下記パッケージをインストールする。

     $ sudo apt-get install nfs-kernel-server
    
  2. Host PCでNFSサーバの設定する

     $ echo "/srv/rootfs     127.0.0.1(rw,wdelay,insecure,no_root_squash,no_subtree_check,sec=sys,rw,insecure,no_root_squash,no_all_squash)" | sudo tee -a /etc/exports
     $ sudo exportfs -v
    
  3. Host PCにUbuntu-base-20.04 のイメージをダウンロードする

     $ wget -P /tmp http://cdimage.ubuntu.com/ubuntu-base/releases/20.04/release/ubuntu-base-20.04.3-base-armhf.tar.gz
     $ sudo tar xf /tmp/ubuntu-base-20.04.3-base-armhf.tar.gz -C /srv/rootfs/armhf
    
  4. [任意] Host PCに下記パッケージをインストールする。

     $ sudo apt-get install qemu-user-static
    
  5. [任意] Ubuntu-base-20.04 の rootfs に qemu-arm-staticをコピーする

     $ sudo update-binfmts --display | grep arm
     qemu-arm (enabled):
      interpreter = /usr/bin/qemu-arm-static
     qemu-armeb (enabled):
      interpreter = /usr/bin/qemu-armeb-static
    
     $ sudo cp /usr/bin/qemu-arm-static /srv/rootfs/armhf/usr/bin/    
    
  6. rootfs の initスクリプトを追加する

#!/bin/bash

PATH=/sbin:/bin:/usr/sbin:/usr/sbin

set -em
trap rescue ERR

function rescue {
        echo -e "\e[31m NG \e[m"
        exec /bin/sh
}

echo -n "[init] Connect console:"
if (exec 0</dev/console) 2>/dev/null; then
    exec 0</dev/console
    exec 1>/dev/console
    exec 2>/dev/console
fi
echo -e "\e[32m OK \e[m"

echo -n "[init] Mount filesystem:"
# mount -t devtmpfs udev /dev
mount -t proc /proc /proc
mount -t sysfs sysfs /sys
echo -e "\e[32m OK \e[m"

echo -n "[init] Mount filesystem additionaly:"
mount -t tmpfs -o size=32m tmpfs /tmp
if [ -d "/dev/pts" ]; then
        mount -t devpts /dev/pts /dev/pts
fi

if [ -d "/sys/kernel/debug" ]; then
        mount -t debugfs none /sys/kernel/debug
fi
echo -e "\e[32m OK \e[m"

exec setsid /sbin/agetty --long-hostname --autologin root -s ttyAMA0 115200,38400,9600 linux

起動確認

$ sudo qemu-system-arm -M vexpress-a9 \
        -smp 1 \
        -m 1024 \
        -kernel output/images/zImage \
        -dtb output/images/vexpress-v2p-ca9.dtb \
        -append "console=ttyAMA0,115200 rootwait ip=on root=/dev/nfs nfsroot=/srv/rootfs/armhf user_debug=31 rw" \
        -net nic,model=lan9118 \
        -net user \
        -nographic

... 

VFS: Mounted root (nfs filesystem) on device 0:14.
devtmpfs: mounted
Freeing unused kernel memory: 1024K
Run /sbin/init as init process
random: fast init done
[init] Connect console: OK 
[init] Mount filesystem: OK 
[init] Mount filesystem additionaly: OK 

Ubuntu 20.04.3 LTS 10.0.2.15 ttyAMA0

10.0.2.15 login: root (automatic login)

Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.10.1 armv7l)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

This system has been minimized by removing packages and content that are
not required on a system that users do not log into.

To restore this content, you can run the 'unminimize' command.
Last login: Sun Oct 10 11:57:52 JST 2021 on ttyAMA0
root@10:~# 

補足

ここでは、手順や起動確認で解説できなかった内容について補足する。

NFSの設定について

QEMUのネットワークは複数の設定ができる。

Documentation/Networking - QEMU

今回は、User Networking を使用しているため、ゲストからホストはNATで扱われる。
そのため、NFSの設定でローカルループバックアドレスに任意のポートから受け付けるようにしている。*2

rootfsの更新について

x86_64アーキテクチャから、armhfのrootfsを直接chrootすることはできない。
そこで、qemu-user-staticを介することでrootfsを操作できるようにしている。

このようにしておくことで、Ubuntu-base 20.04に、systemdがインストールするなどが容易にできる。
その場合は、名前解決できるように適切に設定しておく必要はある。

  # echo  nameserver 8.8.8.8 >> /etc/resolv.conf

おわりに

本記事では、QEMU上でarmhfプロセッサをエミュレートさせて、Ubuntu-base-20.04をrootfsとしてNFS mountする方法を記述した。

Ubuntu baseイメージは最小のrootfsであるため、systemdを追加でインストールしたりなどカスタマイズ性が高い。

変更履歴

  • 2021/10/10: 記事公開

参考

QEMUの設定関連

Ubuntu-base関連

initramfs関連

rootfs関連

*1:今回はQEMUで起動させているので、恩恵は小さいが

*2:ポートフォワーディングやブリッジ接続をすれば、ここも@変えることはできる

Linuxカーネルのファイルアクセスの処理を追いかける (7) write_end

関連記事

概要

QEMUの vexpress-a9 (arm) で Linux 5.15を起動させながら、ファイル書き込みのカーネル処理を確認していく。

本章では、ext2_write_end関数を確認した。

はじめに

ユーザプロセスはファイルシステムという機構によって記憶装置上のデータをファイルという形式で書き込み・読み込みすることができる。
本調査では、ユーザプロセスがファイルに書き込み要求を実行したときにLinuxカーネルではどのような処理が実行されるかを読み解いていく。

調査対象や環境などはPart 1: 環境セットアップを参照。

処理シーケンス図としては、下記の赤枠部分が該当する。

調査対象(シーケンス図)

本記事では、ext2ファイルシステムwrite_iter操作から呼び出されるwrite_end操作を解説する。

write_endの概略

以前解説したwrite_iter操作(generic_file_write_iter)は、write_begin操作とwrite_end操作を呼び出すことになっている。

このwrite_begin操作やwrite_end操作はページキャッシュに対する操作となっており、address_space_opearationsのメンバの一つとして定義される。
write_end操作は、write_iter操作(で呼び出されるgeneric_perform_write関数)から呼び出される。

// 3781:
        status = a_ops->write_end(file, mapping, pos, bytes, copied,
                        page, fsdata);

write_end操作開始時の全体イメージ

変数名
file オープンしたファイル
mapping ファイルが持っているページキャッシュのXArray(radix-tree)
pos 書き込み先の位置
bytes 書き込むバイト数
copied ページキャッシュにコピーされたバイト数
page 取得したページを格納する
fsdata ext2ファイルシステムでは使用しない

ext2ファイルシステムの場合には、write_end操作でext2_write_end関数を実行する。

// 897:
static int ext2_write_end(struct file *file, struct address_space *mapping,
            loff_t pos, unsigned len, unsigned copied,
            struct page *page, void *fsdata)
{
    int ret;

    ret = generic_write_end(file, mapping, pos, len, copied, page, fsdata);
    if (ret < len)
        ext2_write_failed(mapping, pos + len);
    return ret;
}

ext2ファイルシステムでは、汎用APIgeneric_write_end関数を呼び出す。

// 2168:
int generic_write_end(struct file *file, struct address_space *mapping,
            loff_t pos, unsigned len, unsigned copied,
            struct page *page, void *fsdata)
{
    struct inode *inode = mapping->host;
    loff_t old_size = inode->i_size;
    bool i_size_changed = false;

    copied = block_write_end(file, mapping, pos, len, copied, page, fsdata);

    /*
    * No need to use i_size_read() here, the i_size cannot change under us
    * because we hold i_rwsem.
    *
    * But it's important to update i_size while still holding page lock:
    * page writeout could otherwise come in and zero beyond i_size.
    */
    if (pos + copied > inode->i_size) {
        i_size_write(inode, pos + copied);
        i_size_changed = true;
    }

    unlock_page(page);
    put_page(page);

    if (old_size < pos)
        pagecache_isize_extended(inode, old_size, pos);
    /*
    * Don't mark the inode dirty under page lock. First, it unnecessarily
    * makes the holding time of page lock longer. Second, it forces lock
    * ordering of page lock and transaction start for journaling
    * filesystems.
    */
    if (i_size_changed)
        mark_inode_dirty(inode);
    return copied;
}

generic_write_end関数の処理は下記の通りとなっている。

  • ページキャッシュのフラグを更新する
  • バッファキャッシュのフラグを更新する
  • ファイルサイズを更新する

まず初めに、メインとなるblock_write_end関数から確認していく。

キャッシュのフラグの更新

block_write_end関数は下記のような定義となっている。

// 2132:
int block_write_end(struct file *file, struct address_space *mapping,
            loff_t pos, unsigned len, unsigned copied,
            struct page *page, void *fsdata)
{
    struct inode *inode = mapping->host;
    unsigned start;

    start = pos & (PAGE_SIZE - 1);

    if (unlikely(copied < len)) {
        /*
        * The buffers that were written will now be uptodate, so we
        * don't have to worry about a readpage reading them and
        * overwriting a partial write. However if we have encountered
        * a short write and only partially written into a buffer, it
        * will not be marked uptodate, so a readpage might come in and
        * destroy our partial write.
        *
        * Do the simplest thing, and just treat any short write to a
        * non uptodate page as a zero-length write, and force the
        * caller to redo the whole thing.
        */
        if (!PageUptodate(page))
            copied = 0;

        page_zero_new_buffers(page, start+copied, start+len);
    }
    flush_dcache_page(page);

    /* This could be a short (even 0-length) commit */
    __block_commit_write(inode, page, start, start+copied);

    return copied;
}

別スレッドでなどバッファに更新があった場合は、2141行で新規バッファキャッシュの確保など実施する。

今回のケースでは、flush_dcache_page関数でdcacheをフラッシュしたのちに、__block_commit_write関数を呼び出す。

flush_dcache_page関数については下記の記事にて解説済み。

leavatail.hatenablog.com

__block_commit_write関数には、書き込み先のinodeと、書き込み対象のページキャッシュpage, 書き込み対象が記載されているオフセット (ページキャッシュの先頭から)start, 書き込み対象が記載されている末尾 start+copiedを渡す。

__block_commit_write関数の定義は下記の定義となっている。

// 2064:
static int __block_commit_write(struct inode *inode, struct page *page,
        unsigned from, unsigned to)
{
    unsigned block_start, block_end;
    int partial = 0;
    unsigned blocksize;
    struct buffer_head *bh, *head;

    bh = head = page_buffers(page);
    blocksize = bh->b_size;

    block_start = 0;
    do {
        block_end = block_start + blocksize;
        if (block_end <= from || block_start >= to) {
            if (!buffer_uptodate(bh))
                partial = 1;
        } else {
            set_buffer_uptodate(bh);
            mark_buffer_dirty(bh);
        }
        if (buffer_new(bh))
            clear_buffer_new(bh);

        block_start = block_end;
        bh = bh->b_this_page;
    } while (bh != head);

    /*
    * If this is a partial write which happened to make all buffers
    * uptodate then we can optimize away a bogus readpage() for
    * the next read(). Here we 'discover' whether the page went
    * uptodate as a result of this (potentially partial) write.
    */
    if (!partial)
        SetPageUptodate(page);
    return 0;
}

__block_commit_write関数では、各バッファキャッシュのフラグを更新していく。
また、ページキャッシュstruct page構造体からバッファキャッシュstruct buffer_headの取得には、page_buffersマクロを使用する。

// 141:
/* If we *know* page->private refers to buffer_heads */
#define page_buffers(page)                  \
   ({                            \
       BUG_ON(!PagePrivate(page));           \
       ((struct buffer_head *)page_private(page)); \
   })

PagePrivateマクロにより、struct page構造体にprivateが設定されているかどうか確認する。
struct buffer_head構造体はprivateに関連付けれらているため、これがFalseとなる場合はバグであるのでカーネルパニックさせる。

// 260:
#define page_private(page)      ((page)->private)

struct page構造体に紐づけられている各struct buffer_head構造体に対して、フラグの更新をする。
更新されたデータにBH_DirtyBH_Uptodateのフラグを付与する。

バッファキャッシュのフラグを更新する

BH_Dirtyのフラグを更新する際には、mark_buffer_dirty関数で実施する。 mark_buffer_dirty関数の定義は下記の通りとなっている。

// 1082:
void mark_buffer_dirty(struct buffer_head *bh)
{
    WARN_ON_ONCE(!buffer_uptodate(bh));

    trace_block_dirty_buffer(bh);

    /*
    * Very *carefully* optimize the it-is-already-dirty case.
    *
    * Don't let the final "is it dirty" escape to before we
    * perhaps modified the buffer.
    */
    if (buffer_dirty(bh)) {
        smp_mb();
        if (buffer_dirty(bh))
            return;
    }

    if (!test_set_buffer_dirty(bh)) {
        struct page *page = bh->b_page;
        struct address_space *mapping = NULL;

        lock_page_memcg(page);
        if (!TestSetPageDirty(page)) {
            mapping = page_mapping(page);
            if (mapping)
                __set_page_dirty(page, mapping, 0);
        }
        unlock_page_memcg(page);
        if (mapping)
            __mark_inode_dirty(mapping->host, I_DIRTY_PAGES);
    }
}
  • buffer_dirtyマクロ: struct buffer_headb_stateBH_Dirtyが設定されていた場合にTrueを返す。
  • test_set_buffer_dirrtyマクロ: struct buffer_headb_stateBH_Dirtyをセットする。
    • マクロ実施前にBH_Dirtyが設定されていない場合はFalseを返す。
    • マクロ実施後にBH_Dirtyが設定されていない場合はTrueを返す。

mark_buffer_dirty関数では、初めに別のスレッドで同一のstruct buffer_headがdirtyになっていた場合は、何もせずに処理を終了させる。

cgroup系の処理(lock_page_memcgunlock_page_memcg)については、省略する。

test_set_buffer_dirtyTestSetPageDirtyによって、バッファキャッシュとページキャッシュにDirtyフラグを付与する。

バッファキャッシュとページキャッシュにDirtyフラグを付与する

__mark_inode_dirty関数は、writeback用のリストに対象のinodeを追加する。
ただし、下記の記事にてリストに追加されているので、ここでは何も実施しない。

leavatail.hatenablog.com

下記のコードで、was_dirtyI_DIRTY_SYNC (0x1)が代入される。

// 2426:
    if ((inode->i_state & flags) != flags) {
        const int was_dirty = inode->i_state & I_DIRTY;

その後の処理は、!was_dirtyで判定されるため、このタイミングでは処理をせず戻る。

// 2460:
        /*
        * If the inode was already on b_dirty/b_io/b_more_io, don't
        * reposition it (that would break b_dirty time-ordering).
        */
        if (!was_dirty) {
        ...

ファイルサイズの更新

ファイルのもともとのサイズinode->i_sizecopiedだけ増えるので、ファイルサイズの更新処理が入る。

// 2178:
    /*
    * No need to use i_size_read() here, the i_size cannot change under us
    * because we hold i_rwsem.
    *
    * But it's important to update i_size while still holding page lock:
    * page writeout could otherwise come in and zero beyond i_size.
    */
    if (pos + copied > inode->i_size) {
        i_size_write(inode, pos + copied);
        i_size_changed = true;
    }

i_size_write関数は、割り込みなどを考慮してinode->i_sizeを更新する。
i_size_read関数と内容が似ているため、ここでは省略する。

おわりに

本記事では、ext2ファイルシステムwrite_end操作(ext2_write_end)を解説した。
write_end操作は、write_iter操作で書き込んだキャッシュにDirtyフラグを立てて、ファイルのサイズの更新するための操作である。

変更履歴

  • 2021/10/09: 記事公開
  • 2022/09/19: カーネルバージョンを5.15に変更

参考

SDカードの読み込み/書き込み性能を fio の測定結果から比較する

概要

SDカードには規格が定義されており、それぞれの製品には準拠した規格が記載されていることが多い。
本記事では、4種類のSDカードと2種類のSDカードリーダを用いて、それぞれ読み込み/書き込み性能を測定した。

その結果、下記のような傾向が見られた。

  • ファイルシステムを介することによる書き込み性能が大きく低下する
  • SDカードによって性能が最も発揮できるブロックサイズが異なる
  • Raspberry Pi 4の場合だと、並列処理によりパフォーマンスは低下する
  • UHS-II 非対応のカードリーダの場合、UHS-II対応のSDカードの性能を発揮できない

はじめに

SDカードには規格が定義されており、それぞれの製品には準拠した規格が記載されていることが多い。
ユースケースに応じて、適切なSDカードを使用することでその製品を最大限に生かすことができる。 しかし、これら規格は数が多く複雑なものが多い。

そこで複数のSDカードを計測することで、実測値と規格の関係性を調査する。

調査には、未使用のSDカードを4種類を使用し、Raspberriy Pi 4上で計測する。 このとき、UHS-II の影響も調査するために2種類のカードリーダ(UHS-II 未対応、UHS-II 対応) を用意し、 UHS-II対応カードリーダとの組み合わせも考慮する。

目的

  1. ファイルシステムを介することによるオーバーヘッドを計測する
  2. SDカードの違いによってパフォーマンスが異なることを確認する
  3. ブロックサイズの違いによってパフォーマンスが異なることを確認する
  4. 並列処理させることでパフォーマンスが異なることを確認する
  5. UHS-II 非対応のカードリーダがボトルネックになることを確認する

実行環境

Raspberry Pi 4 Model B (Raspberry Pi 4) は microSDカード経由でRaspberry Pi OSを起動させる。 また、Raspbery Pi 4のUSB3.0ポートにSDカードリーダを接続し、そこから計測対象SDカードを挿入する。

計測環境の概要

ここで使用するRaspberry Pi 4のスペックについて、必要な情報だけ抜粋したものを下記に示す。

項目 Raspberry Pi 4
CPU Cortex-A72 (ARM v8) 1.5GHz
メモリ 4GB LPDDR4-3200
OS Raspberry Pi OS (May 7th 2021)
OS格納先ストレージ microSDHC 16GB Class10 UHS-1
ケース 陽極酸化アルミニウム製ヒートシンクケース

今回は、SDカードリーダによる性能の差を図るために下記の二つを用意した。

  • BSCR27U3BK: UHS-IIに対応していないSDカードリーダ
  • MRW-S1: UHS-IIに対応したSDカードリーダ

上記の環境に対して、下記4種類のSDカードを計測する。(カタログに記載されていないものは、空欄としている)

ELITE SDXC UHS-I SanDisk Extreme Pro(並行輸入品) SF-E64 SF-M64T
SDスピードクラス CLASS10 CLASS10 CLASS10
UHSスピードクラス U3 U3 U3 U3
ビデオスピードクラス V30 V30 V30 V60
インタフェース UHS-I UHS-I UHS-II UHS-II
容量 64GB 64GB 64GB 64GB
最大読み出し速度 90 MB/s 170 MB/s 270 MB/s 277 MB/s
最大書き込み速度 45MB/s 90 MB/s 70 MB/s 150 MB/s

計測方法

計測には、Flexible I/O tester-3.12 (fio) を用いて、読み込みと書き込みの性能を計測する。
fioでは、パラメータを指定することで様々な計測を実施することができる。
本実験では、bsnumjobsfilename の値を変更するような計測を実施する。

  • bs: ユーザプログラムから読み込み・書き込みをするの一回当たりのサイズ。4KB・1MB・256MBに変更して確認する。
  • numjobs: 計測プログラムを並列して複数個のプロセス/スレッドで実行する。1・4・8に変更して確認する。
  • filename: 計測プログラムの読み込み・書き込み先のファイルを指定する。「ファイルシステム以下のファイル」と「デバイスファイル」の二つを確認する。

そこで、下記のようなパラメータファイルを用意し、これらの値を書き換えながら計測することにした。

[global]
size=1G
directory=/mnt
runtime=300
invalidate=1
group_reporting=1
numjobs=1 # FIXME

[write]
name=write
description="WRITE TEST"
unlink=0
rw=randwrite # randread
ioengine=sync
exec_prerun="./prerun.sh"
#exec_postrun="./postrun.sh"
#filename=/dev/sda1
time_based
bs=4K # FIXME
// 1:
#!/bin/sh

echo 3 > /proc/sys/vm/drop_caches

また、SDカードの状態による測定値のブレを最小限に抑えるために、測定前にSDカードをSD Card Formatter 5.0.1の上書きフォーマットを実施しておく。

実験結果

ファイルシステムによるオーバーヘッド

ファイルシステムを介することによる書き込み性能の比較

  • ファイルシステムへの書き込みは、デバイスファイルに直接書き込む場合と比較して、オーバーヘッドが出ている。
    • さらに、並列処理されている場合のオーバーヘッドは顕著に出ている。

ファイルシステムを介することによる読み込み性能の比較

SDカードの違い

SDカードの違いによる書き込み性能の比較

  • 一部例外はあったが、カタログに載っている最大書き込み速度と傾向は同じになっている。
    • SF-M64Tは、ブロックサイズが小さい場合に性能が出ていない傾向にあった。

SDカードの違いによる読み込み性能の比較

  • 一部例外はあったが、カタログに載っている最大読み込み速度と傾向は同じになっている。
    • SF-E64はブロックサイズが小さい場合に性能が出ていない傾向にあった。

ブロックサイズの違い

ブロックサイズの違いによる書き込み性能の比較

  • ブロックサイズが大きくすれば、書き込み性能は向上した (~256MB)

ブロックサイズの違いによる読み込み性能の比較

  • ブロックサイズが大きくすれば、読み込み性能は向上した (~256MB)

UHS-II 非対応によるオーバーヘッド

UHS-II非対応カードリーダによる書き込み性能の比較

UHS-II非対応カードリーダによる読み込み性能の比較

  • 読み込み性能・書き込み性能の双方、UHS-II非対応のカードリーダでは UHS-Iの転送速度104MB/sより下回っている

並列処理によるオーバーヘッド

並列処理による書き込み性能の比較

並列処理による読み込み性能の比較

  • 基本的に並列処理数を増やすことで性能が落ちている。

結論

  • ファイルシステムを介することによる書き込み性能が大きく低下する傾向がある
  • SDカードの違いによってパフォーマンスは異なる
    • また、SDカードによって性能が最も発揮できるブロックサイズが異なる
  • Raspberry Pi 4の場合だと、並列処理によりパフォーマンスは低下する
  • UHS-II 非対応のカードリーダの場合、UHS-II対応のSDカードの性能を発揮できない

おわりに

本記事では、さまざまな環境で読み込み書き込み性能を測定した。
SDカード・ブロックサイズ・並列処理といった要因がパフォーマンスに大きく影響を与えていた。

変更履歴

  • 2021/9/11: 記事公開
  • 2023/3/5: 記事タイトル「様々なSDカードの読み込み/書き込み性能の実測値を計測する」から変更

参考

補足

実験で得られたデータを本章に残す。

SD card Card Reader Block size jobs io File performance
ELITE SDXC UHS-I MRW-S1 4KB 1 randwrite Default 1409KB/s
ELITE SDXC UHS-I MRW-S1 4KB 4 randwrite Default 483KB/s
ELITE SDXC UHS-I MRW-S1 4KB 8 randwrite Default 567B/s
ELITE SDXC UHS-I MRW-S1 1MB 1 randwrite Default 23.1MB/s
ELITE SDXC UHS-I MRW-S1 1MB 4 randwrite Default 9877KB/s
ELITE SDXC UHS-I MRW-S1 1MB 8 randwrite Default 148KB/s
ELITE SDXC UHS-I MRW-S1 256MB 1 randwrite Default 32.0MB/s
ELITE SDXC UHS-I MRW-S1 256MB 4 randwrite Default 19.4MB/s
ELITE SDXC UHS-I MRW-S1 256MB 8 randwrite Default 12.8MB/s
ELITE SDXC UHS-I BSCR27U3BK 256MB 1 randwrite Default 31.2MB/s
ELITE SDXC UHS-I BSCR27U3BK 256MB 4 randwrite Default 20.2MB/s
ELITE SDXC UHS-I BSCR27U3BK 256MB 8 randwrite Default 13.4MB/s
ELITE SDXC UHS-I MRW-S1 4KB 1 randwrite /dev/sda1 1556KB/s
ELITE SDXC UHS-I MRW-S1 4KB 4 randwrite /dev/sda1 1523KB/s
ELITE SDXC UHS-I MRW-S1 4KB 8 randwrite /dev/sda1 1573MB/s
ELITE SDXC UHS-I MRW-S1 1MB 1 randwrite /dev/sda1 29.1MB/s
ELITE SDXC UHS-I MRW-S1 1MB 4 randwrite /dev/sda1 26.9MB/s
ELITE SDXC UHS-I MRW-S1 1MB 8 randwrite /dev/sda1 25.7MB/s
ELITE SDXC UHS-I MRW-S1 256MB 1 randwrite /dev/sda1 32.0MB/s
ELITE SDXC UHS-I MRW-S1 256MB 4 randwrite /dev/sda1 27.0MB/s
ELITE SDXC UHS-I MRW-S1 256MB 8 randwrite /dev/sda1 24.9MB/s
ELITE SDXC UHS-I MRW-S1 4KB 1 randread Default 4685KB/s
ELITE SDXC UHS-I MRW-S1 4KB 4 randread Default 4906KB/s
ELITE SDXC UHS-I MRW-S1 4KB 8 randread Default 4862KB/s
ELITE SDXC UHS-I MRW-S1 1MB 1 randread Default 71.4MB/s
ELITE SDXC UHS-I MRW-S1 1MB 4 randread Default 59.3MB/s
ELITE SDXC UHS-I MRW-S1 1MB 8 randread Default 61.0MB/s
ELITE SDXC UHS-I MRW-S1 256MB 1 randread Default 79.7MB/s
ELITE SDXC UHS-I MRW-S1 256MB 4 randread Default 66.6MB/s
ELITE SDXC UHS-I MRW-S1 256MB 8 randread Default 65.5MB/s
ELITE SDXC UHS-I BSCR27U3BK 256MB 1 randread Default 60.2MB/s
ELITE SDXC UHS-I BSCR27U3BK 256MB 4 randread Default 58.0MB/s
ELITE SDXC UHS-I BSCR27U3BK 256MB 8 randread Default 58.2MB/s
ELITE SDXC UHS-I MRW-S1 4KB 1 randread /dev/sda1 4644KB/s
ELITE SDXC UHS-I MRW-S1 4KB 4 randread /dev/sda1 4668KB/s
ELITE SDXC UHS-I MRW-S1 4KB 8 randread /dev/sda1 4850MB/s
ELITE SDXC UHS-I MRW-S1 1MB 1 randread /dev/sda1 68.8MB/s
ELITE SDXC UHS-I MRW-S1 1MB 4 randread /dev/sda1 54.8MB/s
ELITE SDXC UHS-I MRW-S1 1MB 8 randread /dev/sda1 54.1MB/s
ELITE SDXC UHS-I MRW-S1 256MB 1 randread /dev/sda1 83.1MB/s
ELITE SDXC UHS-I MRW-S1 256MB 4 randread /dev/sda1 65.0MB/s
ELITE SDXC UHS-I MRW-S1 256MB 8 randread /dev/sda1 62.8MB/s
SanDisk Extreme Pro MRW-S1 4KB 1 randwrite Default 1950KB/s
SanDisk Extreme Pro MRW-S1 4KB 4 randwrite Default 462KB/s
SanDisk Extreme Pro MRW-S1 4KB 8 randwrite Default 369B/s
SanDisk Extreme Pro MRW-S1 1MB 1 randwrite Default 49.7MB/s
SanDisk Extreme Pro MRW-S1 1MB 4 randwrite Default 7411KB/s
SanDisk Extreme Pro MRW-S1 1MB 8 randwrite Default 85.4KB/s
SanDisk Extreme Pro MRW-S1 256MB 1 randwrite Default 72.9MB/s
SanDisk Extreme Pro MRW-S1 256MB 4 randwrite Default 17.5KB/s
SanDisk Extreme Pro MRW-S1 256MB 8 randwrite Default 9782KB/s
SanDisk Extreme Pro MRW-S1 4KB 1 randwrite /dev/sda1 1907KB/s
SanDisk Extreme Pro MRW-S1 4KB 4 randwrite /dev/sda1 1466KB/s
SanDisk Extreme Pro MRW-S1 4KB 8 randwrite /dev/sda1 2005KB/s
SanDisk Extreme Pro MRW-S1 1MB 1 randwrite /dev/sda1 85.1MB/s
SanDisk Extreme Pro MRW-S1 1MB 4 randwrite /dev/sda1 66.0MB/s
SanDisk Extreme Pro MRW-S1 1MB 8 randwrite /dev/sda1 62.1MB/s
SanDisk Extreme Pro MRW-S1 256MB 1 randwrite /dev/sda1 73.6MB/s
SanDisk Extreme Pro MRW-S1 256MB 4 randwrite /dev/sda1 35.6MB/s
SanDisk Extreme Pro MRW-S1 256MB 8 randwrite /dev/sda1 31.6MB/s
SanDisk Extreme Pro MRW-S1 4KB 1 randread Default 5513KB/s
SanDisk Extreme Pro MRW-S1 4KB 4 randread Default 5753KB/s
SanDisk Extreme Pro MRW-S1 4KB 8 randread Default 5720KB/s
SanDisk Extreme Pro MRW-S1 1MB 1 randread Default 72MB/s
SanDisk Extreme Pro MRW-S1 1MB 4 randread Default 48.1MB/s
SanDisk Extreme Pro MRW-S1 1MB 4 randread Default 54.6MB/s
SanDisk Extreme Pro MRW-S1 256MB 1 randread Default 83.0MB/s
SanDisk Extreme Pro MRW-S1 256MB 4 randread Default 66.1MB/s
SanDisk Extreme Pro MRW-S1 256MB 8 randread Default 57.3MB/s
SanDisk Extreme Pro MRW-S1 4KB 1 randread /dev/sda1 5431KB/s
SanDisk Extreme Pro MRW-S1 4KB 4 randread /dev/sda1 5724KB/s
SanDisk Extreme Pro MRW-S1 4KB 8 randread /dev/sda1 5722KB/s
SanDisk Extreme Pro MRW-S1 1MB 1 randread /dev/sda1 64.3MB/s
SanDisk Extreme Pro MRW-S1 1MB 4 randread /dev/sda1 55.0MB/s
SanDisk Extreme Pro MRW-S1 1MB 8 randread /dev/sda1 55.9MB/s
SanDisk Extreme Pro MRW-S1 256MB 1 randread /dev/sda1 80.0MB/s
SanDisk Extreme Pro MRW-S1 256MB 4 randread /dev/sda1 63.2MB/s
SanDisk Extreme Pro MRW-S1 256MB 8 randread /dev/sda1 64.7MB/s
SF-E64 MRW-S1 4KB 1 randwrite Default 1456KB/s
SF-E64 MRW-S1 4KB 4 randwrite Default 1976KB/s
SF-E64 MRW-S1 4KB 8 randwrite Default 5947B/s
SF-E64 MRW-S1 1MB 1 randwrite Default 41.3MB/s
SF-E64 MRW-S1 1MB 4 randwrite Default 14.2MB/s
SF-E64 MRW-S1 1MB 8 randwrite Default 647KB/s
SF-E64 MRW-S1 256MB 1 randwrite Default 60.3MB/s
SF-E64 MRW-S1 256MB 4 randwrite Default 25.3MB/s
SF-E64 MRW-S1 256MB 8 randwrite Default 15.8MB/s
SF-E64 MRW-S1 4KB 1 randread Default 14.5MB/s
SF-E64 MRW-S1 4KB 4 randread Default 8856KB/s
SF-E64 MRW-S1 4KB 8 randread Default 8758KB/s
SF-E64 MRW-S1 1MB 1 randread Default 29.1MB/s
SF-E64 MRW-S1 1MB 4 randread Default 24.9MB/s
SF-E64 MRW-S1 1MB 8 randread Default 23.9MB/s
SF-E64 MRW-S1 256MB 1 randread Default 164MB/s
SF-E64 MRW-S1 256MB 4 randread Default 132MB/s
SF-E64 MRW-S1 256MB 8 randread Default 130MB/s
SF-M64T MRW-S1 4KB 1 randwrite Default 920KB/s
SF-M64T MRW-S1 4KB 4 randwrite Default 67B/s
SF-M64T MRW-S1 4KB 8 randwrite Default 44B/s
SF-M64T MRW-S1 1MB 1 randwrite Default 10.8MB/s
SF-M64T MRW-S1 1MB 4 randwrite Default 19.1KB/s
SF-M64T MRW-S1 1MB 8 randwrite Default 11.0KB/s
SF-M64T MRW-S1 256MB 1 randwrite Default 129MB/s
SF-M64T MRW-S1 256MB 4 randwrite Default 3281KB/s
SF-M64T MRW-S1 256MB 8 randwrite Default 2738 KB/s
SF-M64T BSCR27U3BK 256MB 1 randwrite Default 69.6MB/s
SF-M64T BSCR27U3BK 256MB 4 randwrite Default 2786KB/s
SF-M64T BSCR27U3BK 256MB 8 randwrite Default 2867KB/s
SF-M64T MRW-S1 4KB 1 randread Default 7760KB/s
SF-M64T MRW-S1 4KB 4 randread Default 8418KB/s
SF-M64T MRW-S1 4KB 4 randread Default 8394KB/s
SF-M64T MRW-S1 1MB 1 randread Default 128MB/s
SF-M64T MRW-S1 1MB 4 randread Default 131MB/s
SF-M64T MRW-S1 1MB 8 randread Default 128MB/s
SF-M64T MRW-S1 256MB 1 randread Default 177MB/s
SF-M64T MRW-S1 256MB 4 randread Default 140MB/s
SF-M64T MRW-S1 256MB 8 randread Default 141MB/s
SF-M64T BSCR27U3BK 256MB 1 randread Default 74.9MB/s
SF-M64T BSCR27U3BK 256MB 4 randread Default 69.3MB/s
SF-M64T BSCR27U3BK 256MB 8 randread Default 72.0MB/s

Linuxカーネルのファイルアクセスの処理を追いかける (6) get_block

関連記事

概要

QEMUの vexpress-a9 (arm) で Linux 5.15を起動させながら、ファイル書き込みのカーネル処理を確認していく。

本章では、ext2_get_block関数を確認した。

はじめに

ユーザプロセスはファイルシステムという機構によって記憶装置上のデータをファイルという形式で書き込み・読み込みすることができる。
本調査では、ユーザプロセスがファイルに書き込み要求を実行したときにLinuxカーネルではどのような処理が実行されるかを読み解いていく。

調査対象や環境などはPart 1: 環境セットアップを参照。

処理シーケンス図としては、下記の赤枠部分が該当する。

調査対象(シーケンス図)

本記事では、ext2ファイルシステムwrite_begin操作から呼び出されるget_block操作を解説する。
また本記事では、get_block操作は、ブロックがすでに確保されている場合のみに限定する。

ext2ファイルシステムのボリュームレイアウト

get_blockの処理に移る前にext2ファイルシステムのレイアウトを解説する。

Ext2パーティションExt2のレイアウト

ext2ファイルシステムは複数のブロックグループ (Block Group)から構成されていて、各ブロックグループは複数のブロックから構成されている。
一つのブロックグループには、次のようなデータが格納されている。

  • スーパーブロック : パーティション全体に関する情報 (1ブロック)
  • ブロックグループディスクリプタ : そのブロックグループに関する情報 (複数ブロック)
  • データブロックビットマップ : そのブロックグループにおけるデータブロックの空き情報 (1ブロック)
  • iノードビットマップ : そのブロックグループにおけるiノードの空き情報(1ブロック)
  • iノードテーブル : iノード (複数ブロック)
  • データブロック : 実際のデータ (複数ブロック)

また、iノードにはi_dataと呼ばれるメンバが合計15個あり、i_dataがデータブロックを参照する。
ただし、i_data[12],i_data[13],i_data[14]に関しては、Indirect Blockと呼ばれるブロック番号のみを保持するブロックを参照する。 それぞれ、Indirect Blockを経由する個数が決まっており、i_data[12]は1回、i_data[13]は2回i_data[14]は3回となっている。

これによって、ファイルサイズの小さいファイルに対しても効率的にデータを保存でき、かつファイルサイズの大きいファイルも扱うことができる。

データブロックのアドレッシング

ext2ファイルシステムの詳細については、解説記事や書籍を参照。

get_blockの概略

前回のwrite_begin操作(ext2_write_begin)は、ファイルシステムのブロックを取得するために、ext2_get_block関数を呼び出す。

このget_block操作はinode 構造体から指定されたブロックを取得する操作である。

関数の定義は次のようになっており、対象ファイルのinode構造体と取得したデータのオフセットiblockを引数としてインプットすることで、対象のデータをbh_resultに返す。
このとき、指定されたオフセットのデータが存在しない場合、かつcreateフラグが立っている場合は、新しくブロックを確保する。

// 779:
int ext2_get_block(struct inode *inode, sector_t iblock,
        struct buffer_head *bh_result, int create)
{
    unsigned max_blocks = bh_result->b_size >> inode->i_blkbits;
    bool new = false, boundary = false;
    u32 bno;
    int ret;

    ret = ext2_get_blocks(inode, iblock, max_blocks, &bno, &new, &boundary,
            create);
    if (ret <= 0)
        return ret;

    map_bh(bh_result, inode->i_sb, bno);
    bh_result->b_size = (ret << inode->i_blkbits);
    if (new)
        set_buffer_new(bh_result);
    if (boundary)
        set_buffer_boundary(bh_result);
    return 0;

}

ext2_get_block関数は、複数ブロックを取得するext2_get_blocks関数から構成される。

ただし、必ずしもブロックが一つのバッファキャッシュに収まるとは限らない。
変数max_blocksは、必要なバッファキャッシュの個数を表している。

ext2_get_blocks関数では、取得するブロックによって処理が異なる。
この関数の大まかなフローチャートを下記に示す。

ext2_get_blocks関数のフローチャート

ext2_get_blocks関数を大きく分けて「取得するブロックが確保済みの場合」と「取得するブロックがまだ確保されていない場合」となる。
本記事では「取得するブロックが確保済みの場合」に注目して処理を確認していく。

まずは、ext2_get_blocks関数から確認していく。

// 620:
static int ext2_get_blocks(struct inode *inode,
               sector_t iblock, unsigned long maxblocks,
               u32 *bno, bool *new, bool *boundary,
               int create)
{
    int err;
    int offsets[4];
    Indirect chain[4];
    Indirect *partial;
    ext2_fsblk_t goal;
    int indirect_blks;
    int blocks_to_boundary = 0;
    int depth;
    struct ext2_inode_info *ei = EXT2_I(inode);
    int count = 0;
    ext2_fsblk_t first_block = 0;

    BUG_ON(maxblocks == 0);

    depth = ext2_block_to_path(inode,iblock,offsets,&blocks_to_boundary);

    if (depth == 0)
        return -EIO;

    partial = ext2_get_branch(inode, depth, offsets, chain, &err);
    /* Simplest case - block found, no allocation needed */
    if (!partial) {
        first_block = le32_to_cpu(chain[depth - 1].key);
        count++;
        /*map more blocks*/
        while (count < maxblocks && count <= blocks_to_boundary) {
            ext2_fsblk_t blk;

            if (!verify_chain(chain, chain + depth - 1)) {
                /*
                * Indirect block might be removed by
                * truncate while we were reading it.
                * Handling of that case: forget what we've
                * got now, go to reread.
                */
                err = -EAGAIN;
                count = 0;
                partial = chain + depth - 1;
                break;
            }
            blk = le32_to_cpu(*(chain[depth-1].p + count));
            if (blk == first_block + count)
                count++;
            else
                break;
        }
        if (err != -EAGAIN)
            goto got_it;
    }

    /* Next simple case - plain lookup or failed read of indirect block */
    if (!create || err == -EIO)
        goto cleanup;

    mutex_lock(&ei->truncate_mutex);
    /*
    * If the indirect block is missing while we are reading
    * the chain(ext2_get_branch() returns -EAGAIN err), or
    * if the chain has been changed after we grab the semaphore,
    * (either because another process truncated this branch, or
    * another get_block allocated this branch) re-grab the chain to see if
    * the request block has been allocated or not.
    *
    * Since we already block the truncate/other get_block
    * at this point, we will have the current copy of the chain when we
    * splice the branch into the tree.
    */
    if (err == -EAGAIN || !verify_chain(chain, partial)) {
        while (partial > chain) {
            brelse(partial->bh);
            partial--;
        }
        partial = ext2_get_branch(inode, depth, offsets, chain, &err);
        if (!partial) {
            count++;
            mutex_unlock(&ei->truncate_mutex);
            goto got_it;
        }

        if (err) {
            mutex_unlock(&ei->truncate_mutex);
            goto cleanup;
        }
    }

    /*
    * Okay, we need to do block allocation.  Lazily initialize the block
    * allocation info here if necessary
   */
    if (S_ISREG(inode->i_mode) && (!ei->i_block_alloc_info))
        ext2_init_block_alloc_info(inode);

    goal = ext2_find_goal(inode, iblock, partial);

    /* the number of blocks need to allocate for [d,t]indirect blocks */
    indirect_blks = (chain + depth) - partial - 1;
    /*
    * Next look up the indirect map to count the total number of
    * direct blocks to allocate for this branch.
    */
    count = ext2_blks_to_allocate(partial, indirect_blks,
                    maxblocks, blocks_to_boundary);
    /*
    * XXX ???? Block out ext2_truncate while we alter the tree
    */
    err = ext2_alloc_branch(inode, indirect_blks, &count, goal,
                offsets + (partial - chain), partial);

    if (err) {
        mutex_unlock(&ei->truncate_mutex);
        goto cleanup;
    }

    if (IS_DAX(inode)) {
        /*
        * We must unmap blocks before zeroing so that writeback cannot
        * overwrite zeros with stale data from block device page cache.
        */
        clean_bdev_aliases(inode->i_sb->s_bdev,
                   le32_to_cpu(chain[depth-1].key),
                   count);
        /*
        * block must be initialised before we put it in the tree
        * so that it's not found by another thread before it's
        * initialised
        */
        err = sb_issue_zeroout(inode->i_sb,
                le32_to_cpu(chain[depth-1].key), count,
                GFP_NOFS);
        if (err) {
            mutex_unlock(&ei->truncate_mutex);
            goto cleanup;
        }
    }
    *new = true;

    ext2_splice_branch(inode, iblock, partial, indirect_blks, count);
    mutex_unlock(&ei->truncate_mutex);
got_it:
    if (count > blocks_to_boundary)
        *boundary = true;
    err = count;
    /* Clean up and exit */
    partial = chain + depth - 1;   /* the whole chain */
cleanup:
    while (partial > chain) {
        brelse(partial->bh);
        partial--;
    }
    if (err > 0)
        *bno = le32_to_cpu(chain[depth-1].key);
    return err;
}

必要なバッファキャッシュの個数maxblocks0となることは通常ありえないので、その場合はBUG_ONマクロによってカーネルパニックを引き起こす。

この関数では、Indirect型が用いて取得するブロック間の関係性を表現する。
Indirect型は、下記のような定義となっており、ポインタと整数型のキー値とバッファキャッシュのポインタを持つ構造体である。

// 114:
typedef struct {
    __le32  *p;
    __le32  key;
    struct buffer_head *bh;
} Indirect;

Indirect型のメンバはそれぞれ、次のデータが格納されていく。

  • p: Indirect Blockの該当のオフセットへのポインタ
  • key: Indirect Blockの該当オフセットに格納されているブロック番号の値
  • bh: 次のIndirect Blockと関連付けているbuffer_headへのポインタ

例えば、Indirect Block #???とIndirect Block #4111の関係性を表すようなIndirect型のデータ構造は次の通りとなる。

Indirect型のイメージ

上記の内容を踏まえたうえで、ext2_get_blocks関数の処理に戻る。

オフセットの取得

ext2_get_blocks関数で最初に実施することは、「オフセットの取得」である。
この処理に該当するソースコードは下記の部分となっている。

// 639:
    depth = ext2_block_to_path(inode,iblock,offsets,&blocks_to_boundary);

    if (depth == 0)
        return -EIO;

ext2_block_to_path関数は、ファイルのinodeとブロック番号iblockを渡し、それぞれのIndirect Blockにおけるオフセットoffsetを返す。

// 163:
static int ext2_block_to_path(struct inode *inode,
            long i_block, int offsets[4], int *boundary)
{
    int ptrs = EXT2_ADDR_PER_BLOCK(inode->i_sb);
    int ptrs_bits = EXT2_ADDR_PER_BLOCK_BITS(inode->i_sb);
    const long direct_blocks = EXT2_NDIR_BLOCKS,
        indirect_blocks = ptrs,
        double_blocks = (1 << (ptrs_bits * 2));
    int n = 0;
    int final = 0;

    if (i_block < 0) {
        ext2_msg(inode->i_sb, KERN_WARNING,
            "warning: %s: block < 0", __func__);
    } else if (i_block < direct_blocks) {
        offsets[n++] = i_block;
        final = direct_blocks;
    } else if ( (i_block -= direct_blocks) < indirect_blocks) {
        offsets[n++] = EXT2_IND_BLOCK;
        offsets[n++] = i_block;
        final = ptrs;
    } else if ((i_block -= indirect_blocks) < double_blocks) {
        offsets[n++] = EXT2_DIND_BLOCK;
        offsets[n++] = i_block >> ptrs_bits;
        offsets[n++] = i_block & (ptrs - 1);
        final = ptrs;
    } else if (((i_block -= double_blocks) >> (ptrs_bits * 2)) < ptrs) {
        offsets[n++] = EXT2_TIND_BLOCK;
        offsets[n++] = i_block >> (ptrs_bits * 2);
        offsets[n++] = (i_block >> ptrs_bits) & (ptrs - 1);
        offsets[n++] = i_block & (ptrs - 1);
        final = ptrs;
    } else {
        ext2_msg(inode->i_sb, KERN_WARNING,
            "warning: %s: block is too big", __func__);
    }
    if (boundary)
        *boundary = final - 1 - (i_block & (ptrs - 1));

    return n;
}

ext2_block_to_path関数のイメージ図は次のとおりである。

ext2_block_to_path関数によるオフセットの取得

chainの作成

各Indirect Blockのオフセットが取得できたら、これらをIndirect型に代入していく。
ext2_get_branch関数が該当する関数となる。

// 644:
    partial = ext2_get_branch(inode, depth, offsets, chain, &err);

ext2_get_branch関数は、ファイルのinodeとIndirect Blockの数depthと各Indirect Blockのオフセットoffsetを渡し、ブロックが未確保の場合には Indirect型のpを返す。

// 234:
static Indirect *ext2_get_branch(struct inode *inode,
                 int depth,
                 int *offsets,
                 Indirect chain[4],
                 int *err)
{
    struct super_block *sb = inode->i_sb;
    Indirect *p = chain;
    struct buffer_head *bh;

    *err = 0;
    /* i_data is not going away, no lock needed */
    add_chain (chain, NULL, EXT2_I(inode)->i_data + *offsets);
    if (!p->key)
        goto no_block;
    while (--depth) {
        bh = sb_bread(sb, le32_to_cpu(p->key));
        if (!bh)
            goto failure;
        read_lock(&EXT2_I(inode)->i_meta_lock);
        if (!verify_chain(chain, p))
            goto changed;
        add_chain(++p, bh, (__le32*)bh->b_data + *++offsets);
        read_unlock(&EXT2_I(inode)->i_meta_lock);
        if (!p->key)
            goto no_block;
    }
    return NULL;

changed:
    read_unlock(&EXT2_I(inode)->i_meta_lock);
    brelse(bh);
    *err = -EAGAIN;
    goto no_block;
failure:
    *err = -EIO;
no_block:
    return p;
}

ext2_get_branch関数でchainがどのように連結されるのかを表したものが下記となる。

間接ブロックのchain生成

ext2_get_branch関数の処理を大まかにまとめると次の通りとなる。

  1. 0番目のchainを作成する
  2. ブロック番号がまだ格納されていない場合、ブロック未確保となるのでext2_get_blocks関数でブロックを確保する
  3. Indirect Blockが存在する場合、実ブロックまで下記をループする
  4. p->keyから次のブロック番号を取得し、そのブロック番号を用いてsb_bread関数で次のブロックを取得する
  5. i_meta_lockのread_lockを取得する
  6. 取得したブロックを検証する
  7. i_meta_lockのread_lockを開放する

ここでchainの作成には、add_chain関数よって実現される。

// 120:
static inline void add_chain(Indirect *p, struct buffer_head *bh, __le32 *v)
{
    p->key = *(p->p = v);
    p->bh = bh;
}

また、作成したchainが妥当であるか確認するために、verity_chain関数を呼び出す。

// 126:
static inline int verify_chain(Indirect *from, Indirect *to)
{
    while (from <= to && from->key == *from->p)
        from++;
    return (from > to);
}

ext2_get_branch関数から呼び出されるverify_chain関数では、下記を確認している。

  • 作成したchain(p) が引数のchain(chain)と同じ、または新しいこと
  • chainのポインタfrom->pで参照している値と、chainの値from->keyの値が一致していること

ext2_get_branch関数でchainの生成ができた、またはブロックの新規確保が必要と判断ができる。

chainの検証

ブロックがすでに確保済みである場合は、chainにある適切なデータを返す。
処理としては下記のブロックとなる。

// 645:
    /* Simplest case - block found, no allocation needed */
    if (!partial) {
        first_block = le32_to_cpu(chain[depth - 1].key);
        count++;
        /*map more blocks*/
        while (count < maxblocks && count <= blocks_to_boundary) {
            ext2_fsblk_t blk;

            if (!verify_chain(chain, chain + depth - 1)) {
                /*
                * Indirect block might be removed by
                * truncate while we were reading it.
                * Handling of that case: forget what we've
                * got now, go to reread.
                */
                err = -EAGAIN;
                count = 0;
                partial = chain + depth - 1;
                break;
            }
            blk = le32_to_cpu(*(chain[depth-1].p + count));
            if (blk == first_block + count)
                count++;
            else
                break;
        }
        if (err != -EAGAIN)
            goto got_it;
    }

このブロックの処理を簡潔にまとめると、「chainの先頭からverify_chain関数で検証しながら、データブロックを変数blkを取得する」。

このとき、適切であるかどうかをverify_chain関数で確認する。

検証に成功した場合

verify_chain関数で検証に成功した場合、got_itラベルにジャンプする。
got_itラベルは下記の場所に定義されている。

// 763:
got_it:
    if (count > blocks_to_boundary)
        *boundary = true;
    err = count;
    /* Clean up and exit */
    partial = chain + depth - 1;   /* the whole chain */
cleanup:
    while (partial > chain) {
        brelse(partial->bh);
        partial--;
    }
    if (err > 0)
        *bno = le32_to_cpu(chain[depth-1].key);
    return err;

ここで、取得したバッファを開放 (リファレンスカウントをデクリメントする)をした後に、bnoに結果を格納して終了する。

検証に失敗した場合

verify_chain関数で検証に失敗した場合は、セマフォを確保した状態で再度chainの生成をする。(失敗する要因として、他のプロセスやコンテキストスイッチなどの影響で変化してしまった場合などがある)

// 679:
    mutex_lock(&ei->truncate_mutex);
    /*
    * If the indirect block is missing while we are reading
    * the chain(ext2_get_branch() returns -EAGAIN err), or
    * if the chain has been changed after we grab the semaphore,
    * (either because another process truncated this branch, or
    * another get_block allocated this branch) re-grab the chain to see if
    * the request block has been allocated or not.
    *
    * Since we already block the truncate/other get_block
    * at this point, we will have the current copy of the chain when we
    * splice the branch into the tree.
    */
    if (err == -EAGAIN || !verify_chain(chain, partial)) {
        while (partial > chain) {
            brelse(partial->bh);
            partial--;
        }
        partial = ext2_get_branch(inode, depth, offsets, chain, &err);
        if (!partial) {
            count++;
            mutex_unlock(&ei->truncate_mutex);
            goto got_it;
        }

        if (err) {
            mutex_unlock(&ei->truncate_mutex);
            goto cleanup;
        }
    }

再度、ext2_get_branch関数を呼び出し、chainが取得できた場合はgot_itラベル、できない場合はcleanupラベルにジャンプする。

バッファキャッシュにマップする

再掲となるが、ext2_get_blocks関数直後のコードは次の通りとなっている。

// 789:
   if (ret <= 0)
        return ret;

    map_bh(bh_result, inode->i_sb, bno);
    bh_result->b_size = (ret << inode->i_blkbits);
    if (new)
        set_buffer_new(bh_result);
    if (boundary)
        set_buffer_boundary(bh_result);
    return 0;

ext2_get_blocks関数によって指定した関数を取得できた場合は、map_bh関数を実行する。
この関数では、引数のbuffer_headに対応するデータが記憶装置上のデータとマップしているフラグを付与する。

// 341:
static inline void
map_bh(struct buffer_head *bh, struct super_block *sb, sector_t block)
{
    set_buffer_mapped(bh);
    bh->b_bdev = sb->s_bdev;
    bh->b_blocknr = block;
    bh->b_size = sb->s_blocksize;
}

また、バッファキャッシュが新規に作られた場合はnewフラグを立て、Boundary Bufferである場合はboundaryフラグを立てる。

get_block操作では、該当のブロックをbh_resultに更新とフラグの更新をして処理を終了する。

おわりに

ext2_get_block関数で登場するデータ構造を表したものが下記となる。

ext2_get_block関数の概要図

変更履歴

  • 2021/7/23: 記事公開
  • 2022/09/19: カーネルバージョンを5.15に変更

参考

Raspberry Pi 4 Model B (64-bit版 Raspberry Pi OS) で dm-integrity のオーバーヘッドを計測する

概要

Raspberry Pi 4 Model B (64-Bit版 Raspberry Pi OS) で dm-integrityの使用方法について確認した。

  • dm-integrityはシーケンシャルライトが40%、ランダムライトが50%の性能低下していることが確認できた。
  • dm-integrityはシーケンシャルリードが10%ランダムリード20%の性能低下していることが確認できた。

はじめに

Raspberry Pi 4 Model B (Raspberry Pi 4) はarm64のシングルボードコンピュータであり、趣味から仕事まで幅広い用途として利用されている。
Raspberry Pi の 推奨OSの一つに DebianベースOSのRaspberry Pi OSがある。 2021年4月30日現在では、安定性や互換性の観点からRaspberry Pi OSのデフォルトが32-bit版となっている。 しかし、64-bit版のRaspberry Pi OSも用意されているので、設定を変更することで利用できる。

一方で、dm-integrityはLinuxカーネルにおける標準提供の機能であり、ブロックの整合性を保障することができる。 dm-integrity では、 Device mapperと呼ばれるブロックデバイスの仮想レイヤを作成する機能を用いて、ジャーナルログを生成する。
ブロックの整合性を保証できる一方で、性能の観点でもストレージの容量の観点でもオーバーヘッドが発生する。

目的

  • dm-integrityを導入したことで読み書き性能がどれだけ変化するかを調査する
  • 改ざん検知機能 (dm-verity と dm-integrity+dm-crypt) の間で読み込み性能の比較をする

dm-integrity とは

dm-integrityは、Linuxカーネル v4.12以降で利用することができるLinux標準提供の機能である。 dm-integrityのデータ構造は下記の通りとなっている。

dm-integrityのディスクレイアウト

Device-mapperの機構を利用して、データと整合性タグをまとめてジャーナルにコミットしてから、データと整合性タグを適切な位置に書き込むことになっている。
ext4といったジャーナリングファイルシステムはファイルの整合性を保障する一方で、dm-integrityではブロック単位の整合性保証する。
そのため、ビットエラーや書き込み中の電源断による整合性保証のために利用される。

また、dm-integrityはdm-cryptと共に利用されることが多い。
dm-cryptは、Linuxカーネルにおける標準提供の機能であり、ブロック単位にを暗号/複合することができる。 二つの機構を組み合わせHMAC-sha256を使うことで、不正なデータの更新(改ざん)を検出することができる。

なぜ64-bit版 のOSで試すのか

2021年4月30日現在、Raspberry Pi OSはDebian 10 (buster) がベースとなっており、cryptsetup 2.1.0がパッケージされている。 一方で、armhf と cryptsetup 2.1.0は安定していない (ように見える)。

gitlab.com

gitlab.com

そこで下記の観点から、64-bit版のRaspberry Pi OSを使用することにした。

  • カーネルコンフィグを修正するために再ビルドが必要であること
  • arm64の読み書き性能を正しく計測するために、OSも64-bit版が適していること
  • 32-bit版OSの既知のバグを踏まないため

実行環境

計測対象のRaspberry Pi 4 Model Bの詳細は下記のとおりである。

名前 詳細
Hardware Raspberry Pi 4 Model B Rev 1.2 (4GB)
OS Raspberry Pi OS arm64 2021-04-09
Kernel Linux version 5.10.3-v8
Kconfig 付録参照
SDカード HDMCSDH16GCL10UIJP-WOA
USBメモリ SILICON POWER SP032GBUF3B02V1K
IPアドレス (eth0) 172.16.1.3
ユーザ名 pi
ホスト名 raspberry
dosfstools 4.1
cryptsetup v2.1.0
integrity v1.6.0
verity v1.7.0
crypt v1.22.0
fio v3.12

カーネルのビルドや環境のセットアップについては、前回の記事を参照。

leavatail.hatenablog.com

本記事では、SDカードにブートに必要なイメージを格納し、USBメモリに作成したext4ファイルシステムで計測する。

本記事におけるデバイスとその用途の関係図

またUSBメモリ(/dev/sda)はMBRパーティションを作成し、8GB(/dev/sda1)と20GB(/dev/sda2)の二つから構成される。

測定方法

今回は以下の4つのパターンにおける読み書き性能を測定した。 (ただしdm-verityはRead-Onlyであるため、③は読み込み性能のみとする)

測定環境

  1. Device mapperを使用しない場合 (実デバイスドライバファイルシステムを構築する)
  2. dm-integrityのみ使用する場合
  3. dm-verityを使用する場合
  4. dm-integrityとdm-cryprtの両方を使用する場合

各測定パターンの構築方法については付録参照。

また、読み書きの測定計測にはfioを利用し、write, randwrite, read, randreadの4つのエンジンを用いる。

各測定は10回計測し、外れ値を除外したスループットの平均値を性能として評価する。
ここで外れ値は、スループットが平均値から100%以上のずれがあるものや、レイテンシの値が異常値(0や極端に値が振れ幅が大きい)であるものとした。

write

writeとrandwriteについては、4KBのバッファI/Oで計測を実施した。*1

read

writeとrandwriteについては、4KBのバッファI/Oで計測を実施した。

性能計測

write測定結果

上記のグラフは左から、「Device mapperを使用しない場合 」「dm-integrityのみ使用する場合」「dm-verityを使用する場合」「dm-integrityとdm-cryprtの両方を使用する場合」におけるwrite, readwriteのスループットを示している。
dm-verityはRead-Onlyパーティションを対象としているため、write,randwriteのスループットを0と表記している。

normal integrity オーバーヘッド verity crypt+integrity オーバーヘッド
write性能 15.7MB/s 8.9MB/s -43.3% 0 5.9MB/s N/A
randwrite性能 14.5MB/s 6.9MB/s -52.5% 0 4.7MB/s N/A

上記の測定結果より、dm-integrityによる書き込みスループットは50%程度減少することが分かった。
これは、dm-integrity用のDevice Mapper層が追加されたことによる命令数の増加や、ジャーナルの書き込みによりI/O数が増加したことが要因として挙げられる。*2

また、dm-cryptとdm-integrityを組み合わた場合、スループットは65%程度減少することが分かった。
そのため、セキュアブートのためにHMACの利用を考えている方は、これらの性能低下を意識してほしい。

read測定結果

上記のグラフは左から、「Device mapperを使用しない場合 」「dm-integrityのみ使用する場合」「dm-verityを使用する場合」「dm-integrityとdm-cryprtの両方を使用する場合」におけるread, readreadのスループットを示している。

normal integrity オーバーヘッド verity crypt+integrity オーバーヘッド
read性能 88.2MB/s 73.9MB/s -12.2% 39.3MB/s 18.4MB/s -53.2%
randread性能 7.2MB/s 6.0MB/s -17.7% 5.9MB/s 5.0MB/s -15.3%

上記の測定結果より、dm-integrityによる読み込みスループットは15%程度減少することが分かった。
これは、dm-integritによる命令数の増加やI/O数が、LInuxのページング機構によって緩和されたため、著しいスループットの減少が見られなかったと考えられる。

一方で、dm-cryptとdm-integrityを組み合わた場合、dm-verityと比較してスループットが大きく減少する(シーケンシャルリードで50%、ランダムリードで15%)ことが分かった。
特にシーケンシャルリード(read性能)に関しては、著しい減少が見られた。
これは、読み込むべきデータが増加したことにより、ページイン・ページアウトの回数が増加したからではないかと考えられる。*3

おわりに

本記事ではRaspberry Pi 4上でdm-integrityのオーバーヘッドを測定した。

  • dm-integrityを導入したことでwrite性能の著しい減少が観測された
  • 改ざん検知機能の間でシーケンシャルリード性能の著しい減少が観測された

変更履歴

  • 2021/5/9: 記事公開

参考

付録

カーネルコンフィグ

bcm2711_defconfigから更新した箇所は下記のとおりである。

617a618                           
> # CONFIG_CRYPTO_CRCT10DIF_ARM64_CE is not set
735c736,737            
< # CONFIG_BLK_DEV_INTEGRITY is not set
---                             
> CONFIG_BLK_DEV_INTEGRITY=y
> CONFIG_BLK_DEV_INTEGRITY_T10=y
2199c2201,2203
< # CONFIG_DM_VERITY is not set
---
> CONFIG_DM_VERITY=m
> # CONFIG_DM_VERITY_VERIFY_ROOTHASH_SIG is not set
> # CONFIG_DM_VERITY_FEC is not set
2202c2206
< # CONFIG_DM_INTEGRITY is not set
---
> CONFIG_DM_INTEGRITY=m
7414c7418
< CONFIG_CRYPTO_GF128MUL=m
---
> CONFIG_CRYPTO_GF128MUL=y
7448,7449c7452,7453
< # CONFIG_CRYPTO_CFB is not set
< CONFIG_CRYPTO_CTR=m
---
> CONFIG_CRYPTO_CFB=y
> CONFIG_CRYPTO_CTR=y
7452,7454c7456,7458
< # CONFIG_CRYPTO_LRW is not set
< # CONFIG_CRYPTO_OFB is not set
< # CONFIG_CRYPTO_PCBC is not set
---
> CONFIG_CRYPTO_LRW=y
> CONFIG_CRYPTO_OFB=y
> CONFIG_CRYPTO_PCBC=y
7476c7480
< # CONFIG_CRYPTO_CRCT10DIF is not set
---
> CONFIG_CRYPTO_CRCT10DIF=y
7482,7485c7486,7489
< # CONFIG_CRYPTO_RMD128 is not set
< # CONFIG_CRYPTO_RMD160 is not set
< # CONFIG_CRYPTO_RMD256 is not set
< # CONFIG_CRYPTO_RMD320 is not set
---
> CONFIG_CRYPTO_RMD128=y
> CONFIG_CRYPTO_RMD160=y
> CONFIG_CRYPTO_RMD256=y
> CONFIG_CRYPTO_RMD320=y
7487c7491
< CONFIG_CRYPTO_SHA256=m
---
> CONFIG_CRYPTO_SHA256=y
7506c7510
< # CONFIG_CRYPTO_CAST6 is not set
---
> CONFIG_CRYPTO_CAST6=m
7610c7614
< # CONFIG_CRC_T10DIF is not set
---
> CONFIG_CRC_T10DIF=y

環境構築

Device mapperを使用しない場合
  1. デバイスドライバにFATファイルシステムを作成する

     pi@raspberrypi:~ $ sudo mkfs.fat /dev/sda1 
     mkfs.fat 4.1 (2017-01-24)
    
  2. 作成したファイルシステムを/mntにマウントする

     pi@raspberrypi:~ $ sudo mount -t vfat /dev/sda1 /mnt/
    
dm-verityを使用する場合

dm-verityでは、改ざんチェックの対象となるブロックデバイス(データデバイス)とチェックのためのブロックデバイス(ハッシュデバイス)の2つを用意する必要がある。

そこで、測定対象デバイス(USBメモリ) 上でパーティション作成した。
このとき、データデバイス/dev/sda1を16GB、ハッシュデバイス/dev/sda2を残りの領域に割り当てた。

下記の手順にて測定環境を構築した。

  1. USBメモリ上にext4ファイルシステムを作成し、テスト用にデータを埋めておく

     pi@raspberrypi:~ $ sudo mkfs.fat /dev/sda1
     mkfs.fat 4.1 (2017-01-24)
     pi@raspberrypi:~ $ mount /dev/sda1 /mnt
     pi@raspberrypi:~ $ sudo dd if=/dev/urandom of=/mnt/file bs=1M count=4K
    
  2. ハッシュデバイスの構築準備

     pi@raspberrypi:~ $  sudo veritysetup format /dev/sda1 /dev/sda2 
     VERITY header information for /dev/sda2
     UUID:                   68bbac35-32bc-455f-a1f9-ae2a62287847
     Hash type:              1
     Data blocks:            4194304
     Data block size:        4096
     Hash block size:        4096
     Hash algorithm:         sha256
     Salt:                           759b9ed6980255b4469c6732b05519fe09890938b8608ef7f37fa462c9e79a41
     Root hash:              c2ba950245bc4ed2b37de9900f19050467db3c051cd4801a10713ca193a5e1a9
    
  3. device-mapper (/dev/mapper/test)を作成

     pi@raspberrypi:~ $ sudo veritysetup create test /dev/sda1 /dev/sda2 c2ba950245bc4ed2b37de9900f19050467db3c051cd4801a10713ca193a5e1a9
     pi@raspberrypi:~ $ ls -la /dev/mapper/test
     lrwxrwxrwx 1 root root 7 12月 30 04:12 /dev/mapper/test -> ../dm-0
    
  4. 作成したファイルシステムを/mntにマウントする

     pi@raspberrypi:~ $ sudo mount -t vfat -o ro /dev/mapper/dmtest /mnt/
    
dm-integrityのみ使用する場合
  1. integrity target用のMapping tableを作成する

     pi@raspberrypi:~ $ sudo integritysetup format /dev/sda1
    
     Formatted with tag size 4, internal integrity crc32c.
     Wiping device to initialize integrity checksum.
     You can interrupt this by pressing CTRL+c (rest of not wiped device will     contain     invalid checksum).
     Finished, time 18:26.389, 8064 MiB written, speed   7.3 MiB/s   
    
  2. device-mapper (/dev/mapper/test)を作成する

     pi@raspberrypi:~ $ sudo integritysetup open /dev/sda1 test
    
  3. 作成したintegrity targetを確認する

     pi@raspberrypi:~ $ sudo integritysetup status test
     /dev/mapper/test is active.
       type:    INTEGRITY
       tag size: 4
       integrity: crc32c
       device:  /dev/sda1
       sector size:  512 bytes
       interleave sectors: 32768
       size:    16516984 sectors
       mode:    read/write
       failures: 0
       journal size: 67043328 bytes
       journal watermark: 50%
       journal commit time: 10000 ms
    
  4. device mapper tableを表示する (値の意味についてはcryptsetupを参照)

     pi@raspberrypi:~ $ sudo dmsetup table
     test: 0 16516984 integrity 8:1 0 4 J 6 journal_sectors:130944 interleave_sectors:32768 buffer_sectors:128 journal_watermark:50 commit_time:10000 internal_hash:crc32c
    
  5. USBメモリ上にext4ファイルシステムを作成し、テスト用のファイルfileを準備する

     pi@raspberrypi:~ $ sudo mkfs.fat /dev/mapper/test
     mkfs.fat 4.1 (2017-01-24)
    
  6. /mnt以下に作成したファイルシステムをマウントする

     pi@raspberrypi:~ $ sudo mount -t vfat /dev/mapper/test /mnt/
    
dm-integrityとdm-cryprtの両方を使用する場合
  1. crypt target + integrity target用のMapping tableを作成する

     pi@raspberrypi:~ $ sudo cryptsetup luksFormat --type luks2 /dev/sda1 --cipher aes-xts-plain64 --integrity hmac-sha256
    Enter passphrase for /dev/sda1: 
    Verify passphrase: 
    Wiping device to initialize integrity checksum.
    You can interrupt this by pressing CTRL+c (rest of not wiped device will contain invalid checksum).
    Finished, time 33:38.376, 7634 MiB written, speed   3.8 MiB/s
    
  2. 作成したDevice mapperのインスタンスを有効化する

     pi@raspberrypi:~ $ sudo cryptsetup open /dev/sda test
     Enter passphrase for /dev/sda: 
    
  3. 作成したDevice mapperを確認する

     pi@raspberrypi:~ $ sudo cryptsetup status test
     /dev/mapper/test is active.
       type:    LUKS2
       cipher:  aes-xts-plain64
       keysize: 768 bits
       key location: keyring
       integrity: hmac(sha256)
       integrity keysize: 256 bits
       device:  /dev/sda1
       sector size:  512
       offset:  0 sectors
       size:    15634464 sectors
       mode:    read/write
    
  4. USBメモリ上にFATファイルシステムを作成する

     pi@raspberrypi:~ $ sudo mkfs.fat /dev/mapper/test
     mkfs.fat 4.1 (2017-01-24)
    
  5. /mnt以下に作成したファイルシステムをマウントする

     pi@raspberrypi:~ $ sudo mount -t vfat /dev/mapper/test /mnt/
    

測定スクリプト

#!/bin/bash -eu

MNTPOINT=/mnt/
TARGET=FILE.TXT
LOOP=10

function usage() {
        echo "Usage: $(basename $0) {0 | 1}"
        echo "    0: measure for write, randwrite"
        echo "    1: measure for read,  randread"
}

function do_fio() {
        if [ $# != 2 ]; then
                echo "Internal Error: function needs 2 argument."
                exit 1
        fi

        echo 3 > /proc/sys/vm/drop_caches
        fio --filename=${MNTPOINT}${TARGET} --direct=$2 --rw=$1 --bs=4K --ioengine=libaio --runtime=60 --time_based --name=$2 --size=1G --output-format=json >> result_$1.log
}

##
## MAIN FUNCTION
##
array_0=("write" "randwrite")
array_1=("read" "randread")

if [ $# != 1 ]; then
        usage
        exit 1
fi

if [ $1 = '0' ]; then
        for pattern in ${array_0[@]}
        do
                echo "Start ${pattern}"
                touch result_${pattern}.log
                for i in `seq ${LOOP}`
                do
                        do_fio ${pattern} 0
                        rm ${MNTPOINT}${TARGET}
                done
        done
elif [ $1 = '1' ]; then
        for pattern in ${array_1[@]}
        do
                echo "Start ${pattern}"
                touch result_${pattern}.log
                for i in `seq ${LOOP}`
                do
                        do_fio ${pattern} 1
                done
        done
else
        usage
        exit 1
fi

*1:direct=1で計測したかったが、randwrite性能が極端に低速であったため断念した

*2:より詳細に調査するのであれば、blktraceなどで確認したほうが良い

*3:より詳細に調査するのであれば、topなどでメモリ使用量やページイン・ページアウトを確認したほうが良い

Linuxカーネルのファイルアクセスの処理を追いかける (5) write_begin

関連記事

概要

QEMUの vexpress-a9 (arm) で Linux 5.15を起動させながら、ファイル書き込みのカーネル処理を確認していく。

本章では、ext2_write_begin関数を確認した。

はじめに

ユーザプロセスはファイルシステムという機構によって記憶装置上のデータをファイルという形式で書き込み・読み込みすることができる。
本調査では、ユーザプロセスがファイルに書き込み要求を実行したときにLinuxカーネルではどのような処理が実行されるかを読み解いていく。

調査対象や環境などはPart 1: 環境セットアップを参照。

処理シーケンス図としては、下記の赤枠部分が該当する。

調査対象(シーケンス図)

本記事では、ext2ファイルシステムwrite_iter操作から呼び出されるwrite_begin操作を解説する。

write_beginの概要

前回解説したwrite_iter操作(generic_file_write_iter)は、write_begin操作とwrite_end操作を呼び出すことになっている。

このwrite_begin操作やwrite_end操作はページキャッシュに対する操作となっており、address_space_opearationsのメンバの一つとして定義される。

ここで、address_spaceについて再掲する。

inodeとaddress_spaceの対応関係

address_spaceは、inodeとページキャッシュ(ファイルのデータ)を紐づけるデータ型となっている。
ファイルに紐づいているページキャッシュは、xarray型のi_pagesで管理される。 (バージョン4.20より前はradix treeで管理される)

詳細については、下記の記事に詳しく記載されているのでそちらを参照。

qiita.com

ext2ファイルシステムのaddress_space_operationsは、ext2_aopsext2_nobh_aopsext2_dax_aopsが定義されている。
これらは、マウントオプションnobhdaxが指定された場合に、使用するappress_space_operationsを変更することになっている。

今回は処理が簡単なext2_aopsを用いて考えていく。
ext2_aopsの定義は下記の通り。

// 964:
const struct address_space_operations ext2_aops = {
    .set_page_dirty     = __set_page_dirty_buffers,
    .readpage       = ext2_readpage,
    .readahead      = ext2_readahead,
    .writepage      = ext2_writepage,
    .write_begin        = ext2_write_begin,
    .write_end      = ext2_write_end,
    .bmap           = ext2_bmap,
    .direct_IO      = ext2_direct_IO,
    .writepages     = ext2_writepages,
    .migratepage        = buffer_migrate_page,
    .is_partially_uptodate  = block_is_partially_uptodate,
    .error_remove_page  = generic_error_remove_page,
};

まずは、write_begin操作のext2_write_begin関数から確認する。

write_begin操作は、write_iter操作(で呼び出されるgeneric_perform_write関数)から呼び出される。

// 3770:
        status = a_ops->write_begin(file, mapping, pos, bytes, flags,
                        &page, &fsdata);
変数名
file オープンしたファイル
mapping ファイルが持っているページキャッシュのXArray(radix-tree)
pos 書き込み先の位置
bytes 書き込むバイト数
flags 0
page 取得したページを格納する
fsdata ext2ファイルシステムでは使用しない

ext2ファイルシステムの場合には、write_begin操作でext2_write_begin関数を実行する。

// 884:
static int
ext2_write_begin(struct file *file, struct address_space *mapping,
        loff_t pos, unsigned len, unsigned flags,
        struct page **pagep, void **fsdata)
{
    int ret;

    ret = block_write_begin(mapping, pos, len, flags, pagep,
                ext2_get_block);
    if (ret < 0)
        ext2_write_failed(mapping, pos + len);
    return ret;
}

この関数では、カーネル内で定義されている汎用の関数block_write_begin関数に、ext2ファイルシステムにおけるブロック取得のext2_get_block関数(次回解説)を渡している。

// 2109:
int block_write_begin(struct address_space *mapping, loff_t pos, unsigned len,
        unsigned flags, struct page **pagep, get_block_t *get_block)
{
    pgoff_t index = pos >> PAGE_SHIFT;
    struct page *page;
    int status;

    page = grab_cache_page_write_begin(mapping, index, flags);
    if (!page)
        return -ENOMEM;

    status = __block_write_begin(page, pos, len, get_block);
    if (unlikely(status)) {
        unlock_page(page);
        put_page(page);
        page = NULL;
    }

    *pagep = page;
    return status;
}

block_write_begin関数では、下記を実施する。

  • ページキャッシュを取得する
  • バッファキャッシュ用のデータ構造を作成する

まずは、ページキャッシュを取得するgrab_cache_page_write_begin関数を確認する。

ページキャッシュを取得する

// 3715:
/*
 * Find or create a page at the given pagecache position. Return the locked
 * page. This function is specifically for buffered writes.
 */
struct page *grab_cache_page_write_begin(struct address_space *mapping,
                    pgoff_t index, unsigned flags)
{
    struct page *page;
    int fgp_flags = FGP_LOCK|FGP_WRITE|FGP_CREAT;

    if (flags & AOP_FLAG_NOFS)
        fgp_flags |= FGP_NOFS;

    page = pagecache_get_page(mapping, index, fgp_flags,
            mapping_gfp_mask(mapping));
    if (page)
        wait_for_stable_page(page);

    return page;
}
EXPORT_SYMBOL(grab_cache_page_write_begin);

grab_cache_page_write_begin関数では、下記を実施する。

  • ページキャッシュを取得する
  • ページキャッシュの割り当て中にアクセスされたページをフラッシュする

ただし、後者('wait_for_stable_page`関数)については、本質でない(かつ未調査である)ため説明を省略する。(該当パッチ)

まずは、ページキャッシュを取得するpagecache_get_page関数を確認する。 このとき、引数のfgp_flagsには次のような値が設定されている。

フラグ名 説明
FGP_LOCK 得られたページをロックする
FGP_WRITE ページに書き込みをする
FGP_CREAT ページが見つからない場合はページを作る

また、引数のgfp_maskには次のような値が設定されている。(実機で0x1100ccaであることを確認済み)

パラメータ名 説明
___GFP_HIGHMEM ZONE_HIGHMEMメモリゾーンに属するページフレームを取得する
___GFP_MOVABLE ページ圧縮中にこのページは移動可能であることを示す
___GFP_IO ページフレームを開放するためにI/O転送処置を行ってもよい
___GFP_FS ファイルシステム関連の操作を行っても良い
___GFP_DIRECT_RECLAIM メモリ確保を読んだタスクのコンテキストでメモリ回収する
___GFP_KSWAPD_RECLAIM kswapdカーネルスレッドを起動してメモリ回収できる
___GFP_HARDWALL cpusetのメモリ割り当てポリシーを強制する
___GFP_SKIP_KASAN_POISON KASANがページの割り当て解除時にポイズニングをスキップする

pagecache_get_page関数の定義は下記のとおりである。

// 1888:
struct page *pagecache_get_page(struct address_space *mapping, pgoff_t index,
        int fgp_flags, gfp_t gfp_mask)
{
    struct page *page;

repeat:
    page = mapping_get_entry(mapping, index);
    if (xa_is_value(page)) {
        if (fgp_flags & FGP_ENTRY)
            return page;
        page = NULL;
    }
    if (!page)
        goto no_page;

    if (fgp_flags & FGP_LOCK) {
        if (fgp_flags & FGP_NOWAIT) {
            if (!trylock_page(page)) {
                put_page(page);
                return NULL;
            }
        } else {
            lock_page(page);
        }

        /* Has the page been truncated? */
        if (unlikely(page->mapping != mapping)) {
            unlock_page(page);
            put_page(page);
            goto repeat;
        }
        VM_BUG_ON_PAGE(!thp_contains(page, index), page);
    }

    if (fgp_flags & FGP_ACCESSED)
        mark_page_accessed(page);
    else if (fgp_flags & FGP_WRITE) {
        /* Clear idle flag for buffer write */
        if (page_is_idle(page))
            clear_page_idle(page);
    }
    if (!(fgp_flags & FGP_HEAD))
        page = find_subpage(page, index);

no_page:
    if (!page && (fgp_flags & FGP_CREAT)) {
        int err;
        if ((fgp_flags & FGP_WRITE) && mapping_can_writeback(mapping))
            gfp_mask |= __GFP_WRITE;
        if (fgp_flags & FGP_NOFS)
            gfp_mask &= ~__GFP_FS;

        page = __page_cache_alloc(gfp_mask);
        if (!page)
            return NULL;

        if (WARN_ON_ONCE(!(fgp_flags & (FGP_LOCK | FGP_FOR_MMAP))))
            fgp_flags |= FGP_LOCK;

        /* Init accessed so avoid atomic mark_page_accessed later */
        if (fgp_flags & FGP_ACCESSED)
            __SetPageReferenced(page);

        err = add_to_page_cache_lru(page, mapping, index, gfp_mask);
        if (unlikely(err)) {
            put_page(page);
            page = NULL;
            if (err == -EEXIST)
                goto repeat;
        }

        /*
        * add_to_page_cache_lru locks the page, and for mmap we expect
        * an unlocked page.
        */
        if (page && (fgp_flags & FGP_FOR_MMAP))
            unlock_page(page);
    }

    return page;
}

まずは、mapping_get_entry関数の定義を確認する。

// 1817:
static struct page *mapping_get_entry(struct address_space *mapping,
        pgoff_t index)
{
    XA_STATE(xas, &mapping->i_pages, index);
    struct page *page;

    rcu_read_lock();
repeat:
    xas_reset(&xas);
    page = xas_load(&xas);
    if (xas_retry(&xas, page))
        goto repeat;
    /*
    * A shadow entry of a recently evicted page, or a swap entry from
    * shmem/tmpfs.  Return it without attempting to raise page count.
    */
    if (!page || xa_is_value(page))
        goto out;

    if (!page_cache_get_speculative(page))
        goto repeat;

    /*
    * Has the page moved or been split?
    * This is part of the lockless pagecache protocol. See
    * include/linux/pagemap.h for details.
    */
    if (unlikely(page != xas_reload(&xas))) {
        put_page(page);
        goto repeat;
    }
out:
    rcu_read_unlock();

    return page;
}

mapping_get_entry関数はmappingからページキャッシュを検索する。

ページキャッシュは、Read-copy update (RCU) による排他制御を実現している。
ここで、RCUとはロックを取得しないという特徴があり、ページキャッシュのような書き込みより読み込みのほうが多いデータ構造に適用される。

RCUの仕組みについて下記の図を用いて解説する。

Read-copy-update (RCU) の概略

4コアのシステムで共通の資源(pageが示すAとB)を扱う場合

  • readの場合、CPU1とCPU2はAをreadできる
  • writeの場合、CPU3はBをreadして、CPU4はBの複製B'にwriteする
    • 新しくBを参照する場合、B'に対してread/writeする
    • Bを参照しなくなったら、BへのポイントをB'に更新する

より詳しく知りたい人は下記の記事で解説されているので、そちらを参照。

tkokamo.hateblo.jp

mapping_get_entry関数の先頭と末尾にあるrcu_read_lock関数とrcu_read_unlock関数は、RCUにおけるreadの開始と終わりを表す。
rcu_read_lock関数とrcu_read_unlock関数は、ロックを取るわけではなく、プリエンプトを無効にするだけとなっている。

RCUを宣言したら、XArrayからページキャッシュの取得を試みる。

  1. XA_STATEマクロでXArrayデータ構造の宣言をする
  2. xas_reset関数でXArrayの状態を初期化する
  3. xas_load関数でXA_STATEで宣言したxasを辿りデータを取得することができる。
  4. internal entryを返すことがある(multi-index entryの場合)ため、必要に応じてxas_retry関数で再試行する
  5. xa_is_value関数で取得したデータが値エントリであることを確認する
  6. page_cache_get_speculative関数でページキャッシュのリファレンスカウントを更新する
  7. 取得したページキャッシュが移動されていないかxas_reload関数で確認する

これらを踏まえて、pagecache_get_page関数に戻る。
pagecache_get_page関数は、find_get_entry関数で取得したページキャッシュが有効かどうかで処理が変わる。

ページキャッシュが存在する場合

pagecache_get_page関数は1901行目のgoto文は実行されず、処理を続ける。

// 1893:
repeat:
    page = mapping_get_entry(mapping, index);
    if (xa_is_value(page)) {
        if (fgp_flags & FGP_ENTRY)
            return page;
        page = NULL;
    }
    if (!page)
        goto no_page;

    if (fgp_flags & FGP_LOCK) {
        if (fgp_flags & FGP_NOWAIT) {
            if (!trylock_page(page)) {
                put_page(page);
                return NULL;
            }
        } else {
            lock_page(page);
        }

        /* Has the page been truncated? */
        if (unlikely(page->mapping != mapping)) {
            unlock_page(page);
            put_page(page);
            goto repeat;
        }
        VM_BUG_ON_PAGE(!thp_contains(page, index), page);
    }

    if (fgp_flags & FGP_ACCESSED)
        mark_page_accessed(page);
    else if (fgp_flags & FGP_WRITE) {
        /* Clear idle flag for buffer write */
        if (page_is_idle(page))
            clear_page_idle(page);
    }
    if (!(fgp_flags & FGP_HEAD))
        page = find_subpage(page, index);

ページキャッシュが有効である場合の挙動は以下の通りとなる。

  1. 既にPG_lockedフラグが設定されている場合、ページキャッシュが獲得するまでwaitする。(lock_page関数)
  2. これまでの間にページキャッシュが切り捨てられてしまった場合には、フラグの解除とリファレンスカウントを下げて、再度ページキャッシュの取得を試みる。
  3. ページがidle状態になっていれば、PAGE_EXT_IDLEフラグを落とす。
  4. 取得したページキャッシュを返す*1

状況によって異なるが、参考としてページキャッシュ取得時のフラグを下記に示す。

フラグ名 説明
PG_uptodate ページ読み込みが完了している
PG_lru LRUリスト内にある
PG_private privateメンバを使用している

ページキャッシュが存在していない場合

下記の文献を参照。

kernhack.hatenablog.com

次に、ページキャッシュが有効である場合(xa_is_valuetrue)について考える。
pagecache_get_page関数はno_pageラベルにジャンプする。

// 1932:
no_page:
    if (!page && (fgp_flags & FGP_CREAT)) {
        int err;
        if ((fgp_flags & FGP_WRITE) && mapping_can_writeback(mapping))
            gfp_mask |= __GFP_WRITE;
        if (fgp_flags & FGP_NOFS)
            gfp_mask &= ~__GFP_FS;

        page = __page_cache_alloc(gfp_mask);
        if (!page)
            return NULL;

        if (WARN_ON_ONCE(!(fgp_flags & (FGP_LOCK | FGP_FOR_MMAP))))
            fgp_flags |= FGP_LOCK;

        /* Init accessed so avoid atomic mark_page_accessed later */
        if (fgp_flags & FGP_ACCESSED)
            __SetPageReferenced(page);

        err = add_to_page_cache_lru(page, mapping, index, gfp_mask);
        if (unlikely(err)) {
            put_page(page);
            page = NULL;
            if (err == -EEXIST)
                goto repeat;
        }

        /*
        * add_to_page_cache_lru locks the page, and for mmap we expect
        * an unlocked page.
        */
        if (page && (fgp_flags & FGP_FOR_MMAP))
            unlock_page(page);
    }

    return page;
}

もし、有効なページキャッシュが存在していなかった場合、__page_cache_alloc関数でページを確保する。
__page_cache_alloc関数の定義は下記の通りである。

// 299:
#ifdef CONFIG_NUMA
extern struct page *__page_cache_alloc(gfp_t gfp);
#else
static inline struct page *__page_cache_alloc(gfp_t gfp)
{
    return alloc_pages(gfp, 0);
}
#endif

今回の環境はCONFIG_NUMA=nであるので、__page_cache_alloc関数はalloc_pagesを呼び出す。

// 595:
static inline struct page *alloc_pages(gfp_t gfp_mask, unsigned int order)
{
    return alloc_pages_node(numa_node_id(), gfp_mask, order);
}

numa_node_id()はマクロであり、現在使用しているCPU(今回の場合は0)となる。
alloc_pages関数は、alloc_pages_node関数を呼び出す。

// 578:
static inline struct page *alloc_pages_node(int nid, gfp_t gfp_mask,
                        unsigned int order)
{
    if (nid == NUMA_NO_NODE)
        nid = numa_mem_id();

    return __alloc_pages_node(nid, gfp_mask, order);
}
// 564:
static inline struct page *
__alloc_pages_node(int nid, gfp_t gfp_mask, unsigned int order)
{
    VM_BUG_ON(nid < 0 || nid >= MAX_NUMNODES);
    VM_WARN_ON((gfp_mask & __GFP_THISNODE) && !node_online(nid));

    return __alloc_pages(gfp_mask, order, nid, NULL);
}

__alloc_pages_node関数はパラメータのチェックをして、__alloc_pages関数を呼び出す。

__alloc_pages関数については、下記の記事に解説が載っているので、解説は省略する。

kernhack.hatenablog.com

kernhack.hatenablog.com

kernhack.hatenablog.com

__page_cache_alloc関数でページキャッシュを取得した直後の状態

ページキャッシュを取得できたら、add_to_page_cache_lru関数を実行する。
最近参照したデータほど再度参照する可能性が高いということから、LRUリストでも管理している。

add_to_page_cache_lru関数はLRUリストに追加する関数であり、定義は下記のようになっている。

// 977:
int add_to_page_cache_lru(struct page *page, struct address_space *mapping,
                pgoff_t offset, gfp_t gfp_mask)
{
    void *shadow = NULL;
    int ret;

    __SetPageLocked(page);
    ret = __add_to_page_cache_locked(page, mapping, offset,
                     gfp_mask, &shadow);
    if (unlikely(ret))
        __ClearPageLocked(page);
    else {
        /*
        * The page might have been evicted from cache only
        * recently, in which case it should be activated like
        * any other repeatedly accessed page.
        * The exception is pages getting rewritten; evicting other
        * data from the working set, only to cache data that will
        * get overwritten with something else, is a waste of memory.
        */
        WARN_ON_ONCE(PageActive(page));
        if (!(gfp_mask & __GFP_WRITE) && shadow)
            workingset_refault(page, shadow);
        lru_cache_add(page);
    }
    return ret;
}

938行目の__SetPageLockedマクロは引数のpageに対して、PG_lockedフラグを設定する。

フラグ名 説明
PG_locked ページはロック状態

その後、939行目の__add_to_page_cache_locked関数でpageとaddress_space構造体を関連付ける。(概要は省略する)

// 442:
void lru_cache_add(struct page *page)
{
    struct pagevec *pvec;

    VM_BUG_ON_PAGE(PageActive(page) && PageUnevictable(page), page);
    VM_BUG_ON_PAGE(PageLRU(page), page);

    get_page(page);
    local_lock(&lru_pvecs.lock);
    pvec = this_cpu_ptr(&lru_pvecs.lru_add);
    if (pagevec_add_and_need_flush(pvec, page))
        __pagevec_lru_add(pvec);
    local_unlock(&lru_pvecs.lock);
}

lru_cache_add関数では、獲得したページをlruリストにすぐには登録しない。
pagevec_add関数で一旦CPU変数へ登録して、後ほどまとめてlryリストに登録する。

pagevec_add関数が0を返すとき、溜まったページキャッシュを__pagevec_lru_add関数でlruリストに登録する。

バッファキャッシュ用のデータ構造を作成する

Linuxではページキャッシュとは別にバッファキャッシュというデータ構造が存在している。

バッファキャッシュはストレージのデータをメモリにキャッシュするためのものである。
ファイルシステムの観点でいえば、ファイルの実データやinode, ディレクトリなどが対象となっている。 さらに、バッファキャッシュはページキャッシュと紐づけることになっている。

write_beginでは、__block_write_begin関数でバッファキャッシュを準備する。

// 2109:
int block_write_begin(struct address_space *mapping, loff_t pos, unsigned len,
        unsigned flags, struct page **pagep, get_block_t *get_block)
{
    pgoff_t index = pos >> PAGE_SHIFT;
    struct page *page;
    int status;

    page = grab_cache_page_write_begin(mapping, index, flags);
    if (!page)
        return -ENOMEM;

    status = __block_write_begin(page, pos, len, get_block);
    if (unlikely(status)) {
        unlock_page(page);
        put_page(page);
        page = NULL;
    }

    *pagep = page;
    return status;
}

grab_cache_page_write_begin関数によってページキャッシュが取得できたら、__block_write_begin関数でバッファキャッシュ用のデータ構造を作成する。
__block_write_begin関数の定義は下記のとおりである。

// 2057:
int __block_write_begin(struct page *page, loff_t pos, unsigned len,
        get_block_t *get_block)
{
    return __block_write_begin_int(page, pos, len, get_block, NULL);
}

__block_write_begin関数は、仮引数と__block_write_begin_int関数に渡すだけとなっている。
__block_write_begin_int関数の定義は下記の通りである。

// 1972:
int __block_write_begin_int(struct page *page, loff_t pos, unsigned len,
        get_block_t *get_block, const struct iomap *iomap)
{
    unsigned from = pos & (PAGE_SIZE - 1);
    unsigned to = from + len;
    struct inode *inode = page->mapping->host;
    unsigned block_start, block_end;
    sector_t block;
    int err = 0;
    unsigned blocksize, bbits;
    struct buffer_head *bh, *head, *wait[2], **wait_bh=wait;

    BUG_ON(!PageLocked(page));
    BUG_ON(from > PAGE_SIZE);
    BUG_ON(to > PAGE_SIZE);
    BUG_ON(from > to);

    head = create_page_buffers(page, inode, 0);
    blocksize = head->b_size;
    bbits = block_size_bits(blocksize);

    block = (sector_t)page->index << (PAGE_SHIFT - bbits);

    for(bh = head, block_start = 0; bh != head || !block_start;
        block++, block_start=block_end, bh = bh->b_this_page) {
        block_end = block_start + blocksize;
        if (block_end <= from || block_start >= to) {
            if (PageUptodate(page)) {
                if (!buffer_uptodate(bh))
                    set_buffer_uptodate(bh);
            }
            continue;
        }
        if (buffer_new(bh))
            clear_buffer_new(bh);
        if (!buffer_mapped(bh)) {
            WARN_ON(bh->b_size != blocksize);
            if (get_block) {
                err = get_block(inode, block, bh, 1);
                if (err)
                    break;
            } else {
                iomap_to_bh(inode, block, bh, iomap);
            }

            if (buffer_new(bh)) {
                clean_bdev_bh_alias(bh);
                if (PageUptodate(page)) {
                    clear_buffer_new(bh);
                    set_buffer_uptodate(bh);
                    mark_buffer_dirty(bh);
                    continue;
                }
                if (block_end > to || block_start < from)
                    zero_user_segments(page,
                        to, block_end,
                        block_start, from);
                continue;
            }
        }
        if (PageUptodate(page)) {
            if (!buffer_uptodate(bh))
                set_buffer_uptodate(bh);
            continue; 
        }
        if (!buffer_uptodate(bh) && !buffer_delay(bh) &&
            !buffer_unwritten(bh) &&
             (block_start < from || block_end > to)) {
            ll_rw_block(REQ_OP_READ, 0, 1, &bh);
            *wait_bh++=bh;
        }
    }
    /*
    * If we issued read requests - let them complete.
    */
    while(wait_bh > wait) {
        wait_on_buffer(*--wait_bh);
        if (!buffer_uptodate(*wait_bh))
            err = -EIO;
    }
    if (unlikely(err))
        page_zero_new_buffers(page, from, to);
    return err;
}

__block_write_begin_int関数の最初に引数のチェックがある。
下記のどれかを満たしていない場合、BUG_ONマクロでカーネルパニックさせる。

  • ページにロックがかかっていること
  • 開始オフセットがページサイズより小さいこと
  • 終了オフセットがページサイズより小さいこと
  • 終了オフセットが開始オフセットより大きいこと

バッファキャッシュを準備する

引数をチェックし問題がなければ、create_page_buffers関数でbuffer_headを生成する。

// 1672:
static struct buffer_head *create_page_buffers(struct page *page, struct inode *inode, unsigned int b_state)
{
    BUG_ON(!PageLocked(page));

    if (!page_has_buffers(page))
        create_empty_buffers(page, 1 << READ_ONCE(inode->i_blkbits),
                     b_state);
    return page_buffers(page);
}

すでにバッファキャッシュが確保済みである場合は、page_has_buffersマクロがTrueとなりバッファキャッシュを再度確保せずに終了する。
そうでなければ、create_empty_buffers関数でバッファキャッシュの作成を試みる。

// 1555:
void create_empty_buffers(struct page *page,
            unsigned long blocksize, unsigned long b_state)
{
    struct buffer_head *bh, *head, *tail;

    head = alloc_page_buffers(page, blocksize, true);
    bh = head;
    do {
        bh->b_state |= b_state;
        tail = bh;
        bh = bh->b_this_page;
    } while (bh);
    tail->b_this_page = head;

    spin_lock(&page->mapping->private_lock);
    if (PageUptodate(page) || PageDirty(page)) {
        bh = head;
        do {
            if (PageDirty(page))
                set_buffer_dirty(bh);
            if (PageUptodate(page))
                set_buffer_uptodate(bh);
            bh = bh->b_this_page;
        } while (bh != head);
    }
    attach_page_private(page, head);
    spin_unlock(&page->mapping->private_lock);
}

create_empty_buffers関数の処理は大きく分けて下記のとおりである。

  • RAM上にバッファキャッシュを確保する
  • バッファキャッシュ間で循環ループを作成する
  • バッファキャッシュとファイルシステムのデータを同期させる

バッファキャッシュを確保する

バッファキャッシュの確保は、alloc_page_buffers関数で実施する。
ただし、今回は説明を簡略化するためにcgroupの話は省着する。

ブロックサイズが1024, ページサイズが4096の場合、4つのbuffer_headを生成する。

alloc_page_buffers関数によって得られるデータ構造のイメージは下記のとおりである。

バッファキャッシュの確保

alloc_page_buffers関数の定義は下記のとおりである。

// 814:
struct buffer_head *alloc_page_buffers(struct page *page, unsigned long size,
        bool retry)
{
    struct buffer_head *bh, *head;
    gfp_t gfp = GFP_NOFS | __GFP_ACCOUNT;
    long offset;
    struct mem_cgroup *memcg, *old_memcg;

    if (retry)
        gfp |= __GFP_NOFAIL;

    /* The page lock pins the memcg */
    memcg = page_memcg(page);
    old_memcg = set_active_memcg(memcg);

    head = NULL;
    offset = PAGE_SIZE;
    while ((offset -= size) >= 0) {
        bh = alloc_buffer_head(gfp);
        if (!bh)
            goto no_grow;

        bh->b_this_page = head;
        bh->b_blocknr = -1;
        head = bh;

        bh->b_size = size;

        /* Link the buffer to its page */
        set_bh_page(bh, page, offset);
    }
out:
    set_active_memcg(old_memcg);
    return head;
/*
 * In case anything failed, we just free everything we got.
 */
no_grow:
    if (head) {
        do {
            bh = head;
            head = head->b_this_page;
            free_buffer_head(bh);
        } while (head);
    }

    goto out;
}

alloc_page_buffers関数では、831行目のwhile文がメインとなる。

  1. alloc_buffer_head関数でbuffer_headを取得する
  2. 確保したbhをひとつ前のheadに繋げる
  3. set_bh_page関数で、ページキャッシュとバッファを紐づける

alloc_buffer_head関数は、kmem_cache_zalloc関数をデータを確保する。

// 3334:
struct buffer_head *alloc_buffer_head(gfp_t gfp_flags)
{
    struct buffer_head *ret = kmem_cache_zalloc(bh_cachep, gfp_flags);
    if (ret) {
        INIT_LIST_HEAD(&ret->b_assoc_buffers);
        spin_lock_init(&ret->b_uptodate_lock);
        preempt_disable();
        __this_cpu_inc(bh_accounting.nr);
        recalc_bh_state();
        preempt_enable();
    }
    return ret;
}
EXPORT_SYMBOL(alloc_buffer_head);

また、set_bh_page関数は下記の通りとなっている。

// 1442:
void set_bh_page(struct buffer_head *bh,
        struct page *page, unsigned long offset)
{
    bh->b_page = page;
    BUG_ON(offset >= PAGE_SIZE);
    if (PageHighMem(page))
        /*
        * This catches illegal uses and preserves the offset:
        */
        bh->b_data = (char *)(0 + offset);
    else
        bh->b_data = page_address(page) + offset;
}

循環ループを作成する

alloc_page_buffers関数でbuffer_headのリストを生成した後、各データのb_state変数を設定する。

// 1561:
    bh = head;
    do {
        bh->b_state |= b_state;
        tail = bh;
        bh = bh->b_this_page;
    } while (bh);
    tail->b_this_page = head;

その後、末尾のbuffer_headと先頭のbuffer_headをつなげる。

循環リストの更新

バッファキャッシュとファイルシステムを紐付ける

create_page_buffers関数によってバッファキャッシュを作成した後、取得したバッファキャッシュに書き込み対象ファイルのデータを読み込む。

// 1989:
    head = create_page_buffers(page, inode, 0);
    blocksize = head->b_size;
    bbits = block_size_bits(blocksize);

    block = (sector_t)page->index << (PAGE_SHIFT - bbits);

    for(bh = head, block_start = 0; bh != head || !block_start;
        block++, block_start=block_end, bh = bh->b_this_page) {
        block_end = block_start + blocksize;
        if (block_end <= from || block_start >= to) {
            if (PageUptodate(page)) {
                if (!buffer_uptodate(bh))
                    set_buffer_uptodate(bh);
            }
            continue;
        }
        if (buffer_new(bh))
            clear_buffer_new(bh);
        if (!buffer_mapped(bh)) {
            WARN_ON(bh->b_size != blocksize);
            if (get_block) {
                err = get_block(inode, block, bh, 1);
                if (err)
                    break;
            } else {
                iomap_to_bh(inode, block, bh, iomap);
            }

            if (buffer_new(bh)) {
                clean_bdev_bh_alias(bh);
                if (PageUptodate(page)) {
                    clear_buffer_new(bh);
                    set_buffer_uptodate(bh);
                    mark_buffer_dirty(bh);
                    continue;
                }
                if (block_end > to || block_start < from)
                    zero_user_segments(page,
                        to, block_end,
                        block_start, from);
                continue;
            }
        }
        if (PageUptodate(page)) {
            if (!buffer_uptodate(bh))
                set_buffer_uptodate(bh);
            continue; 
        }
        if (!buffer_uptodate(bh) && !buffer_delay(bh) &&
            !buffer_unwritten(bh) &&
             (block_start < from || block_end > to)) {
            ll_rw_block(REQ_OP_READ, 0, 1, &bh);
            *wait_bh++=bh;
        }
    }
    /*
    * If we issued read requests - let them complete.
    */
    while(wait_bh > wait) {
        wait_on_buffer(*--wait_bh);
        if (!buffer_uptodate(*wait_bh))
            err = -EIO;
    }
    if (unlikely(err))
        page_zero_new_buffers(page, from, to);
    return err;
}

1989行の実行直後ではページディスクリプタは下記のようなフラグが立っている。

フラグ名 説明
PG_uptodate ページ読み込みが完了している
PG_lru LRUリスト内にある
PG_private privateメンバを使用している

create_page_buffers関数の以降の処理は下記の通りとなる。

  1. ページキャッシュにPG_uptodate*2が付与されていた場合、バッファキャッシュにもBH_Uptodateを付与し、次のバッファキャッシュを確認する
  2. バッファキャッシュにBH_New*3が付与されていた場合、フラグをクリアする
  3. バッファキャッシュにBH_mapped*4が付与されていない場合、引数のget_blockハンドラを実行してバッファキャッシュにマッピングする
  4. get_blockハンドラでバッファキャッシュを新規にマッピングした場合、ブロックデバイスのバッファをフラッシュする (参考)
  5. get_blockハンドラでバッファキャッシュを新規にマッピングした場合かつページディスクリプタPG_Uptodateである場合、BH_Newフラグを落としBH_Uptodatemark_buffer_dirty関数を実行して次のバッファキャッシュを確認する。
  6. get_blockハンドラでバッファキャッシュを新規にマッピングした場合で、ページキャッシュのゼロ化が必要な場合、ゼロ化し次のバッファキャッシュを確認する
  7. ページディスクリプタPG_Uptodateであるにもかかわらず、バッファキャッシュがBH_Uptodateでない場合、BH_Uptodateフラグを立てて次のバッファキャッシュを確認する。
  8. バッファキャッシュにBH_UptodateBH_Delay*5かつBH_Unwritten*6の何れも立っていない場合、読み込みのためのIOを発行する。

各バッファキャッシュの更新を終えた後、読み込みのためのIOを発行していた場合は、2048行目のwait_on_bufferインライン関数でIOの完了を待つ。

write_begin処理内で失敗した場合

これまでの処理で何かしらも問題が発生した場合、block_write_begin関数の返り値に負の値が返る。

// 883:
static int
ext2_write_begin(struct file *file, struct address_space *mapping,
        loff_t pos, unsigned len, unsigned flags,
        struct page **pagep, void **fsdata)
{
    int ret;

    ret = block_write_begin(mapping, pos, len, flags, pagep,
                ext2_get_block);
    if (ret < 0)
        ext2_write_failed(mapping, pos + len);
    return ret;
}

その場合、ext2_write_failed関数を呼び、ページキャッシュの解放とaddress_spaceのロールバックをする。詳細は割愛。

// 59:
static void ext2_write_failed(struct address_space *mapping, loff_t to)
{
    struct inode *inode = mapping->host;

    if (to > inode->i_size) {
        truncate_pagecache(inode, inode->i_size);
        ext2_truncate_blocks(inode, inode->i_size);
    }
}

おわりに

本記事では、ext2ファイルシステムwrite_begin操作(ext2_write_begin)を解説した。
write_begin操作は、write_iter操作でページキャッシュに書き込むための準備をするための操作である。

変更履歴

  • 2021/4/24: 記事公開
  • 2022/09/19: カーネルバージョンを5.15に変更

参考

*1:find_subpage関数については調査中

*2:バッファのデータが有効であることを示すフラグ

*3:作成後、まだ一度もアクセスされていないことを表すフラグ

*4:バッファキャッシュをディスクにマッピングしていることを示すフラグ

*5:まだ対応付けしていないことを表すフラグ

*6:確保済みだが書き込みされていないことを表すフラグ