本篇笔记主要复习四种常见的数据同步机制:原子变量、关中断、锁、信号量。
为什么需要同步机制
一般来说,我们在做驱动开发或应用开发的时候,不可避免地会涉及到数据同步的问题。哪种数据需要做同步,一般来讲是多个线程需要共享的全局变量,多个进程需要共享的内存空间,中断和线程需要共享的全局数据等。
大学里操作系统课上,老师讲这一个概念时,感觉太过于书面化了。导致刚开始接触这个概念时,知道同步是干嘛的,但是在自己遇到相关问题,或是在写代码时,往往不知道怎么去解决相关问题,或不知道什么地方该用同步机制,什么地方不该用。
后来自己总结理解了一下,用一个有点味道的类比加深了自己的理解。如果介意这个有味道的类比的话,我也无能为力了(其实直接跳过就好了)。假设我们到景区旅游,内急了上要上厕所,那么公共厕所就可以看做是一种公共的资源(全局变量)。很多人都需要使用它,假设景区只有一个公共厕所,现在所有人都要使用。
如果厕所的门不带锁,不加限制的话。我们来想象一个魔幻的场景,你(线程A)刚进厕所,确认厕所环境牢固(拿到全局变量的值),正准备蹲下丢炸弹的时候(正准备修改),突然有一个壮汉进来把你给拉出去了(切换到线程B,也想要修改全局变量),这个壮汉体重很大,把坑位的支撑的架子搞得有裂缝了(修改了全局变量),但还没有塌。等你回到厕所后,并不知道厕所的状态已经发生了变化(你得到的全局变量的值还是老的值),因此当你再次进去的时候,厕所就塌了(全局变量的值错误)。这个魔幻的场景在使用厕所的人变得更多的时候会更加混乱,自由地飞“翔”不再遥远。
如果给厕所的门加上锁,那么事情就比较正常了。你进到厕所后,加上锁,丢完炸弹后,放开锁,然后出来。中间别人即使想抢坑位也打不开门。
在实际的编码过程中,要保护的变量也是类似的公共资源。如果将上面的上厕所的过程作为整体,那么其实我们保护的就是一段代码,这段代码一般会有读-改-写的操作,这样的代码段我们称为临界区。因此写代码的时候,对于多个进程、多个线程或者线程和中断之间需要访问的公共资源,需要考虑看是否要做同步保护(是否需要看具体场景,比如如果都是读操作就无所谓),用什么方式做保护。
一个同步问题的案例
我们以一个案例来实际看看同步问题。
//全局变量counter
int g_counter = 0;
//某中断处理程序
void x_isr_handler()
{
g_counter++;
}
//某工作线程
void x_work_thread_handler()
{
g_counter++;
}
通常来说,g_counter++会编译成3条指令:
1. 将内存中g_counter的值读到某个寄存器中
2. 对寄存器中的值加1
3. 将寄存器的值写回到内存
假设最初CPU运行x_work_thread_handler,当CPU执行完第一步后,取到了g_counter的值0,准备进行下一步操作时,x中断发生了,CPU被打断,因此进入x_isr_handler函数。
中断处理函数不会被线程打断,因此,x_isr_handler顺序执行完所有操作,g_counter现在的值变成了1。
中断处理函数结束运行,切换回x_work_thread_handler,此时这个函数里继续进行第二个步骤,由于在这个函数中,保存g_counter值的寄存器里的值是旧值0,因此当这个函数执行完后,g_counter的值还是1。
这样问题就产生了,原本g_counter应该加了两次,但是实际下来g_counter只加了一次。
怎么解决这个问题呢,纯软件的方式似乎难以做到,针对这个场景,有两种方式:1. 将g_counter++的操作变成单个原子操作;2. 在x_work_thread_handler处理过程中关闭中断。
原子操作
先来看原子操作,原子操作的意思是,g_counter++这个操作,要么一次性完全做完,要么就不做。要做到这一点,需要使用特殊的硬件指令实现,C语言中一般会使用内嵌汇编来达到这个目的。
关于GCC内嵌汇编,本笔记简单说明一下,想要详细了解的,可以自行百度。
__asm__ __volatile__(代码部分:输出部分列表: 输入部分列表:损坏部分列表);
内嵌汇编以__asm__ __volatile__开头,中间分为四个部分:
代码部分 - 实际的汇编代码
输出部分列表: 让 GCC 能够处理 C 语言左值表达式与汇编代码的结合(可理解为汇编指令输出结果到C语言定义的变量中)
输入部分列表:让 GCC 能够处理 C 语言表达式、变量、常量,让它们能够输入到汇编代码中去(可理解为汇编指令的输入参数对应的C语言定义的变量)
损坏部分列表:GCC 汇编代码中用到了哪些寄存器,以便 GCC 在汇编代码运行前,生成保存这些寄存器值的代码,在内嵌汇编代码运行后,产生恢复这些寄存器值的代码。
在x86平台上,一般会实现下面几个原子变量的操作函数
//定义一个原子类型
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_sub为例,来看怎么理解内嵌汇编
static inline void atomic_sub(int i, atomic_t *v)
{
__asm__ __volatile__("lock;" "subl %1,%0"
: "+m" (v->a_count)
: "ir" (i));
}
代码部分: "lock;" "subl %1,%0",
%1,%0是占位符,它表示输出、输入列表中变量或表态式,
占位符的数字从输出部分开始依次增加,这些变量或者表态式
会被GCC处理成寄存器、内存、立即数放在指令中。
输出列表:"+m" (v->a_count),“+m”表示(v->a_count)和内存地址关联
输入列表:"ir" (i)); “ir” 表示i是和立即数或者寄存器关联
破坏列表:无
回到那个有问题的变量同步的例子中,案例中只有单个变量g_counter需要保护,这种场景下,用原子操作做同步是最佳的方案。将这两个函数修改成下面这个样子:
//全局变量counter
atomic_t g_counter;
//某中断处理程序
void x_isr_handler()
{
atomic_inc(&g_counter);
}
//某工作线程
void x_work_thread_handler()
{
atomic_inc(&g_counter);
}
这样修改后,对单变量的使用场景就不会发生问题了。
关中断
原子操作对于单个基础变量的场景来说是比较好的同步方案,但是实际的程序中,全局资源一般是一个复杂的数据结构。这种时候,原子操作显然不能满足要求。
对于单核CPU来说,还可以通过关CPU中断的方式来实现同步。x86平台上通过cli,sti指令来关闭和开启中断。这两条指令主要设置CPU的eflags寄存器的IF bit来控制中断的开关。IF bit设置与否决定CPU是否会响应中断。
x86上开关中断的指令,需要Ring0权限。相关示例代码如下:
//关闭中断
void irq_disable()
{
__asm__ __volatile__("cli": : :"memory");
}
//开启中断
void irq_enable()
{
__asm__ __volatile__("sti": : :"memory");
}
void fun1()
{
irq_disable();
//临界区
irq_enable();
}
void fun2()
{
irq_disable();
//临界区
irq_enable();
}
上面的例子初看似乎没有什么问题。单核CPU上,临界区里的代码在运行的时候并不会被打断 。但我们稍微改改,看下面的调用关系代码:
void fun1()
{
irq_disable();
//临界区
irq_enable();
}
void fun2()
{
irq_disable();
fun1();
//临界区
irq_enable();
}
如果fun2中调用了fun1然后再运行临界区代码,此时会发生什么情况呢?fun1运行结束后,CPU的中断被打开了,因此后面临界区运行的时候,是会被中断打断的。这样又会导致同步问题。
为了解决这个问题,我们需要irq_disable/enable()能够嵌套调用,我们使用pushfl和popfl指令来实现这个功能,代码如下:
typedef u32_t cpuflg_t;
static inline void irq_disable_save_flags(cpuflg_t* flags)
{
__asm__ __volatile__(
"pushfl \t\n" //把eflags寄存器压入当前栈顶
"cli \t\n" //关闭中断
"popl %0 \t\n"//把当前栈顶弹出到flags为地址的内存中
: "=m"(*flags)
:
: "memory"
);
}
static inline void irq_enable_restore_flags(cpuflg_t* flags)
{
__asm__ __volatile__(
"pushl %0 \t\n"//把flags为地址处的值寄存器压入当前栈顶
"popfl \t\n" //把当前栈顶弹出到eflags寄存器中
:
: "m"(*flags)
: "memory"
);
}
这里外部传递的flags参数应该如何使用(不同的变量还是同一个变量)留给大家思考。
自旋锁
前面提到的关中断方式,大家应该都注意到了一句话,"对于单核CPU来说"。为什么要强调这个事情呢?因为单核CPU上,同一时间只有一条代码执行流,除了中断会中止当前代码执行流,不会再有其它同时执行的流程。这种情况下只要控制了中断,就能安全地操作全局数据。但在多核CPU上,情况则完全不同。
我们首先要知道,关中断的指令,关闭的是执行这条指令的核心的中断响应,而不是所有核心的中断。在多核CPU上,如果执行的代码会在多个CPU上同时执行,显然想通过关中断来实现同步是不可能做到的。
在多核CPU系统中,OS基本都会提供一种叫做自旋锁的机制来做同步。自旋锁(spin lock),这个名词在我第一次听到的时候觉得很新奇,啥叫自己旋转的锁,旋转值得是个啥。我们看一下它的原理就明白了:首先读取锁变量,判断其值是否已经加锁,如果未加锁则执行加锁,然后返回,表示加锁成功;如果已经加锁了,就要返回第一步继续执行后续步骤。其实这里的旋转的意思就很明显了,就是没有拿到所的情况下,CPU自己不停地循环回到第一个步骤。CPU拿不到锁干着急,只有来回不停地绕着一个小地方来回踱步。
上面的流程图看起来非常简单,逻辑似乎也没问题。但是深究起来,我们会看到有bug。流程中的锁的值读取、判断和加锁是三个步骤。如果这三个步骤不是原子的,会出现一个大问题。假设两个CPU核在同一时刻开始对同一个自旋锁(初始未加锁)进行加锁操作,两个CPU首先都读取锁的变量值,它们都会认为自旋锁没有加锁(步骤1,2),因此两颗CPU都进入到了加锁的步骤(步骤3)。
单纯从软件角度想要解决这种问题几乎是不可能的,我们必须让硬件具备这样的能力。在x86中,提供了一条xchg原子交换指令。这条指令能够实现的功能是:让内存和寄存器的值原子地进行交换。我们先将x86上自旋锁的示例实现代码放出来,再来看xchg是如何实现自旋锁的:
//自旋锁结构
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(spinlock_t * lock)
{
__asm__ __volatile__ (
"1: \n"
"lock; xchg %0, %1 \n"//把值为1的寄存器和lock内存中的值进行交换
"cmpl $0, %0 \n" //用0和交换回来的值进行比较
"jnz 2f \n" //不等于0则跳转后面2标号处运行
"jmp 3f \n" //若等于0则跳转后面3标号处返回
"2: \n"
"cmpl $0, %1 \n"//用0和lock内存中的值进行比较
"jne 2b \n"//若不等于0则跳转到前面2标号处运行继续比较
"jmp 1b \n"//若等于0则跳转到前面1标号处运行,交换并加锁
"3: \n" :
: "r"(1), "m"(*lock));
}
//解锁函数
static inline void x86_spin_unlock(spinlock_t * lock)
{
__asm__ __volatile__(
"movl $0, %0\n"//解锁把lock内存中的值设为0就行
:
: "m"(*lock));
}
看到x86_spin_lock的代码,是不是初看有点迷惑,不知所云。别害怕,这个函数的核心精华就是xchg这条指令。这里有一个很巧妙的地方:寄存器的值是1(查看输入列表),这里用1和自旋锁的lock值进行交换,分两种情况来看。1. lock还没有加锁,lock = 0,此时lock会被设置为1,加锁也就成功了;2. lock加锁了,lock = 1,此时lock已经加锁,即便和1进行交换,lock的值也没有发生改变。执行完xchg后,后面的步骤做判断lock值的时候就不会有问题了。
需要注意的是,上面的自旋锁代码,在线程和中断中都使用的时候,是有问题的。大家可以想想这样一个场景会发生什么样的结果:在线程中spin_lock()已经走完,但还没有走到spin_unlock()。此时来了一个中断,中断里也场景获取同一个自旋锁,于是中断里也调用了spin_lock()。这时会发生尴尬的问题,由于自旋锁的性质,导致中断函数陷入了死循环,中断不做完,线程又没有机会得到调度。为了解决这种问题,就有了带开关中断版本的自旋锁,示例代码如下:
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));
}
(注:pushfq和pushfl作用一样,在amd64平台上使用)
信号量
无论是关中断,还是自旋锁,大家应该都能感觉到有一个问题:这些同步机制能保护资源,但适用的场景都应该是一些短时间能够完成的操作。
首先来看关中断的方式,CPU关闭了中断响应后,对外设、时钟中断的处理过程就会延迟,因此代码不能够无限制地长时间关闭中断,反而应该尽可能地恢复中断响应。
再来看自旋锁,这个也比较好理解,当CPU拿不到锁的时候,处于一个死循环中,本身也做不了其它事情。因此自旋锁保护的操作也应该尽可能短小,要很快地处理完,否则其他任务的调度就会受影响。
但真实的场景中,我们会经常碰到这种情况:CPU对磁盘发出了读数据请求,由于磁盘的I/O速度较低,数据未准备好之间,CPU希望能够切换处理其他任务,而不是一直傻傻地等这次I/O操作完成。
对于这种场景,信号量提供了一个不错的解决方案。信号量的概念和原理相信大家耳朵都听烂了吧,本笔记也不会讲一大堆废话。直接用示例代码来分析。首先给出信号量的数据结构定义(仅保留关键信息,案例并非真实操作系统中使用的数据结构):
#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,具体数量根据实际需求而定。初始化之后,我们就可以使用信号量了,一般信号量有两种操作:down和up,分别对应sem_count减1和加1操作。下面是一个信号量操作的示例代码:
//获取信号量
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;
}
其中sem_waitlist功能和Linux内核信号量的wait_list。现在不必深究,重点是理解代码整体的思路。
下一篇笔记,会重温一下Linux相关的同步机制方案。