linux内核“任务”之定时器、内核线程、系统调用

一、内核定时器

1.基本概念

在某些场景下,我们需要在特定的时间后做某些动作,但是又不想一直等待而浪费CPU,这个时候定时器是非常合适的机制。定时器用于在将来的某个时间点执行某个函数以完成特定的任务。
内核定时器告诉内核在指定的时间点使用特定的参数来调用特定的函数。定时器是异步运行于其注册者的,定时器运行时,注册该定时器的任务可能在休眠也可能在其它处理器上运行,甚至可能已经退出。
linux中内核定时器是基于(软)中断实现的,也就是它处于中断上下文而非进程上下文。在非进程上下文有些原则要遵循:
  • 不允许访问用户空间
  • current无意义,因而也不可用
  • 不能进行睡眠或者调度。 不能调用 schedule 或者某种 wait_event, 也不能调用任何可能引起睡眠的函数。信号量也不可用,因为信号量可能引起休眠。
内核代码通过调用函数 in_interrupt()可以判断当前是否处于中断上下文, 只要返回非0就表示处于中断上下文。内核可以通过调用in_atomic()判断当前是否允许调度。不允许调度的情况包括:处于中断上下文以及拥有自旋锁的上下文。

由于定时器是异步执行的,因而定时器处理函数必须注意进行互斥保护。

2.linux内核支持的定时器

linux内核支持两种类型的定时器:
  • 经典定时器:精度取决于计算机时钟中断运行频率的定时器。该定时器精度一般比较低,为1000/HZ ms的精度。该定时器以固定的频率产生,即每隔1000/HZ ms产生一次。如果没有使能动态时钟特性,则在该定时器到期时,可能并没有真正的定时事件发生,比如系统中只添加了如下定时器:11ms,52ms,78ms到期的定时器,但是经典定时器会在4的倍数ms处到期(4,8,12...),因而说定时器到期的时间点不一定有定时事件发生。
  • 高分辨率定时器:经典定时器的精度比较低,在有的场合需要更高精度的定时器,比如多媒体应用,因而系统引入了该类型的定时器。该定时器本质上可以在任意时刻发生。
这里也需要特别之处两个概念:
  • 动态时钟:只有在有任务需要实际执行时,才激活周期时钟,否则就禁用周期时钟的技术。作法是如果需要调度idle来运行,禁用周期时钟;直到下一个定时器到期为止或者有中断发生时为止再启用周期时钟。单触发时钟是实现动态时钟的前提条件,因为动态时钟的关键特性是可以根据需要来停止或重启时钟,而纯粹的周期时钟不适用于这种场景。
  • 周期时钟:周期性的产生时钟时间的时钟。
从应用上来说,定时器主要有两个用途:
  • 超时:表示在一定时间后会发生的事件,事实上,在使用超时时,大多数情形下,并不期望超时时间发生,定时器往往会在超时之前被取消。另外即便不取消,超时事件也往往不是一个精确的事件,比如在网络中用到的各种超时定时器,它们表达的意思往往是如果在这个时间点之前还没有...,则可以认为...,这个时间的取值往往是一个经验值或者估算值,并不是精确的时间要求。在这种场合经典定时器是够用的。
  • 定时器:用于实现时序,比如播放声音时,需要定期往声卡发送数据,这种场合下,对时间就有比较严格的要求,如果不在某个时间点发送数据到声卡,就会出现声音失真。这时就要使用高精度定时器。
通过配置可以使得linux工作在如下模式:
  • 高分辨率动态时钟
  • 高分辨率周期时钟
  • 低分辨率动态时钟
  • 低分辨率周期时钟

3.低分辨率内核定时器

低分辨率定时器是最常见的内核定时器,内核会使用处理器的时钟中断或者其他任何适当的周期性时钟源作为定时器的时间基线。时钟中断会定期发生,每秒HZ次。该中断对应的定时器处理函数一般为timer_interrupt,在该函数的处理中,最终会调到do_timer和update_process_timers。其中do_timer会负责全系统范围的、全局的任务:更新jiffies,处理进程统计;而后一个函数则会进行进程统计,产生TIMER_SOFTIRQ,向调度器提供时间感知。

当定时器到期时,在定时器处理函数被调用之前,定时器会被从激活链表移走,因而如果想要在本次执行完后再过一段时间后重新执行,就需要重新添加定时器。在SMP系统中,定时器函数会由注册它的CPU执行。

内核定时器的实现要满足以下要求和假设:

  • 定时器管理必须尽可能简化.
  • 其设计必须在活动定时器大量增加时具有很好的伸缩性
  • 大部分定时器在几秒或最多几分钟内到期, 而带有长延时的定时器是相当少见.
  • 一个定时器应当在注册它的同一个 CPU 上运行.

低分辨率内核定时器的实现是很巧妙的。它基于一个每-CPU 数据结构。timer_list的base字段包含了指向该结构的指针。如果 base是NULL, 这个定时器没有被调用运行; 否则, 这个指针告知哪个数据结构(也就是哪个CPU)正在运行它。

无论何时内核代码注册一个定时器(通过add_timer或者mod_timer), 操作最终由internal_add_timer执行( 在kernel/timer.c), 它会将新的定时器添加到和当前CPU关联的“级联表”中的定时器双向链表中。
级联表的工作方式:
  1. 如果定时器在接下来的 0 到 255 jiffies 内到期, 则它被添加到专供短时定时器的256个链表中的一个, 使用expires(即添加到那个链表是由到期时间的比特位决定的)的低8位决定加到那个链表中
  2. 如果它在将来更久时间到期(但又在16384个jiffies之前 ), 则它被添加到64个链表之一,这64个链表与expires的8-13比特位相关,expires的这6个比特决定了使用哪个链表。
  3. 类似的技术被应用于expires的比特位14-19,20-25以及26-31。
  4. 如果定时器在更久的经来到期,则将其放入26-31比特对应的链表中,具体使用那个链表取决于(0xffffffff+base->timer_jiffies的结果的高6位)
这里描述的低8:6:6:6:6比特组并不特别准确(每个比特组定义了该级级联表的大小),根据配置还可能采用6:4:4:4:4的比特组。这里只是描述下其设计原理。
当__run_timers被激活时, 它会执行当前定时器滴答上的所有pending的定时器。如果当前jiffies是256的倍数, 这个函数会将下一级定时器链表重新散列到256短期链表中,同时还可能根据jiffies上的比特位划分对其它级别的定时器做级联处理。它的基本原理在于级联表是以定时器到期的jiffies组织的,而base->timer_jiffies则记录了当前jiffies,因而如果__run_timers被执行时,它首先取base->timer_jiffies最低的几个比特确定的链表中的定时器并执行它们,并且如果base->timer_jiffies为0,则就开始检查下一个比特组。对于除了最低的比特组外的所有比特组,其处理逻辑为:如果该比特组不为0,则就将该比特组确定的链表中的定时器重新添加到系统中,并且不再继续检查下一个比特组,否则检查会检查下一个比特组。

这种技术使用了较多的链表头,占用了一些额外的内存,但是很好的满足了对内核定时器的要求和假设。但是由于会受其它中断的影响,因而它并不绝对准确。该技术的图形化示意如图:


4.低分辨率定时器相关API

struct timer_list
{
  /* ... */
  unsigned long expires;
  void (*function)(unsigned long);
  unsigned long data;
};
这里并没有列出timer_list的所有元素,仅列出来了比较重要的几个:
  • expires:以jiffies为单位的超时时间。
  • function:定时器处理函数
  • data:定时器处理函数的参数
该结构在使用之前必须被初始化,初始化可以保证所有字段被正确的设置,包括那些对用户不可见的字段。对于定时器数据结构要用init_timer初始化,或者对于静态的数据结构可以通过将TIMER_INITIALIZER赋值给它来完成初始化。
void init_timer(struct timer_list *timer);
完成对动态申请的定时器数据结构的初始化。
struct timer_list TIMER_INITIALIZER(_function, _expires, _data);
完成对静态定时器数据结构的初始化
void add_timer(struct timer_list * timer);
添加定时器到系统
int del_timer(struct timer_list * timer);
从系统中删除定时器。
在初始化后,调用add_timer将定时器添加到系统之前可以修改列出的定时器数据结构的三个域。
int mod_timer(struct timer_list *timer, unsigned long expires);
更新一个定时器的超时时间。
int del_timer_sync(struct timer_list *timer);
该函数功能类似于del_timer,但是它保证当它返回时,定时器函数不在任何 CPU 上运行。在SMP架构中使用del_timer_sync可以避免竞态。大多数情况下应该优先考虑使用del_timer_sync。对于该函数要注意:
  1. 如果在非原子上下文调用了该函数,则函数可能休眠
  2. 如果在原子上下文调用了它,它会保持忙等待
  3. 不能在中断上下文调用它
  4. 调用这不能持有可能阻塞定时器处理函数完成处理的锁。在拥有锁时,调用del_timer_sync要特别小心,因为如果定时器函数也企图获取相同的锁,就可能造成死锁。
  5. 如果调用了该函数,则要求定时器处理函数不能重新注册自己。如果定时器函数会重新注册自己, 则需要进行特殊处理以确保在调用了该函数来删除定时器时,定时器不被重新注册。可以设置一个" 关闭 "标志,然后由定时器处理函数检查这个标志来确保满足这个要求。
int timer_pending(const struct timer_list * timer);

返回真或假来表示定时器当前是否正被调度执行。

5.高分辨率定时器

常规的低分辨率内核定时器针对“超时”的应用场景做了优化,同时低分辨率定时器的实现是和jiffies紧密相连的,因而无法应用于需要高精度定时的场合,为此linux提供了高分辨率定时器hrtimer。

hrtimer 是建立在 per-CPU 时钟事件设备上的,如果SMP系统中只存在一个全局的时钟事件设备,则这样的系统无法支持高分辨率定时器。高分辨率定时器需要由CPU的本地时钟事件设备来支持,即它是per-CPU的。为了支持 hrtimer,内核需要配置 CONFIG_HIGH_RES_TIMERS=y。hrtimer 有两种工作模式:低精度模式(low-resolution mode)与高精度模式(high-resolution mode)。虽然 hrtimer 子系统是为高精度的 timer 准备的,但是系统可能在运行过程中动态切换到不同精度的时钟源设备,因此,hrtimer 必须能够在低精度模式与高精度模式下自由切换。由于低精度模式是建立在高精度模式之上的,因此即便系统只支持低精度模式,部分支持高精度模式的代码仍然会编译到内核当中。

高分辨率定时器基于红黑树实现。它独立于周期时钟,并且使用纳秒作为时间单位。
高分辨率定时器可以基于两种时钟实现:
  • 单调时钟CLOCK_MONOTONIC:从0开始单调递增
  • 实时时钟CLOCK_REALTIME:系统实际时间,可能跳跃,比如系统时间改变。
高分辨率模式下高分辨率定时器的时钟事件设备会在定时器到期时引发中断,该中断由hrtimer_interrupt处理。如果没有提供高分辨率时钟,则高分辨率定时器的到期操作由hrtimer_run_queues完成。
在最新的代码中,如果高精度定时器的处理函数没有返回HRTIMER_NORESTART,则定时器处理函数处理完后定时器框架会自动重启高精度定时器,相关代码片段如下:
        restart = fn(timer);
        ...

        /*
         * Note: We clear the CALLBACK bit after enqueue_hrtimer and
         * we do not reprogramm the event hardware. Happens either in
         * hrtimer_start_range_ns() or in hrtimer_interrupt()
         */
        if (restart != HRTIMER_NORESTART) {
                BUG_ON(timer->state != HRTIMER_STATE_CALLBACK);
                enqueue_hrtimer(timer, base);
        }

6.高分辨率定时器数据结构和API

hrtimer_bases 是实现 hrtimer 的核心数据结构,通过 hrtimer_bases,hrtimer 可以管理挂在每一个 CPU 上的所有 timer。每个 CPU 上的 timer list 不再使用 timer wheel 中多级链表的实现方式,而是采用了红黑树(Red-Black Tree)来进行管理。其定义如下:
DEFINE_PER_CPU(struct hrtimer_cpu_base, hrtimer_bases)
高分辨率时钟使用了结构体hrtimer_clock_base来表示时钟基础。主要包括:
  • cpu_base:该时钟基础所属包括的各CPU时钟基础结构
  • get_time:获取时间的函数
  • resolution:该定时器的分辨率
  • active:所在的红黑树的根相关信息
结构体hrtimer_cpu_base表示每CPU时钟基础。主要包括:
  • expires_next:将要到期的下一个事件的绝对时间
  • hres_active:是否启用了高精度模式
结构体hrtimer用于定时高精度定时器。主要包括:
  • node:用于将定时器维护在红黑树中
  • _softexpires:定时器到期的绝对时间
  • function:定时器到期时执行的函数
  • base:指向时钟基础
  • state:状态
    • HRTIMER_STATE_INACTIVE:定时器处于非活动状态
    • HRTIMER_STATE_ENQUEUED:定时器在时钟基础上排队,等待到期
    • HRTIMER_STATE_CALLBACK:正在执行到期的回调函数
    • HRTIMER_STATE_MIGRATE:被迁移到了其他CPU。

void hrtimer_init(struct hrtimer *timer, clockid_t which_clock, enum hrtimer_mode mode);初始化定时器

int hrtimer_cancel(struct hrtimer *timer);尝试取消定时器,并且如果定时器在被执行就等待它被执行完
int hrtimer_try_to_cancel(struct hrtimer *timer);尝试取消定时器,如果此时定时器是active的就返回1,如果是非active的就返回0,如果正被执行就返回-1,前两种情况定时器会被取消,最后一种情形定时器不被取消。
int hrtimer_start(struct hrtimer *timer, ktime_t tim, const enum hrtimer_mode mode);在当前CPU上启动定时器
u64 hrtimer_forward(struct hrtimer *timer, ktime_t now, ktime_t interval);使定时器在now后interval的时间点超时

二、内核线程

1.基本概念

内核线程实际上是直接由内核本身启动的进程。当前系统中有一些工作是由内核线程完成的:

  • 周期性地将修改的内存页与页来源块设备同步
  • 如果内存页很少使用,则写入交换区
  • 管理延时动作
  • 实现文件系统的事务日志
  • 执行软中断(ksoftirqd)
内核线程可能用于两种场景:
  • 启动一个内核线程,然后一直处于等待状态直到被唤醒以完成某种服务
  • 启动一个周期性运行的内核线程,以检查特定资源的使用情况,并作出适当的反映
内核线程由内核自身生成,其特点在于:
  1. 它们在内核态执行,而不是用户态。
  2. 它们只可以访问虚拟地址空间的内核部分(高于TASK_SIZE的所有地址),但不能访问用户空间。 

task_struct进程描述符中包含两个跟进程地址空间相关的字段mm, active_mm,对于普通用户进程来说,mm指向虚拟地址空间的用户空间部分,而对于内核线程,mm为NULL。active_mm主要用于优化,由于内核线程不与任何特定的用户层进程相关,内核并不需要倒换虚拟地址空间的用户层部分,保留旧设置即可。由于内核线程之前可能是任何用户层进程在执行,故用户空间部分的内容本质上是随机的,内核线程决不能修改其内容,故将mm设置为NULL,同时如果切换出去的是用户进程,内核将原来进程的mm存放在新内核线程的active_mm中。假如内核线程之后运行的进程与之前是同一个,内核并不需要修改用户空间地址表,TLB中 信息仍然有效;只有在内核线程之后执行的进程与此前用户层进程不同时,才需要切换,并清除对应TLB数据。

2.内核线程的信号处理

默认情况下内核线程以及daemonize的用户线程阻塞所有信号。如果内核线程想要允许信号发送过来,则需要调用allow_signal以允许发送信号给自己。但是内核线程的信号处理方式和用户空间的是不同的,用户线程的信号处理程序由系统自动调用,其入口为do_signal,该函数在用户线程重新被调度时可能会被执行。但是对于内核线程来说,是不会走到该函数的,这就意味着内核线程的信号处理必须采用不同的方式。简单的来说内核线程需要采用如下方式来处理发送给它的信号:
do {
    /* the process procedure of the kernel thread */
} whle(!signal_pending(current)) 

3.实现方式即相关API

内核线程可以通过两种方式实现:

  1. 将一个函数传递给kernel_thread
  2. 创建内核更常用的方法是辅助函数kthread_create,该函数创建一个新的内核线程。最初线程是停止的,需要使用wake_up_process启动它。或使用kthread_run,与kthread_create不同的是,其创建新线程后立即唤醒它。

long kernel_thread(int (fn) (void *), void *arg, unsigned long flags)

参数及意义:

  • fn:为要执行的函数的指针
  • arg:函数参数
  • flags:线程标志
struct task_struct *kthread_create(int (*threadfn)(void *data), void *data, const char namefmt[], ...)
其参数及意义:
  • threadfn:thread的入口函数
  • data:threadfn的参数
  • namefmt:thread的名字
该函数用于创建一个最初处于停止状态的内核线程。当被唤醒时,threadfn会被调用,同时data作为其参数,threadfn可以有两种方式结束:
  1. 直接调用do_exit来退出
  2. 或者在某个地方会对该thread调用kthread_stop
kthread_run(threadfn, data, namefmt, ...) 
它是一个宏,创建完一个thread后立即唤醒它。它实际上是在调用完了kthread_create后立即调用了wake_up_process
int kthread_stop(struct task_struct *k) 
该函数用于停止一个由kthread_creat创建的thread。如果thread会自己调用do_exit则不能对该thread调用该thread_stop
void kthread_bind(struct task_struct *k, unsigned int cpu)
将刚创建的thread绑定到指定的CPU。CPU不一定是online的,但是调用该函数时thread必须处于stopped状态(也就是刚执行完kthread_create)。

三、系统调用

1.基本概念

系统调用:系统调用指的是操作系统提供给应用程序调用的一组“特殊”接口。应用程序可以通过这组“特殊”接口来获得操作系统内核提供的服务。
从逻辑上来说,系统调用可被看成是一个内核与用户空间程序交互的接口——系统调用把应用程序的请求传给内核,调用相应的的内核函数完成所需的处理,将处理结果返回给应用程序。
系统服务之所以需要通过系统调用来提供给用户空间的根本原因是为了对系统进行“保护”,防止恶意用户破坏系统或者由于不小心而破坏了系统。系统调用的特殊之处在于规定了用户进程进入内核的具体位置。

Linux系统只提供了最基本和最有用的系统调用,可以通过man 2 syscalls 命令查看,或到 ./include/linux/syscalls.h源文件中查看。

2.系统调用的功能

系统调用主要完成以下任务:

  • 进程管理
  • 时间操作
  • 信号处理
  • 调度
  • 模块
  • 文件系统
  • 内存管理
  • 进程间通信
  • 网络功能
  • 系统信息和设置
  • 系统安全

从另一反面来说无论完成什么任务,都可以归属到下面几类:  

  • 控制硬件——系统调用往往作为硬件资源和用户空间的抽象接口,比如读写文件时用到的write/read调用。
  • 设置系统状态或读取内核数据——因为系统调用是用户空间和内核的唯一通讯手段,所以用户设置系统状态,比如开/关某项内核服务(设置某个内核变量),或读取内核数据都必须通过系统调用。
  • 进程管理——创建、执行进程,以及获取进程的状态等信息。

之所以使用系统服务是因为:
  • 有些服务必须获得内核数据,比如一些服务必须获得中断或系统时间等内核数据。
  • 从安全角度考虑,在内核中提供的服务相比用户空间提供的毫无疑问更安全,很难被非法访问到。
  • 从效率考虑,在内核实现服务避免了和用户空间来回传递数据以及保护现场等步骤,因此效率往往要比在用户空间实现高许多。比如,httpd等服务。
  • 如果内核和用户空间都需要使用该服务,那么最好实现在内核空间,比如随机数产生。

3.和C库的关系

在大多数情况下,应用程序直接使用C库中的API而不是系统调用,对于C库来说,有的C库提供的功能是完全由在用户态完成的,有的要借助一个系统调用来实现,有的要借助多于一个系统调用来实现。

4.重启系统调用

如果正被执行的系统调用被中断了,内核需要通知调用者,系统调用被中断了,这时会返回-EINTR错误,需要注意的是这里的中断是指系统调用被发送给该进程的信号给中断了,而不是一般意义上的中断(硬件中断、软中断本身并不会使得系统调用返回-EINTR,否则系统调用被打断就成了常态了。)。但是这会加大调用者的工作量,它必须检查返回值,如果是返回了该错误就要重新发起系统调用。可以通过设置信号的SA_RESTART标识来使得系统调用在被该信号中断时被自动重新启动。

5.系统调用的实现

当发起系统调用时,需要传递一个系统调用号给内核,一个系统调用号对应于一个系统调用,随后CPU进入内核模式。不管使用何种方式实现系统调用(比如通过X86的int $0x80或者通过sysenter指令进入),内核都会得到一个系统调用号,然后根据该系统调用号执行相应的系统调用,并在执行完成后返回一个整数值给用户程序,0表示系统调用成功,负数表示失败。系统调用的处理过程:

  1. 在内核态保存大多数寄存器的内容
  2. 调用相应的服务程序
  3. 退出系统调用程序:用保存在内核栈中的值加载寄存器,并切换回用户态
linux定义了NR_syscalls个系统调用。可以传递参数给系统调用,但是传递的参数要满足两个要求:
  1. 每个参数长度不能超过寄存器长度
  2. 参数个数不能超过6个,因为使用6个寄存器来传递参数
如果参数超过了6个,就要用一个寄存器来保存存放这些参数的值所在的内存区域。如果使用了C库,用户就不用关心这些细节。在执行系统调用之前,内核也会做些合法性检查比如参数验证。如果系统调用要返回大量数据给其调用者,则必须通过调用者指定的内存区交换该数据,该内存区必须是调用者可访问的区域。
系统调用虽然是在内核执行,但它并非一个纯粹意义上的内核线程。它只是代表用户进程在内核运行,因此它可以访问进程的许多信息(比如current结构——当前进程的控制结构),而且可以被其他进程抢占(在从系统调用返回时,由system_call函数判断是否该再调度),可以休眠,还可接收信号等等。需要注意的是系统调用完成后,在回到或者说把控制权交回到发起调用的用户进程前,内核会有一次调度。如果发现有优先级别更高的进程或当前进程的时间片用完,那么就会选择高优先级的进程或重新选择进程运行。除了再调度需要考虑外,再就是内核需要检查是否有挂起的信号,如果发现当前进程有挂起的信号,那么还需要先返回用户空间处理信号处理例程(处于用户空间),然后再回到内核,重新返回用户空间,有些麻烦但这个反复过程是必须的。
调用性能问题

系统调用需要涉及到用户空间和内核空间的来回切换,因而需要花费一些额外的时间。在大多数情况下这个时间是可接受的,如果应用对性能要求很高,但是又想使用系统提供的服务,可以将程序放入内核。

可以使用ptrace或者strace来追踪系统调用。

展开阅读全文

没有更多推荐了,返回首页