【Linux】内核学习笔记(七)——定时器和时间管理

本篇是《Linux内核学习笔记》系列的第七篇。

系统定时器是一种可编程硬件芯片,能以固定的频率产生中断,这些中断驱动了内核中的相当一部分函数。利用定时器中断,我们可以使某些函数以固定的频率被重复执行,或者推后一个给定的时间再执行。不仅如此,它也在进程调度中发挥着重要作用。

定时器为内核带来了“时间”这个概念。

1. 时钟中断频率

可以在下面看到内核的时钟中断频率值。

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

CONFIG_HZ可以在make menuconfig指令中设置,也可以直接修改内核配置文件,笔者的配置为x86的默认值100。

至于USER_HZ,这是用户空间中能看到的频率。因为在很长一段时间里内核都维持着100Hz的频率值,这导致某些用户程序错误地将100硬编码进代码里。当内核频率变为1000Hz,这些用户程序可能错误地将2小时判断为20小时。所以内核直接摆了决定只告诉用户空间这一固定值。内核如果要将jiffies(后面会提到)相关的数据传到用户空间,通过一些函数转化为clock即可。如果你使用过Linux系统提供的计时设施,那么一定不会对CLOCK_PER_SEC感到陌生。

HZ值可以调整,那么应该选取什么样的HZ值呢?显而易见,高频率能提供更精确的抢占调度,并更准确地分析系统资源消耗,但会增加处理器负担,导致高能耗。所以要视具体情况而定。

2. jiffies

jiffies是一个全局变量。它在科学应用中经常表示各种时间间隔,一般指10ms,特别适合用于表示Linux系统的时间,即系统启动后定时器产生的节拍的总数。它的声明如下:

/*
 * The 64-bit value is not atomic - you MUST NOT read it
 * without sampling the sequence number in jiffies_lock.
 * get_jiffies_64() will do this for you as appropriate.
 */
extern u64 __cacheline_aligned_in_smp jiffies_64;
extern unsigned long volatile __cacheline_aligned_in_smp __jiffy_arch_data jiffies;

后面我们会看到这个神奇变量的定义。先来明确一个换算关系:

jiffies = seconds * HZ

例如,如果HZ取值为100,那么一个jiffy就是10ms,同时seconds可以精确到小数点后两位。

2.1. jiffies的内部表示

32位系统的jiffies真的只有很少一点,对于100Hz系统来说,它在497天后就会溢出;而1000Hz系统则在49.7天就会溢出。但是64位的jiffies那可就大了,想想四十亿个50天是多长时间……

然而出于兼容性的考虑,特别是32位系统仍然使用原来32位的jiffies,不能放弃对该变量的维护,或者改变其定义;而同时维护32位和64位两个变量,又带来不必要的开销。如果能这样就好了:反正jiffies加1的时候,jiffies_64也加1,不论如何它们的低32位都是一样的!要是jiffies和jiffies_64能做到用一条add语句同步更新,那就一箭双雕了。原来的代码不受影响,新的代码又可以稳定地依靠jiffies_64。

我们知道jiffies_64和jiffies都是全局变量,具有强弱符号之分。要想达到上面的要求,应该考虑为jiffies_64作一个强符号定义,这样链接器会为它分配实际空间。而jiffies全部为弱符号,这样就可以想个办法作为引用,指向jiffies_64的空间。这样所有外部模块中对符号jiffies的引用,最终都指向jiffies_64。真是魔法一般的操作。

不过这对于设计Linux的魔法师们,还真不是问题。上面已经看过了jiffies和jiffies_64的声明,下面就列出jiffies_64的强符号定义。

__visible u64 jiffies_64 __cacheline_aligned_in_smp = INITIAL_JIFFIES;

EXPORT_SYMBOL(jiffies_64);

那么怎样处理jiffies,让它引用到jiffies_64?这是令笔者今日大长见识的地方。

jiffies = jiffies_64;

vmlinux.lds.S是主内核映像的链接脚本,靠这一条语句,让符号jiffies指向为jiffies_64分配的空间。在64位下,unsigned long就是64位,所以jiffiesjiffies_64是一回事。在32位下,unsigned long是32位,链接器会将jiffiesjiffies_64的小端对齐,从而jiffies指代jiffies_64的低32位。

链接和符号重定位的问题是解决了,但还有不少问题需要考虑周全才行。例如,这么链接之后,jiffies会以编译器难以预料的玄学方式改变,毕竟编译器只靠符号区分不同变量,才不知道具体指向了什么空间,后者是链接器负责的。所以,必须为jiffies加一个volatile修饰符,要求编译器每次使用jiffies都要直接从内存区域取值,防止在错误的假设(即认为寄存器中的值寄存期间一直未变)下进行错误的优化。

2.2. jiffies的使用

有时我们会比较两个节拍数,或者判断一个节拍数是否落在某区间内。对于这样的需求,内核准备了一套宏,比如:

#define time_after(a,b)		\
	(typecheck(unsigned long, a) && \
	 typecheck(unsigned long, b) && \
	 ((long)((b) - (a)) < 0))
#define time_before(a,b)	time_after(b,a)

它完备地考虑到了32位jiffies可能存在的溢出问题,并充分利用了编译优化,所以在编写例如设备驱动程序时,最好使用这些API来完成上述需求。不过,从这段代码的注释来看,似乎当时的gcc还不是很争气吧(

 * Do this with "<0" and ">=0" to only test the sign of the result. A
 * good compiler would generate better code (and a really good compiler
 * wouldn't care). Gcc is currently neither.

3. 时钟中断处理程序

3.1. 注册

x86系统中,时钟中断处理程序的注册如下所示,可以看到时钟中断的IRQ号被无条件设成0。

static void __init setup_default_timer_irq(void)
{
	unsigned long flags = IRQF_NOBALANCING | IRQF_IRQPOLL | IRQF_TIMER;

	/*
	 * Unconditionally register the legacy timer interrupt; even
	 * without legacy PIC/PIT we need this for the HPET0 in legacy
	 * replacement mode.
	 */
	if (request_irq(0, timer_interrupt, flags, "timer", NULL))
		pr_info("Failed to register legacy timer interrupt\n");
}

3.2. 时钟中断处理程序

书上说这一程序是与体系结构相关的例程,但看起来简单到令人失望:

/*
 * Default timer interrupt handler for PIT/HPET
 */
static irqreturn_t timer_interrupt(int irq, void *dev_id)
{
	global_clock_event->event_handler(global_clock_event);
	return IRQ_HANDLED;
}

它调用一个函数句柄,但是我只能推测它调用的是tick_handle_periodic,还没有找到确切的证据。欢迎各位dalao在评论区补充。该函数调用另一位于个同一文件中的函数tick_periodic,它们都是体系结构无关的:

/*
 * Periodic tick
 */
static void tick_periodic(int cpu)
{
	if (tick_do_timer_cpu == cpu) {
		raw_spin_lock(&jiffies_lock);
		write_seqcount_begin(&jiffies_seq);

		/* Keep track of the next tick event */
		tick_next_period = ktime_add(tick_next_period, tick_period);

		do_timer(1);
		write_seqcount_end(&jiffies_seq);
		raw_spin_unlock(&jiffies_lock);
		update_wall_time();
	}

	update_process_times(user_mode(get_irq_regs()));
	profile_tick(CPU_PROFILING);
}

tick_periodic所做的很多重要工作在do_timerupdate_process_times中完成。

首先是do_timer

/*
 * Must hold jiffies_lock
 */
void do_timer(unsigned long ticks)
{
	jiffies_64 += ticks;
	calc_global_load();
}

在持有jiffies_64锁的情况下,可以调用该函数进行节拍值修改,然后更新系统平均负载统计值。修改节拍数后,会更新墙上时间,之后调用update_process_times,该函数更新进程花费时间。通过user_tick可判断时间花是在在用户空间还是内核空间。

/*
 * 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;

	PRANDOM_ADD_NOISE(jiffies, user_tick, p, 0);

	/* Note: this timer irq context must be accounted for as well. */
	account_process_tick(p, user_tick);
	run_local_timers();
	rcu_sched_clock_irq(user_tick);
#ifdef CONFIG_IRQ_WORK
	if (in_irq())
		irq_work_tick();
#endif
	scheduler_tick();
	if (IS_ENABLED(CONFIG_POSIX_TIMERS))
		run_posix_cpu_timers();
}

account_process_tick会实际上更新进程所花费的时间,它的参数是一个task_struct指针,本例中就是current宏的计算结果。下面的代码已根据笔者机器的配置做了一些删减。

/*
 * Account a single tick of CPU time.
 * @p: the process that the CPU time gets accounted to
 * @user_tick: indicates if the tick is a user or a system tick
 */
void account_process_tick(struct task_struct *p, int user_tick)
{
	u64 cputime, steal;


	cputime = TICK_NSEC;
	steal = steal_account_process_time(ULONG_MAX);

	if (steal >= cputime)
		return;

	cputime -= steal;

	if (user_tick)
		account_user_time(p, cputime);
	else if ((p != this_rq()->idle) || (irq_count() != HARDIRQ_OFFSET))
		account_system_time(p, HARDIRQ_OFFSET, cputime);
	else
		account_idle_time(cputime);
}

steal似乎是和多虚拟机共享处理器有关的指标。这是笔者个人的理解,不一定正确:当虚拟机A和B共享同一台物理机时,如果hypervisor正将资源分配给B,那么A的虚拟CPU就要等待。这段时间A的虚拟CPU没有睡眠,但也没有在执行计算,所以这段时间被B偷走了(steal)。具体内容不再深挖。

不过还有一件事值得注意,那就是cputime似乎被全额分给了current进程。这么分不一定准确,因为上个时间片中,很有可能发生中断,中断处理程序可不属于进程上下文,这些时间不该算到进程中;同时该进程也可能和其他进程共享时间片。《Linux内核设计与实现》提到传统的Unix就是这么处理,Linux 2.6并没有很好的解决办法。而在如今,如果确实还是没有良好的办法,可以靠提高时钟频率来提高准确度。现在1000Hz并不会给机器带来太大负担。

更新进程时间后,update_process_times又调用run_local_timers将发起一个软中断,即用下半部处理所有即将到时的定时器,我们会在下面讨论。

/*
 * Called by the local, per-CPU timer interrupt on SMP.
 */
void run_local_timers(void)
{
	struct timer_base *base = this_cpu_ptr(&timer_bases[BASE_STD]);

	hrtimer_run_queues();
	/* Raise the softirq only if required. */
	if (time_before(jiffies, base->next_expiry)) {
		if (!IS_ENABLED(CONFIG_NO_HZ_COMMON))
			return;
		/* CPU is awake, so check the deferrable base. */
		base++;
		if (time_before(jiffies, base->next_expiry))
			return;
	}
	raise_softirq(TIMER_SOFTIRQ);
}

最后,scheduler_tick负责为每一个进程减少时间片值,相信如果在《操作系统》课中学过抢占式调度原理,对这个操作应该会有印象。它就是在这里实现的。如果内核启用了SMP,那么它还要平衡每个处理器上的运行队列。

4. 定时器

中断下半部是推后执行的。有时我们不止希望推后,还希望推后一个指定的时间长度执行该任务,这时就需要在时钟中断处理程序中发起软中断。显然可以每一次时钟中断时都发起软中断,不过效率比较低,从run_local_timers的代码中我们也可以看出,它在jiffies到达某个expire的时刻时,才发起软中断。

很容易想象到定时器的用法:指定一个时刻(或时间间隔),再给一个回调函数(或许需要一些参数),接下来就可以倒一杯卡布奇诺静静等待函数执行了。

这是定时器的数据结构。

struct timer_list {

	struct hlist_node	entry;
	unsigned long		expires;
	void			(*function)(struct timer_list *);
	u32			flags;

};

hlist_node是哈希链表,在第四节有介绍。使用起来比较简单,首先定义并初始化它:

DEFINE_TIMER(my_timer, my_function);

一步到位,但是一定注意,my_timer的生存期起码得能坚持到回调函数执行完毕之时,一般来说全局变量的生存期还是算够格的。或者这样也行:

struct timer_list my_timer;

如果是这么定义的,接下来要对定时器初始化。选什么函数,取决于定时器在哪里初始化。

timer_setup(&my_timer, my_function, my_flags);
// timer_setup_on_stack(&my_timer, my_function, my_flags);

初始化定时器后,可以选择性地填充timer_listexpiresfunctionflags,分别用于指定定时器的执行时间、回调函数和定时器选项,然后用下面的代码激活定时器

add_timer(&my_timer);

就大功告成了!当然,也可以用下面的语句修改已激活的定时器,或者激活一个未激活的定时器:

mod_timer(&my_timer, jiffies_64 + new_delay);

还可以取消定时器。通常要像这样进行同步取消,因为有的处理器可能已经在运行这个回调函数,需要等待它们执行完毕。

del_timer_sync(&my_timer);

说起来,回调函数的格式看起来很奇怪,它传入的是timer_struct结构的指针,但是这指针指向的结构里啥也没有啊?那回调函数需要参数的时候怎么办?别着急,我们会在schedule_timeout的实现中进行描述。

不过时刻牢记,定时器是用软中断实现的,所以传入的回调函数必须得遵守一点规矩。具体来说:

  • 软中断不属于进程上下文,不能访问用户空间;
  • 允许响应中断,但不能休眠;
  • 多处理器下允许并发执行,需要注意数据一致性(也可以只使用单处理器数据)。

后面的章节有定时器的模块编程示例

5. 延迟执行的手段

5.1. 短延迟

有时要求内核代码在很小的一个时延后执行,可能是微秒或纳秒级,比定时器周期还小。这种情况应采用忙等待的办法,Linux已经提供了具体的接口:mdelayudelayndelay。它们通过执行确定次数的循环来达尽可能到精确的延时效果(通常是长一点点),因为CPU每秒能执行的指令数是可以获知的。

然而应时刻注意,持锁忙等或禁止中断都是粗鲁的做法,它们大幅会降低系统性能。只有等待时间很少且需要精确等待时间时,才用这些函数。

5.2. schedule_timeout

a

相比之下这是一种比较礼貌的方式:

set_current_state(TASK_INTERRUPTIBLE);
schedule_timeout(s*HZ);

先将任务设置为可睡眠的状态(TASK_UNINTERRUPTIBLE也行,不过睡眠期间不接受任何信号),然后告诉调度程序,s秒后再重新调度我执行。

不过既然要调用调度程序,那么也需要满足一些性质。即必须处于进程上下文(禁止在中断上下文使用),而且不能持有锁,这样的代码才是可睡眠的。

schedule_timeout是用定时器实现的。其思路可简单地归结为:程序先设一个定时器,要求在一个给定的时间后唤醒它,然后就调用schedule让出处理器。等时间一到,内核会调用回调函数将该进程唤醒。

上源代码。

/**
 * schedule_timeout - sleep until timeout
 * @timeout: timeout value in jiffies
 *
 * Make the current task sleep until @timeout jiffies have elapsed.
 * The function behavior depends on the current task state
 * (see also set_current_state() description):
 *
 * %TASK_RUNNING - the scheduler is called, but the task does not sleep
 * at all. That happens because sched_submit_work() does nothing for
 * tasks in %TASK_RUNNING state.
 *
 * %TASK_UNINTERRUPTIBLE - at least @timeout jiffies are guaranteed to
 * pass before the routine returns unless the current task is explicitly
 * woken up, (e.g. by wake_up_process()).
 *
 * %TASK_INTERRUPTIBLE - the routine may return early if a signal is
 * delivered to the current task or the current task is explicitly woken
 * up.
 *
 * The current task state is guaranteed to be %TASK_RUNNING when this
 * routine returns.
 *
 * Specifying a @timeout value of %MAX_SCHEDULE_TIMEOUT will schedule
 * the CPU away without a bound on the timeout. In this case the return
 * value will be %MAX_SCHEDULE_TIMEOUT.
 *
 * Returns 0 when the timer has expired otherwise the remaining time in
 * jiffies will be returned. In all cases the return value is guaranteed
 * to be non-negative.
 */
signed long __sched schedule_timeout(signed long timeout)
{
	struct process_timer timer;
	unsigned long expire;

	switch (timeout)
	{
	case MAX_SCHEDULE_TIMEOUT:
		/*
		 * These two special cases are useful to be comfortable
		 * in the caller. Nothing more. We could take
		 * MAX_SCHEDULE_TIMEOUT from one of the negative value
		 * but I' d like to return a valid offset (>=0) to allow
		 * the caller to do everything it want with the retval.
		 */
		schedule();
		goto out;
	default:
		/*
		 * Another bit of PARANOID. Note that the retval will be
		 * 0 since no piece of kernel is supposed to do a check
		 * for a negative retval of schedule_timeout() (since it
		 * should never happens anyway). You just have the printk()
		 * that will tell you if something is gone wrong and where.
		 */
		if (timeout < 0) {
			printk(KERN_ERR "schedule_timeout: wrong timeout "
				"value %lx\n", timeout);
			dump_stack();
			current->state = TASK_RUNNING;
			goto out;
		}
	}

	expire = timeout + jiffies;

	timer.task = current;
	timer_setup_on_stack(&timer.timer, process_timeout, 0);
	__mod_timer(&timer.timer, expire, MOD_TIMER_NOTPENDING);
	schedule();
	del_singleshot_timer_sync(&timer.timer);

	/* Remove the timer from the object tracker */
	destroy_timer_on_stack(&timer.timer);

	timeout = expire - jiffies;

 out:
	return timeout < 0 ? 0 : timeout;
}
EXPORT_SYMBOL(schedule_timeout);

首先是有一些边边角角的情况要处理。比如,如果参数timeoutMAX_SCHEDULE_TIMEOUT,那不能让它休眠,而是直接进行调度。想想看,如果无限期休眠,定时器不叫醒它,那谁把它叫醒?长眠不起可就坏了。其次,timeout不能是负数,这没啥意义。

定时器的创建、激活和注销没什么好说的,一切尽在代码中。关键是,process_timeout怎么知道唤醒的是哪个进程?参数是从哪里传入的?接下来看看这个函数的实现:

struct process_timer {
	struct timer_list timer;
	struct task_struct *task;
};

static void process_timeout(struct timer_list *t)
{
	struct process_timer *timeout = from_timer(timeout, t, timer);

	wake_up_process(timeout->task);
}

看到from_timer这条语句,其实能猜出个八九了。timer_list就像list_head一样,内核是将定时器结构attach在真正要传入回调函数的参数旁边,而不是向定时器中塞入参数。用之前讲过的container_of技术,就可以拿到包含定时器结构的父结构指针。这可比变长参数优雅多了,再一次被Linux开发者的智慧所折服!

#define from_timer(var, callback_timer, timer_fieldname) \
	container_of(callback_timer, typeof(*var), timer_fieldname)

发表评论

您的电子邮箱地址不会被公开。