最近C言語で学ぶソケットAPI入門という投稿をアップさせて頂いていて、TCP、UDPを使ったプログラムまではとりあえず書いてみたので、そろそろネットワークプログラミングでは必要不可欠な並列化処理や多重化処理の方に移ろうと思っています。
そこで、今回はマルチプロセスの処理について掘り下げようと思ってます。マルチプロセスを実現するにあたって重要なシステムコールはforkです。
もともとLinuxで子プロセスを生成するシステムコールはforkというものであることは知っていますし、使い方も何となく知ってはいました。
ただ、それがどういった実装によって実現されているのかまでは知らなかったので、この際ちゃんとソースコードを追って調べてみました。
とはいえソースコードだけ追っていくのが辛い場面もあったりしたので、そんな時はとりあえずfork関数を使った適当なコードをgccで静的リンクさせたオブジェクトコードに出力して、それをobjdumpでディスアセンブルとかしながら、ソースコードの流れと実際に出力されたマシン語を照らし合わせつつ、なんて極めて地味な作業をしてました。
100%理解できているわけじゃありませんし、解釈があっているか怪しいところもありますが、更に調査を続けて正確なものを残せるように少しずつアップデートしていきます。
そして詳しい方、助言頂けると大変助かります。一緒にカフェでオープンソースのソースコードを読み会える友達も募集中ですw
参考にした各ソースコード
Linuxカーネル:2.6.11
proc系のコマンド:procps-3.2.8
glibc:glibc-2.12.1
CPUはx86_64です。
※バージョンについては特に理由がありませんが、古すぎず新しすぎずみたいなところです。
##プロセスとは
プロセスとはプログラムを実行するためのアドレス空間と、その処理を行う為に必要となる情報の集まりのことです。
プログラムは通常、HDDなどの補助記憶装置の中に格納されていますが、メモリに読み込まれて、プロセスの中で実行されます。
OSを構成している要素の中でも特に重要なプログラムをカーネルと言いますが、プロセスはメモリに読み込まれたカーネルによって管理されるものです。
CPUが実行時のモードはLinuxの場合は、ユーザーモード、カーネルモードの2種類があり、カーネルモードではシステムリソースに制限なくアクセスできるので、ハードウェアやシステム全体に関わる処理が行うことができ、ユーザーモードではそういったリソースにアクセスする必要がない固有のアドレス空間内のアプリケーション処理を行うことができます。
ただし、プロセス内でシステムコールを発行することによって、CPUを一時的にカーネルモードに切り替え、CPUに依存する命令を実行することができるようになっています。ちなみに、システムコールというのはプロセスによってOSに依頼される、カーネルモードで実行する特定の処理群のことを言います。
ここで私のLinuxで現在存在するプロセスを一覧化して表示するpsコマンドを、オプション付きで使ってみます。
ps -ef f
root 21449 1 0 21:34 ? Ss 0:00 php-fpm: master process (/etc/php-fpm.conf)
apache 21450 21449 0 21:34 ? S 0:00 \_ php-fpm: pool www
プロセスはプロセスディスクリプタと呼ばれるtask_struct構造体に情報を持ち、プロセスIDというプロセスに対して1対1で対応するpid_t型のメンバを持っています。
この場合は2列目の21449と21450はプロセスIDを意味しています。
task_struct構造体はLinuxカーネル内のinclude/linux/sched.h
にて定義されていますが200行近い構造体だったので引用は控えます。
プロセスに関する情報が詰まった重要な構造体なので、秋の夜長に目を通しておくことをオススメします。
そして3列目がそのプロセスを生成したプロセスのプロセスIDを意味しています。あるプロセスは通常何らかの親プロセスの中で生成されます。
2行目の親プロセスは1行目のプロセスであることがわかりますが、その1行目のプロセスの親プロセスIDは1となっています。
これはinitというプロセスを表し、親プロセスが存在しないか親が子より先に死んでしまった時に、里親のように引き継ぐプロセスです。initはカーネルによって生成され、全プロセスの先祖となるプロセスでもあります。
initが親となる状態も後ほどプログラムを実行して確かめますが、まずは単純に子プロセスを生成するプログラムを書いて実行してみましょう。
#include <sys/types.h> /* pid_t */
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(int argc, char *argv[]) {
pid_t pid, ret;
printf("start!\n");
if((ret = fork()) == -1){
perror("fork() failed.");
exit(EXIT_FAILURE);
}
pid = getpid();
printf("return from fork() is %d\n", ret);
printf("pid is %d\n", pid);
printf("end!\n");
return 0;
}
初めに、start!と表示し、fork関数によってforkシステムコール(後に明らかになりますが内部的にはclone)を実行しています。
fork()は失敗した時に-1を返すのでその場合即座にプログラムの実行を終了します。
fork()が成功した場合はgetpid関数でプロセスIDを取得して、現在のプロセスのIDを取得して表示します。
なお、ここではプロセスディスクリプタ構造体の厳密なプロセスIDを示すpidメンバでなく、スレッドグループリーダのプロセスIDであるtgidメンバを取得しています。
getpidシステムコールはカーネル内でsys_getpid関数を呼び出しますが、ソースコードはkernel/timer.cの967行目にて下記のように実装されています。
asmlinkage long sys_getpid(void)
{
return current->tgid;
}
プロセッサによって違いはありますが、ここでの変数currentは現在のプロセスディスクリプタであるtask_struct型構造体へのポインタです。
task_struct型構造体オブジェクトのpidメンバではなく、tgidメンバを指していることがわかります。
この違いはマルチプロセスではなく、マルチスレッドのプログラミングを考える時に大事な意味を持っていますので心の隅に置いといて下さい。
そして、psコマンドのプロセスIDも同じくtgidメンバを表示しています。
以下はpsコマンドのソースコードの一部ですが、プロセス一覧を表示するために/proc以下のプロセスを読み込んで、proc_t構造体へのポインタであるpを通して、tgidのメンバとtidメンバにプロセスIDを格納しています。
static int simple_nextpid(PROCTAB *restrict const PT, proc_t *restrict const p) {
static struct direct *ent; /* dirent handle */
char *restrict const path = PT->path;
for (;;) {
ent = readdir(PT->procfs);
if(unlikely(unlikely(!ent) || unlikely(!ent->d_name))) return 0;
if(likely( likely(*ent->d_name > '0') && likely(*ent->d_name <= '9') )) break;
}
p->tgid = strtoul(ent->d_name, NULL, 10);
p->tid = p->tgid;
memcpy(path, "/proc/", 6);
strcpy(path+6, ent->d_name); // trust /proc to not contain evil top-level entries
return 1;
}
forkは子プロセスの時は0、親プロセスの時は子プロセスのプロセスIDを返します。
下記はプログラムの実行結果と、実行中のpsコマンドの状態です。
start!
return from fork() is 21526
pid is 21525
end!
return from fork() is 0
pid is 21526
end!
tajima 21181 21180 0 20:51 pts/0 Ss 0:00 | \_ -bash
tajima 21525 21181 0 22:24 pts/0 S+ 0:00 | \_ ./a.out
tajima 21526 21525 0 22:24 pts/0 S+ 0:00 | \_ ./a.out
子プロセスと親プロセスがどちらが実行されるかはスケジューラ次第ですが、この場合は親プロセス、子プロセスの順に実行されたことがわかります。
ここでstart!の表示が1度しか行われていないことに気づきます。新しいプロセスはforkシステムコールの直後から実行を開始するからです。
その理由は後ほどforkの内部の処理を追うのでわかると思います。
さて、先ほどのプログラムを下記のように変えてみます。
getpid関数の戻り値によって親プロセスか子プロセスかを判定していますが、親プロセスの時は10秒、子プロセスの時に30秒のsleepを実行しています。
pid = getpid();
if(ret == 0){
sleep(30);
} else {
sleep(10);
}
printf("return from fork() is %d\n", ret);
printf("pid is %d\n", pid);
プログラムを起動した直後にpsコマンドで確認すると、
tajima 22501 22438 0 21:32 pts/0 S+ 0:00 | \_ ./a.out
tajima 22502 22501 0 21:32 pts/0 S+ 0:00 | \_ ./a.out
先ほどと同じような結果になってますが、親プロセスがsleepから目覚めてプログラムの実行を終了してしまった状態で、もう一度psコマンドを実行すると、
tajima 22502 1 0 21:32 pts/0 S 0:00 ./a.out
子プロセスの親がプロセスID1のプロセス、すなわちinitになっていることが確認できます。
場合によっては親と子で同期を取った処理を実行したいこともあるでしょう。
そのために親プロセスが子プロセスが終了するまで待つ、あるいはシグナルによって子プロセスの終了を検知するなどいくつかの方法が用意されてます。
下記のプログラムではwaitpid()システムコールを実行して、子プロセスが終了するまで親プロセスの実行をブロックします。
子プロセスが終了した後、親プロセスは10秒後に破棄されます。
#include <sys/wait.h>
#include <sys/types.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(int argc, char *argv[]) {
pid_t pid, ret;
printf("start\n");
if((ret = fork()) == -1){
perror("fork() failed.");
exit(EXIT_FAILURE);
}
pid = getpid();
if(ret == 0){
sleep(30);
} else {
if(waitpid(-1, NULL, 0) < 0) {
perror("waitpid() failed.");
exit(EXIT_FAILURE);
}
sleep(10);
}
printf("return from fork() is %d\n", ret);
printf("pid is %d\n", pid);
}
waitpid()は、第2引数にint型のポインタを引数として渡せば、子プロセスの終了ステータスを受け取ったり、第3引数にオプションの論理和を指定すれば動作を変化させることなどができます。
今回は子プロセスの終了という状態変化によって親プロセスに制御が返る想定ですが、終了だけにとどまらず、停止、再開などの状態変化を検出することができます。
例によってpsコマンドを実行すると、しばらくは下記のよう表示され、
tajima 22438 22437 0 21:28 pts/0 Ss 0:00 | \_ -bash
tajima 22609 22438 0 22:33 pts/0 S+ 0:00 | \_ ./a.out
tajima 22610 22609 0 22:33 pts/0 S+ 0:00 | \_ ./a.out
子プロセスが終了すると下記のように表示されることが確認できます。
tajima 22438 22437 0 21:28 pts/0 Ss 0:00 | \_ -bash
tajima 22609 22438 0 22:33 pts/0 S+ 0:00 | \_ ./a.out
補足すると、子プロセスは親プロセスによってwait系のシステムコールが呼ばれてプロセスの後始末をしてもらうまでゾンビプロセスという状態になっています。initはこの迷えるゾンビの親となり魂を解放するようにwait4システムコールを発行して後始末を行います。
##forkをハックする
さていよいよ、forkの実装について見ていきます。
fork関数を使ったプログラムをスタティックリンクさせて実行ファイルにしたものを、ディスアセンブルすると以下のようになりました。
0000000000400494 <main>:
400494: 55 push %rbp
400495: 48 89 e5 mov %rsp,%rbp
400498: e8 f3 d1 00 00 callq 40d690 <__libc_fork>
40049d: b8 00 00 00 00 mov $0x0,%eax
4004a2: c9 leaveq
4004a3: c3 retq
__libc_fork
というシンボルの関数を呼んでいます。
なので同じファイル内の__libc_forkを追っていけばまさにCPUがどういう処理をしているのか、そのまんまなのですがいきなりそれを読み解くのは辛いので、まずはC言語のソースコードでその実装を確認してみます。
pid_t
__libc_fork (void)
{
pid_t pid;
struct used_handler
{
struct fork_handler *handler;
struct used_handler *next;
} *allp = NULL;
/* Run all the registered preparation handlers. In reverse order.
While doing this we build up a list of all the entries. */
struct fork_handler *runp;
while ((runp = __fork_handlers) != NULL)
{
/* Make sure we read from the current RUNP pointer. */
atomic_full_barrier ();
unsigned int oldval = runp->refcntr;
if (oldval == 0)
/* This means some other thread removed the list just after
the pointer has been loaded. Try again. Either the list
is empty or we can retry it. */
/* 省略 */
#ifdef ARCH_FORK
pid = ARCH_FORK ();
#else
# error "ARCH_FORK must be defined so that the CLONE_SETTID flag is used"
pid = INLINE_SYSCALL (fork, 0);
#endif
/* 省略 */
ARCH_FORKというマクロを追うと、INLINE_SYSCALLというマクロに出会うので、更に追っていきます。
#define ARCH_FORK() \
INLINE_SYSCALL (clone, 4, \
CLONE_CHILD_SETTID | CLONE_CHILD_CLEARTID | SIGCHLD, 0, \
NULL, &THREAD_SELF->tid)
# define INLINE_SYSCALL(name, nr, args...) \
({ \
unsigned long int resultvar = INTERNAL_SYSCALL (name, , nr, args); \
if (__builtin_expect (INTERNAL_SYSCALL_ERROR_P (resultvar, ), 0)) \
{ \
__set_errno (INTERNAL_SYSCALL_ERRNO (resultvar, )); \
resultvar = (unsigned long int) -1; \
} \
(long int) resultvar; })
# define INTERNAL_SYSCALL(name, err, nr, args...) \
INTERNAL_SYSCALL_NCS (__NR_##name, err, nr, ##args)
# define INTERNAL_SYSCALL_NCS(name, err, nr, args...) \
({ \
unsigned long int resultvar; \
LOAD_ARGS_##nr (args) \
LOAD_REGS_##nr \
asm volatile ( \
"syscall\n\t" \
: "=a" (resultvar) \
: "0" (name) ASM_ARGS_##nr : "memory", "cc", "r11", "cx"); \
(long int) resultvar; })
INLINE_SYSCALLというマクロは更に内部でINTERNAL_SYSCALL_NCSというマクロ関数に変換されます。
その内部でインラインアセンブラの記述でraxレジスタに__NR_cloneの値が格納され、
cloneのシステムコールが実行されていることがわかります。
ちなみに、出力オペランド=aがraxレジスタに出力することを表し、入力オペランド0が入力レジスタも出力と同じraxレジスタであることを意味します。
#define __NR_clone 56
x86_64系のプロセッサではcloneのシステムコールの番号は56ということがわかります。
その証拠を先程のアセンブラ内のコードでも確認してみます。
40d736: b8 38 00 00 00 mov $0x38,%eax
40d73b: 0f 05 syscall
eaxレジスタ(rax)に16進数の0x38を入れています。これは10進数の56を意味します。
clone()システムコールはsys_clone関数によって実装されており、内部ではdo_fork関数が呼ばれています。
asmlinkage long sys_clone(unsigned long clone_flags, unsigned long newsp, void __user *parent_tid, void __user *child_tid, struct pt_regs *regs)
{
if (!newsp)
newsp = regs->rsp;
return do_fork(clone_flags, newsp, regs, 0, parent_tid, child_tid);
}
long do_fork(unsigned long clone_flags,
unsigned long stack_start,
struct pt_regs *regs,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr)
{
struct task_struct *p;
int trace = 0;
long pid = alloc_pidmap();
long pid = alloc_pidmap();
によってPIDの使用状況を管理するpidmap_arrayとよばれる配列から子プロセスのための新しいPIDを取得します。
typedef struct pidmap {
atomic_t nr_free;
void *page;
} pidmap_t;
static pidmap_t pidmap_array[PIDMAP_ENTRIES] =
{ [ 0 ... PIDMAP_ENTRIES-1 ] = { ATOMIC_INIT(BITS_PER_PAGE), NULL } };
pidmap_arrayはstruct pidmapという構造体を要素として持ち、pidmap_tという型にtypedefされています。
p = copy_process(clone_flags, stack_start, regs, stack_size, parent_tidptr, child_tidptr, pid);
copy_process関数によって、親プロセスのプロセスディスクリプタをコピーした、task_struct構造体へのポインタを返します。
以下、要点になる部分だけ抜粋して見ていきます。
p = dup_task_struct(current);
currentは現在のプロセスのtask_struct構造体へのポインタを指しますが、この内容を子プロセスのtask_struct構造体にコピーし、そのポインタを返却しています。
その後、コピーした子プロセスのtask_struct構造体へのポインタへ様々な初期化処理をしていきます。
そして、pt_regs構造体へのポインタを引数としてcopy_thread関数を呼び出し、子プロセスのカーネルモードスタックを設定しています。
pt_regs構造体にはカーネルモードが呼び出された時のレジスタの値が保存されています。
struct pt_regs {
unsigned long r15;
unsigned long r14;
unsigned long r13;
unsigned long r12;
unsigned long rbp;
unsigned long rbx;
/* arguments: non interrupts/non tracing syscalls only save upto here*/
unsigned long r11;
unsigned long r10;
unsigned long r9;
unsigned long r8;
unsigned long rax;
unsigned long rcx;
unsigned long rdx;
unsigned long rsi;
unsigned long rdi;
unsigned long orig_rax;
/* end of arguments */
/* cpu exception frame or undefined */
unsigned long rip;
unsigned long cs;
unsigned long eflags;
unsigned long rsp;
unsigned long ss;
/* top of stack page */
};
int copy_thread(int nr, unsigned long clone_flags, unsigned long rsp,
unsigned long unused,
struct task_struct * p, struct pt_regs * regs)
{
int err;
struct pt_regs * childregs;
struct task_struct *me = current;
childregs = ((struct pt_regs *) (THREAD_SIZE + (unsigned long) p->thread_info)) - 1;
*childregs = *regs;
childregs->rax = 0;
childregs->rsp = rsp;
if (rsp == ~0UL) {
childregs->rsp = (unsigned long)childregs;
}
p->thread.rsp = (unsigned long) childregs;
p->thread.rsp0 = (unsigned long) (childregs+1);
p->thread.userrsp = me->thread.userrsp;
childregs = ((struct pt_regs *) (THREAD_SIZE + (unsigned long) p->thread_info)) - 1;
この処理はプロセスに割り当てたメモリ領域のカーネルスタックの最上位アドレスにpt_regs構造体を配置するための処理です。pt_regs構造体のサイズ1つぶん引いたアドレスが、pt_regsの格納場所になるという実装だと思います。
childregsにはregsの内容を一度まるごとコピーしてから、部分的にレジスタの値を変更するという手法をとっています。
ここで注目すべきはchildregs->rax = 0
という箇所です。raxレジスタに0を入れています。
C言語では戻り値はraxレジスタやeaxレジスタなどで返却することになっていますから、子プロセスの場合はこれがそのままユーザープロセス空間に返される返却値になります。
p->thread.rsp0にはスタックの最上位アドレスが格納されます。
if ((p->ptrace & PT_PTRACED) || (clone_flags & CLONE_STOPPED)) {
/*
* We'll start up with an immediate SIGSTOP.
*/
sigaddset(&p->pending.signal, SIGSTOP);
set_tsk_thread_flag(p, TIF_SIGPENDING);
}
デバッガによって子プロセスが監視されているか、あるいはCLONE_STOPPEDフラグが立っている場合は、子プロセスの実行を停止させるようにします。
if (!(clone_flags & CLONE_STOPPED))
wake_up_new_task(p, clone_flags);
else
p->state = TASK_STOPPED;
CLONE_STOPPEDフラグが立っていない場合は、wake_up_new_task関数を実行し、親子プロセスのスケジューリングを適切に行うように調整し、CLONE_STOPPEDフラグが立っている場合はstateメンバにTASK_STOPPEDフラグを立てます。
if (unlikely (trace)) {
current->ptrace_message = pid;
ptrace_notify ((trace << 8) | SIGTRAP);
}
デバッガのための処理です。この処理によってデバッガが親プロセス、子プロセスを適切にトレースできるようにします。
if (clone_flags & CLONE_VFORK) {
wait_for_completion(&vfork);
if (unlikely (current->ptrace & PT_TRACE_VFORK_DONE))
ptrace_notify ((PTRACE_EVENT_VFORK_DONE << 8) | SIGTRAP);
}
vfork()システムコールを使用した時の処理です。vforkは親プロセスと子プロセスが同じメモリアドレス空間を共有するので、子プロセスが終了するまで、親プロセスの実行を停止させます。後述しますが、Linuxがforkによる複製の無駄を省くために実装している工夫の1つです。
asmlinkage long sys_vfork(struct pt_regs *regs)
{
return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, regs->rsp, regs, 0,
NULL, NULL);
}
} else {
free_pidmap(pid);
pid = PTR_ERR(p);
}
return pid;
copy_process関数での処理が失敗した場合は、子プロセスの為に取得したプロセスIDをpidmap_arrayに未使用フラグを立てて解放した後、エラーコードに変換したものをpidに格納します。失敗しなかった場合はそのまま取得した子プロセスのプロセスIDを返却します。
そして処理がユーザーモードで__libc_fork
に戻りpidが0かどうかで親プロセス子プロセスで分岐し、適切な後処理を行います。そしてユーザープロセスにプロセスIDを返します。
親プロセスのコピーは無駄ではないのか?
Linuxでは親プロセスの資源を子にコピーすることで、新しいプロセスを生成するという手法をとっていますが、通常子プロセスはexecveなどのシステムコールを利用して、親プロセスとは違うアドレス空間の処理を実行することが多いので、この複製処理は非効率的に思えます。
そういった問題に対処する為、両方のプロセスが同一の物理ページを共有し、片方に変更があった時のみ新しくページを割り当てるコピーオンライトや、オープンファイルディスクリプタなど、プロセスごとのカーネルデータ構造は共有するなどの方法で効率化を図っています。
次回、C言語で学ぶソケットAPI入門について書く時はマルチプロセスを活用したプログラミングを行います。