うわっ・・・わたしのロードアベレージ低すぎ・・・?
はじめに
そもそもロードアベレージとはなんでしょうか。どの程度であれば正常で、どの程度の値であるべきでしょうか。
「サーバーのロードアベレージが530000になっている!これは異常だ!」と連絡が来ていますが、果たして本当に異常でしょうか。
本記事では、カーネルソースリーディングやカーネルモジュール作成を通じロードアベレージの理解を深めるとともに、あなたが未だかつてないロードアベレージを記録するためのTipsをお届けすることを目的としています。
ロードアベレージって何?
「実行中または実行可能 (R状態) 、および割り込み不可能 (D状態) なスレッドの数を移動平均したもの」です。
馴染みがない方は参考として Red Hat のナレッジベースを参照されることをお勧めいたします。
ロードアベレージの正体
kernel/sched/load.cを読んだことがある方、興味のない方は読み飛ばしてください。
簡単な定義・計算方法はコメントに記載のある通りであり、R状態およびD状態のスレッドを「アクティブスレッド」としてカウントし、それを指数移動平均することによって計算します。
* The global load average is an exponentially decaying average of nr_running +
* nr_uninterruptible.
*
* Once every LOAD_FREQ:
*
* nr_active = 0;
* for_each_possible_cpu(cpu)
* nr_active += cpu_of(cpu)->nr_running + cpu_of(cpu)->nr_uninterruptible;
*
* avenrun[n] = avenrun[0] * exp_n + nr_active * (1 - exp_n)
calc_global_load() が定期的に実行され、だいたい5秒に一度、「アクティブスレッド数」をもとにグローバル変数 avenrun を更新しています。
また、「アクティブスレッド数」は calc_load_tasks 変数で管理されており、本関数内部やそのほかのコンテキスト (calc_global_load_tick())で更新されています。
/*
* calc_load - update the avenrun load estimates 10 ticks after the
* CPUs have updated calc_load_tasks.
*
* Called from the global timer code.
*/
void calc_global_load(void)
{
unsigned long sample_window;
long active, delta;
sample_window = READ_ONCE(calc_load_update);
if (time_before(jiffies, sample_window + 10))
return;
/*
* Fold the 'old' NO_HZ-delta to include all NO_HZ CPUs.
*/
delta = calc_load_nohz_read();
if (delta)
atomic_long_add(delta, &calc_load_tasks);
active = atomic_long_read(&calc_load_tasks);
active = active > 0 ? active * FIXED_1 : 0;
avenrun[0] = calc_load(avenrun[0], EXP_1, active);
avenrun[1] = calc_load(avenrun[1], EXP_5, active);
avenrun[2] = calc_load(avenrun[2], EXP_15, active);
WRITE_ONCE(calc_load_update, sample_window + LOAD_FREQ);
/*
* In case we went to NO_HZ for multiple LOAD_FREQ intervals
* catch up in bulk.
*/
calc_global_nohz();
}
そしてユーザーが cat /proc/loadavg 等でロードアベレージの取得を試みるとき、procfs に登録されたハンドラ loadavg_proc_show() が発火し、グローバル変数 avnrun が読み出されることとなります。
static int loadavg_proc_show(struct seq_file *m, void *v)
{
unsigned long avnrun[3];
get_avenrun(avnrun, FIXED_1/200, 0);
seq_printf(m, "%lu.%02lu %lu.%02lu %lu.%02lu %u/%d %d\n",
LOAD_INT(avnrun[0]), LOAD_FRAC(avnrun[0]),
LOAD_INT(avnrun[1]), LOAD_FRAC(avnrun[1]),
LOAD_INT(avnrun[2]), LOAD_FRAC(avnrun[2]),
nr_running(), nr_threads,
idr_get_cursor(&task_active_pid_ns(current)->idr) - 1);
return 0;
}
static int __init proc_loadavg_init(void)
{
struct proc_dir_entry *pde;
pde = proc_create_single("loadavg", 0, NULL, loadavg_proc_show);
pde_make_permanent(pde);
return 0;
}
fs_initcall(proc_loadavg_init);
ロードアベレージの増やし方
先ほどの定義より、ロードアベレージを増やすには、RまたはD状態のスレッドをたくさん生やしてあげればよいですね。
R状態のスレッドをたくさん生やすのであれば、適当にFork爆弾でも仕掛けておけば十分です。
しかしながら、R状態のスレッドをたくさん生やしてしまうと、「本当に」負荷がかかってしまい得策ではありませんので、なんとかしてD状態のスレッドを生成させましょう。
ところで、D状態とはなんでしょうか。
「ディスクI/Oしているときに遷移するやつだよね」という認識の方も多いのではないでしょうか。
確かにその通りです。例えば submit_bio_wait() では、IOを要求したのちに blk_wait_io() でその完了を待っています。
/**
* submit_bio_wait - submit a bio, and wait until it completes
* @bio: The &struct bio which describes the I/O
*
* Simple wrapper around submit_bio(). Returns 0 on success, or the error from
* bio_endio() on failure.
*
* WARNING: Unlike to how submit_bio() is usually used, this function does not
* result in bio reference to be consumed. The caller must drop the reference
* on his own.
*/
int submit_bio_wait(struct bio *bio)
{
DECLARE_COMPLETION_ONSTACK_MAP(done,
bio->bi_bdev->bd_disk->lockdep_map);
bio->bi_private = &done;
bio->bi_end_io = submit_bio_wait_endio;
bio->bi_opf |= REQ_SYNC;
submit_bio(bio);
blk_wait_io(&done);
return blk_status_to_errno(bio->bi_status);
}
EXPORT_SYMBOL(submit_bio_wait);
blk_wait_io() では最終的に以下のコールを辿ることで、最終的に、自身のスレッドの実行状態をDに設定 (__set_current_state())」し、タイムアウト時間が過ぎるまで自身がスケジュールされないようにします。
blk_wait_io()
-> wait_for_completion_io() or wait_for_completion_io_timeout()
-> wait_for_common_io()
-> __wait_for_common()
-> do_wait_for_common()
-> __set_current_state(TASK_UNINTERRUPTIBLE)
-> schedule_timeout(timeout)
つまり、実際に何かしらのタスク (ディスクIOなど) を行っていなくとも、「自身のスレッドの実行状態をD状態に設定し、しばらくスケジュールされない」動きをすれば良さそうです。
ただ、Upstream のカーネルにはそんな無意味な動きをしてくれるコンポーネントは存在しないため、自作のカーネルモジュールで機能を追加することします。
カーネルモジュール
設計
スレッドを作る方法はさまざまありますが、可能な限り手軽に実現したいため、カーネルモジュールロード時にカーネルスレッドを作成するアプローチを取ります。
カーネルスレッドを作成する API・マクロはたくさんありますが、本モジュールでは、最もハイレベルな API である kthread_run() を使うこととします。
以下のように load の回数だけ for ループを回し、kthread_run でカーネルスレッドを生成するという極めてシンプルな構造です。
for (i = 0; i < load; i++) {
struct task_struct *k = kthread_run(kthread_fn, NULL, "noload/%07d", i);
if (IS_ERR(k)) {
ret = PTR_ERR(k);
goto err;
}
kthreads[i] = k;
}
生成されたスレッドでは第一引数のシンボルの関数が実行されます。つまり、今回は「自身をD状態にしてひたすら眠り続けるkthread_fn()」が実行されることとなります。
schedule_timeout_uninterruptible() は自身をD状態に設定してタイムアウト分スリープする便利なAPIですので、ぜひ使いましょう。
static int kthread_fn(void *unused) {
while (!kthread_should_stop())
schedule_timeout_uninterruptible(timeout_secs() * HZ);
return 0;
}
初期化・クリーンアップ処理、およびインストール方法については割愛します。興味のある方は完全版をご覧ください。
テスト
本カーネルモジュールでは、モジュールパラメーター load によって生成するD状態のスレッド数を指定できるようにしており、例えば以下のように実行することができます。
$ sudo insmod noload.ko load=10
$ ps aux
...
root 1263 0.0 0.0 0 0 ? D 16:13 0:00 [noload/0000000]
root 1264 0.0 0.0 0 0 ? D 16:13 0:00 [noload/0000001]
root 1265 0.0 0.0 0 0 ? D 16:13 0:00 [noload/0000002]
root 1266 0.0 0.0 0 0 ? D 16:13 0:00 [noload/0000003]
root 1267 0.0 0.0 0 0 ? D 16:13 0:00 [noload/0000004]
root 1268 0.0 0.0 0 0 ? D 16:13 0:00 [noload/0000005]
root 1269 0.0 0.0 0 0 ? D 16:13 0:00 [noload/0000006]
root 1270 0.0 0.0 0 0 ? D 16:13 0:00 [noload/0000007]
root 1271 0.0 0.0 0 0 ? D 16:13 0:00 [noload/0000008]
root 1272 0.0 0.0 0 0 ? D 16:13 0:00 [noload/0000009]
指数移動平均で計算するためしばらく時間がかかりますが、数分待つと目標ロードアベレージに到達するでしょう。
$ cut -d ' ' -f 1 /proc/loadavg
9.78
ボトルネック、何がある?
唐突ですが、構成の都合上 load パラメータを変更して本気を出す前に、スレッドを大量に生成する上でのボトルネックについて考えていきます。
ざっと思いつく壁は以下に示す通りです。
- メモリリソース
- threads-max
- pid_max
メモリ
まず、スレッド生成時には、スレッドを管理するためのカーネルの構造体 task_struct をはじめ、さまざまなデータ構造のためのメモリが消費されることとなります。
自身の環境で検証したところ合計で8KiB程度のメモリが利用されているようでした。なお、こちらはカーネルのビルドコンフィグによって大きく変化しうるものです。
struct task_struct *k = kthread_run(kthread_fn, NULL, "noload/%07d", i);
また、スレッドのスタック領域にもメモリが割り当てられることとなります。KASAN無効・ページサイズ4KiB環境を仮定すると、このサイズは16KiBとなります。
#define KASAN_STACK_ORDER 0
#define THREAD_SIZE_ORDER (2 + KASAN_STACK_ORDER)
#define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER)
つまり、これらの合計の約24KiB程度が各スレッド生成のたびに消費されることとなり、自身の環境では「利用可能なメモリ / 24 KiB」が理想的な上限となります。
threads-max
カーネルによる保護装置として threads-max によるスレッド数の上限が決まっています。
デフォルトでは、メモリ総量の1/8を THREAD_SIZE で割った数、つまりとても小さい数が設定されているため、このままではメモリを有効に使うことはできません。
During initialization the kernel sets this value such that even if the maximum number of threads is created, the thread structures occupy only a part (1/8th) of the available RAM pages.
そのため、「利用可能なメモリ / 24 KiB」程度まで sysctl コマンド等で拡張しておくことをお勧めします。
$ sudo sysctl -w kernel.threads-max=530000
pid_max
次なる障壁として pid_max、割り当て可能な PID の上限があります。
スレッド生成時に PID がこれを超過するとラップアラウンドするため、実質的にこれを超える数のスレッドを生成することはできません。
こちらのハードリミットは PID_MAX_LIMIT であり、多くの環境では4194304 (= 4 * 1024 * 1024) にハードコードされています。
/*
* A maximum of 4 million PIDs should be enough for a while.
* [NOTE: PID/TIDs are limited to 2^30 ~= 1 billion, see FUTEX_TID_MASK.]
*/
#define PID_MAX_LIMIT (IS_ENABLED(CONFIG_BASE_SMALL) ? PAGE_SIZE * 8 : \
(sizeof(long) > 4 ? 4 * 1024 * 1024 : PID_MAX_DEFAULT))
...
static int pid_max_max = PID_MAX_LIMIT;
static const struct ctl_table pid_table[] = {
{
.procname = "pid_max",
.data = &init_pid_ns.pid_max,
.maxlen = sizeof(int),
.mode = 0644,
.proc_handler = proc_dointvec_minmax,
.extra1 = &pid_max_min,
.extra2 = &pid_max_max,
},
...
};
仮に400万を超えるスレッドを生成するシナリオにおいては、こちらが現実的なブロッカーとなり、PID_MAX_LIMIT を書き換えた上でビルドする必要が出てきますので注意が必要です。
「わたしのロードアベレージは530000です」
さて、あらかじめ問題となりそうなボトルネックを明らかにしたことですし、必要なメモリを見積り、カーネルパラメーターをセットした上で、スレッドを生やしていきましょう。
スレッド一つあたりに利用されるメモリの量は、自身の環境では24KiBとわかっているため、53万スレッドに必要な必要なメモリの量はだいたい12.2GiB程度です。 一般的なパソコンでも16GiB程度のメモリは積んでいるでしょうから、ここが問題になることはないはずです。
次に、threads-max, pid_max が不十分である場合には、増やしておきましょう。
# sysctl -w kernel.threads-max=540000
kernel.threads-max = 540000
# sysctl -w kernel.pid_max=4000000
kernel.pid_max = 4000000
モジュールパラーメーターを変更してインストールし、しばらくすると、
$ sudo insmod noload.ko load=530000
無事に戦闘力が530000に到達しました🎉
$ cut -d ' ' -f 1 /proc/loadavg
530000.09
「さらなる高みへ」
さらに戦闘力を高めたいあなたが取れる選択肢は以下のどちらかです。
- メモリを増やす
- スレッドあたりに必要なメモリを減らす
EC2 X8iインスタンスファミリーを使えるようなお金持ちは、札束で解決しましょう。
そうではない方は、以下のようなポイントでチューニングする余地があるかどうか検討してみましょう。
struct task_structにはコンフィグで有効になるフィールドが多数あります。
ビルドコンフィグをチューニングすることで、必要なメモリを節約することができるはずです
struct task_struct {
#ifdef CONFIG_THREAD_INFO_IN_TASK
/*
* For reasons of header soup (see current_thread_info()), this
* must be the first element of task_struct.
*/
struct thread_info thread_info;
#endif
...
#ifdef CONFIG_MEM_ALLOC_PROFILING
struct alloc_tag *alloc_tag;
#endif
- なんとかして
THREAD_SIZEを小さくできないでしょうか。
THREAD_SIZE_ORDER は本当に2である必要があるでしょうか。実際に各スレッドがどの程度スタックを使っているのかを観測してみるといいかもしれません。
#define KASAN_STACK_ORDER 0
#define THREAD_SIZE_ORDER (2 + KASAN_STACK_ORDER)
#define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER)
最後に
本記事では「あなたが未だかつてないロードアベレージを記録するためのTips」をお伝えすると同時に、「ロードアベレージが意味を持たないシナリオ」について実践的に理解するための基本的な事項を書き記しました。
カーネルモジュール自体に興味を持たれた方は、ぜひリポジトリ全体もご覧ください。