(2020年3月追記あり)(2022年12月追記あり)
Unix系OSのpsコマンドには、wchanという項目を表示するオプションがある。wchanは、何かを待っている (stat項がSやD) プロセスやスレッド (Linux内部の用語ではタスク) が、何を待っているかを示すヒントを示すもので、トラブルシューティングなどでは重要な項目だ。*BSDを含むUnixでは、カーネル内で待ちに入る時に文字列を指定することになっており、この文字列がwchanに現れる。一方で、Linuxでは、待ちに入ったカーネル内の関数名を示すようになっている。このwchanの関数名がどのように求められているのか調べてみた。
Linuxのwchan
Linuxのカーネル内で待ちに入る時には、schedule()という関数を呼び出す。プロセススケジューラが呼び出され、他の走行可能なタスクが一つ選択されてタスク切り替えが行われる (または、走行可能なタスクがない場合はCPUが止まる)。mutex待ち、セマフォ待ちなどを行う時も、mutex_lock()やdown()などの先で、schedule()が呼ばれる。
しかしながら、psコマンドのwchan項にはschedule()もmutex/セマフォ関連の関数名も、登場することはない。単にschedule()を呼んだ関数を表示するだけであれば、wchan項はもっとmutex_lock (既定では最初の6文字で切られてしまうので、"mutex_") で埋め尽くされても良さそうだ。何らかの仕組みでこれらの関数はスキップしてさらにこれを呼び出した関数をwchanとして出しているに違いない。
タスクの情報はカーネルしか持っていないため、psコマンドはカーネルから各タスクの情報を得ているはずだ。タスクの情報はprocファイルシステム経由で取り出すことができるので、これを使っていると思われるが、念のために調べてみる。
$ strace -o pslog.txt ps l
F UID PID PPID PRI NI VSZ RSS WCHAN STAT TTY TIME COMMAND
4 10010 2520 2499 20 0 207112 5532 poll_s Ssl+ tty2 0:00 /usr/lib/gd
4 10010 2522 2520 20 0 516488 155716 ep_pol Sl+ tty2 27:22 /usr/lib/xo
... (略)
pslog.txtにはpsコマンドが発行したシステムコールが引数とともに記録されている。ざっくり追いかけてみると、まずディレクトリ/procを開き、getdents(2)でディレクトリ内のエントリを取得している。
openat(AT_FDCWD, "/proc", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 5
fstat(5, {st_mode=S_IFDIR|0555, st_size=0, ...}) = 0
(略)
getdents(5, /* 452 entries */, 32768) = 12120
次いで、取得した各エントリの中にある、stat、status、cmdlineの各ファイルを読んでいるのがわかる。ときおり、wchanというファイルも読んでいることがあるが、これは選択されたプロセスで、かつstatがSやDのプロセスでは、ということだと考えられる。
openat(AT_FDCWD, "/proc/2520/wchan", O_RDONLY) = 6
read(6, "poll_schedule_timeout", 63) = 21
close(6) = 0
/proc/PID/wchanに関数名らしきものが入っていて、psの出力結果"poll_s"と先頭6文字が一致しているのがわかる。
poll_schedule_timeoutを追ってみる
Linuxのソースからpoll_schedule_timeoutを検索してみると、fs/select.cの中にある関数名がヒットする。執筆時点のHEADでは以下の通り。
static int poll_schedule_timeout(struct poll_wqueues *pwq, int state,
ktime_t *expires, unsigned long slack)
{
int rc = -EINTR;
set_current_state(state);
if (!pwq->triggered)
rc = schedule_hrtimeout_range(expires, slack, HRTIMER_MODE_ABS);
__set_current_state(TASK_RUNNING);
/*
* (コメント略)
*/
smp_store_mb(pwq->triggered, 0);
return rc;
}
この中で、schedule_hrtimeout_range()は、kernel/time/hrtimer.cの中で定義されている関数で、タイムアウトまで待つ、とコメントにある。他にpoll_schedule_timeout()が呼び出しているset_current_state()やsmp_store_mb()などの関数を追ってみても、待つことはないようなので、schedule_hrtimeout_range()の延長で待ちに入っているとわかる。schedule_hrtimeout_range()を追ってみると、同じファイルで定義されているschedule_hrtimeout_range_clock()を呼び、そこにschedule()の呼び出しがある。wchanには、schedule()を呼び出したschedule_hrtimeout_range_clock()ではなく、それを呼び出したschedule_hrtimeout_range()でもなく、それを呼び出したpoll_schedule_timeout()が示されているわけだ。
##procファイルシステムのソースを読む
ではprocファイルシステムのファイルwchanに示されている情報はどうやって算出されているのだろう。procファイルシステムのソースコードは、Linuxのソースツリーのfs/proc/*にある。ここでwchanという文字列を検索すると、fs/proc/base.cのproc_pid_wchan()という関数が見つかる。
static int proc_pid_wchan(struct seq_file *m, struct pid_namespace *ns,
struct pid *pid, struct task_struct *task)
{
unsigned long wchan;
char symname[KSYM_NAME_LEN];
if (!ptrace_may_access(task, PTRACE_MODE_READ_FSCREDS))
goto print0;
wchan = get_wchan(task);
if (wchan && !lookup_symbol_name(wchan, symname)) {
seq_puts(m, symname);
return 0;
}
print0:
seq_putc(m, '0');
return 0;
}
get_wchan()というのが怪しそうだ。返ってきたwchanは整数で、symnameが文字列なので、lookup_symbol_name()でwchanをアドレスとみなしてシンボル名に変換しているのだろう。
get_wchan()は、機種依存部で定義されていて、x86ではarch/x86/kernel/process.cで定義されている。そのキモは以下だ。
unsigned long get_wchan(struct task_struct *p)
{
unsigned long start, bottom, top, sp, fp, ip, ret = 0;
int count = 0;
(略)
fp = READ_ONCE_NOCHECK(((struct inactive_task_frame *)sp)->bp);
do {
if (fp < bottom || fp > top)
goto out;
ip = READ_ONCE_NOCHECK(*(unsigned long *)(fp + sizeof(unsigned long)));
if (!in_sched_functions(ip)) {
ret = ip;
goto out;
}
fp = READ_ONCE_NOCHECK(*(unsigned long *)fp);
} while (count++ < 16 && p->state != TASK_RUNNING);
(以下)略
16回ループするか、タスクの状態がTASK_RUNNING (走行可能、psのstat項がR) になるまで (ループ中に他のCPUなどで動き出したりする可能性もあるので) ループする。
Linuxはgccのオプション-fno-omit-frame-pointer付きでコンパイルされており、全ての関数は以下のような形で始まる (実際にはデバッグ用途などのhookを挿入するための仕組みも入る)。
function:
push %rbp
mov %rsp, %rbp
...
機械語命令についてここで詳しく説明することは控えるが、これの意味するところは、関数に入ると即座にrbpレジスタをスタックに保存し、更新されたスタックポインタをrbpレジスタにコピーする、ということだ。結果的に、rbpレジスタの指すメモリには、functionが呼ばれる前のrbpレジスタの値が入り、その次のアドレス (スタックは0番地に向かって進むので) にはfunction終了後に戻るべき命令のアドレスが入る。rbpレジスタは、フレームポインタと呼ばれるもので、functionが他の関数を呼ぶまで変更されることはなく、またfunctionから戻る時にスタックに保存されていた元の値に復元される。
先ほどのループに戻ろう。doの前にある代入式で、fpにはschedule()の先でタスク切り替えが起こった時に (スタックに) 保存されたrbpレジスタの値が入る。doループの最初の条件文は、fpの値がタスクのスタックの範囲内であることを確認する。範囲外になることはないはずであるが、万一範囲外となると、panicする可能性があるためこれを防ぐ。次の代入式は、fpの次のアドレスにある値をipに入れる。fpにはrbpレジスタの値が入っていたので、これは戻りアドレスである。次のin_sched_functions()はこの後で見てみるが、名前からしてタスクスケジューラの関数であるか否かを真偽値で返すに違いない。「タスクスケジューラの関数」でなければ、ipを返し、そうでなければfpを更新してループの最初に戻る。fpは、現在fpの指すアドレスにある値に更新される。fpの値はrbpレジスタの値であったから、呼び出し元でのrbpレジスタの値に更新されることになる。
このようにしてスタックをたどっていくことで、タスクスイッチに至るまでの関数呼び出しの様子を逆にたどっていくことができる。これを「タスクスケジューラの関数」から出るまで繰り返すのが、get_wchan()というわけだ。
「タスクスケジューラの関数」とは
最後に、「タスクスケジューラの関数」を判定するin_sched_functions()を見てみよう。これは、kernel/sched/core.cにある。
int in_sched_functions(unsigned long addr)
{
return in_lock_functions(addr) ||
(addr >= (unsigned long)__sched_text_start
&& addr < (unsigned long)__sched_text_end);
}
in_lock_functions()は、kernel/locking/spinlock.cにある。
notrace int in_lock_functions(unsigned long addr)
{
/* Linker adds these: start and end of __lockfunc functions */
extern char __lock_text_start[], __lock_text_end[];
return addr >= (unsigned long)__lock_text_start
&& addr < (unsigned long)__lock_text_end;
}
合わせると、引数addrが、ある2つの範囲、つまり、__sched_text_startから__sched_text_endまでか、__lock_text_startから__lock_text_endまでかのどちらかにあるかないかを返していることになる。
__sched_text_startと__sched_text_endは、Cやアセンブラのソースコードの中で定義されているシンボルではない。実は、Linuxのコンパイル時にリンカが生成するシンボルである。
include/asm-generic/vmlinux.lds.hに以下のような定義がある。
#define SCHED_TEXT \
ALIGN_FUNCTION(); \
__sched_text_start = .; \
*(.sched.text) \
__sched_text_end = .;
このファイルは、.hというファイル名で、Cのプリプロセッサの文法で書かれているが、Cのソースコードから#includeされるわけではなく、リンカに与えられるスクリプトから参照されている。Linuxでは、リンカのスクリプトもCプリプロセッサにかけられた後でリンカに渡される。x86のリンカスクリプトは、arch/x86/kernel/vmlinux.lds.Sにあり、確かにその中には#include という記述も、SCHED_TEXTの参照もある。
上記引用部の意味するところは、
- 現在のアドレスに、__sched_text_startというシンボルを与える。
- .sched.textというセクションのコードをかき集めてここにリンクする。
- 現在のアドレスに、__sched_text_endというシンボルを与える。
である。ELFセクションについては、詳しくは各自ググるなどしてほしいが、.sched.textという文字列は、include/linux/sched/debug.hに現れる。
#define __sched __attribute__((__section__(".sched.text")))
実はあえて引用を省いた、schedule_hrtimeout_range()やschedule_hrtimeout_range_clock()には、この__schedという属性がついている。
int __sched schedule_hrtimeout_range(ktime_t *expires, u64 delta,
const enum hrtimer_mode mode)
{
return schedule_hrtimeout_range_clock(expires, delta, mode,
CLOCK_MONOTONIC);
}
もちろん、schedule()などにもついている。実は、タスクスケジューラ内の関数にはこの__schedというのがいちいちついていて、全部.sched.textというセクションに集めてリンクされるわけだ。__lock_text_start、__lock_text_endも実は同じようなもので、タスクスケジューラ関連ではなくロック関連というだけの違いである。
さて、さっきのget_wchan()のループを注意してみると、Linuxのどこかの関数Aから__schedのついた「タスクスケジューラの関数」Bをよび、Bが__schedのつかない「タスクスケジューラの関数」ではない関数Cを呼び、さらにCが「タスクスケジューラの関数」であるDを呼んだとする。ここでタスクが待ちに入ると、wchanは、欲しい情報であるAではなく、Cを指すことになる。こういうことが起きないように、「タスクスケジューラの関数」から呼び出され、かつ待ちに入る可能性のある関数には、注意深く__schedがつけられていると考えられる。というか、そもそもタスクスケジューラの中から安易に待ちに入る可能性のある関数を呼ぶと、デッドロックなど割とひどい事態に陥るため、__schedのつかない、つまり「タスクスケジューラの関数」ではない関数を呼び出すことは避けられている、と考えるべきかもしれない。
まとめ
- Linuxで、psコマンドのwchan項は、/proc/PID/wchanから得ている (上記では触れなかったが、スレッド別に表示した場合は/proc/PID/task/TID/wchan)。
- procファイルシステムのwchanで示されるのは、「スケジューラの関数」を呼び出して待ちに入った関数のシンボル名である。
- 「スケジューラの関数」は、リンカにより連続したアドレスに集めてリンクされ、その先頭と末尾にはそれぞれ__sched_text_start、__sched_text_endというシンボルがつけられる。
- Linuxの関数名は長くなる一方なのに、psのwchanは6文字で変わらなくてクソ。
追記 (2020年3月)
以上で書いたことは、RHEL 8 (CentOS 8などを含む) や最近のFedoraなどでは当てはまらない。これは、-fno-omit-frame-pointerオプション付きでコンパイルされなくなったためである。
$ ps alxc
F UID PID PPID PRI NI VSZ RSS WCHAN STAT TTY TIME COMMAND
4 0 1 0 20 0 180900 16508 - Ss ? 0:04 systemd
1 0 2 0 20 0 0 0 - S ? 0:00 kthread
(略)
0 42 2787 2647 20 0 617620 22028 - Ssl ? 0:00 gsd-wac
0 42 2797 2647 20 0 522552 8480 - Ssl ? 0:00 gsd-wwa
0 42 2798 2647 20 0 947008 61240 - Ssl ? 0:00 gsd-xse
0 42 2829 2647 20 0 549532 16176 - Sl ? 0:00 gsd-pri
0 42 2873 2647 20 0 160644 6960 - Ssl ? 0:00 at-spi2
0 1000 2981 2529 20 0 218684 1304 - R+ pts/1 0:00 ps
これは、Linux-4.14でORC unwinderという機構が入り、フレームポインタをたどらなくてもバックトレースが取れるようになったこと、そして、上記get_wchan()がORC unwinderに対応せず、フレームポインタを辿る実装のままであることによる (panic時などにはORC unwinderでバックトレースが表示される)。誰も困っていないの??
追記 (2022年12月)
Linux-5.16にて、get_wchanがORC unwinderに対応したことで、やっとwchan項がまともになった。RHEL 9は5.14ベースであるが、この修正をバックポートしているため、RHEL 9 (AL9、RL9含む) ではちゃんとpsコマンドでwchanが取得できる。