LoginSignup
15
11

More than 5 years have passed since last update.

I/OスケジューラのCFQとクラスを理解する

Last updated at Posted at 2017-04-05

はじめに

POSIXのタスクのスケジューリングポリシーに類似させて作られたLinux独自のI/Oスケジューリングクラスについての理解を深めようという趣旨の雑記です。なお、Linux-4.10くらいとutil-linux-2.29.2くらいを見ています。

概要

Linuxはタスク(スレッド)ごとにioprio値(io_context)を持つ。これはクラスとデータを組み合わせた値となっている。クラスは下記の4つ。

  • IOPRIO_CLASS_NONE
  • IOPRIO_CLASS_RT
  • IOPRIO_CLASS_BE
  • IOPRIO_CLASS_IDLE

IOPRIO_CLASS_RTIOPRIO_CLASS_BEの時はさらに0から7までの優先度をデータとして持つ。ioprioはこれらを下記のようなマクロで組み合わせて表示される。kernel/include/linux/ioprio.hより、

ioprio.h
 10 #define IOPRIO_CLASS_SHIFT      (13)
 11 #define IOPRIO_PRIO_MASK        ((1UL << IOPRIO_CLASS_SHIFT) - 1)
 12 
 13 #define IOPRIO_PRIO_CLASS(mask) ((mask) >> IOPRIO_CLASS_SHIFT)
 14 #define IOPRIO_PRIO_DATA(mask)  ((mask) & IOPRIO_PRIO_MASK)
 15 #define IOPRIO_PRIO_VALUE(class, data)  (((class) << IOPRIO_CLASS_SHIFT) | data)

何も設定していない場合の初期値はIOPRIO_CLASS_NONEで、fork(2)などでこの値は引き継ぐ。変更するには、システムコールのioprio_set(2)、コマンドツールのionice(1)などを使う。ここで設定したioprio値はI/Oスケジューラで使われる。

なお例外はあって、clone(2)CLONE_IOつけて呼ぶと、ioprio値(taskのio_context)を共有するタスクを作れる。kernel/kernel/fork.c:copy_io()より、

fork
1259         /*
1260          * Share io context with parent, if CLONE_IO is set
1261          */
1262         if (clone_flags & CLONE_IO) {
1263                 ioc_task_link(ioc);
1264                 tsk->io_context = ioc;
1265         } else if (ioprio_valid(ioc->ioprio)) {
1266                 new_ioc = get_task_io_context(tsk, GFP_KERNEL, NUMA_NO_NODE);
1267                 if (unlikely(!new_ioc))
1268                         return -ENOMEM;
1269 
1270                 new_ioc->ioprio = ioc->ioprio;
1271                 put_io_context(new_ioc);
1272         }

ちなみにioprio_set(2)は、glibcライブラリ関数もマクロも提供されないので、kernelのioprio.hからマクロをコピペして持って来つつSYSCALL部分を自分で書かないといけない。現にutil-linuxのioniceはそうしている。

ionice.c
static inline int ioprio_set(int which, int who, int ioprio)
{
    return syscall(SYS_ioprio_set, which, who, ioprio);
}

static inline int ioprio_get(int which, int who)
{
    return syscall(SYS_ioprio_get, which, who);
}

enum {
    IOPRIO_CLASS_NONE,
    IOPRIO_CLASS_RT,
    IOPRIO_CLASS_BE,
    IOPRIO_CLASS_IDLE,
};

enum {
    IOPRIO_WHO_PROCESS = 1,
    IOPRIO_WHO_PGRP,
    IOPRIO_WHO_USER,
};

#define IOPRIO_CLASS_SHIFT  (13)
#define IOPRIO_PRIO_MASK    ((1UL << IOPRIO_CLASS_SHIFT) - 1)

#define IOPRIO_PRIO_CLASS(mask) ((mask) >> IOPRIO_CLASS_SHIFT)
#define IOPRIO_PRIO_DATA(mask)  ((mask) & IOPRIO_PRIO_MASK)
#define IOPRIO_PRIO_VALUE(class, data)  (((class) << IOPRIO_CLASS_SHIFT) | data)

IOPRIO_CLASS_NONEというクラス

ろくにドキュメントもなく断言すると議論を呼ぶこと承知で、IOPRIO_CLASS_NONEは「invalid」とか「null」だとかいう意味ではなく、そういう名前の1つのクラスだと考えた方がよいと思われる。util-linux/schedutils/ionice.c:209付近より、

ionice.c
    switch (ioclass) {
        case IOPRIO_CLASS_NONE:
            if ((set & 1) && !tolerant)
                warnx(_("ignoring given class data for none class"));
            data = 0;
            break;
        case IOPRIO_CLASS_RT:
        case IOPRIO_CLASS_BE:
            break;
        case IOPRIO_CLASS_IDLE:
            if ((set & 1) && !tolerant)
                warnx(_("ignoring given class data for idle class"));
            data = 7;
            break;
        default:
            if (!tolerant)
                warnx(_("unknown prio class %d"), ioclass);
            break;
    }

IOPRIO_CLASS_NONEを指定しようとした時は、優先度を入れようとしていないことを確認して0にしているくらいで、特にIOPRIO_CLASS_BEに倒したりエラーにしたりはしていない。後にもいろいろ関連すること記述するが、少なくともionice(1)を見る限り、IOPRIO_CLASS_NONEという種類のクラスがあると考えた方がよい。

I/Oスケジューラの種類

LinuxではブロックデバイスごとにI/Oスケジューラを設定できる。動的に変更することもできる。どれになるかは下記の順で決まる。

  1. /sys/block/sda1/queue/schedulerに値を書く (詳細はググればたくさん見つかるのでそちらに任せる)
  2. cmdlineに elevator=cfqなどを足す
  3. コンパイルオプションCONFIG_DEFAULT_IOSCHED="cfq"などを足す

一般的な環境だとcfq, noop, deadlineの3つが、最近のAndroidだとさらにrow(Read Over Write scheduling algorithm)が使える。ただし、これらの中でI/Oスケジューラのioprioが有効に使われるのはcfqとrowだけ。さらに一般的に使われているかどうかやカスタマイズ性を考えると、通常はcfq限定と考えた方がいい。

他にもVR, BFQ, FIOPS, SIO, SIOplus, ZENなどがあるらしいけど、msmのkernelを見る限り公式に登録されていることは確認できなかった。

I/Oスケジューラの目的

ユースケースにより結果が変わるが、I/Oスケジューラは「できるだけ早く」処理できるものを選ぶことになる。「早い」というと少々曖昧だが、大きく2つのポイントが大事にある。

1つめがthrouthput(スループット)。よくベンチマークとかでMiB/secIOPSで示されるやつで、単位時間当たりの処理できた数。電車でたとえると1時間当たりに運べる人の数。

2つめがlatency(レイテンシ)。要求を出してから処理されるまでにかかる時間。ベンチマークでは示されることが少ないので気にしない人もいるが、多くの場合throuthputよりもこちらの方が重要なくらいだったりする。電車でたとえると駅に着いてから待ち時間含め目的地に着くまでの時間。

他にも、輻輳時に破綻しない(ラッシュ時に電車が遅れたりしない)、要求ごとに優先度を設定できる(普通・急行と分けて輸送する)、最悪時間を設定できる(どの駅でも最低N本/h電車が止まる)、滞りなく大量のwriteを処理することに特化する、などのポイントもある。ユースケースと目的に合わせて適切なI/Oスケジューラを選んだりパラメータチューニングすることになる。

CFQとクラスの詳細

CFQの概要

CFQ(Completely Fair Queuing)は従来型のヘッドのシークや回転待ちを考慮したHDDを主なターゲットとしつつ、ioprioの考え方を取り入れて作られた。(というのがCFQ version 3とかなんとか) さらに、名前にも現れているけど、タスクスケジューリングのCFS(Completely Fair Scheduler)に類似した、ioprio値からスケーリングさせた値で公平にタイムスライスを割当てるようなスケジューリングが取り入れられている。sysfsのfifo_expire_sync, fifo_expire_asyncを使って、待ちすぎになってしまわないようにする考え方もある。

CFQのソースコード上の略語集

  • elevator - Requestやrqを入れ替え可能なI/Oスケジューラに標準的に渡すために作られた機構
  • rr ----- Round Robin、cfqqをディスパッチするときに使うRBツリーとほぼ同じ意味
  • rb ----- Red Blackツリー、データ構造の話、workloadに合わせて複数持つ、cfqqをrbツリーに入れて管理しディスパッチするときに使う
  • rq ----- Request Queue、Request(要求)を複数詰めるためのもの、I/Oスケジューラは上から来たrqをどのように下に渡すかが仕事となる
  • CFQ ---- Completely Fair Queuing Scheduler
  • cfgd --- struct cfq_data、CFQの1つのインスタンス、1つのブロックデバイスに1つ存在する
  • icq ---- struct io_cq、io_context(タスク毎に持つ)とrqを結びつける
  • cic ---- struct cfq_io_cq、先頭のメンバにicqを内包する、io_contextとcfqqを結びつける
  • cfqq --- struct cfq_queue、RQのCFQ内での表現、RQと1対1に対応する
  • wl ----- workload、複数存在する、rbツリーにほぼ等しい

なお、rqの中のrequestを詰めるための部分もRBツリーになっている模様。elevator側で処理されるのでCFQのコードにはあまり現れない。(が、elv_rb_add()などの関数は見える。)

CFQのqueueの持ち方

  • タスク(io_context)毎に作られるsync queue
  • CFQインスタンスに対し1つだけ持つIOPRIO_CLASS_RTと優先度に応じた8つのasync queue
  • CFQインスタンスに対し1つだけ持つIOPRIO_CLASS_BEと優先度に応じた8つのasync queue
  • CFQインスタンスに対し1つだけ持つIOPRIO_CLASS_IDLEと優先度に応じた1つのasync queue
  • CFQインスタンスに対し1つだけ持つメモリ確保できないくらい緊急事態の時に使うoom queue

だけqueueが用意される(async_cfqq[2][IOPRIO_BE_NR]とasync_idle_cfqqとoom_cfqqとタスク毎に動的に作られる分)。別のブロックデバイスには別のCFQインスタンスが割当たるので、async queueはブロックデバイスを横断しての共有はされない。

タスク(io_context)ははじめてqueueを使うときに、上記のqueueからsync用に1つ、async用に1つ取ってきて設定する。このとき、タスク毎に作られるsync queueは、io_contextのioprioの値に設定される。あとからioprio_set(2)した時は変更に追随するが、あとからsched_setscheduler(2)した時は変更に追随しないため、後述するバグが存在する。

なお、READ要求はsyncキューになり、O_SYNCつきのwrite要求もsyncキュー、それ以外のwrite要求(遅延write)がasyncキューとなる。このため、ioprio値の働き方がreadとwriteとで大きく変わるので注意が必要。というか、遅延write時はkworkerやjbd2/sda1がCFQにwrite要求を出すので、アプリのタスクのio_contextでasync queueが使われるのは通常はないと思われる。(全く来ないわけじゃないようだけど何すれば来るかわからなかった)。sync系(sync(2), fsync(2), fdatasync(2), syncfs(2), sync_file_range(2), sync_file_range2(2), msync(2))はdirtyなバッファキャッシュをwriteする要求がsyncをしたio_contextのsyncキューに入る。

CFQworkload

queueのディスパッチを管理するために、queueを入れるためのworkloadと呼ばれるRBツリーを持つ。workloadは全部で7つ存在する(service_trees[2][3]とservice_tree_idle)

  • RT_WORKLOADASYNC_WORKLOAD
  • RT_WORKLOADSYNC_NOIDLE_WORKLOAD
  • RT_WORKLOADSYNC_WORKLOAD
  • BE_WORKLOADASYNC_WORKLOAD
  • BE_WORKLOADSYNC_NOIDLE_WORKLOAD
  • BE_WORKLOADSYNC_WORKLOAD
  • IDLE_WORKLOAD (1つだけ)

要求が入ったqueueは上記7つのうちのいずれかのworkload(RBツリー)に入れられる。RBツリーのrb_keyは、slice時間に基づく優先度絡めたよくわからないスケール計算した値を用いつつも、おおむねFIFOの順になるよう入れられる。ディスパッチするときにqueueをどうやって選ぶかはchoose_wl_class_and_type()で決まる。細かい条件が多くコードを抜粋すると長くなるので省略しつつ、おおざっぱには、

  1. 優先度高いCLASSから探す(RT_WORKLOAD, BE_WORKLOAD, IDLE_WORKLOAD)
  2. それぞれのCLASSでは、workloadに要求がありworkloadのexpire時間を過ぎていなくてIDLE期間中ならば、同じworkloadにとどまろうとする。
  3. そうでない場合(new_workload:)は、同じCLASSの中にある全workloadの中からrb_keyがもっとも小さいものを含むworkloadを選ぶ(cfq_choose_wl_type())、つまり3つのRBツリー(ASYNC_WORKLOAD, SYNC_NOIDLE_WORKLOAD, SYNC_WORKLOAD)を巡回する。
  4. workloadのRBツリーの先頭にあるqueueをディスパッチ対象に選ぶ。
  5. queueのslice時間が過ぎていない限り同じqueueでディスパッチを続ける。ただしfifo_expireした(期限までに処理が始まらなかった)要求があったら、slice時間を延長してそのqueueでディスパッチを続ける。すみませんウソでした。fifo_expireした要求を先に処理する(cfq_check_fifo())。ただしqueueをまたいだfifo_expireの確認までは行っていない模様。

なおIDLE期間とは、HDDのシークを考慮したもので、「今は要求がないけどもうちょっと待てば前の続きの要求が来てしかもそれがシーケンシャル(HDDシークのペナルティ少ない)な可能性が高い」という考えのもとで、あえて何もせずに待つ期間のこと。IOPRIO_CLASS_IDLEやIDLE_WORKLOADとは関係ないので注意。

CFQのqueueのpreempt

CFQではworkloadをたどりながらqueueを選ぶものの、preempt(強制的に横取りあと勝ち)の仕組みも持つ。cfq_should_preempt()付近がこれに該当する。

  1. newがclass_idleならfalse
  2. oldがclass_idleならtrue
  3. oldがclass_rtでnewがclass_rtじゃなかったらfalse
  4. newがsync queueでoldがasync queueでoldがcfq_cfqq_must_dispatch()じゃなかったらtrue
  5. cfqg_is_descendant()じゃなかったらfalse (CONFIG_CFQ_GROUP_IOSCHEDが無効ならcfqg_is_descendant()は常にtrue)
  6. oldがslice時間を使い切っていたらtrue
  7. newがclass_rtでoldがclass_rtじゃなかったらtrue
  8. old,newともにSYNC_NOIDLE_WORKLOADならtrue
  9. newにREQ_PRIOつき要求が来たらtrue (REQ_PRIOはext4のjournalの書き込み)
  10. ....

などとなっており、正確に追うのもつらい内容になっている。

なお、old, newともにclass_rtでsync queueの場合はおおむねpreemptが起こらない。つまり、同じIOPRIO_CLASS_RTでも優先度の高い側が横取りすることはできない。優先度が高いとrb_keyは小さくなるためcfq_choose_wl_type()で選ばれやすくはなるものの、必ずしも優先度の順になるわけではないので注意が必要と思われる。(タスクスケジュールの場合、SCHED_FIFO, SCHED_RRはpriority高い側が必ず横取りできる)

また、preemptが起こるとworkloadも切り替わる(cfq_preempt_queue())。preemptの前のworkloadを覚えておいたり戻ったりする機能がないので、preemptが起こるとworkloadのスケジュールにも影響を与える。

CFQprio_trees

prio_trees[CFQ_PRIO_LISTS]というものも存在し、あたかも優先度に基づいてスケジューリングする仕組みに使われそうな名前に見えるが、これは、queueをまたいだmergeのために使われ(cfq_close_cooperator())、スケジューリングには直接影響しない模様。

prio_trees[CFQ_PRIO_LISTS]は、優先度ごとにセクタ位置をkeyとしてRBツリーとして管理される。queueをまたいだmergeは、クラスも優先度も同じsync queueの場合にのみ行われ、要求位置のセクタが近い(CFQQ_CLOSE_THR(==8*1024)セクタ)場合に行われる。

なお、同じqueueの中でのmergeはもっと積極的に行われている模様。elevatorと連携したmergeの名のつくCFQのエントリ関数がたくさん登録されている(今回ここは詳細まで追っていない)

特殊なIOPRIO_CLASS_NONE

※自信ないところなので詳しい方捕捉くださると助かります

IOPRIO_CLASS_NONE(何も指定していない)の場合、タスクのスケジューリングポリシーに従って暗黙に選択される。kernel/block/cfq-iosched.c:cfq_init_prio_data()より、

cfq-iosched.c
3679         switch (ioprio_class) {
3680         default:
3681                 printk(KERN_ERR "cfq: bad prio %x\n", ioprio_class);
3682         case IOPRIO_CLASS_NONE:
3683                 /*
3684                  * no prio set, inherit CPU scheduling settings
3685                  */
3686                 cfqq->ioprio = task_nice_ioprio(tsk);
3687                 cfqq->ioprio_class = task_nice_ioclass(tsk);
3688                 break;

task_nice_ioprio(), task_nice_ioclass()kernel/include/linux/ioprio.hより、

ioprio.h
 48 /*
 49  * if process has set io priority explicitly, use that. if not, convert
 50  * the cpu scheduler nice value to an io priority
 51  */
 52 static inline int task_nice_ioprio(struct task_struct *task)
 53 {
 54         return (task_nice(task) + 20) / 5;
 55 }
 56 
 57 /*
 58  * This is for the case where the task hasn't asked for a specific IO class.
 59  * Check for idle and rt task process, and return appropriate IO class.
 60  */
 61 static inline int task_nice_ioclass(struct task_struct *task)
 62 {
 63         if (task->policy == SCHED_IDLE)
 64                 return IOPRIO_CLASS_IDLE;
 65         else if (task->policy == SCHED_FIFO || task->policy == SCHED_RR)
 66                 return IOPRIO_CLASS_RT;
 67         else
 68                 return IOPRIO_CLASS_BE;
 69 }

となり、sync queueはタスクのスケジューリングポリシーで変わる。ただこのあたりについてはろくにドキュメントがない。ionice(1)のマニュアルより、

For kernels after 2.6.26 with CFQ io scheduler a process that has not asked for an io priority 
inherits CPU scheduling class. The io priority is derived from the cpu nice level of the process 
(same as before kernel 2.6.26).

くらい。英語のStackOverflowにこの点を質問するやつがあった気がするけど、今改めて探して見る限り見つからなかった。

なおasync queueの場合は、kernel/block/cfq-iosched.c:cfq_async_queue_prio()より、

cfq-iosched.c
3808 static struct cfq_queue **
3809 cfq_async_queue_prio(struct cfq_group *cfqg, int ioprio_class, int ioprio)
3810 {
3811         switch (ioprio_class) {
3812         case IOPRIO_CLASS_RT:
3813                 return &cfqg->async_cfqq[0][ioprio];
3814         case IOPRIO_CLASS_NONE:
3815                 ioprio = IOPRIO_NORM;
3816                 /* fall through */
3817         case IOPRIO_CLASS_BE:
3818                 return &cfqg->async_cfqq[1][ioprio];
3819         case IOPRIO_CLASS_IDLE:
3820                 return &cfqg->async_idle_cfqq;
3821         default:
3822                 BUG();
3823         }
3824 }

と、IOPRIO_CLASS_NONEのIOPRIO_NORMが選ばれるが、先に書いた通り、通常はkworkerやjbd2/sda1など以外はasync queueを使わないので、こちらのケースはあまり関係がない。

IOPRIO_CLASS_NONEのバグと思われる挙動

先のionice(1)のドキュメントのように「IOPRIO_CLASS_NONEだとCPU scheduling classから継承する」のが意図した仕様と思われるが、実際に試すと、IOPRIO_CLASS_NONEの場合「はじめてI/OスケジューラにアクセスしたときのCPUスケジューリングポリシーを継承して決まる」ように見える。つまり、一度でもI/Oスケジューラにアクセスすると、あとでCPUスケジューリングポリシーだけ変えてもI/Oの方はもう変わらないという挙動になる。以下再現コード。

mysched.h
#ifndef __MYSCHED_H__
#define __MYSCHED_H__

typedef int32_t  s32;
typedef uint32_t u32;
typedef uint64_t u64;
/* copy and paste from kernel's "include/linux/sched.h" */
struct sched_attr {
  u32 size;

  u32 sched_policy;
  u64 sched_flags;

  /* SCHED_NORMAL, SCHED_BATCH */
  s32 sched_nice;

  /* SCHED_FIFO, SCHED_RR */
  u32 sched_priority;

  /* SCHED_DEADLINE */
  u64 sched_runtime;
  u64 sched_deadline;
  u64 sched_period;
};

static inline int sched_setattr(pid_t pid, struct sched_attr *attr, unsigned int flags) {
  return syscall(SYS_sched_setattr, pid, attr, flags);
}
static inline int sched_getattr(pid_t pid, struct sched_attr *attr, unsigned int size, unsigned int flags) {
  return syscall(SYS_sched_getattr, pid, attr, size, flags);
}

#endif /* __MYSCHED_H__ */
mysched.h
#ifndef __MYSCHED_H__
#define __MYSCHED_H__

typedef int32_t  s32;
typedef uint32_t u32;
typedef uint64_t u64;
/* copy and paste from kernel's "include/linux/sched.h" */
struct sched_attr {
  u32 size;

  u32 sched_policy;
  u64 sched_flags;

  /* SCHED_NORMAL, SCHED_BATCH */
  s32 sched_nice;

  /* SCHED_FIFO, SCHED_RR */
  u32 sched_priority;

  /* SCHED_DEADLINE */
  u64 sched_runtime;
  u64 sched_deadline;
  u64 sched_period;
};

static inline int sched_setattr(pid_t pid, struct sched_attr *attr, unsigned int flags) {
  return syscall(SYS_sched_setattr, pid, attr, flags);
}
static inline int sched_getattr(pid_t pid, struct sched_attr *attr, unsigned int size, unsigned int flags) {
  return syscall(SYS_sched_getattr, pid, attr, size, flags);
}

#endif /* __MYSCHED_H__ */
sched_test.c
#include  <stdio.h>
#include  <stdlib.h>
#include  <string.h>
#include  <stdint.h>
#include  <fcntl.h>
#include  <unistd.h>
#include  <err.h>
#include  <sched.h>
#include  <linux/sched.h>
#include  <sys/types.h>
#include  <sys/syscall.h>

#include  "myioprio.h"
#include  "mysched.h"

#define mywarn(fmt,...)  warn("[%s:%d] " fmt, __FUNCTION__ , __LINE__,##__VA_ARGS__)
static pid_t gettid(void)
{
  return syscall(SYS_gettid);
}

static void write_file(const char *path, int do_sync) {
  int fd;
  char buf[4096];
  fd = open (path, O_RDWR | O_CREAT);
  if (fd < 0) {
    mywarn("open %s", path);
    return;
  }
  memset (buf, 0x5a, sizeof(buf));
  write (fd, buf, sizeof(buf));
  close(fd);
  if (do_sync) {
    sync();
  }
  printf("write %s by %d\n", path, gettid());
}

static void my_set_ioprio(int my_class){
  int my_prio, ret, my_data;
  switch (my_class) {
  case IOPRIO_CLASS_NONE:
  case IOPRIO_CLASS_IDLE:
    my_data = 0;
    break;
  default:
    my_data = IOPRIO_NORM;
    break;
  }
  my_prio = IOPRIO_PRIO_VALUE(my_class, my_data);
  ret = ioprio_set(IOPRIO_WHO_PROCESS, 0, my_prio);
  if (ret) {
    mywarn("ioprio_set %d", my_class);
  }
}

static void my_sched_setattr_deadline(){
  int ret;
  struct sched_attr attr;
  memset (&attr, 0, sizeof(attr));
  ret = sched_getattr(0, &attr, sizeof(attr), 0);
  if (ret) {
    mywarn("sched_getattr deadline");
  }

}

static void my_set_sched(int policy){
  struct sched_param param;
  int ret;
  memset (&param, 0, sizeof(param));
  switch(policy){
  case SCHED_DEADLINE:
    // cannot use sched_setscheduler()
    my_sched_setattr_deadline();
    return;
  case SCHED_FIFO:
  case SCHED_RR:
    param.sched_priority = sched_get_priority_max(policy);
    break;
  default:
    break;
  }
  ret = sched_setscheduler(0, policy, &param);
  if (ret) {
    mywarn("sched_setscheduler %d", policy);
  }
}

int main(int argc, char* argv[]){
  const char file_path[] = "/tmp/mnt/hoge.txt";
  int scenario, do_sync;

  if (argc < 3) {
    errx(1, "usage: mytest (scenario) (do_sync)");
  }
  scenario = atoi(argv[1]) ? 1:0;
  do_sync  = atoi(argv[2]) ? 1:0;

  switch (scenario) {
  case 0:
  default:
    my_set_sched(SCHED_FIFO);
    write_file(file_path, do_sync); // 0a
    my_set_ioprio(IOPRIO_CLASS_BE);
    write_file(file_path, do_sync); // 0b
    my_set_sched(SCHED_OTHER);
    write_file(file_path, do_sync); // 0c
    my_set_ioprio(IOPRIO_CLASS_NONE);
    write_file(file_path, do_sync); // 0d
    break;
  case 1:
    write_file(file_path, do_sync); // 1a
    my_set_sched(SCHED_FIFO);
    write_file(file_path, do_sync); // 1b
    my_set_ioprio(IOPRIO_CLASS_BE);
    write_file(file_path, do_sync); // 1c
    my_set_sched(SCHED_OTHER);
    write_file(file_path, do_sync); // 1d
    my_set_ioprio(IOPRIO_CLASS_NONE);
    write_file(file_path, do_sync); // 1e
    break;
  }

  return 0;
}

プログラムをディスクからreadするのかキャッシュから読むのか、だけでも挙動が変わったので、さすがにバグじゃないかと思っている。テストプログラムを実行するとき、はじめて実行する(execに読み込みが発生する)場合は0aでもIOPRIO_CLASS_NONEのIOPRIO_NORMが選ばれるが、2回目以降の(execに読み込みが発生しない)場合はIOPRIO_CLASS_RTの4が選ばれる。なおsyncしない場合は先の通りkworkerやjbd2/sda1のasync queueが使われるので、この話は関係しない。

check_ioprio_changed()でioprio値が変わっていないことを確認する箇所があるが、ここでIOPRIO_CLASS_NONEの場合はtask->policy(とさらにnice値?)まで確認しないといけないのではないかと思われる。ただし、ドキュメントがないので何が正しいのかはわからない。

cfq-iosched.c
3719         /*
3720          * Check whether ioprio has changed.  The condition may trigger
3721          * spuriously on a newly created cic but there's no harm.
3722          */
3723         if (unlikely(!cfqd) || likely(cic->ioprio == ioprio))
3724                 return;

ん待てよ?すでに要求が詰まった(workloadに登録された)sync queueの優先度をあとから変更するのは本当にいいんだろうか。cfq_resort_rr_list()呼ぶだけじゃ不十分?

ポエム: CFQって本当に使い物になるの?

優先度に応じて時間配分が変わるだけならまだIOPRIO_CLASS_BE(Best Effort)というのもわかるけど、IOPRIO_CLASS_RTやIOPRIO_CLASS_IDLEでpreemptするという設計なら、もうちょっとちゃんと作られていないとまずい気がする。IOPRIO_CLASS_RTの中の優先度でも時間配分していたり、過去の修正で「IOPRIO_CLASS_IDLEが後回しされるせいでFilesystemのロックが取りっぱなしになってふん詰まるから一時的に優先度上げるぜ」とかがあったり(REQ_PRIOのフラグ)とか。妙な時間配分、expire時間の根拠、idle/noidleの話、など、確かにHDD時代は必要だったのかもしれないけど、ちょっとヒューリスティックすぎやしないかと。あれか、ioprioとは「I/Oをスケジュールする」のが目的じゃなくて「I/Oによりタスクスケジュールが阻害されるのを防ぐ」のが目的なのか?

SSD時代になって、rotational(QUEUE_FLAG_NONROTが立っているかどうか)によりシーク待ちやexpire時間を調整している(blk_queue_nonrot())とはいえ、レイテンシを重視すると「cfqよりもdeadlineの方がいい」という主張も何となくわかる。今のままのCFQがSSDに適していないのはまぁいいとして、じゃどこを変えればSSDにフィットするのかも見えてこない。そろそろSSD時代からNVMe時代に移ってくるので、全く新しいI/Oスケジューラが求められているんじゃないかと思う。(DIMMタイプのSSDは...どうなんだろ、Intelの3D XPointはRAMのようにランダムアクセスするからI/Oスケジューラ関係なし?)

※コメントでもらった捕捉より。CFQだけで何とかするというよりブロックデバイス層の別の機能も使って(multi queueなど)という設計思想の方向のようです。

あとがき

I/Oスケジューラ入れ替えてパラメータチューニングして、な記事はよくあるけど、ioprio値とCFQの関係に踏み込んだものは皆無に近かったので書いてみた。ただ、肝心の時間配分のスケーリングのアルゴリズムがソース読んでも全くわからんぞ...Linux-4.10で入ったREQ_BACKGROUNDblk-wbt.c(write back throttling)でキューの深さをTCP/IPのと同じようなアルゴリズムで待ち調整しているところに効くようで、でもこのへんもまだよくわからなくて、やっぱりVMIO難しい。

単純にスケジューリングのアルゴリズムについては、cfq-iosched.cが4936行あるとはいえ、少し集中的に読み込めば本記事のようなところまではおむね見えてくるんじゃないかと思う。

参考サイト

Linux Kernel Documentation

Linux manuals

その他

15
11
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
11