(2021年11月20日打卡第十三天)
打卡第十三天:08 | 锁:并发操作中,解决数据同步的四种方法
学习本节,了解解决数据同步的四种方法。
1、如何用代码实现原子操作
GCC 支持嵌入汇编代码的模板,不同于其它 C 编译器支持嵌入汇编代码的方式,为了优化用户代码,GCC 设计了一种特有的嵌入方式,它规定了汇编代码嵌入的形式和嵌入汇编代码需要由哪几个部分组成,如下面代码所示。
__asm__ __volatile__(代码部分:输出部分列表: 输入部分列表:损坏部分列表);
可以看到代码模板从 asm 开始(当然也可以是 asm),紧跟着 volatile,然后是跟着一对括号,最后以分号结束。括号里大致分为 4 个部分:
- 汇编代码部分,这里是实际嵌入的汇编代码。
- 输出列表部分,让 GCC 能够处理 C 语言左值表达式与汇编代码的结合。
- 输入列表部分,也是让 GCC 能够处理 C 语言表达式、变量、常量,让它们能够输入到汇编代码中去。
- 损坏列表部分,告诉 GCC 汇编代码中用到了哪些寄存器,以便 GCC 在汇编代码运行前,生成保存它们的代码,并且在生成的汇编代码运行后,恢复它们(寄存器)的代码。它们之间用冒号隔开,如果只有汇编代码部分,后面的冒号可以省略。但是有输入列表部分而没有输出列表部分的时候,输出列表部分的冒号就必须要写,否则 GCC 没办法判断,同样的道理对于其它部分也一样。
- 下面将用一个函数 atomic_add 为例子说一下,如下所示。
static inline void atomic_add(int i, atomic_t *v)
{
__asm__ __volatile__("lock;" "addl %1,%0"
: "+m" (v->a_count)
: "ir" (i));
}
//"lock;" "addl %1,%0" 是汇编指令部分,%1,%0是占位符,它表示输出、输入列表中变量或表态式,占位符的数字从输出部分开始依次增加,这些变量或者表态式会被GCC处理成寄存器、内存、立即数放在指令中。
//: "+m" (v->a_count) 是输出列表部分,“+m”表示(v->a_count)和内存地址关联
//: "ir" (i) 是输入列表部分,“ir” 表示i是和立即数或者寄存器关联
- 实现原子操作的完整代码如下:
//定义一个原子类型
typedef struct s_ATOMIC{
volatile s32_t a_count; //在变量前加上volatile,是为了禁止编译器优化,使其每次都从内存中加载变量
}atomic_t;
//原子读
static inline s32_t atomic_read(const atomic_t *v)
{
//x86平台取地址处是原子
return (*(volatile u32_t*)&(v)->a_count);
}
//原子写
static inline void atomic_write(atomic_t *v, int i)
{
//x86平台把一个值写入一个地址处也是原子的
v->a_count = i;
}
//原子加上一个整数
static inline void atomic_add(int i, atomic_t *v)
{
__asm__ __volatile__("lock;" "addl %1,%0"
: "+m" (v->a_count)
: "ir" (i));
}
//原子减去一个整数
static inline void atomic_sub(int i, atomic_t *v)
{
__asm__ __volatile__("lock;" "subl %1,%0"
: "+m" (v->a_count)
: "ir" (i));
}
//原子加1
static inline void atomic_inc(atomic_t *v)
{
__asm__ __volatile__("lock;" "incl %0"
: "+m" (v->a_count));
}
//原子减1
static inline void atomic_dec(atomic_t *v)
{
__asm__ __volatile__("lock;" "decl %0"
: "+m" (v->a_count));
}
应用场景中的代码就变成下面这样了:无论有没有中断,或者什么时间来中断,都不会出错。
atomic_t a = {0};
void interrupt_handle()
{
atomic_inc(&a);
}
void thread_func()
{
atomic_inc(&a);
}
2、如何实现中断控制
写代码实现关闭开启中断了,x86 CPU 上关闭、开启中断有专门的指令,即 cli、sti 指令,它们主要是对 CPU 的 eflags 寄存器的 IF 位(第 9 位)进行清除和设置,CPU 正是通过此IF位来决定是否响应中断信号。这两条指令只能 Ring0 权限才能执行,代码如下。
typedef u32_t cpuflg_t;
static inline void hal_save_flags_cli(cpuflg_t* flags)
{
__asm__ __volatile__(
"pushfl \t\n" //把eflags寄存器压入当前栈顶
"cli \t\n" //关闭中断
"popl %0 \t\n"//把当前栈顶弹出到eflags为地址的内存中
: "=m"(*flags)
:
: "memory"
);
}
static inline void hal_restore_flags_sti(cpuflg_t* flags)
{
__asm__ __volatile__(
"pushl %0 \t\n"//把flags为地址处的值寄存器压入当前栈顶
"popfl \t\n" //把当前栈顶弹出到eflags寄存器中
:
: "m"(*flags)
: "memory"
);
}
void foo()
{
hal_save_flags_cli();
//操作数据第一步……
hal_restore_flags_sti();
}
void bar()
{
hal_save_flags_cli();
foo();
//操作数据第二步……
hal_restore_flags_sti();
}
从上面的代码中不难发现,硬件工程师早就想到了如何解决在嵌套函数中关闭、开启中断的问题:pushfl 指令把 eflags 寄存器压入当前栈顶,popfl 把当前栈顶的数据弹出到 eflags 寄存器中。
hal_restore_flags_sti() 函数的执行,是否开启中断完全取决于上一次 eflags 寄存器中的值,并且 popfl 指令只会影响 eflags 寄存器中的 IF 位。这样,无论函数嵌套调用多少层都没有问题。
是否sti,要看之前保存的eflags寄存器内容,假如之前中断就是关闭的,那么执行之后中断依然是关闭的。
在bar函数中关闭中断后,在foo函数中又关闭中断的时候保存的eflags.IF仍然为0,所以在foo中开启中断的时候恢复的eflags.IF仍然为0,并没有开启中断;
当返回bar后,处理完数据,开启中断所用的flags变量里面保存的eflags.IF为1,所以中断开启成功。
3、自旋锁工作原理与实现
自旋锁的原理,它是这样的:首先读取锁变量,判断其值是否已经加锁,如果未加锁则执行加锁,然后返回,表示加锁成功;如果已经加锁了,就要返回第一步继续执行后续步骤,因而得名自旋锁。
这个原子交换指令挺巧妙的。如果锁变量没加锁(值为0),可以用1跟它原子交换,从而实现读取+判断+加锁的原子操作;如果锁变量已加锁(值为1),用1跟它交换,相当于什么也没做,表示锁已被占用了只能进行自旋(重复判断锁变量是否为0)等待锁变量解锁。
//自旋锁结构
typedef struct
{
volatile u32_t lock;//volatile可以防止编译器优化,保证其它代码始终从内存加载lock变量的值
} spinlock_t;
//锁初始化函数
static inline void x86_spin_lock_init(spinlock_t * lock)
{
lock->lock = 0;//锁值初始化为0是未加锁状态
}
//加锁函数
static inline void x86_spin_lock_disable_irq(spinlock_t * lock,cpuflg_t* flags)
{
__asm__ __volatile__(
"pushfq \n\t"
"cli \n\t"
"popq %0 \n\t"
"1: \n\t"
"lock; xchg %1, %2 \n\t"
"cmpl $0,%1 \n\t"
"jnz 2f \n\t"
"jmp 3f \n"
"2: \n\t"
"cmpl $0,%2 \n\t"
"jne 2b \n\t"
"jmp 1b \n\t"
"3: \n"
:"=m"(*flags)
: "r"(1), "m"(*lock));
}
//解锁函数
static inline void x86_spin_unlock_enabled_irq(spinlock_t* lock,cpuflg_t* flags)
{
__asm__ __volatile__(
"movl $0, %0\n\t"
"pushq %1 \n\t"
"popfq \n\t"
:
: "m"(*lock), "m"(*flags));
}
上述代码的中注释已经很清楚了,关键点在于 xchg 指令,xchg %0, %1 。其中,%0 对应 “r”(1),表示由编译器自动分配一个通用寄存器,并填入值 1,例如 mov eax,1。而 %1 对应"m"(*lock),表示 lock 是内存地址。把 1 和内存中的值进行交换,若内存中是 1,则不会影响;因为本身写入就是 1,若内存中是 0,一交换,内存中就变成了 1,即加锁成功。
1.分配一个寄存器r值为1,交换r和lock的值; 2. 判断换回来的r值是否为0,是表示加锁成功,可以直接返回,否则继续执行; 3. 判断lock的值是否为0(为0说明已经解锁),是则重新进行交换加锁的逻辑,否则进行不断自旋判断。
自旋锁依然有中断嵌套的问题,也就是说,在使用自旋锁的时候我们仍然要注意中断。
上锁之后,如果被中断切走,中断处理程序如果也试图上锁,则将陷入无限的等待中,占用CPU忙等待,消耗大量的计算资源,而且根本等不到锁。
Foo调用bar,foo先获得自旋锁,此时lock变量的值为1。bar执行交换操作,寄存器中交换到lock变量,值为1。所以进入loop等到lock变量的值恢复为0,但是这个操作只有foo可以完成,而且spinlock是cpu busy的,换言之在这个过程调用中loop将一直执行并用完时间片。
以上代码已经实现了关中断下获取自旋锁,以及恢复中断状态释放自旋锁。在中断环境下也完美地解决了问题。
4、信号量的功能、数据结构与具体实现
信号量既能对资源数据进行保护(同一时刻只有一个代码执行流访问),又能在资源无法满足的情况下,让 CPU 可以执行其它任务。
我们要设计的信号量这个数据结构,至少需要一个变量来表示互斥,比如大于 0 则代码执行流可以继续运行,等于 0 则让代码执行流进入等待状态。还需要一个等待链,用于保存等待的代码执行流。
这个数据结构的实现代码如下所示。
#define SEM_FLG_MUTEX 0
#define SEM_FLG_MULTI 1
#define SEM_MUTEX_ONE_LOCK 1
#define SEM_MULTI_LOCK 0
//等待链数据结构,用于挂载等待代码执行流(线程)的结构,里面有用于挂载代码执行流的链表和计数器变量,这里我们先不深入研究这个数据结构。
typedef struct s_KWLST
{
spinlock_t wl_lock;
uint_t wl_tdnr;
list_h_t wl_list;
}kwlst_t;
//信号量数据结构
typedef struct s_SEM
{
spinlock_t sem_lock;//维护sem_t自身数据的自旋锁
uint_t sem_flg;//信号量相关的标志
sint_t sem_count;//信号量计数值
kwlst_t sem_waitlst;//用于挂载等待代码执行流(线程)结构
}sem_t;
搞懂了信号量的结构,我们再来看看信号量的一般用法,注意信号量在使用之前需要先进行初始化。这里假定信号量数据结构中的 sem_count 初始化为 1,sem_waitlst 等待链初始化为空。
使用信号量的步骤,我已经给你列好了。
- 第一步,获取信号量。
-
首先对用于保护信号量自身的自旋锁 sem_lock 进行加锁。
-
检查信号值 sem_count 是否不小于1,如果大于等于1则执行“减 1”操作。
-
上步中检查 sem_count 如果小于1,就让进程进入等待状态并且将其挂入 sem_waitlst 中,然后调度其它进程运行。否则表示获取信号量成功。当然最后别忘了对自旋锁 sem_lock 进行解锁。
信号量的意义是什么呢?其实我觉得用打印机的例子更合适。有三个进程打算调用打印机,但很明显,打印机同时只能被一个进程调用。所以,假设进程一成功抢到了信号,由于打印机空闲,所以信号量为1。先加锁关闭中断,保证同时没有其他cpu和中断来改变信号量,然后检测信号量是否小于1,如果大于等于1执行减一,信号量变为0,对自旋锁 sem_lock 进行解锁,继续执行进程调用打印机的过程。
这时,如果进程1被中断了,也不用担心打印机会被其他进程违法调用。进程二尝试调用打印机,加锁,关中断,先检测信号量是否小于1,此时信号量为0,所以进程二不能占用,把进程二挂入链表,进入等待。进程三也进行类似操作。
回到进程一,因为它已经获得了信号,所以不需要检测信号量。当进程一完成后,信号量被加一,变回1,剩下进程二、三继续抢占信号量。
-
第二步,代码执行流开始执行相关操作,例如读取键盘缓冲区。
-
第三步,释放信号量。
- 首先对用于保护信号量自身的自旋锁 sem_lock 进行加锁。
- 对信号值 sem_count 执行“加 1”操作,并检查其值是否不小于1。
- 上步中检查 sem_count 值如果不小于1,就执行唤醒 sem_waitlst 中进程的操作,并且需要调度进程时就执行进程调度操作,不管 sem_count 是否小于1(通常会等于1)都标记信号量释放成功。当然最后别忘了对自旋锁 sem_lock 进行解锁。
好,下面我们来看看实现上述这些功能的代码,按照理论书籍上说,信号量有两个操作:down,up,代码如下。
//获取信号量
void krlsem_down(sem_t* sem)
{
cpuflg_t cpufg;
start_step:
krlspinlock_cli(&sem->sem_lock,&cpufg);
if(sem->sem_count<1)
{//如果信号量值小于1,则让代码执行流(线程)睡眠
krlwlst_wait(&sem->sem_waitlst);
krlspinunlock_sti(&sem->sem_lock,&cpufg);
krlschedul();//切换代码执行流,下次恢复执行时依然从下一行开始执行,所以要goto开始处重新获取信号量
goto start_step;
}
sem->sem_count--;//信号量值减1,表示成功获取信号量
krlspinunlock_sti(&sem->sem_lock,&cpufg);
return;
}
//释放信号量
void krlsem_up(sem_t* sem)
{
cpuflg_t cpufg;
krlspinlock_cli(&sem->sem_lock,&cpufg);
sem->sem_count++;//释放信号量
if(sem->sem_count<1)
{//如果小于1,则说数据结构出错了,挂起系统
krlspinunlock_sti(&sem->sem_lock,&cpufg);
hal_sysdie("sem up err");
}
//唤醒该信号量上所有等待的代码执行流(线程)
krlwlst_allup(&sem->sem_waitlst);
krlspinunlock_sti(&sem->sem_lock,&cpufg);
krlsched_set_schedflgs();
return;
}
上述代码中的 krlspinlock_cli,krlspinunlock_sti 两个函数,只是对前面自旋锁函数的一个封装,krlschedul、krlwlst_wait、krlwlst_allup、krlsched_set_schedflgs 这几个函数会在进程相关课程进行探讨。