值得学习的Linux内核锁(二)

实现

描述读写锁只需要1个变量即可,因此我们可以定义读写锁的结构体如下。

typedef struct {
volatile unsigned int lock;
} arch_rwlock_t;

既然区分读写操作,因此肯定会有两个申请锁函数,分别是读和写。首先,我们看一下read_lock操作的实现。

static inline void arch_read_lock(arch_rwlock_t *rw)
{
unsigned int tmp;
sevl(); /* 1 */
do {
wfe();
tmp = rw->lock;
tmp++; /* 2 */
} while(tmp & (1 << 31)); /* 3 */
rw->lock = tmp;
}
  1. sevl()函数是ARM64架构中SEVL汇编指令。SEVL和SEV的区别是,SEVL仅仅修改本地CPU的PE寄存器值,这样下面的WFE指令第一次执行的时候不会睡眠。
  2. 增加读者计数,最后会更新到rw->lock中。
  3. 更新rw->lock前提是没有写者,因此这里会判断是否有写者已经进入临界区(判断方法是rw->lock变量bit31的值)。如果,有写者已经进入临界区,就在这里循环,并WFE指令睡眠。类似上面介绍的spin lock实现。

当读进程离开临界区的时候会调用read_unlock释放锁。read_unlock实现如下。

static inline void arch_read_unlock(arch_rwlock_t *rw)
{
rw->lock--;
sev();
}
实现很简单,和spin_unlock如出一辙。递减读者计数,然后使用SEV指令唤醒所有的CPU,检查等待状态的进程是否可以获取锁。

读操作看完了,我们看看写操作是如何实现的。arch_write_lock实现如下。

static inline void arch_write_lock(arch_rwlock_t *rw)
{
unsigned int tmp;
sevl();
do {
wfe();
tmp = rw->lock;
} while(tmp); /* 1 */
rw->lock = 1 << 31; /* 2 */
}
  1. 由于写者是排他的(读者和写者都不能有),因此这里只有rw->lock的值为0,当前的写者才可以进入临界区。
  2. 置位rw->lock的bit31,代表有写者进入临界区。

当写进程离开临界区的时候会调用write_unlock释放锁。write_unlock实现如下。

static inline void arch_write_unlock(arch_rwlock_t *rw)
{
rw->lock = 0; /* 1 */
sev(); /* 2 */
}
  1. 同样由于写者是排他的,因此只需要将rw->lock置0即可。代表没有任何进程进入临界区。毕竟是因为同一时间只能有一个写者进入临界区,当这个写者离开临界区的时候,肯定是意味着现在没有任何进程进入临界区。
  2. 使用SEV指令唤醒所有的CPU,检查等待状态的进程是否可以获取锁。

以上的代码实现其实会导致写进程饿死现象。例如,A、B、C三个进程进入读临界区,D进程尝试获得写锁,此时只能等待A、B、C三个进程退出临界区。如果在退出之前又有F、G进程进入读临界区,那么将出现D进程饿死现象。

【文章福利】小编推荐自己的Linux内核技术交流群:【977878001】整理一些个人觉得比较好得学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!!!前100进群领取,额外赠送一份价值699的内核资料包(含视频教程、电子书、实战项目及代码)

内核资料直通车:Linux内核源码技术学习路线+视频教程代码资料

学习直通车:Linux内核源码/内存调优/文件系统/进程管理/设备驱动/网络协议栈

互斥量(mutex)

前文提到的semaphore在初始化count计数的时候,可以分为计数信号量和互斥信号量(二值信号量)。mutex和初始化计数为1的二值信号量有很大的相似之处。他们都可以用做资源互斥。但是mutex却有一个特殊的地方:只有持锁者才能解锁。但是,二值信号量却可以在一个进程中获取信号量,在另一个进程中释放信号量。如果是应用在嵌入式应用的RTOS,针对mutex的实现还会考虑优先级反转问题。

原理

既然mutex是一种二值信号量,因此就不需要像semaphore那样需要一个count计数。由于mutex具有“持锁者才能解锁”的特点,所以我们需要一个变量owner记录持锁进程。释放锁的时候必须是同一个进程才能释放。当然也需要一个链表头,主要用来便利睡眠等待的进程。原理和semaphore及其相似,因此在代码上也有体现。

实现

mutex的实现代码和linux中实现会有差异,但是依然可以为你呈现设计的原理。下面的设计代码更像是部分RTOS中的代码。mutex和semaphore一样,我们需要两个类似的结构体分别描述mutex。

struct mutex_waiter {
struct list_head list;
struct task_struct *task;
};
struct mutex {
long owner;
struct list_head wait_list;
};

struct mutex_waiter的list成员是当进程无法获取互斥量的时候挂入mutex的wait_list链表。

首先实现申请互斥量的函数。

void mutex_take(struct mutex *mutex)
{
struct mutex_waiter waiter;
if (!mutex->owner) {
mutex->owner = (long)current; /* 1 */
return;
}
waiter.task = current;
list_add_tail(&waiter.list, &mutex->wait_list); /* 2 */
schedule(); /* 2 */
}
  1. 当mutex->owner的值为0的时候,代表没有任何进程持有锁。因此可以直接申请成功。然后,记录当前申请锁进程的task_struct。
  2. 既然不能获取互斥量,自然就需要睡眠等待,挂入等待链表。

互斥量的释放代码实现也同样和semaphore有很多相似之处。不信,你看。

int mutex_release(struct mutex *mutex)
{
struct mutex_waiter *waiter;
 
if (mutex->owner != (long)current) /* 1 */
return -1;
 
if (list_empty(&mutex->wait_list)) {
mutex->owner = 0; /* 2 */
return 0;
}
 
waiter = list_first_entry(&mutex->wait_list, struct mutex_waiter, list);
list_del(&waiter->list);
mutex->owner = (long)waiter->task; /* 3 */
wake_up_process(waiter->task); /* 4 */
 
return 0;
}
  1. mutex具有“持锁者才能解锁”的特点就是在这行代码体现。
  2. 如果等待链表没有进程,那么自然只需要将mutex->owner置0,代表没有锁是释放状态。
  3. mutex->owner的值改成当前可以持锁进程的task_struct。
  4. 从等待进程链表取出第一个进程,并从链表上移除。然后就是唤醒该进程。

一. 基本概念

● linux内核中产生竞态的原因

SMP对称多处理器 (多核CPU)

比如都要操作LCD

进程和进程之间的抢占共享资源,进程和中断之间发生共享资源的抢占,中断和中断之间的资源抢占(中断是有优先级的)

比如:LCD 网卡 可见的内存 (文件 共享内存 全局变量)。

● 共享资源

文件、硬件设备、共享内存、内核中的全局变量等

● 并发

多任务同时执行,对于单核的CPU来说,宏观上并行,微观上串行。而并发的执行单元对共享资源的访问则很容易导致竞态(Race Conditions)

● 临界区

访问共享资源的代码段

对某段代码而言,可能会在程序中多次被执行,每次执行的过程我们称作代码的执行路径。当两个或多个代码路径要竞争共同的资源的时候,该代码段就是临界区。

二. 解决竞争状态的策略

常用一下四种策略:(速记:中原武林很自信)

1)中断屏蔽(内核空间)

不推荐使用

2)原子操作(内核空间)

事务的原子性:要么做完 ,要么不做

3)自旋锁(内核空间)

自旋锁相应快,逻辑不允许重入,要等待锁释放的

4)信号量 (用户空间)

相对慢,要从睡眠态唤醒

1. 中断屏蔽

中断屏蔽可以保证正在执行的内核执行路径不被中断处理程序抢占,防止竞态的产生,但内核的正常运行依赖于中断机制。在屏蔽中断期间,任何中断都无法得到处理,而必须等待屏蔽解除。所以关中断的时间要非常短, 如果关中断时间过长,可能直接造成内核崩溃,建议在写驱动过程中尽量不使用。

使用流程为:关中断----访问共享资源----开中断

使用方法如下:

  local_irq_disable()
   local_irq_enable()
   //更安全的:
   local_irq_save()                       //保存中断的状态(开/关)       关闭中断
   local_irq_restore()                    //恢复保存的中断状态  

2. 原子操作

原子操作底层表现为一条汇编指令(ldrex、strex)。所以他们在执行过程中不会被别的代码路径所中断。

事务的原子性就是要么做完 要么不做。而如何实现的原子性不被打断,不需要去关注,内核中实现的原子操作都是与CPU架构息息相关的,只需要掌握原子的使用方法即可。

很好理解,用上厕所的例子来说明。厕所就是共享资源,去上厕所的行为被称作代码路径。

原子操作就是大家每次上厕所都用时非常短,短到什么程度呢,只要一条汇编指令的时间。当然拉的量也非常少(只改变一个整型或者是位)。所以就不存在抢厕所的问题了。

2.1 位原子操作

  // arch/arm/include/asm/bitops.h
  set_bit(nr, void *addr)      // addr内存中的nr位置1
  clear_bit
  change_bit
  test_bit
  ...

2.2 整型原子操作

使用步骤:

//1)定义原子变量     atomic_t tv;  //就是用原子变量来代替整形变量
    //核心数据结构:
    typedef struct {
        int counter;
    } atomic_t;
//2) 设置初始值的两种方法   
     tv = ATOMIC_INIT(0);    //① 定义原子变量 v 并初始化为0
     atomic_set(&tv, i)      //② 设置原子变量的值为 i
//3) 操作原子变量
    int atomic_read(atomic_t *v)       //返回原子变量的值        
    atomic_add(int i, atomic_t *v);    //v += i
    atomic_sub(int i, atomic_t *v);    //v -= i
    atomic_inc(atomic_t *v);           //v++;
    atomic_dec(atomic_t *v)            //v--  
    ...   

代码过长,具体代码:Linux内核的竞态与并发——原子操作实例

3. 自旋锁

多处理器之间设置一个全局变量V,表示锁。并定义当V=1时为锁定状态,V=0时为解锁状态自旋锁同步机制是针对多处理器设计的,属于忙等机制。

自旋锁,逻辑不允许重入,要等待锁释放的,注意以下:

1) 自旋锁的获取与释放逻辑上要保证成对出现

2) 只允许一个持有单元,获取锁不成功原地自旋等待

3) 临界区中不能调用引起阻塞或者睡眠的函数

4) 临界区执行速度要快, 持有自旋锁期间,整个系统几乎不做任务切换,持有自旋锁时间过长,会导致整个系统性能严重下降

5) 避免死锁, A,B互相锁死,可以建议使用spin_trylock(&btn_lock)

还是用上厕所的例子:这次给厕所上把锁,只有拥有这个锁钥匙的人A才能进厕所。进去后把锁锁上,外面的人B急得团团转(自旋),A出来后把锁释放,在门口等着的B拿了钥匙赶紧开了锁进去了。但是缺点就是,B在外面团团转,没有功夫去做别的事情,所以一旦A 上厕所的时间很长,B就浪费了很长时间在自旋上。对系统的性能有所影响。

使用步骤:

// 1)定义一个自旋锁变量: 
    spinlock_t btn_lock;
// 2) 初始化自旋锁 :  
    spin_lock_init(&btn_lock)
// 3) 获取自旋锁 (获取权利)
   spin_lock(&btn_lock);       //获取自旋锁不成功,原地自旋等待,直到锁被释放,获取成功才返回
   //或:
   int spin_trylock(&btn_lock);//不成功,直接返回一个错误信息,调试的时候可用,可以避免死锁
// 4) 访问共享资源
// 5) 释放自旋锁
   spin_unlock(&btn_lock);

自旋锁还有很多衍生自旋锁:读锁 写锁 顺序锁 内核的大锁:

// 1)定义一个自旋锁变量: 
    spinlock_t btn_lock;
// 2) 初始化自旋锁 :  
    spin_lock_init(&btn_lock)
// 3) 获取自旋锁 (获取权利)
    unsigned long flags;
    spin_lock_irq(&lock);            // = spin_lock() + local_irq_disable()
    //或
    spin_lock_irqsave(&lock, flags); // = spin_lock() local_irq_save()
// 4) 访问共享资源
// 5) 释放自旋锁
   spin_unlock_irq(&lock);          // = spin_unlock()+ local_irq_enable()
   //或
   spin_unlock_irqrestore(&lock, flags); // = spin_unlock() + local_irq_restore()

Linux内核的竞态与并发——自旋锁实例

Q:编程时有可能需要在临界区代码中执行阻塞睡眠函数 怎么办?
A:这时可以考虑使用信号量来保护临界区。

4 信号量

在用户空间只有进程的概念。当一个临界区有多个用户态进程竞争时,最好的方法是用信号量保护这个临界区。只有得到信号量进程才能执行临界区代码,当获取不到信号量时,进程进入休眠状态。

因此,我们可以说,信号量是进程级的互斥机制,它代表进程来争夺共享资源,如果竞争失败,就会发生进程上下文切换,当前进程进入睡眠状态,CPU运行其他进程。

此外,信号量在SMP(对称多处理器)系统同样起作用;内核中的信号量也只能用于内核态编程

比方说:一间公共厕所N 个坑位,N 不为 1, 且 N为有限个,算是N个资源。在同一时间可以容纳N个人,当满员的时候,外面的人必须等待里面的人出来,释放一个资源,然后才能在进一个,当他进去之后,厕所又满员了,外面的人还得继续等待……

● 特点:

a.基于自旋锁机制实现的
b.可以有多个持有者,获取信号量不成功睡眠等待
c. 可以调用引起阻塞或者睡眠的函数
d. 用信号量保护的临界区执行速度相对慢

  • 内核中关于信号量的核心数据结构
struct semaphore {
    raw_spinlock_t        lock;
    unsigned int          count;//计数器
    ...

● 使用步骤:

 // 1)定义一个信号量   
     struct semaphore btn_sem;
 // 2) 初始化信号量          
     void sema_init(&btn_sem, 5);    //该信号量可以被5个执行单元持有
    //还可以通过以下宏完成信号量的定义和赋值为1             
    DEFINE_SEMAPHORE(btn_sem);
 // 3) 获取信号量,本质就是给其中的计数-1(获取权利)
       //成功立即返回,失败使用调用者进程进入睡眠状态(深度睡眠kiii -9都杀不死) ,
      //直到可以获取信号量成功才被唤醒、返回
    void down(struct semaphore *sem);

      //成功立即返回,失败进入可中断的睡眠状态(潜睡眠,可被ctrl+c打断)
    //可以获取信号量 + 收到信号(ctrl+c)
      int down_interruptible(struct semaphore *sem); //关注返回值

    //失败立即返回一个错误信息,不会导致睡眠
    //可以在中断上下文中使用
     int down_trylock(struct semaphore *sem);

    //失败进入可以kill的睡眠状态 
    int down_killable(struct semaphore *sem);     
    
    //获取信号量,指定超时时间为x
    //如果获取信号量不成功,对应的进程进入睡眠状态
    //可能因为信号量可用而被唤醒/也可能因为定时时间到而被唤醒
    int down_timeout(struct semaphore *sem, long jiffies);
 // 4) 执行临界区代码,访问共享资源
 // 5)释放信号量,本质就是给计数器+1
    void up(struct semaphore *sem);

Linux内核的竞态与并发——信号量实例

5 互斥体

在 FreeRTOS 和 UCOS 中也有互斥体,将信号量的值设置为 1 就可以使用信号量进行互斥访问了,虽然可以通过信号量实现互斥,但是 Linux 提供了一个比信号量更专业的机制来进行互斥,它就是互斥体—mutex。互斥访问表示一次只有一个线程可以访问共享资源,不能递归申

请互斥体。在我们编写 Linux 驱动的时候遇到需要互斥访问的地方建议使用 mutex。 Linux 内核

使用 mutex 结构体表示互斥体,定义如下(省略条件编译部分):

struct mutex {
/* 1: unlocked, 0: locked, negative: locked, possible waiters */
    atomic_t count;
    spinlock_t wait_lock;
};

在使用 mutex 之前要先定义一个 mutex 变量。在使用 mutex 的时候要注意如下几点:

①、 mutex 可以导致休眠,因此不能在中断中使用 mutex,中断中只能使用自旋锁。

②、和信号量一样, mutex 保护的临界区可以调用引起阻塞的 API 函数。

③、因为一次只有一个线程可以持有 mutex,因此,必须由 mutex 的持有者释放 mutex。并

且 mutex 不能递归上锁和解锁。

1. 资源

2. 锁的基本概念

互斥锁:

当有一个线程要访问共享资源(临界资源)之前会对线程访问的这段代码(临界区)进行加锁。如果在加锁之后没释放锁之前其他线程要对临界资源进行访问,则这些线程会被阻塞睡眠,直到解锁,如果解锁时有一个或者多个线程阻塞,那么这些锁上的线程就会变成就绪状态,然后第一个变为就绪状态的线程就会获取资源的使用权,并且再次加锁,其他线程继续阻塞等待。

读写锁:

也叫做共享互斥锁,读模式共享,写模式互斥。有点像数据库负载均衡的读写分离模式。它有三种模式:读加锁状态,写加锁状态和不加锁状态。简单来说就是只有一个线程可以占有写模式的读写锁,但是可以有多个线程占用读模式的读写锁。

当写加锁的模式下,任何线程对其进行加锁操作都会被阻塞,直到解锁。

当在读加锁的模式下,任何线程都可以对其进行读加锁的操作,但所有试图进行写加锁操作的线程都会被阻塞。直到所有读线程解锁。但是当读线程太多时,写线程一直被阻塞显然是不对的,所以一个线程想要对其进行写加锁时,就会阻塞读加锁,先让写加锁线程加锁

自旋锁

自旋锁和互斥锁很像,唯一不同的是自旋锁访问加锁资源时,会一直循环的查看是否释放锁。这样要比互斥锁效率高很多,但是只会占用CPU。所以自旋锁适用于多核的CPU。但是还有一个问题是当自旋锁递归调用的时候会造成死锁现象。所以慎重使用自旋锁。

乐观锁

这其实是一种思想,当线程去拿数据的时候,认为别的线程不会修改数据,就不上锁,但是在更新数据的时候会去判断以下其他线程是否修改了数据。通过版本来判断,如果数据被修改了就拒绝更新,之所以叫乐观锁是因为并没有加锁。

悲观锁

当线程去哪数据的时候,总以为别的线程会去修改数据,所以它每次拿数据的时候都会上锁,别的线程去拿数据的时候就会阻塞。

这两种锁一般用于数据库,当一个数据库的读操作远远大于写的操作次数时,使用乐观锁会加大数据库的吞吐量。

互斥锁:同一时间,只有一个线程可以访问共享变量。

自选锁:不让线程切换。

原子操作:操作不可分割。

CAS 比较并交换,比较变量有没有被修改。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值