操作系统 -- 锁:并发操作中,解决数据同步的四种方法
在让机器跑起来之前,要先弄清楚数据同步的问题,如果不解决数据同步的问题,后面机器跑来了,就会出现很多不可预知的结果。
非预期结果的全局变量
int a = 0;
void interrupt_handle()
{
a++;
}
void thread_func()
{
a++;
}
这段代码描述的是一个线程中的函数和中断处理函数,他们分别对一个全局变量执行加1操作。
通常编译器会把a++语句翻译成3条指令。
1、把a加载到某个寄存器中
2、这个寄存器加1
3、把这个寄存器写会内存
不难推断,可能导致结果不确定的情况是这样的:thread_func函数还没执行完第2条指令,中断来了,CPU就转而去处理中断了,即开始运行interrupt_handdle函数,这个函数运行完a = 1,CPU还回去继续执行第3条指令,此时a依然是1,就出现了问题。
显然在 t2 时刻发生了中断,导致了 t2 到 t4 运行了 interrupt_handle 函数,t5 时刻 thread_func 又恢复运行,导致 interrupt_handle 函数中 a 的操作丢失,因此出错。
方法一:原子操作 拿下单体变量
解决上述问题,有两种思路:一种是把a++变成原子操作;另一种是控制中断,比如在执行a++之前关闭中断,执行完之后开启中断。
靠编译器自动生成原子操作不太可能:第一,编译器没有这么智能,能检测哪个变量需要原子操作;第二,编译器必须要考虑代码的移植性。
好在现代 C 语言已经支持嵌入汇编代码,可以在 C 函数中按照特定的方式嵌入汇编代码了,实现原子操作就更方便了,代码如下:
//定义一个原子类型
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));
}
加上 lock 前缀的 addl、subl、incl、decl 指令都是原子操作,lock 前缀表示锁定总线
PS:
GCC 支持嵌入汇编代码的模板:
为了优化用户代码,GCC 设计了一种特有的嵌入方式,它规定了汇编代码嵌入的形式和嵌入汇编代码需要由哪几个部分组成,如下面代码所示。
__asm__ __volatile__(代码部分:输出部分列表: 输入部分列表:损坏部分列表);
模板从 asm 开始(当然也可以是 asm),紧跟着 volatile,然后是跟着一对括号,最后以分号结束。括号里大致分为 4 个部分:
- 1、汇编代码部分,这里是实际嵌入的汇编代码。
- 2、输出列表部分,让 GCC 能够处理 C 语言左值表达式与汇编代码的结合。
- 3、输入列表部分,也是让 GCC 能够处理 C 语言表达式、变量、常量,让它们能够输入到汇编代码中去。
- 4、损坏列表部分,告诉 GCC 汇编代码中用到了哪些寄存器,以便 GCC 在汇编代码运行前,生成保存它们的代码,并且在生成的汇编代码运行后,恢复它们(寄存器)的代码。
他们之间用冒号隔开
举例:
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是和立即数或者寄存器关联
注:
限制字符列表
限制字符有很多种,有些是与特定体系结构相关,此处仅列出常用的限定字符和i386中可能用到的一些常用的限定符。它们的作用是指示编译器如何处理其后的C语言变量与指令操作数之间的关系。
分类 限定符 描述
通用寄存器 "a" 将输入变量放入eax
这里有一个问题:假设eax已经被使用,那怎么办?
其实很简单:因为GCC 知道eax 已经被使用,它在这段汇编代码
的起始处插入一条语句pushl %eax,将eax 内容保存到堆栈,然
后在这段代码结束处再增加一条语句popl %eax,恢复eax的内容
"b" 将输入变量放入ebx
"c" 将输入变量放入ecx
"d" 将输入变量放入edx
"s" 将输入变量放入esi
"d" 将输入变量放入edi
"q" 将输入变量放入eax,ebx,ecx,edx中的一个
"r" 将输入变量放入通用寄存器,也就是eax,ebx,ecx,
edx,esi,edi中的一个
"A" 把eax和edx合成一个64 位的寄存器(use long longs)
内存 "m" 内存变量
"o" 操作数为内存变量,但是其寻址方式是偏移量类型,
也即是基址寻址,或者是基址加变址寻址
"V" 操作数为内存变量,但寻址方式不是偏移量类型
" " 操作数为内存变量,但寻址方式为自动增量
"p" 操作数是一个合法的内存地址(指针)
寄存器或内存 "g" 将输入变量放入eax,ebx,ecx,edx中的一个
或者作为内存变量
"X" 操作数可以是任何类型
立即数
"I" 0-31之间的立即数(用于32位移位指令)
"J" 0-63之间的立即数(用于64位移位指令)
"N" 0-255之间的立即数(用于out指令)
"i" 立即数
"n" 立即数,有些系统不支持除字以外的立即数,
这些系统应该使用"n"而不是"i"
匹配 " 0 ", 表示用它限制的操作数与某个指定的操作数匹配,
"1" ... 也即该操作数就是指定的那个操作数,例如"0"
"9" 去描述"%1"操作数,那么"%1"引用的其实就
是"%0"操作数,注意作为限定符字母的0-9 与
指令中的"%0"-"%9"的区别,前者描述操作数,
后者代表操作数。
& 该输出操作数不能使用过和输入操作数相同的寄存器
操作数类型 "=" 操作数在指令中是只写的(输出操作数)
"+" 操作数在指令中是读写类型的(输入输出操作数)
浮点数 "f" 浮点寄存器
"t" 第一个浮点寄存器
"u" 第二个浮点寄存器
"G" 标准的80387浮点常数
% 该操作数可以和下一个操作数交换位置
例如addl的两个操作数可以交换顺序
(当然两个操作数都不能是立即数)
# 部分注释,从该字符到其后的逗号之间所有字母被忽略
* 表示如果选用寄存器,则其后的字母被忽略
有了这些原子操作函数之后 ,前面场景中的代码就变成下面这样了:无论有没有中断,或者什么时间来中断,都不会出错。
atomic_t a = {0};
void interrupt_handle()
{
atomic_inc(&a);
}
void thread_func()
{
atomic_inc(&a);
}
方法二:中断控制 搞定复杂变量
中断是 CPU 响应外部事件的重要机制,时钟、键盘、硬盘等 IO 设备都是通过发出中断来请求 CPU 执行相关操作的(即执行相应的中断处理代码),比如下一个时钟到来、用户按下了键盘上的某个按键、硬盘已经准备好了数据。
有原子操作了,为什么还需要中断控制呢?
原子操作只适合单体变量,如整数,但操作系统的数据结构有的可能有几百字节大小,其中可能包含多种不同的数据类型,这显然用原子操作无法解决
X86 CPU上关闭、开启中断有专门的指令,即cli、sti指令,它们主要是对CPU的eflags寄存器的IF位(第9位)进行清除和设置,CPU正是通过此位来决定是否相应中断信号。这两个指令只能Ring0权限才能执行。
代码如下:
//关闭中断
void hal_cli()
{
__asm__ __volatile__("cli": : :"memory");
}
//开启中断
void hal_sti()
{
__asm__ __volatile__("sti": : :"memory");
}
//使用场景
void foo()
{
hal_cli();
//操作数据……
hal_sti();
}
void bar()
{
hal_cli();
//操作数据……
hal_sti();
}
但看似完美的解决了问题,但其实有重大缺陷,hal_cli()、hal_sti(),无法嵌套使用,举例:
void foo()
{
hal_cli();
//操作数据第一步……
hal_sti();
}
void bar()
{
hal_cli();
foo();
//操作数据第二步……
hal_sti();
}
关键问题在 bar 函数在关中断下调用了 foo 函数,foo 函数中先关掉中断,处理好数据然后开启中断,回到 bar 函数中,bar 函数还天真地以为中断是关闭的,接着处理数据,以为不会被中断抢占。
那要怎么解决这个问题呢?
需要修改下开启、关闭中断的函数即可。
可以这样操作:在关闭中断函数中先保存 eflags 寄存器,然后执行 cli 指令,在开启中断函数中直接恢复之前保存的 eflags 寄存器就行了,具体代码如下。
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"//把当前栈顶弹出到flags为地址的内存中
: "=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"
);
}
pushfl 指令把 eflags 寄存器压入当前栈顶,popfl 把当前栈顶的数据弹出到 eflags 寄存器中。
hal_restore_flags_sti() 函数的执行,是否开启中断完全取决于上一次 eflags 寄存器中的值,并且 popfl 指令只会影响 eflags 寄存器中的 IF 位。这样,无论函数嵌套调用多少层都没有问题。
PS:
保护模式特权级:
为了区分那些指令(如in、out、cli)和哪些资源(如寄存器、I/O端口、内存地址)可以被访问,CPU实现了特选级。
特权级分为4级,R0~R3,每个特权级执行指令的数量不同,R0可以执行所有指令,R1、R2、R3依次递减,他们只能执行上一级指令数量的子集,而内存的访问则是靠后面所说的短描述符和特权级相互配合去实现,如下图:
上面的圆环图,从外到内,既能体现权力的大小,又能体现各特权级对资源控制访问的多少,还能体现各特权级之间的包含关系。R0 拥有最大权力,可以访问低特权级的资源,反之则不行。
方法三: 自旋锁 协调多核心CPU
由于以前是单 CPU,同一时刻只有一条代码执行流,除了中断会中止当前代码执行流,转而运行另一条代码执行流(中断处理程序),再无其它代码执行流。这种情况下只要控制了中断,就能安全地操作全局数据。
但现在CPU都变成了多核,或者主板上安装了多颗CPU,同一时刻下系统中存在多条代码执行流,控制中断只能控制本地CPU的中断,无法控制其他CPU核心的中断。
自旋锁的原理
首先读取锁变量,判断其值是否已经加锁,如果未加锁则执行加锁,然后返回,表示加锁成功;如果已经加锁了,就要返回第一步继续执行后续步骤,因而得名自旋锁
自旋锁原理示意图:
想要正确执行它,就必须保证读取锁变量和判断并加锁的操作是原子执行的。否则,CPU0 在读取了锁变量之后,CPU1 读取锁变量判断未加锁执行加锁,然后 CPU0 也判断未加锁执行加锁,这时就会发现两个 CPU 都加锁成功,因此这个算法出错了。
怎么解决这个问题呢?这就要找硬件要解决方案了,x86 CPU 给我们提供了一个原子交换指令,xchg,它可以让寄存器里的一个值跟内存空间中的一个值做交换。例如,让 eax=memlock,memlock=eax 这个动作是原子的,不受其它 CPU 干扰。
代码实现:
//自旋锁结构
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));
}
关键点在于 xchg 指令,xchg %0, %1 。
其中,%0 对应 “r”(1),表示由编译器自动分配一个通用寄存器,并填入值 1,例如 mov eax,1。而 %1 对应"m"(*lock),表示 lock 是内存地址。把 1 和内存中的值进行交换,若内存中是 1,则不会影响;因为本身写入就是 1,若内存中是 0,一交换,内存中就变成了 1,即加锁成功。
在中断处理程序访问某个自旋锁保护的某个资源时,依然有问题,所以我们要写的自旋锁函数必须适应这样的中断环境,也就是说,它需要在处理中断的过程中也能使用,如下所示
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));
}
以上代码实现了关中断下获取自旋锁,以及恢复中断状态释放自旋锁。
方法四:信号量 CPU 时间管理大师
无论原子操作,还是自旋锁,都不适合长时间等待的情况,因为有很多资源(数据)它有一定的时间性,去获取它,CPU并不能立即返回给你,需要等待一段时间才能把数据返回给你。
这种情况,用自旋锁来同步访问这种资源,着实浪费
另一种同步机制:既能对资源数据进行保护(同一时刻只有一个代码流访问),又能在资源无法满足的情况下,让CPU可以执行其他任务。---信号量是 1965 年荷兰学者 Edsger Dijkstra 提出的,是一种用于资源互斥或者进程间同步的机制。
信号量有三个主要问题:等待、互斥、唤醒(即重新激活等待的代码执行流)。
这就需要一种全新的数据结构来解决这些问题。这个数据结构至少需要一个变量来表示互斥,比如大于 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”操作,并检查其值是否小于 0。
- 上步中检查 sem_count 如果小于 0,就让进程进入等待状态并且将其挂入 sem_waitlst 中,然后调度其它进程运行。否则表示获取信号量成功。当然最后别忘了对自旋锁 sem_lock 进行解锁。
- 第二步,代码执行流开始执行相关操作,例如读取键盘缓冲区。
- 第三步,释放信号量。
- 首先对用于保护信号量自身的自旋锁 sem_lock 进行加锁。
- 对信号值 sem_count 执行“加 1”操作,并检查其值是否大于 0。
- 上步中检查 sem_count 值如果大于 0,就执行唤醒 sem_waitlst 中进程的操作,并且需要调度进程时就执行进程调度操作,不管 sem_count 是否大于 0(通常会大于 0)都标记信号量释放成功。当然最后别忘了对自旋锁 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 两个函数,只是对前面自旋锁函数的一个封装
附:linux的自旋锁和信号量