linux内核设计与实现总结(3-10章)

        之前一直是做的嵌入式音视频方向,对于驱动方向也是小白一个,希望能通过这本书对驱动和内核有更加深入的了解。

这里记录一些看书过程中遇到的一些比较重要的地方,如果写的有问题请指正。


第三章 进程管理

slab分配器:实际上是建立在伙伴系统之上,slab使用的内存空间是通过伙伴算法进行分配的,slab通过自己的算法堆小块内存进行了管理

伙伴算法:把所有的空闲页框分组为 11 块链表,每一块链表分别包含大小为1,2,4,8,16,32,64,128,256,512 和 1024 个连续的页框。对1024 个页框的最大请求对应着 4MB 大小的连续RAM 块。每一块的第一个页框的物理地址是该块大小的整数倍。例如,大小为 16个页框的块,其起始地址是 16 * 2^12 (2^12 = 4096,这是一个常规页的大小)的倍数。

BSD进程记账:开启后,内核每次在一个进程终止时写一个记帐记录。这些记帐记录一般是命令名的少量二进制数据、使用 的CPU时间量、用户ID和组ID、开始时间,等等

SMP系统:多处理器系统

进程中住相关信息保存在task_struct这个结构体中,结构体定义在sched.h中。

Linux中可以用ps命令查看所有进程的信息:

ps -eo pid,tid,ppid,comm

Linux中创建进程与其他系统有个主要区别,Linux中创建进程分2步:fork()和exec()。

fork: 通过拷贝当前进程创建一个子进程

exec: 读取可执行文件,将其载入到内存中运行


第四章 进程调度

调度我认为最主要是为了让客户有更好的使用体验,为加快进程的执行效率而生。

调度是一个平衡的过程。一方面,它要保证各个运行的进程能够最大限度的使用CPU(即尽量少的切换进程,进程切换过多,CPU的时间会浪费在切换上);另一方面,保证各个进程能公平的使用CPU(即防止一个进程长时间独占CPU的情况)

进程被分为I/O消耗型 和cpu消耗型,这种分类也并非绝对,有些进程可以两者兼备

I/O消耗型:只运行短短一会儿,随后会开始等待那些可阻塞资源,如键盘鼠标…………,调度策略应该为增加调度频率减少运行时间。

cpu消耗型:大部分时间花在cpu处理中(执行代码),对于这种类型进程,调度策略应该为降低调度频率增加运行时间。

通过以下指令可以查看,所有进程的相关信息

ps -eo state,uid,pid,ppid,rtprio,ni,time,comm

现代进程调度策略:进程调度由优先级和时间片来决定,优先级一个是nice值-20~+19,越大优先级越低,一个是实时优先级0~99,越大优先级越高。

现代进程调度策略的问题:

1、nice值到时间片需要将nice值对应到处理器的绝对时间,优先级低的处理时间少,多个低优先级进程会导致进程切换频繁。

2、相对的nice,高优先级级下问题不大,当低优先级时时间片差的必定较多,比如一个优先级差5ms,高优先级0为100ms,1为95ms问题不大,但是当低优先级18 10ms,19为5ms,前者处理时间为后者2倍。

3、时间片需要时节拍器的整数倍

4、存在某些睡眠/唤醒一个进程的后门,打破公平原则

CFS(完全公平调度算法):CFS的抢占时机取决于新的可运行程序消耗了多少处理器使用比,从而完全摈弃了时间片。CFS确保每个进程使用公平的处理器使用比,主要通过不同的权重因子来分配时间,消耗的使用比比当前进程小,则新进程立刻投入运行,抢占当前进程。2.6.23内核版本之后

cfs 中的选择下一个运行的程序的规则为选择vruntime最小的进程,其中使用红黑树(rbtree)储存每个进程的vruntime时间。

睡眠与唤醒-----等待队列

在内核里面,等待队列是有很多用处的,尤其是在中断处理、进程同步、定时等场合 。可以使用等待队列在实现阻塞进程的唤醒。它以队列为基础数据结构,与进程调度机制紧密结合,能够用于实现内核中的 异步事件通知机制 ,同步对系统资源的访问等。

struct wait_queue_head {
    spinlock_t        lock;
    struct list_head    head;
};

这个结构体有三个用的比较多的接口

init_waitqueue_head(wq_head) 初始化

wait_event_timeout(wq_head, condition, timeout) 睡眠

wake_up(x) 唤醒

用户抢占

从系统调用或者中断处理程序返回用户空间时检查need_resched是否被设置,如果need_resched被设置则调用schedule()进行用户抢占

内核抢占

linux在2.6版本中引入了内核抢占。

如果此时能保证安全的调度,也可以立刻进行内核抢占,只要现在进程中没有持有锁,那么就支持安全的内核抢占。在每个进程的thread_info中引入了参数preempt_count进行锁的计数。当中断返回内核空间时,内核会检查 need_resched 是否被设置,preempt_count 是否为0 来判断是否可以进行内核抢占。

内核抢占可能发生在:

1:中断处理程序完成,返回内核空间之前

2:当内核代码再一次具有可抢占性的时候,如解锁及使能软中断等。 

也可以通过调用preempt_schedule_irq()函数来间接调用schedule()函数可以进行显式的内核抢占。

preempt_counter 字段是32位的, 除了抢占计数器之外还包括其他标志位, 只要 preempt_counter 整体不为0, 就不能进行内核抢占, 这个设计一下子简化了对众多不能抢占的情况的检测: 

Bit  0-7:  抢占计数器, 表示显式禁用内核抢占的次数
Bit  8-15: 软中断计数器, 记录可延迟函数被禁用的次数
Bit 16-27: 硬中断计数器, 表示中断处理程序的嵌套数, irq_enter()递增它, irq_exit()递减它
Bit    28: PREEMPT_ACTIVE 标志, 内核抢占的标志

Schedule函数工作流程如下:

(1)清理当前运行中的进程

(2)选择下一个要运行的进程(pick_next_task)

(3)设置新进程的运行环境

(4) 进程上下文切换


第五章 系统调用

实现一个系统调用需要注意

参数检查

传输到内核的指针必须保证1、指针指向的区域为内核区域 2、指针指向的内存区域在进程的地址空间里3、进程调用系统调用不能绕过内存访问限制

注意:内核不论何时都不能轻率的接受来自用户空间的指针

内核与用户空间数据拷贝 copy_to_user() copy_from_user()


第六章 内核数据结构

链表

内核链表特点是将链表结构体保存在用户数据结构体中,这样可以保证链表独立于用户数据之外,方便用户数据的单独操作

list1

list2

我们在通过链表中的pre/next进入到链表的上一个/下一个节点时,此时我们只能获得链表的数据结构,如果想要获得用户数据的数据结构,需要使用到下面这个接口

#define list_entry(ptr, type, member) \
    container_of(ptr, type, member)

container_of 的定义如下

#define container_of(ptr, type, member) ({          \
    const typeof(((type *)0)->member)*__mptr = (ptr);    \
             (type *)((char *)__mptr - offsetof(type, member)); })

第一个参数ptr代表链表指针

第二个参数type代表数据结构体的类型

第三个参数member代表type中定义的这个链表的名字

struct student
{
    int id;
    char* name;
    struct list_head list;
};

以student为例。type是struct student,ptr是指向stuct list的指针,也就是指向member类型的指针,member就是 list

typeof(x)的作用就是提取x的类型

 typeof(((type *)0)->member)的意思就是提取链表结构体的l类型

offsetof的定义如下,用于计算从结构体头到mem的中间的偏移量

#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)

链表结构体地址减去这个偏移量就是数据结构体的头地址

剩下的链表的操作均比较常规

队列(FIFO)

内核中的队列是以字节形式保存数据的,所以获取数据的时候,需要知道数据的大小。

如果从队列中取得数据时指定的大小不对的话,取得数据会不完整或过大。

内核中关于队列定义的头文件位于:<linux/kfifo.h> include/linux/kfifo.h

头文件中定义的函数的实现位于:kernel/kfifo.c

内核队列编程需要注意的是:

  • 队列的size在初始化时,始终设定为2的n次方
  • 使用队列之前将队列结构体中的锁(spinlock)释放

映射

每个唯一的id对应一个自定义的数据结构。

映射的使用需要注意的是,给自定义的数据结构申请一个id的时候,不能直接申请id,先要分配id(函数idr_pre_get),分配成功后,在获取一个id(函数idr_get_new)。

参考查看:idr机制(32叉树)_paomadi的专栏-CSDN博客

二叉树(具体实现)

内核中用到的比较多的是红黑树,他是一种自平衡二叉搜索树,搜索的时间复杂度为logn,自平衡二叉搜索树的特点是所有叶子节点之间的深度差不超过1的二叉搜索树。

红黑树的特性如下

  • 所有节点都有颜色,要么红色,要么黑色
  • 根节点是黑色,所有叶子节点也是黑色
  • 叶子节点中不包含数据
  • 非叶子节点都有2个子节点
  • 如果一个节点是红色,那么它的父节点和子节点都是黑色的
  • 从任何一个节点开始,到其下叶子节点的路径中都包含相同数目的黑节点

红黑树中最长的路径就是红黑交替的路径,最短的路径是全黑节点的路径,再加上根节点和叶子节点都是黑色,从而可以保证红黑树中最长路径的长度不会超过最短路径的2倍。

内核中关于红黑树定义的头文件位于:<linux/rbtree.h> include/linux/rbtree.h

头文件中定义的函数的实现位于:lib/rbtree.c

struct rb_node
{
    unsigned long  rb_parent_color;
#define    RB_RED        0
#define    RB_BLACK    1
    struct rb_node *rb_right;
    struct rb_node *rb_left;
} __attribute__((aligned(sizeof(long))));

        __attribute__((aligned(sizeof(long))));代表的是long的大小对齐,long在32位里面是4字节,64位里面是8字节。这表示在指向这个结构体的指针的最后两位必定是0,比如0xAB00,红黑树的颜色存储在最后一位中如0xAB01就代表的是黑色,0xAB00代表的就是红色。


第七章 中断和中断处理

       为了提高cou与外设间协通工作的性能,引入了中断机制

        如果没有中断,则使用轮询机制,这个机制很多时候cpu询问的时候并没有出现中断导致出现无用功。

        中断机制是硬件在需要的时候向CPU发出信号,CPU暂时停止正在进行的工作,来处理硬件请求的一种机制。

        这里说的的中断一般称为异步中断,同步中断一般又叫做异常,

        同步中断是在cpu处理完中断请求的所有工作之后才做出反馈,在处理器执行到由于编程实物导致的错误指令是,或者出现缺页异常等情况,就会通过内核来处理,处理器就会产生一个异常。他的产生式必须考虑与处理器时钟同步。

异步中断是CPU处理中断的时间过长,所以先将硬件复位,使硬件可以继续自己的工作,然后在适当时候处理中断请求中耗时的部分。

异步中断举个例子:网卡的工作原理

  1.     网卡收到数据包后,向CPU发出中断信号,请求处理接收到的数据包
  2.     CPU将收到的数据包拷贝到内存后,即通知网卡继续工作
  3.     至于数据包拷贝至内存后的处理会在适当的时候进行 

注册中断的函数位置:<linux/interrupt.h>  include/linux/interrupt.h

/*
 * irg     - 表示要分配的中断号
 * handler - 实际的中断处理程序
 * flags   - 标志位,表示此中断的具有特性
 * name    - 中断设备名称的ASCII 表示,这些会被/proc/irq和/proc/interrupts文件使用
 * dev     - 用于共享中断线,多个中断程序共享一个中断线时(共用一个中断号),依靠dev来区别各个中断程序
 * 返回值:
 * 执行成功:0
 * 执行失败:非0
 */
int request_irq(unsigned int irq,
                irq_handler_t handler,
                unsigned long flags,
                const char* name,
                void *dev)

中断处理程序标志位

IRQF_DISABLED :该标志位被设置,意味着内核在处理中断处理程序本身期间,禁止其他所有中断。多数中断处理程序不会去设置这位,这种用法留给希望快速执行的轻量级中断

IRQF_SAMPLE_RANDOM:这个标志位设置,表示这个中断对内核熵池有贡献,内核熵池负责给各种随机时间提供随机数,如果有些硬件中断没有规律性,那么是很好的熵源

IRQF_TIMER:系统定时器的中断

IRQF_SHARED:此标志表明可以在多个中断处理程序中共享中断线,在同一个中断线上每个中断处理程序必须设置此标志。

共享的中断处理程序

共享与非共享的处理程序的差异主要有三处:

1、request_irq中的flag参数需要设置成IRQF_SHARED

2、request_irq中的dev参数必须唯一,要能通过dec来区分是哪个中断程序,设备指针的参数就可以满足这个要求,不能传递null值。

3、中断处理程序必须能够区分它的设备是否真的产生了中断

中断上下文

        ·中断处理程序打断了其他的代码,所有中断处理程序必须尽可能的迅速简洁。其他一些不是需要非常实时的工作放在下部分来执行。

        中断上下文是内核代表硬件运行于内核空间,他是硬件通过触发信号,来调用中断处理程序时所处的一种状态。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理,中断上下文就可以理解为硬件传递过来的这些参数和内核需要保存的一些环境。主要是被中断的进程的环境。

中断上下文不能进行睡眠,主要原因为1、中断上下文与进程无关,不能通过current宏关联当前进程

中断处理机制

19113723-21463cd0f94f455480d282ac1b38138d.png (769×246)

 中断控制方法

常用的中断控制方法见下表:

函数

说明

local_irq_disable()禁止本地中断传递
local_irq_enable()激活本地中断传递
local_irq_save()保存本地中断传递的当前状态,然后禁止本地中断传递
local_irq_restore()恢复本地中断传递到给定的状态
disable_irq()禁止给定中断线,并确保该函数返回之前在该中断线上没有处理程序在运行
disable_irq_nosync()禁止给定中断线
enable_irq()激活给定中断线
irqs_disabled()如果本地中断传递被禁止,则返回非0;否则返回0
in_interrupt()如果在中断上下文中,则返回非0;如果在进程上下文中,则返回0
in_irq()如果当前正在执行中断处理程序,则返回非0;否则返回0

第八章 下半部分与推后执行的工作

哪些工作必须放在上半部执行

1、如果一个任务对时间是否敏感,将其放在上半部

2、如果一个任务和硬件有关,将其放在上半部

3、如果一个任务要保证不被其他中断打断,将其放在上半部

除此之外其他所有任务可以考虑放在下半部

实现中断下半部的机制主要有一下几种:

1、软中断

2、tasklet

3、工作队列

软中断

https://www.cnblogs.com/wang_yb/archive/2013/04/23/3037268.html

Linux内核中的下半部机制之软中断(softirq) | Blog of UnicornX

软中断不会抢占另外一个软中断,唯一可以抢占软中断的是中断处理程序

软中断的代码定义在<linux/interrupt.h>

软中断是在编译期间静态分配的,不能动态的注册与注销

软中断的流程图如下所示:

softirq

一个软中断只有被触发之后才有可能执行

在下列地方,待处理的软中断会被检查和执行

1、从一个硬件中断代码处返回时

2、在ksoftirqd内核线程中

3、在哪些显示检查和执行待处理的软中断的代码中,如网络子系统

软中断的描述符

新增加的中断类型,需要根据希望赋予他的优先级来决定加入的位置

enum
{
    HI_SOFTIRQ=0,\\优先级高的tasklets
    TIMER_SOFTIRQ,\\定时器的下半部
    NET_TX_SOFTIRQ,\\发送网络数据包
    NET_RX_SOFTIRQ,\\接受网络数据包
    BLOCK_SOFTIRQ,\\BLOCK装置
    BLOCK_IOPOLL_SOFTIRQ,
    TASKLET_SOFTIRQ,\\正常优先权的tasklets
    SCHED_SOFTIRQ,\\调度程度
    HRTIMER_SOFTIRQ,\\高分辨率定时器
    RCU_SOFTIRQ, \\RCU锁定   /* Preferable RCU should always be the last softirq */

    NR_SOFTIRQS
};

        软中断在被触发之后,会在在一个合适的时刻,待处理(已经被raise的)的软中断会被检查和执行,这些时间点包括如下几种情况:

1、从一个中断处理程序(irq_handler_t类型的回调函数)返回退出中断上下文时。这是最常见的执行点。中断处理程序的最后会调用到do_softirq()去执行软中断,这个时候还是在中断上下文处理。

2、在ksoftirqd内核线程中,当出现大量的软中断需要处理时,会唤醒一组内核线程来辅助处理。运行在进程上下文。

3、在那些显式检查并执行软中断的代码(某些中断处理程序)中,比如网络子系统。这种触发发生在硬件中断处理程序中,那么并不会立刻处理,而是留到ksoftirqd中去处理,因此运行在进程上下文。

按照最严格的情况,软中断运行在中断上下文,不能睡眠

linux中,执行软中断有专门的内核线程,每个处理器对应一个线程,名称ksoftirqd/n (n对应处理器号)

通过top命令查看我的单核虚拟机,CentOS系统中的ksoftirqd线程如下:

[root@vbox ~]# top | grep ksoftirq
    4 root      20   0     0    0    0 S  0.0  0.0   0:00.02 ksoftirqd/0

tasklet

Linux内核中的下半部机制之tasklet - 知乎

tasklet也是利用软中断来实现的,但是它提供了比软中断更好用的接口(其实就是基于软中断又封装了一下),

使用的是软中断类型中的HI_SOFTIRQ以及SCHED_SOFTIRQ

所以除了对性能要求特别高的情况,一般建议使用tasklet来实现自己的中断。

tasklet与软中断中间的差别是,在不同的处理器上面可以运行相同类型的软中断,而类型相同的tasklet不能同时执行

tasklet对应的结构体在 <linux/interrupt.h> 中

struct tasklet_struct
{
    struct tasklet_struct *next; /* 链表中的下一个tasklet */
    unsigned long state;         /* tasklet状态 */
    atomic_t count;              /* 引用计数器 */
    void (*func)(unsigned long); /* tasklet处理函数 */
    unsigned long data;          /* tasklet处理函数的参数 */
};

其中的state,用于标识tasklet状态,这一个无符号长整数,当前只使用了bit[1]和bit[0]两个状态位。其中,bit[1]=1表示这个tasklet当前正在某个CPU上被执行,它仅对SMP系统才有意义,其作用就是为了防止多个CPU同时执行一个tasklet的情形出现;bit[0]=1表示这个tasklet已经被调度去等待执行了但还没有开始执行,其作用是阻止同一个tasklet在被运行之前被重复调度,考虑如下情况:一个tasklet已经被触发过一次,即调度过一次,但可能还没有来得及被执行。

tasklet状态只有3种值:

  1. 值 0 表示该tasklet没有被调度
  2. 值 TASKLET_STATE_SCHED 表示该tasklet已经被调度
  3. 值 TASKLET_STATE_RUN 表示该tasklet已经运行

引用计数器count 的值不为0,表示该tasklet被禁止。

tasklet支持动态声明和静态声明。

/* 动态声明一个tasklet 传递一个tasklet_struct指针给初始化函数 */
extern void tasklet_init(struct tasklet_struct *t,
             void (*func)(unsigned long), unsigned long data);

//调度 tasklet 执行,如果tasklet在运行中被调度, 它在完成后会再次运行; 这保证了在其他事件被处理当中发生的事件受到应有的注意. 这个做法也允许一个 tasklet 重新调度它自己
tasklet_schedule(&my_tasklet);

//确保了 tasklet 不会被再次调度来运行,通常当一个设备正被关闭或者模块卸载时被调用。如果 tasklet 正在运行, 这个函数等待直到它执行完毕。若 tasklet 重新调度它自己,则必须阻止在调用 tasklet_kill 前它重新调度它自己,如同使用 del_timer_sync 
extern void tasklet_kill(struct tasklet_struct *t);

Tasklet的设计原则为“谁触发,谁执行”,为此,Linux为系统中的每一个CPU都定义了一个tasklet队列头部,来表示应该由各个CPU负责执行的tasklet队列。(tasklet_vec、tasklet_hi_vec)

tasklet_schedule()运行时会屏蔽中断,但是在调用tasklet中处理函数的过程中是可以中断的。

tasklet_schedule()的处理流程如下

①首先,调用local_irq_save()函数来关闭当前CPU的中断,以保证下面的步骤在当前CPU上原子地被执行。

②然后,将待调度的tasklet添加到当前CPU对应的tasklet队列的首部。

③接着,调用raise_softirq_irqoff()函数在当前CPU上触发软中断请求TASKLET_SOFTIRQ,具体地就是将软中断状态寄存器irq_stat中对应TASKLET_SOFTIRQ的状态位raise/设置为pending状态。这样内核将在稍后的一个恰当的时间点对该触发的TASKLET_SOFTIRQ软中断进行处理。

④最后,调用local_irq_restore()函数来开当前CPU的中断。

tasklet_action(),该函数的作用是该函数将当前CPU的tasklet队列(如tasklet_vec)中的各个tasklet放到当前CPU上来执行

它的运行流程如下:

① 首先,在当前CPU关中断的情况下,“原子”地读取当前CPU的tasklet队列tasklet_vec的头部指针,将其保存到局部变量list指针中,然后将当前CPU的tasklet队列头部指针设置为NULL,

以表示理论上如果后面的while循环执行完后如果没有未处理的tasklet则链表tasklet_vec为空(但最后的实际结果却并不一定如此,下面将会看到)。

② 然后,用一个while{}循环来遍历由list所指向的tasklet队列,队列中的各个元素就是将在当前CPU上执行的tasklet。循环体的执行步骤如下:

③ 用指针t来表示当前队列元素,即当前需要执行的tasklet。

④ 更新list指针为list->next,使它指向下一个要执行的tasklet。

⑤ 用tasklet_trylock()试图对当前要执行的tasklet(由指针t所指向)进行加锁,tasklet_trylock()中会将tasklet_struct中的state状态改为TASKLET_STATE_RUN,如果加锁成功(当前没有任何其他CPU正在执行这个tasklet),则用原子读函数atomic_read()进一步判断count成员的值。

⑥ 如果count为0,说明这个tasklet是允许执行的,于是:

⑥-① 先清除TASKLET_STATE_SCHED位;

⑥-② 然后,调用这个tasklet的可执行函数func;

⑥-③ 调用tasklet_unlock()来清除TASKLET_STATE_RUN位。(执行完func后才清除)

⑥-④ 最后,执行continue语句跳过下面的步骤,回到while循环继续遍历队列中的下一个元素。可以认为处理成功的tasklet将会从最终的tasklet_vec链表中移除。

⑦ 如果count不为0,说明这个tasklet是禁止运行的,于是调用tasklet_unlock()清除前面用tasklet_trylock()设置的TASKLET_STATE_RUN位。

⑧ 如果tasklet_trylock()加锁不成功,或者因为当前tasklet的count值非0而不允许执行时,我们必须将这个tasklet重新放回到当前CPU的tasklet队列中,即放回tasklet_vec中,以留待这个CPU下次服务软中断向量TASKLET_SOFTIRQ时再执行。为此进行这样几步操作:

⑧-① 先关CPU中断,以保证下面操作的原子性。

⑧-② 把这个tasklet重新放回到当前CPU的tasklet队列的首部;

⑧-③ 调用__raise_softirq_irqoff()函数在当前CPU上再触发一次软中断请求TASKLET_SOFTIRQ;

⑧-④ 开中断。

⑨ 最后,回到while循环继续遍历队列。

tasklet_schedule调用之后,什么时候才会调到tasklet中的function?

A:见之前软中断的执行时机

工作队列

        工作队列是创建一个内核线程去执行下半部分的工作,如果推后执行的任务需要睡眠,那么久选择工作队列,如果不需要就先软中断、tasklet。

        工作队列子系统是一个用于创建内核线程的接口,通过它可以创建一个工作者线程来专门处理中断的下半部工作。

  工作者线程的结构体为workqueue_struct,不同类型的工作者线程对应着一个workqueue_struct,里面包含一个cpu_workqueue_struct的数组,它定义在kernel/workqueue.c中,表示每个处理器对应着一个cpu_workqueue_struct。

工作相关结构体work_struct放在<linux/workqueue.h>中,其中包含希望执行的func。

缺省的工作者线程名称是 events/n (n对应处理器号),大部分情况下使用默认工作者线程就行,有些情况下比如说XFS文件系统就需要为自己创建新的工作者线程。

/*缺省队列使用接口*/
/*初始化工作队列*/
INIT_WORK(_work, _func) 

/*工作队列调度入队列,交给缺省的工作线程。一旦工作线程被唤醒,work就会马上被调度*/ 
static inline bool schedule_work(struct work_struct *work)

/*排入队列的所有工作都执行完之前休眠,不会取消延迟队列*/
static inline void flush_scheduled_work(void)

/*新建的工作队列名字大多为xxx_workqueue
create_workqueue
queue_work
flush_workqueue
destroy_workqueue*/

通过top命令查看我的单核虚拟机,CentOS系统中的events线程如下:

[root@vbox ~]# top | grep event
    7 root      20   0     0    0    0 S  0.0  0.0   0:03.71 events/0

使用工作队列的方法如下

       创建推后的工作,可以是静态的也可以是动态的,DECLARE_WORK是静态的,INIT_WORK是动态的。

工作队列处理函数

typedef void (*work_func_t)(struct work_struct *work);

操作处理函数运行在进程上下文中运行,但是不能访问用户空间,因为内核中没有相关映射。

工作被创建之后就可以直接被调度schedule_work(struct work_struct *work);

刷新现有的工作,这个步骤不是必须的 刷新现有工作的意思就是在追加新的工作之前,保证队列中的已有工作已经执行完了。

下半部机制

上下文

复杂度

执行性能

顺序执行保障

软中断中断
(需要自己确保软中断的执行顺序及锁机制)

(全部自己实现,便于调优)
没有
tasklet中断
(提供了简单的接口来使用软中断)
同类型不能同时执行
工作队列进程
(在进程上下文中运行,与写用户程序差不多)

 第九章 内核同步介绍

界区:访问和操作共享数据的代码段

争条件:如果两个执行线程有可能处于同一个临界区中同时执行,我们称他为竞争条件

同步的作用其实是防止在临界区中形成竞争条件。

如果临界区里是原子操作(即整个操作完成前不会被打断),那么自然就不会出竞争条件。

但在实际应用中,临界区中的代码往往不会那么简单,所以为了保持同步,引入了锁机制。

内核中造成竞争条件的原因

竞争原因

说明

中断中断随时会发生,也就会随时打断当前执行的代码。如果中断和被打断的代码在相同的临界区,就产生了竞争条件
软中断和tasklet软中断和tasklet也会随时被内核唤醒执行,也会像中断一样打断正在执行的代码
内核抢占内核具有抢占性,发生抢占时,如果抢占的线程和被抢占的线程在相同的临界区,就产生了竞争条件
睡眠及用户空间的同步用户进程睡眠后,调度程序会唤醒一个新的用户进程,新的用户进程和睡眠的进程可能在同一个临界区中
对称多处理2个或多个处理器可以同时执行相同的代码

编写代码之前就要考虑好临界区在哪,以及怎么加锁,难的不是怎么加锁,难的是提前确认哪些地方需要加锁,以及锁的粒度

编写内核代码时,时时记着下面这些问题:

  1. 这个数据是不是全局的?除了当前线程以外,其他线程能不能访问它?
  2. 这个数据会不会在进程上下文或者中断上下文中共享?它是不是要在两个不同的中断处理程序中共享?
  3. 进程在访问数据时可不可能被抢占?被调度的新程序会不会访问同一数据?
  4. 当前进程会不会睡眠(或者阻塞)在某些资源上,如果是,它会让共享数据处于何种状态?
  5. 怎样防止数据失控?
  6. 如果这个函数又在另一个处理器上被调度将会发生什么?

死锁

死锁就是所有线程都在相互等待释放资源,导致谁也无法继续执行下去。

下面一些简单的规则可以帮助我们避免死锁:

  1. 如果有多个锁的话,尽量确保每个线程都是按相同的顺序加锁,按加锁相反的顺序解锁。(即加锁a->b->c,解锁c->b->a)
  2. 防止发生饥饿。即设置一个超时时间,防止一直等待下去。
  3. 不要重复请求同一个锁。
  4. 设计应力求简单。加锁的方案越复杂就越容易出现死锁。

锁的粒度

锁的粒度太粗,扩展性越不好,扩展性是对系统可扩展程度的一个量度。(我的理解是,锁的范围越大,整体程序扩展起来越麻烦,因为要保护的东西非常多,需要考虑的也会较多)

锁的粒度越细,系统开销越大,程序也越复杂,所以对于争用不是很频繁的锁,就没有必要细化了。


第十章 内核同步方法        

原子操作

原子操作是由编译器来保证的,保证一个线程对数据的操作不会被其他线程打断。

原子操作有2类:

  1. 原子整数操作,有32位和64位。头文件分别为<asm/atomic.h>和<asm/atomic64.h>
  2. 原子位操作。头文件 <asm/bitops.h>

自旋锁

自旋锁的特点就是当一个线程获取了锁之后,其他试图获取这个锁的线程一直在循环等待获取这个锁,直至锁重新可用。

由于线程实在一直循环的获取这个锁,所以会造成CPU处理时间的浪费,因此最好将自旋锁用于能很快处理完的临界区。

自旋锁使用时有2点需要注意

  1. 自旋锁是不可递归的,递归的请求同一个自旋锁会自己锁死自己。
  2. 线程获取自旋锁之前,要禁止当前处理器上的中断。(防止获取锁的线程和中断形成竞争条件)
      比如:当前线程获取自旋锁后,在临界区中被中断处理程序打断,中断处理程序正好也要获取这个锁,
      于是中断处理程序会等待当前线程释放锁,而当前线程也在等待中断执行完后再执行临界区和释放锁的代码。

中断处理下半部的操作中使用自旋锁尤其需要小心:

  1. 下半部处理和进程上下文共享数据时,由于下半部的处理可以抢占进程上下文的代码,
      所以进程上下文在对共享数据加锁前要禁止下半部的执行,解锁时再允许下半部的执行。
  2. 中断处理程序(上半部)和下半部处理共享数据时,由于中断处理(上半部)可以抢占下半部的执行,
      所以下半部在对共享数据加锁前要禁止中断处理(上半部),解锁时再允许中断的执行。
  3. 同一种tasklet不能同时运行,所以同类tasklet中的共享数据不需要保护。
  4. 不同类tasklet中共享数据时,其中一个tasklet获得锁后,不用禁止其他tasklet的执行,因为同一个处理器上不会有tasklet相互抢占的情况
  5. 同类型或者非同类型的软中断在共享数据时,也不用禁止下半部,因为同一个处理器上不会有软中断互相抢占的情况

读写自旋锁

读写自旋锁除了和普通自旋锁一样有自旋特性以外,还有以下特点:
1、读锁之间是共享的

2、写锁之间是互斥的

3、读写锁之间是互斥的
当读锁被获取时写锁会一直等待,如果读写不能清晰的分开,建议使用一般的自旋锁即可。

信号量

信号量也是一种锁,和自旋锁不同的是,线程获取不到信号量的时候,不会像自旋锁一样循环的去试图获取锁,

而是进入睡眠,直至有信号量释放出来时,才会唤醒睡眠的线程,进入临界区执行。

由于使用信号量时,线程会睡眠,所以等待的过程不会占用CPU时间。所以信号量适用于等待时间较长的临界区。

信号量消耗的CPU时间的地方在于使线程睡眠和唤醒线程,

如果 (使线程睡眠 + 唤醒线程)的CPU时间 > 线程自旋等待的CPU时间,那么可以考虑使用自旋锁。

down()表示获取信号量,如果信号量值<0,则线程进入睡眠,up()表示释放信号量

一般用的比较多的是down_interruptible()方法,因为以 TASK_UNINTERRUPTIBLE 方式睡眠无法被信号唤醒。

于 TASK_INTERRUPTIBLE 和 TASK_UNINTERRUPTIBLE 补充说明一下:

  • TASK_INTERRUPTIBLE - 可打断睡眠,可以接受信号并被唤醒,也可以在等待条件全部达成后被显式唤醒(比如wake_up()函数)。
  • TASK_UNINTERRUPTIBLE - 不可打断睡眠,只能在等待条件全部达成后被显式唤醒(比如wake_up()函数)。

信号量结构体具体如下:

/* Please don't access any members of this structure directly */
struct semaphore {
    spinlock_t        lock;
    unsigned int        count;
    struct list_head    wait_list;
};

可以发现信号量结构体中有个自旋锁,这个自旋锁的作用是保证信号量的down和up等操作不会被中断处理程序打断。

读写信号量

读写信号量和信号量之间的关系 与 读写自旋锁和普通自旋锁之间的关系 差不多。

读写信号量都是二值信号量,即计数值最大为1,增加读者时,计数器不变,增加写者,计数器才减一。

也就是说读写信号量保护的临界区,最多只有一个写者,但可以有多个读者。

互斥体

互斥体也是一种可以睡眠的锁,相当于二值信号量,只是提供的API更加简单,使用的场景也更严格一些,如下所示:

  1. mutex的计数值只能为1,也就是最多只允许一个线程访问临界区
  2. 在同一个上下文中上锁和解锁
  3. 不能递归的上锁和解锁
  4. 持有个mutex时,进程不能退出
  5. mutex不能在中断或者下半部中使用,也就是mutex只能在进程上下文中使用
  6. mutex只能通过官方API来管理,不能自己写代码操作它

完成变量

完成变量的机制类似于信号量,

比如一个线程A进入临界区之后,另一个线程B会在完成变量上等待,线程A完成了任务出了临界区之后,使用完成变量来唤醒线程B。

完成变量的API也很简单:

方法

描述

init_completion(struct completion *)初始化指定的动态创建的完成变量
wait_for_completion(struct completion *)等待指定的完成变量接受信号
complete(struct completion *)发信号唤醒任何等待任务

使用完成变量的例子可以参考:kernel/sched.c 和 kernel/fork.c

一般在2个任务需要简单同步的情况下,可以考虑使用完成变量。

顺序锁

顺序锁为读写共享数据提供了一种简单的实现机制。

之前提到的读写自旋锁和读写信号量,在读锁被获取之后,写锁是不能再被获取的,

也就是说,必须等所有的读锁释放后,才能对临界区进行写入操作。

顺序锁则与之不同,读锁被获取的情况下,写锁仍然可以被获取。

使用顺序锁的读操作在读之前和读之后都会检查顺序锁的序列值,如果前后值不符,则说明在读的过程中有写的操作发生,

那么读操作会重新执行一次,直至读前后的序列值是一样的。

顺序锁优先保证写锁的可用,所以适用于那些读者很多,写者很少,且写优于读的场景。

顺序锁的使用例子可以参考:kernel/timer.c和kernel/time/tick-common.c文件

禁止抢占

        其实使用自旋锁已经可以防止内核抢占了,但是有时候仅仅需要禁止内核抢占,不需要像自旋锁那样连中断都屏蔽掉。,比如说一个任务a在操作参数foo是被抢占,此时b又去操作了foo,导致出现了伪并发的情况,如果不想出现这种情况,这时候就需要使用禁止内核抢占的方法了:

方法

描述

preempt_disable()增加抢占计数值,从而禁止内核抢占
preempt_enable()减少抢占计算,并当该值降为0时检查和执行被挂起的需调度的任务
preempt_enable_no_resched()激活内核抢占但不再检查任何被挂起的需调度的任务
preempt_count()返回抢占计数

这里的preempt_disable()和preempt_enable()是可以嵌套调用的,disable和enable的次数最终应该是一样的。

禁止抢占的头文件参见:<linux/preempt.h>

顺序和屏障

对于一段代码,编译器或者处理器在编译和执行时可能会对执行顺序进行一些优化,从而使得代码的执行顺序和我们写的代码有些区别。

一般情况下,这没有什么问题,但是在并发条件下,可能会出现取得的值与预期不一致的情况

比如下面的代码:

/* 
 * 线程A和线程B共享的变量 a和b
 * 初始值 a=1, b=2
 */
int a = 1, b = 2;

/*
 * 假设线程A 中对 a和b的操作
 */
void Thread_A()
{
    a = 5;
    b = 4;
}

/*
 * 假设线程B 中对 a和b的操作
 */
void Thread_B()
{
    if (b == 4)
        printf("a = %d\n", a);
}

由于编译器或者处理器的优化,线程A中的赋值顺序可能是b先赋值后,a才被赋值。

所以如果线程A中 b=4; 执行完,a=5; 还没有执行的时候,线程B开始执行,那么线程B打印的是a的初始值1。

这就与我们预期的不一致了,我们预期的是a在b之前赋值,所以线程B要么不打印内容,如果打印的话,a的值应该是5。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值