Linux のスリープ処理、タイマ処理の詳細を見る

UNIX でプロセスを一時的にスリープさせるには sleep(3) が使えます。sleep() は引数に秒単位でしか時間を指定できないので、より短い時間を指定したい場合は usleep(3) (マイクロ秒) や nanosleep(2) (ナノ秒) を使うことになります。sleep(), usleep() はライブラリ関数、nanosleep() はシステムコール*1です。

この usleep() や nanosleep() で 1ms 程度の短い時間プロセスを停止したとして、正確にその時間だけ停止させることはできるでしょうか。http://shiroikumo.at.infoseek.co.jp/linux/time/ にあるコードを参考に、実際に動かしてみます。カーネル 2.6.19 x86_64、CentOS 5 で試します。

まず、nanosleep() で 1ms のスリープを行ってみます。参照先のサイトにあるように、スリープ間隔を gettimeofday(2) で計測したものを出力します。

#define _POSIX_C_SOURCE 200112L

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <sys/time.h>
#include <unistd.h>

enum {
    LOOP = 10,
};

int main (int argc, char **argv)
{
    struct timeval tv[LOOP];
    struct timespec req;

    req.tv_sec  = 0;
    req.tv_nsec = 1000000; // 1ms

    for (int i = 0; i < LOOP; i++) {
        nanosleep(&req, NULL);
        gettimeofday(tv + i, NULL);
    }

    for (int i = 0; i < LOOP; i++)
        printf("%ld:%06ld\n", (long)tv[i].tv_sec, (long)tv[i].tv_usec);

    return 0;
}

以下が実行結果です。

% ./a.out
1200922869:179356
1200922869:183413
1200922869:187452
1200922869:191493
1200922869:195554
1200922869:199594
1200922869:203634
1200922869:207675
1200922869:211716
1200922869:215756

ループ1回毎に 1ms ではなく 4ms ほどの時間が空いてしまっています。またこれ以上 req.tv_nsec を小さくしていっても 4ms 間隔になるのはこれ以上縮められないようです。

同様に usleep() も試してみます。こちらは C ではなく、Perl の Time::HiRes を使いました。Time::HiRes は裏で libc の usleep() を呼びます。ロジックの内容は上記の nanosleep() のものとほぼ同じです。

#!/usr/local/bin/perl
use strict;
use warnings;
use Time::HiRes qw/gettimeofday usleep/;

my @tv;

for (my $i = 0; $i < 10; $i++) {
    $tv[$i] = [ gettimeofday ];
    usleep(1000); # 1ms
}

for (my $i = 0; $i < 10; $i++) {
    printf "%d:%d\n", @{$tv[$i]};
}

以下が実行結果です。

% perl usleep.pl
1200922887:495952
1200922887:500306
1200922887:504345
1200922887:508404
1200922887:512445
1200922887:516485
1200922887:520524
1200922887:524566
1200922887:528605
1200922887:532646

こちらも同様に約 4ms ほど間隔が空いてしまっています。結局、nanosleep() でも usleep() でも、小さな値を指定した場合間に 4ms 程度の時間が空いてしまうようです。

nanosleep() の精度

この nanosleep の精度の問題ですが、結論から言うと

  • glibc の sleep(3) / usleep(3) はともに nanosleep(2) を使って実装されている
  • nanosleep() はカーネルの動的タイマ (High-resolution kernel timers) とスケジューラを使って実装されている
  • 動的タイマはタイマ割り込みのタイミングで処理されるため、タイマ割り込みの周期よりも短い精度は出せない
  • カーネル 2.6.19 のタイマ割り込みの周期はデフォルトで 4ms に設定されている

というのが原因です。

Linux の時限処理の多くはタイマ割り込みを利用して実装されていますが、タイマ割り込みは頻度が高すぎるとカーネルモードでの動作のオーバーヘッドが大きくなりすぎてしまうため、周期は 1 〜 10ms 程度に設定されるのが一般的です。カーネル 2.6.19 では 4ms がデフォルトです。タイマ割り込みの周期だけでなく、スケジューラがプロセスを一時停止から再度実行するまでの時間もリアルタイムであることは保証されておらず、結局マイクロ秒、ナノ秒を指定できるシステムコールであっても、その機能はインタフェース通りにはならず、環境依存ということになります。

以下、このタイマ割り込みや nanosleep() の実装をもう少し詳しく見るために、カーネルのコードを深追いしていきます。見ていくのは 2.6.20、i386 です。

タイマ割り込み信号を発生させるハードウェア

まず、前提としてタイマ割り込み信号を発生させるハードウェアについて。

AT互換機では、タイマ割り込みは HPET もしくは PIT というハードウェアによって発生させることができます。割り込み番号はいずれも IRQ 0 です。割り込み周期はカーネルの初期化時に I/O ポート経由で設定します。

HPET の正式名称は High Precision Event Timer で、今後 PIT を置換していくと思われる高精度タイマーです。サーバー用のハードなど高品質なものには HPET が搭載されています。

% dmesg | grep HPET
ACPI: HPET (v001 DELL   PE_SC3   0x00000001 DELL 0x00000001) @ 0x00000000000f293b
ACPI: HPET id: 0x1166a201 base: 0xfed00000
time.c: Using 14.318180 MHz WALL HPET GTOD HPET timer.

DELLPowerEdge SC1435 には HPET が搭載されていました。

コンシューマ機のマザーボードにはまだ HPET が搭載されていないことも多く、従来の Programmable Interval Timer = PIT によってタイマ割り込みを発生させることが多いようです。Intel の G965 チップセットでは

% dmesg | grep PIT
time.c: Using 3.579545 MHz WALL PM GTOD PIT/TSC timer.

と PIT が使われていました。Linux は HPET があれば HPET を、なければ PIT を使うようになっています。

カーネルの初期化処理

では、カーネルのコードを追っていきます。今回はタイマ割り込みの起動経路をはっきりさせたい。そこで、カーネル初期化周りを中心にみていきます。

主なカーネルの初期化処理は init/main.c の start_kernel() に記述されていますが、タイマ割り込み関連で見ておきたいのは

  • init_IRQ()
  • init_timers()
  • time_init()

あたりです。

asmlinkage void __init start_kernel(void)
{
...
    /* ハード割り込み周りの初期化。PIT の準備込み */
    init_IRQ();

    pidhash_init();

    /* 動的タイマの初期化処理 */
    init_timers();

    hrtimers_init();
    softirq_init();
    timekeeping_init();

    /* 時刻関連オブジェクトや変数の初期化。HPET の有無判定も */
    time_init();
...
    /* HPET がある場合、HPET の初期化 */
    if (late_time_init)
        late_time_init();
...
}

割り込み発生源のハードウェアの初期化

まずは割り込み発生源となる PIT/HPET の初期化処理を見ていきます。

PIT の初期化

ハードウェア割り込みの初期化処理が記述されている init_IRQ() を辿ると arch/i386/kernel/i8253.c の setup_pit_timer() にたどり着きます。その名の通り PIT の初期化処理がここに記述されています。

void setup_pit_timer(void)
{
    unsigned long flags;

    spin_lock_irqsave(&i8253_lock, flags);
    outb_p(0x34,PIT_MODE);      /* binary, mode 2, LSB/MSB, ch 0 */
    udelay(10);
    outb_p(LATCH & 0xff , PIT_CH0); /* LSB */
    udelay(10);
    outb(LATCH >> 8 , PIT_CH0); /* MSB */
    spin_unlock_irqrestore(&i8253_lock, flags);
}

このコードで登場する LATCH は

  • CLOCK_TICK_RATE = 8254チップの内部オシレータの周波数。1,193,182
  • HZ = 1秒あたりのタイマ割り込みのおおよその回数。カーネルコンパイル時に任意の値に設定可能。

という二つの値 CLOCK_TICK_RATE と HZ の比を四捨五入した値を得るマクロです。LATCH の値を I/O ポートPIT_CH0 に出力すると、PIT で 1秒間に HZ 回数タイマ割り込みが発生するようになります。

HZ の値は include/asm-i386/param.h に定義されています。

#ifdef __KERNEL__
# define HZ     CONFIG_HZ   /* Internal kernel timer frequency */
# define USER_HZ    100     /* .. some user interfaces are in "ticks" */
# define CLOCKS_PER_SEC     (USER_HZ)   /* like times() */
#endif

CONFIG_HZ と、カーネルコンパイル時に設定可能な値がデフォルト値として指定されています。.config を見ると

% grep 'CONFIG_HZ=' .config
CONFIG_HZ=250

と、確かに 250 が指定されています。1秒間に250回タイマ割り込みをする、という設定、すなわち「4ms に一回タイマ割り込みを発生させる」という設計になっているのがわかります。

HPET の初期化

前述の通り HPET があればタイマ割り込み発生源には HPET が使われますが、HPET の初期化はもう少し後になります。時間関連の変数やオブジェクトを初期化する time_init() の中で、HPET の有無を判定し HPET があった場合フラグを立てておいて、HPET の初期化を後で実行するようにしておきます。

arch/i386/kernel/time.c

void __init time_init(void)
{
    struct timespec ts;
#ifdef CONFIG_HPET_TIMER
    /* HPET が利用できるのであればフラグを立てて、あとで初期化 */
    if (is_hpet_capable()) {
        /*
         * HPET initialization needs to do memory-mapped io. So, let
         * us do a late initialization after mem_init().
         */
        late_time_init = hpet_time_init;
        return;
    }
#endif
    ts.tv_sec = get_cmos_time();
    ts.tv_nsec = (INITIAL_JIFFIES % HZ) * (NSEC_PER_SEC / HZ);

    do_settimeofday(&ts);

    do_time_init();
}

動的タイマ (High-resolution timers)

話は変わって、動的タイマの話。Linuxカーネル内部で時限処理を行うために"動的タイマ" という仕組みを備えています。動的タイマに任意の処理を追加した上、任意の時間を指定すると、その時間に追加した処理が起動するような仕組みです。例えばデバイスドライバで I/O のタイムアウトを実装したり、後述の nanosleep() や setitimer() などの時限系のシステムコールでもよく使われています。

次は動的タイマの初期化処理を見ていくことにします。

動的タイマを起動(遅延処理)するソフト割り込みハンドラの登録

この動的タイマですが start_kernel() の init_timers() が初期化処理に当たり、この中でタイマそのものを起動するソフト割り込みハンドラが登録されます。init_timers は kernel/timer.c に定義されいます。

void __init init_timers(void)
{
    int err = timer_cpu_notify(&timers_nb, (unsigned long)CPU_UP_PREPARE,
                (void *)(long)smp_processor_id());

    BUG_ON(err == NOTIFY_BAD);
    register_cpu_notifier(&timers_nb);

    /* ソフト割り込み TIMER_SOFTIRQ に run_timer_softirq() を登録 */
    open_softirq(TIMER_SOFTIRQ, run_timer_softirq, NULL);
}

open_softirq() はソフト割り込みハンドラをカーネルに登録する関数です。

ハードウェア割り込みとソフト割り込み

Linux カーネルは内外から受け取った割り込みに対して、あらかじめ設定したおいた任意の処理 (ハンドラ) を起動します。このとき

  • 優先度の高い仕事はハードウェア割り込みハンドラで即座に処理
  • 優先度が低かったり、時間がかかる処理はソフト割り込みハンドラであとから遅延実行

という二段階に分けて処理が行われます。init_timers() の内容から、動的タイマの起動にあたる run_timer_softirq() はソフト割り込みハンドラとして処理されることがわかりました。

ハードウェア割り込み周辺

タイマがソフト割り込みハンドラによって起動されることはわかりました。次は、そのソフト割り込み自体を起動するハードウェア割り込みハンドラの初期化処理を見ていきたいと思います。

ハードウェア割り込みハンドラの登録

time_init() 内では時計関連のオブジェクトや変数を初期化した上、PIT もしくは HPET が発生させた IRQ 0 番に対するハードウェア割り込みハンドラの登録が行われます。arch/i386/kernel/time.c にあります。

void __init time_init(void)
{
    struct timespec ts;
#ifdef CONFIG_HPET_TIMER
    /* HPET 判定処理 */
    if (is_hpet_capable()) {
        /*
         * HPET initialization needs to do memory-mapped io. So, let
         * us do a late initialization after mem_init().
         */
        late_time_init = hpet_time_init;
        return;
    }
#endif
    /* ハードウェアクロックから時刻を取得 */
    ts.tv_sec = get_cmos_time();
    ts.tv_nsec = (INITIAL_JIFFIES % HZ) * (NSEC_PER_SEC / HZ);

    /* 取得した時刻で時計を設定 */
    do_settimeofday(&ts);

    /* ハードウェア割り込みハンドラの登録へ */
    do_time_init();
}

do_time_init() を更に追うと、arch/i386/mach-default/setup.c の time_init_hook() にたどり着きます。time_init_hook() では setup_irq() によりハードウェア割り込みハンドラの登録が行われます。

static struct irqaction irq0  = { timer_interrupt, IRQF_DISABLED, CPU_MASK_NONE, "timer", NULL, NULL};

/**
 * time_init_hook - do any specific initialisations for the system timer.
 *
 * Description:
 *  Must plug the system timer interrupt source at HZ into the IRQ listed
 *  in irq_vectors.h:TIMER_IRQ
 **/
void __init time_init_hook(void)
{
    /* IRQ 0 に irq0 構造体をセット */
    setup_irq(0, &irq0);
}

タイマ割り込みに相当する IRQ 0 に struct irqaction の irq0 構造体をセットしているのがわかります。この構造体に割り込みハンドラ timer_interrupt() が設定されていることから、IRQ0 が発生すると timer_interrupt() がその度起動することがわかります。

ハードウェアハンドラの処理内容

以前に 負荷とは何か - naoyaのはてなダイアリー でも触れたとおり、このタイマ割り込みで起動されるハードウェア割り込みハンドラではロードアベレージの計算や、プロセスアカウンティング処理が行われます。*2

timer_interrupt() から追ってくと、kernel/timer.c にハードウェア割り込みハンドラから呼ばれるプロセスアカウンティング処理の update_process_times() が定義されていることが分かります。この update_process_times() 関数内では、プロセスアカウンティング処理以外に動的タイマの起動要求を出す関数 run_local_timers() が実行されていました。

/*
 * Called from the timer interrupt handler to charge one tick to the current
 * process.  user_tick is 1 if the tick is user time, 0 for system.
 */
void update_process_times(int user_tick)
{
    struct task_struct *p = current;
    int cpu = smp_processor_id();

    /* Note: this timer irq context must be accounted for as well. */
    if (user_tick)
        account_user_time(p, jiffies_to_cputime(1));
    else
        account_system_time(p, HARDIRQ_OFFSET, jiffies_to_cputime(1));

    /* 動的タイマを走らせる */
    run_local_timers();

    if (rcu_pending(cpu))
        rcu_check_callbacks(cpu, user_tick);
    scheduler_tick();
    run_posix_cpu_timers(p);
}

run_local_timers() の中身はというと、

void run_local_timers(void)
{
    /* TIMER_SOFTIRQ 割り込み要求 */
    raise_softirq(TIMER_SOFTIRQ);
    softlockup_tick();
}

raise_softirq() でソフト割り込み TIMTER_SOFTIRQ の要求を出しています。TIMER_SOFTIRQ には前に見たとおり、動的タイマの起動処理である run_timer_softirq() が割り込みハンドラとして登録されているのでした。なお、raise_softirq() はソフト割り込みの要求を出すだけで、実際にその割り込みが処理されるのはもう少しあと、ハードウェア割り込みハンドラ終了時になります。

初期化処理まとめ

長くなってしまいましたが、ここまでの一連の処理から

  • タイマ割り込みは PIT/HPET から発生する。周期はカーネルが 4ms に設定する。
  • タイマ割り込みが発生するとハードウェア割り込みハンドラ timer_interrupt() が起動する。
  • timer_interrupt() から呼ばれる update_process() 内でソフト割り込み TIMER_SOFTIRQ が要求される
  • ソフト割り込み TIMER_SOFTIRQ に対してはソフト割り込みハンドラ run_timer_softirq() が登録されており、動的タイマ処理がここから起動される

ということがはっきりしました。「4ms のタイマ割り込みを契機に動的タイマが都度処理される」ということになります。

nanosleep(2) の実装をみる

では最後に、今回の本丸である nanosleep(2) の実装を見てみましょう。sys_nanosleep() を入り口に見ていきます。sys_nanosleep() では nanosleep 呼び出しの引数で与えられた timespec 構造体をカーネル空間にコピーして、詳細処理へ移動します。

asmlinkage long
sys_nanosleep(struct timespec __user *rqtp, struct timespec __user *rmtp)
{
    struct timespec tu;

    /* ユーザプロセスで指定された timespec 構造体をコピー */
    if (copy_from_user(&tu, rqtp, sizeof(tu)))
        return -EFAULT;

    if (!timespec_valid(&tu))
        return -EINVAL;

    /* nanosleep 処理の詳細へ移動 */
    return hrtimer_nanosleep(&tu, rmtp, HRTIMER_REL, CLOCK_MONOTONIC);
}

続けて hrtimer_nanosleep() を見ていきます。(後半の処理は省略します。なお、省略した後半では割り込みなどで nanosleep() が中断された場合のリスタート処理を行っています。) 接頭辞の "hrtimer" は "High-resolution timers" の略で、これまでにみてきた動的タイマのことです。

long hrtimer_nanosleep(struct timespec *rqtp, struct timespec __user *rmtp,
               const enum hrtimer_mode mode, const clockid_t clockid)
{
    struct restart_block *restart;
    struct hrtimer_sleeper t; /* スリープ用タイマオブジェクトを作成 */
    struct timespec tu;
    ktime_t rem;

    /* タイマを初期化 */
    hrtimer_init(&t.timer, clockid, mode);

    /* タイマの有効期限にユーザープロセスで指定された値をセット */
    t.timer.expires = timespec_to_ktime(*rqtp);

    /* nanosleep 処理本体を起動 */
    if (do_nanosleep(&t, mode))
        return 0;

    ...

    return -ERESTART_RESTARTBLOCK;
}

タイマを初期化して有効期限をセットしています。このあたりから nanosleep() の実装には動的タイマが使われていることがわかります。

更に追っていきます。do_nanosleep() です。

static int __sched do_nanosleep(struct hrtimer_sleeper *t, enum hrtimer_mode mode)
{
    /* 引数にカレントプロセスのプロセスディスクリプタを指定し、スリープタイマを初期化 */
    hrtimer_init_sleeper(t, current);

    do {
        /* カレントプロセスを待ち状態に変更 */
        set_current_state(TASK_INTERRUPTIBLE);
      
        /* タイマ開始。hrtimer_start() ではタイマキューにタイマをエンキュー。 */
        hrtimer_start(&t->timer, t->timer.expires, mode);

        /* カレントプロセスの実行権を手放す → コンテキストスイッチ */
        schedule();

        hrtimer_cancel(&t->timer);
        mode = HRTIMER_ABS;

    } while (t->task && !signal_pending(current));

    return t->task == NULL;
}

do_nanosleep がやっている処理はコメントに記載した通りで、

  • スリープタイマ初期化
  • カレントプロセスを待ち状態に変更
  • タイマ開始 (エンキューするのみ。実際の時限処理はソフト割り込みハンドラ run_timer_softirq() が起動
  • スケジューラを呼び出し実行権を手放し、次のプロセスにスイッチ

という流れです。プロセスをスリープ状態にする過程が見えました。ここで重要なのは最初のスリープタイマの初期化処理です。スリープタイマの初期化処理内部では、タイマが切れたときに実行されるコールバック関数が登録されます。その様子を見てみましょう。

void hrtimer_init_sleeper(struct hrtimer_sleeper *sl, struct task_struct *task)
{
    /* タイマが切れた際に起動するコールバックに hrtimer_wakeup() を登録 */
    sl->timer.function = hrtimer_wakeup;

    /* task には current が入っている */
    sl->task = task;
}

static int hrtimer_wakeup(struct hrtimer *timer)
{
    struct hrtimer_sleeper *t =
        container_of(timer, struct hrtimer_sleeper, timer);

    /* タスクを取り出す。ここでは current */
    struct task_struct *task = t->task;

    t->task = NULL;

    if (task)
        wake_up_process(task); /* 待ち状態にあるタスクを起こす */

    return HRTIMER_NORESTART;
}

コールバック関数にはカレントプロセスのプロセスディスクリプタである current が渡っており、コールバック関数内ではそのプロセスに対して wake_up_process() を呼んでいるのがわかります。この wake_up_process() により 実行可能状態 (TASK_RUNNING) となってランキューに入ります。いずれスケジューラがランキューからプロセスを取り出し、do_nanosleep() の schedule() 呼び出し直後から実行を再開します。処理が再開するとタイマは完全に停止し、システムコールが終了します。

nanosleep() が動的タイマを利用して実装されていること、タイマ処理以外ではスケジューラにその挙動を委ねていることがわかりました。さきのタイマ割り込みの頻度や、動的タイマの起動処理と併せて見ると、冒頭で結論として示したとおり、タイマ割り込み周期以上の精度が得られない理由がこれではっきりします。(このあたりの精度の問題を解決するのがリアルタイムOS、ということだそうですが、門外漢なのでその仕組み等々はよく知りません。)

なお、nanosleep() だけでなく、周期的に SIGALRM を発生させてユーザープロセスの時限処理に使える setitimer() なども同様に、動的タイマを用いて実装されています。

gettimeofday(2)

ところで、割り込み周期は 4ms であるのに、時刻取得の gettimeofday(2) の精度はマイクロ秒単位です。これはどのように実現されているのでしょうか、このあたりも調べたので、またの機会にまとめてみたいと思います。

まとめ

  • Linux のスリープ処理の詳細とタイマ処理を見て来ました
  • カーネル内部で利用される動的タイマは、タイマ割り込みを契機にソフト割り込みハンドラから起動されます
  • Linux カーネルのタイマ割り込みの周期は 4ms です
  • sleep(3), usleep(3) の実装の母体にもなっている nanosleep(2) は動的タイマとスケジューラの挙動を利用して実装されています
  • nanosleep(2) の実装の核になるカーネル内部の動的タイマの仕組みがタイマ割り込みの周期に依存している以上、4ms 以上の精度は実現できません。またスケジューラの挙動も精度に関係します。

例によって間違い等多々あるかもしれません。ツッコミ大歓迎です。

追記

コメント欄でご指摘いただきましたが、タイマ割り込み周期 4ms はデフォルト値で、HZ の値はカーネルコンパイル時に任意の値に設定可能です。id:dayflower さんありがとうございます。

*1:libc がライブラリ関数としてラップしています

*2:ユニプロセッサ環境の場合は PIT/HPET からの割り込みでプロセスアカウンティング処理が行われますが、マルチプロセッサ環境ではローカルAPIC からの割り込みがその契機になります。