Webサーバのマルチスレッドでの実装における優位性をLinux Kernel 3.3のソースから読み解く

表題の通り、現行のカーネルの実装において、マルチスレッドとマルチプロセスでの実装の優位性はどういう所にあるのかを知りたい、というのが今回の調査の意図だ。

そのために、前提知識としてマルチスレッドとマルチプロセスの特徴について、カーネルのソースを見ていきたいと思う。カーネルの海に飛び込むには、やはり最新のLinux Kernel 3.3のソースを選択することにした。前置きの段階で少し長くなってしまいそうだ。ではカーネルの海に飛び込みたいと思う。

とはいっても、まずは最初の一歩が必要なので、当たりをつけようと思う。キーワードは、

  • マルチスレッド
  • マルチプロセス
  • コンテキストスイッチ
  • メモリ空間の共有
  • TLB(トランスレーション・ルックアサイド・バッファ)

である。

前置き

では、まずプロセス周りの実装を見るために、linux-3.3/arch/x86/kernel/process.cから見ていく。なぜかというと、スレッドを生成する際に呼ばれるシステムコールはclone()であり、それはprocess.cの中でsys_clone()として定義されている。

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->sp;
    return do_fork(clone_flags, newsp, regs, 0, parent_tid, child_tid);
}

すると、どうやらclone_flagsを元にdo_fork()関数を呼んでいるようだ。clone_flagの内、スレッドを生成する際にはいくつかのフラグを組み合わせることによって、生成したいタスク(プロセスやスレッド)を生成することができる。スレッドの場合は、フラグの一つとしてCLONE_VMというフラグを渡す。

CLONE_VM
CLONE_VM が設定された場合、呼び出し元のプロセスと子プロセスは同じメモリ空間で 実行される。特に、呼び出し元のプロセスや子プロセスの一方がメモリに書き込んだ内容はもう一方のプロセスからも見ることができる。さらに、子プロセスや呼び出し元のプロセスの一方が mmap(2) や munmap(2) を使ってメモリをマップしたりアンマップした場合、もう一方のプロセスにも影響が及ぶ。

引用:http://www.linuxcertif.com/man/2/clone/ja/
※ ここでいうプロセスはスレッドも含んでいると思われる。

また、プロセス生成の際に呼ばれるシステムコールのfork()の実装であるsys_fork()もprocess.cの中にあったので、ついてに見ておく。

int sys_fork(struct pt_regs *regs)
{
    return do_fork(SIGCHLD, regs->sp, regs, 0, NULL, NULL);
}

おやおや、こちらもdo_forkを呼んでいる。スレッドだろうがプロセスだろうが、kernelからみたら一つの関数に収まっているようだ。へー。これによって、プロセスのfork()だろうと、スレッドのclone()だろうとdo_fork()が呼ばれていることが分かった。そこで、do_fork()の中をみるために、linux-3.3/kernel/fork.cに飛び込む。

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 nr;

 /* 省略 */

    p = copy_process(clone_flags, stack_start, regs, stack_size,
             child_tidptr, NULL, trace);

 /* 省略 */

}

すると、中ではプロセスやスレッドの情報をコピーするためのcopy_process()がある。さらにこいつを見ていく。

static struct task_struct *copy_process(unsigned long clone_flags,
                    unsigned long stack_start,
                    struct pt_regs *regs,
                    unsigned long stack_size,
                    int __user *child_tidptr,
                    struct pid *pid,
                    int trace)
{
    int retval;
    struct task_struct *p;
    int cgroup_callbacks_done = 0;

 /* 省略 */

    /* Perform scheduler related setup. Assign this task to a CPU. */
    sched_fork(p);

    retval = perf_event_init_task(p);
    if (retval)
        goto bad_fork_cleanup_policy;
    retval = audit_alloc(p);
    if (retval)
        goto bad_fork_cleanup_policy;
    /* copy all the process information */
    retval = copy_semundo(clone_flags, p);
    if (retval)
        goto bad_fork_cleanup_audit;
    retval = copy_files(clone_flags, p);
    if (retval)
        goto bad_fork_cleanup_semundo;
    retval = copy_fs(clone_flags, p);
    if (retval)
        goto bad_fork_cleanup_files;
    retval = copy_sighand(clone_flags, p);
    if (retval)
        goto bad_fork_cleanup_fs;
    retval = copy_signal(clone_flags, p);
    if (retval)
        goto bad_fork_cleanup_sighand;
    retval = copy_mm(clone_flags, p);
    if (retval)
        goto bad_fork_cleanup_signal;
    retval = copy_namespaces(clone_flags, p);
    if (retval)
        goto bad_fork_cleanup_mm;
    retval = copy_io(clone_flags, p);
    if (retval)
        goto bad_fork_cleanup_namespaces;
    retval = copy_thread(clone_flags, stack_start, stack_size, p, regs);
    if (retval)
        goto bad_fork_cleanup_io;

 /* 省略 */

}

かなり省略して、見たいところだけ。上記の箇所でプロセスもしくはスレッドの情報のコピー処理を行っている。ここで、メモリの情報をコピーするためのcopy_mm()に注目して、この中に飛び込む。

static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
    struct mm_struct *mm, *oldmm;
    int retval;

 /* 省略 */

    oldmm = current->mm;
    if (!oldmm)
        return 0;

    if (clone_flags & CLONE_VM) {
        atomic_inc(&oldmm->mm_users);
        mm = oldmm;
        goto good_mm;
    }

 /* 省略 */

}

すると、このルーチンで、以前スレッドを生成するためにdo_forkに渡した新しく作るプロセスやスレッドとメモリ空間を共有するためのCLONE_VMフラグの場合のメモリコピーの実装が分かる。CLONE_VMフラグの場合は、ユーザープロセス毎のメモリ空間管理構造体へのポインタであるmm_structに、同じメモリ空間へのポインタをコピーしている。これによって、スレッドを生成した場合には同じメモリ空間を指し示すため、「スレッドはメモリを共有する」という実装がどういうことか明らかになる。

なるほど!

本題

ここまでが前置きで、ようやく本題に入ることができる。例えばWebサーバのような実装で、スレッドを再利用して変数の共有無くしてスレッドにも独立性を与えようとすればするほど、あんまりプロセスと変わらなくなってしまうような気がしていた。なぜなら、プロセスは今やコピーオンライトだし、タスク(プロセスとスレッドを含む)を再利用するならスレッドの優位性ってあんまりなくて、プロセスに対するスレッドの優位性は生成・破棄が早いところにこそある、と考えていたからだ。

では、それは本当だろうか?

ここで、マルチタスク環境におけるコンテキストスイッチの観点から、スレッドとプロセスの差を見ていく。では、ついにkernel/sched/core.cの内部を見ていこう。この中にcontext_switch()がある。

static inline void
context_switch(struct rq *rq, struct task_struct *prev,
           struct task_struct *next)
{
    struct mm_struct *mm, *oldmm;

    prepare_task_switch(rq, prev, next);

    mm = next->mm;
    oldmm = prev->active_mm;
    /*
     * For paravirt, this is coupled with an exit in switch_to to
     * combine the page table reload and the switch backend into
     * one hypercall.
     */
    arch_start_context_switch(prev);

    if (!mm) {
        next->active_mm = oldmm;
        atomic_inc(&oldmm->mm_count);
        enter_lazy_tlb(oldmm, next);
    } else
        switch_mm(oldmm, mm, next);

 /* 省略 */

}

すると、中ではメモリ空間に関するswitch処理が行われている。そのメインとなる処理はswitch_mm(oldmm, mm, next)だ。こいつをさらに見ていく。arch/x86/include/asm/mmu_context.hの中で定義されている。

static inline void switch_mm(struct mm_struct *prev, struct mm_struct *next,
                 struct task_struct *tsk)
{
    unsigned cpu = smp_processor_id();

    if (likely(prev != next)) {

 /* 省略 */

        /* Re-load page tables */
        load_cr3(next->pgd);

        /* stop flush ipis for the previous mm */
        cpumask_clear_cpu(cpu, mm_cpumask(prev));

        /*
         * load the LDT, if the LDT is different:
         */
        if (unlikely(prev->context.ldt != next->context.ldt))
            load_LDT_nolock(&next->context);
    }

 /* 省略 */

}

ここで、少しハードウェアよりの話をしたい。

まず、通常、コンテキストスイッチによってメモリ空間が切り替わるタイミングでcr3レジスタが更新される。そこで、TLB(トランスレーション・ルックアサイド・バッファ)というバッファがフラッシュされる。TLBとは、マイクロプロセッサがメモリ空間にアクセスする場合に、仮想アドレスから物理アドレスへの変換を高速に実現するためのキャッシュ機構である。TLBにアドレスのキャッシュが無いと、ページテーブルを順にたどっていくコストが発生する。つまり、TLBがフラッシュされると一旦キャッシュが無くなった状態になる。

では、このコンテキストスイッチ時のフラッシュ及びcr3レジスタの更新は実際にどう実装されているのか見ていこう。

switch_mm()内のcr3レジスタが更新される箇所を見ていく。

    if (likely(prev != next)) {

        cpumask_set_cpu(cpu, mm_cpumask(next));

        /* Re-load page tables */
        load_cr3(next->pgd);

        /* stop flush ipis for the previous mm */
        cpumask_clear_cpu(cpu, mm_cpumask(prev));

        /*
         * load the LDT, if the LDT is different:
         */
        if (unlikely(prev->context.ldt != next->context.ldt))
            load_LDT_nolock(&next->context);
    }

ほーほー。どうやらcr3レジスタが更新される条件は、ユーザープロセス毎のメモリ空間管理構造体であるmm_structへのポインタが異なっていた場合だとわかる。上記箇所で、 if (likely(prev != next)) によってポインタの比較がなされた場合、スレッド間ではCLONE_VMフラグによってdo_forkされているため、このmm_structが指し示す先は同じである。その結果、load_cr3(next->pgd)によるcr3レジスタの更新がされない。

つまり、スレッドをclone()する際にCLONE_VMフラグを渡すことでメモリ空間を共有している場合(つまりはマルチスレッド)は、コンテキストスイッチ時にTLBフラッシュがされないことになる。一方でマルチプロセス間では、mm_structが異なるため、コンテキストスイッチ毎にTLBがフラッシュされる。一般的にTLBフラッシュはコストが高いとされているため、ここでマルチスレッドの優位性が得られるわけだ。

なるほど、そういうことか。ここにもマルチスレッドの優位性があるわけだ。ようやくここまで辿りつくことができた。スレッドとプロセスにしても、優位性を語るには非常に細かい実装まで見ていく必要がある。

以上から、スレッドやプロセスの再利用においても、TLBのフラッシュにかかるコスト(フラッシュされることによるキャッシュのヒット率の低下も?)の観点からスレッドの方が優位性があることが分かった。

「Webサーバのマルチスレッドでの実装における優位性をLinux Kernel 3.3のソースから読み解く」への1件のフィードバック

コメントは受け付けていません。