• 内核同步问题


    linux内核同步问题

    Linux内核设计与实现 十、内核同步方法

    [手把手教Linux驱动5-自旋锁、信号量、互斥体概述]()

    基础概念:

    并发:多个执行单元同时进行或多个执行单元微观串行执行,宏观并行执行

    竞态:并发的执行单元对共享资源(硬件资源和软件上的全局变量)的访问而导致的竟态状态。

    临界资源:多个进程访问的资源

    临界区:多个进程访问的代码段

    并发场合:

    1、单CPU之间进程间的并发:时间片轮转,调度进程。 A进程访问打印机,时间片用完,OS调度B进程访问打印机。

    2、单cpu上进程和中断之间并发:CPU必须停止当前进程的执行中断;

    3、多cpu之间

    4、单CPU上中断之间的并发

    使用偏向:

    需求 建议加锁方式
    低开销、短期加锁 优先自旋锁
    长期锁定 优先互斥锁
    中断上下文加锁 自旋锁
    需要睡眠的持有锁(单线程) 互斥锁
    需要睡眠的持有锁(多线程) 信号量

    1、信号量(semaphore)

    信号量用于进程之间的同步,进程在信号量保护的临界区代码里面是可以睡眠的(需要进行进程调度),这是与自旋锁最大的区别。

    信号量又称为信号灯,它是用来协调不同进程间的数据对象的,而最主要的应用是共享内存方式的进程间通信。本质上,信号量是一个计数器,它用来记录对某个资源(如共享内存)的存取状况。它负责协调各个进程,以保证他们能够正确、合理的使用公共资源。它和spin lock最大的不同之处就是:无法获取信号量的进程可以睡眠,因此会导致系统调度。

    1.1、特点

    1、用于进程与进程之间的同步

    2、允许多个进程进入临界区代码执行,临界区代码允许睡眠;

    3、信号量本质是基于调度器的,在UP和SMP下没有区别;进程获取不到信号量将陷入休眠,并让出CPU;

    4、不支持进程和中断之间的同步

    5、进程调度也是会消耗系统资源的,如果一个int型共享变量就需要使用信号量,将极大的浪费系统资源

    6、信号量可以用于多个线程,用于资源的计数(有多种状态)

    1.2、常用函数

    信号量加锁以及解锁过程:

    sema_init(&sp->dead_sem, 0); /初始化/

    down(&sema);

    临界区代码

    up(&sema);

    信号量定义:

    struct semaphore {
    	raw_spinlock_t		lock;
    	unsigned int		count;
    	struct list_head	wait_list;
    };
    

    信号量初始化:

    static inline void sema_init(struct semaphore *sem, int val)
    {
    	static struct lock_class_key __key;
    	*sem = (struct semaphore) __SEMAPHORE_INITIALIZER(*sem, val);
    	lockdep_init_map(&sem->lock.dep_map, "semaphore->lock", &__key, 0);
    }
    

    dowm函数实现:

    static inline int __sched __down_common(struct semaphore *sem, long state,
    								long timeout)
    {
    	struct task_struct *task = current;/*当前进程代表的结构体*/
    	struct semaphore_waiter waiter;
    
    	list_add_tail(&waiter.list, &sem->wait_list);
    	waiter.task = task;
    	waiter.up = false;
    
    	for (;;) {
    		if (signal_pending_state(state, task))
    			goto interrupted;
    		if (unlikely(timeout <= 0))
    			goto timed_out;
    		__set_task_state(task, state);
    		raw_spin_unlock_irq(&sem->lock);
    		timeout = schedule_timeout(timeout);
    		raw_spin_lock_irq(&sem->lock);
    		if (waiter.up)
    			return 0;
    	}
    
     timed_out:
    	list_del(&waiter.list);
    	return -ETIME;
    
     interrupted:
    	list_del(&waiter.list);
    	return -EINTR;
    }
    static noinline void __sched __down(struct semaphore *sem)
    {
    	__down_common(sem, TASK_UNINTERRUPTIBLE, MAX_SCHEDULE_TIMEOUT);
    }
    
    void down(struct semaphore *sem)
    {
    	unsigned long flags;
    
    	raw_spin_lock_irqsave(&sem->lock, flags);/*自旋锁*/
    	if (likely(sem->count > 0))
    		sem->count--;
    	else
    		__down(sem);
    	raw_spin_unlock_irqrestore(&sem->lock, flags);
    
    }
    

    up函数实现:

    void up(struct semaphore *sem)
    {
    	unsigned long flags;
    
    	raw_spin_lock_irqsave(&sem->lock, flags);/*自旋锁*/
    	if (likely(list_empty(&sem->wait_list)))
    		sem->count++;
    	else
    		__up(sem);
    	raw_spin_unlock_irqrestore(&sem->lock, flags);
    
    }
    

    1.3、实现原理

    信号量一般可以用来标记可用资源的个数。

    举2个生活中的例子:

    1. 我们要坐火车从南京到新疆,这个'任务'特别的耗时,只能在车上等着车到站,但是我们没有必要一直睁着眼睛等着车到站,最好的情况就是我们上车就直接睡觉,醒来就到站,这样从人(用户)的角度来说,体验是最好的,对比于进程,程序在等待一个耗时的任务的时候,没有必须要占用CPU,可以暂停当前任务使其进入休眠状态,当等待的事件发生之后再由其他任务唤醒,这种场景采用信号量比较合适。
    2. 我们在等待电梯、等待洗手间,这种场景需要等待的事件并不是很多,如果我们还要找个地方睡一觉,然后等电梯到了或者洗手间可以用了再醒来,那很显然这也没有必要,我们只需要排好队,刷一刷抖音就可以了,对比于计算机程序,比如驱动在进入中断例程,在等待某个寄存器被置位,这种场景需要等待的时间很短暂,系统开销远小于进入休眠的开销,所以这种场景采用自旋锁比较合适。

    dowm函数实现原理解析:

    (1)down

    判断sem->count是否 > 0,大于0则说明系统资源够用,分配一个给该进程,否则进入__down(sem);

    (2)__down

    调用__down_common(sem, TASK_UNINTERRUPTIBLE, MAX_SCHEDULE_TIMEOUT);其中TASK_UNINTERRUPTIBLE=2代表进入睡眠,且不可以打断;MAX_SCHEDULE_TIMEOUT休眠最长LONG_MAX时间;

    (3)list_add_tail(&waiter.list, &sem->wait_list);

    把当前进程加入到sem->wait_list中;

    (3)先解锁后加锁;

    进入__down_common前已经加锁了,先把解锁,调用schedule_timeout(timeout),当waiter.up=1后跳出for循环;退出函数之前再加锁;

    2、原子变量(atomic)

    Linux内核ARM构架中原子变量的底层实现研究

    rk3288 原子操作和原子位操作

    原子变量适用于只共享一个int型变量;

    2.1、特点

    1、原子操作是指不被打断的操作,即它是最小的执行单位。

    2、最简单的原子操作就是一条条的汇编指令(不包括一些伪指令,伪指令会被汇编器解释成多条汇编指令)

    2.2、常用函数

    常见函数:

    #define ATOMIC_INIT(i)	{ (i) }    /*初始化原子变量*/
    #define atomic_inc(v)		atomic_add(1, v)   /*原子变量加1*/
    #define atomic_dec(v)		atomic_sub(1, v)   /*原子变量减1*/
    
    #define atomic_inc_and_test(v)	(atomic_add_return(1, v) == 0)   /*原子变量加1并测试是否等于0*/
    #define atomic_dec_and_test(v)	(atomic_sub_return(1, v) == 0)   /*原子变量减1并测试是否等于0*/
    

    2.3、实现原理

    以atomic_inc为例介绍实现过程

    在Linux内核文件arch\arm\include\asm\atomic.h中。
    执行atomic_read、atomic_set这些操作都只需要一条汇编指令,所以它们本身就是不可打断的。
    需要特别研究的是atomic_inc、atomic_dec这类读出、修改、写回的函数。

    但是atomic_add在内核中是很难找到的,因为没有这个直接的声明。而是一种宏实现。

    所以atomic_add的原型是下面这个宏:

    #define ATOMIC_OPS(op, c_op, asm_op)					\
    	ATOMIC_OP(op, c_op, asm_op)					\
    	ATOMIC_OP_RETURN(op, c_op, asm_op)				\
    	ATOMIC_FETCH_OP(op, c_op, asm_op)
    
    ATOMIC_OPS(add, +=, add)
    
    #define ATOMIC_OP(op, c_op, asm_op)					\
    static inline void atomic_##op(int i, atomic_t *v)			\
    {									\
    	unsigned long tmp;						\
    	int result;							\
    									\
    	prefetchw(&v->counter);						\
    	__asm__ __volatile__("@ atomic_" #op "\n"			\
    "1:	ldrex	%0, [%3]\n"						\
    "	" #asm_op "	%0, %0, %4\n"					\
    "	strex	%1, %0, [%3]\n"						\
    "	teq	%1, #0\n"						\
    "	bne	1b"							\
    	: "=&r" (result), "=&r" (tmp), "+Qo" (v->counter)		\
    	: "r" (&v->counter), "Ir" (i)					\
    	: "cc");							\
    }
    

    atomic_add等效于:

    static inline void atomic_add(int i, atomic_t *v)			\
    {									\
    	unsigned long tmp;						\
    	int result;							\
    									\
    	prefetchw(&v->counter);						\
    	__asm__ __volatile__("@ atomic_" #op "\n"			\
    "1:	ldrex	%0, [%3]\n"						\
    "	" #asm_op "	%0, %0, %4\n"					\
    "	strex	%1, %0, [%3]\n"						\
    "	teq	%1, #0\n"						\
    "	bne	1b"							\
    	: "=&r" (result), "=&r" (tmp), "+Qo" (v->counter)		\
    	: "r" (&v->counter), "Ir" (i)					\
    	: "cc");							\
    }
    

    result(%0) tmp(%1) (v->counter)(%2) (&v->counter)(%3) i(%4)

    注意:根据内联汇编的语法,result、tmp、&v->counter对应的数据都放在了寄存器中操作。如果出现上下文切换,切换机制会做寄存器上下文保护。

    (1)ldrex %0, [%3]

    意思是将&v->counter指向的数据放入result中,并且(分别在Local monitor和Global monitor中)设置独占标志。

    (2)add %0, %0, %4

    result = result + i

    (3)strex %1, %0, [%3]

    意思是将result保存到&v->counter指向的内存中,此时 Exclusive monitors会发挥作用,将保存是否成功的标志放入tmp中。

    (4) teq %1, #0

    测试strex是否成功(tmp == 0 ??)

    (5)bne 1b

    如果发现strex失败,从(1)再次执行。

    3、自旋锁(spinlock)

    Spinlock 是内核中提供的一种比较常见的锁机制,自旋锁是“原地等待”的方式解决资源冲突的,即,一个线程获取了一个自旋锁后,另外一个线程期望获取该自旋锁,获取不到,只能够原地“打转”(忙等待)。由于自旋锁的这个忙等待的特性,注定了它使用场景上的限制 —— 自旋锁不应该被长时间的持有(消耗 CPU 资源),一般应用在中断上下文

    3.1、特点

    1、spinlock是一种死等机制

    2、信号量可以允许多个执行单元进入,spinlock不行,一次只能允许一个执行单元获取锁,并且进入临界区,其他执行单元都是在门口不断的死等

    3、由于不休眠,因此spinlock可以应用在中断上下文中;

    4、由于spinlock死等的特性,因此临界区执行代码尽可能的短;

    3.2、常用函数

    spinlock加锁以及解锁过程:

    ​ spin_lock(&devices_lock);

    ​ 临界区代码

    ​ spin_unlock(&devices_lock);

    spinlock初始化

    #define spin_lock_init(_lock)				\
    do {							\
    	spinlock_check(_lock);				\
    	raw_spin_lock_init(&(_lock)->rlock);		\
    } while (0)
    

    进程和进程之间同步

    static __always_inline void spin_lock(spinlock_t *lock)
    {
    	raw_spin_lock(&lock->rlock);
    }
    

    本地软中断之间同步

    static __always_inline void spin_lock_bh(spinlock_t *lock)
    {
    	raw_spin_lock_bh(&lock->rlock);
    }
    

    本地硬中断之间同步

    static __always_inline void spin_lock_irq(spinlock_t *lock)
    {
    	raw_spin_lock_irq(&lock->rlock);
    }
    

    本地硬中断之间同步并且保存本地中断状态

    #define spin_lock_irqsave(lock, flags)				\
    do {								\
    	raw_spin_lock_irqsave(spinlock_check(lock), flags);	\
    } while (0)
    

    尝试获取锁

    static __always_inline int spin_trylock(spinlock_t *lock)
    {
    	return raw_spin_trylock(&lock->rlock);
    }
    

    3.3、实现原理

    image-20220401225902320

    arch_spinlock_t结构体定义如下:

    #define TICKET_SHIFT	16
    
    typedef struct {
    	union {
    		u32 slock;/*union*/
    		struct __raw_tickets {
    #ifdef __ARMEB__  /*大端模式*/
    			u16 next;
    			u16 owner;
    #else/*小端模式*/
    			u16 owner;
    			u16 next;
    #endif
    		} tickets;
    	};
    } arch_spinlock_t;
    

    arch_spin_lock的实现如下:

    static inline void arch_spin_lock(arch_spinlock_t *lock)
    {
    	unsigned long tmp;
    	u32 newval;
    	arch_spinlock_t lockval;
    
    	prefetchw(&lock->slock);/*从lock中取出slock*/
    	__asm__ __volatile__(
    "1:	ldrex	%0, [%3]\n"
    "	add	%1, %0, %4\n"
    "	strex	%2, %1, [%3]\n"
    "	teq	%2, #0\n"
    "	bne	1b"
    	: "=&r" (lockval), "=&r" (newval), "=&r" (tmp)
    	: "r" (&lock->slock), "I" (1 << TICKET_SHIFT)
    	: "cc");
    
    	while (lockval.tickets.next != lockval.tickets.owner) {
    		wfe();/*等待,系统开销很大*/
    		lockval.tickets.owner = ACCESS_ONCE(lock->tickets.owner);
    	}
    
    	smp_mb();
    }
    

    lockval(%0) newval(%1) tmp(%2) &lock->slock(%3) 1 << TICKET_SHIFT(%4)

    (1)ldrex %0, [%3]

    把lock->slock的值赋值给lockval;并且(分别在Local monitor和Global monitor中)设置独占标志。

    (2)add %1, %0, %4

    newval =lockval +(1<<16); 相当于next+1;

    (3)strex %2, %1, [%3]

    newval =lockval +(1<<16); 相当于next+1;

    意思是将newval保存到 &lock->slock指向的内存中,此时 Exclusive monitors会发挥作用,将保存是否成功的标志放入tmp中。

    (4) teq %2, #0

    测试strex是否成功

    (5)bne 1b

    如果发现strex失败,从(1)再次执行。

    通过上面的分析,可知关键在于strex的操作是否成功的判断上。而这个就归功于ARM的Exclusive monitors和ldrex/strex指令的机制。

    (6)while (lockval.tickets.next != lockval.tickets.owner)

    如何lockval.tickets的next和owner是否相等。相同则跳出while循环,否则在循环内等待判断;

    **(7)wfe()和smp_mb() 最终调用#define barrier() asm volatile("": : :"memory") **

    阻止编译器重排,保证编译程序时在优化屏障之前的指令不会在优化屏障之后执行。

    arch_spin_unlock的实现如下:

    static inline void arch_spin_unlock(arch_spinlock_t *lock)
    {
    	smp_mb();
    	lock->tickets.owner++;
    	dsb_sev();
    }
    

    退出锁时:tickets.owner++

    3.4、死锁以及解决办法

    出现死锁的情况:

    1、拥有自旋锁的进程A在内核态阻塞了,内核调度B进程,碰巧B进程也要获得自旋锁,此时B只能自旋转。 而此时抢占已经关闭,(单核)不会调度A进程了,B永远自旋,产生死锁。

    2、进程A拥有自旋锁,中断到来,CPU执行中断函数,中断处理函数,中断处理函数需要获得自旋锁,访问共享资源,此时无法获得锁,只能自旋,产生死锁。

    如何避免死锁:

    1、如果中断处理函数中也要获得自旋锁,那么驱动程序需要在拥有自旋锁时禁止中断;

    2、自旋锁必须在可能的最短时间内拥有

    3、避免某个获得锁的函数调用其他同样试图获取这个锁的函数,否则代码就会死锁;不论是信号量还是自旋锁,都不允许锁拥有者第二次获得这个锁,如果试图这么做,系统将挂起;

    4、锁的顺序规则(a) 按同样的顺序获得锁;b) 如果必须获得一个局部锁和一个属于内核更中心位置的锁,则应该首先获取自己的局部锁 ;c) 如果我们拥有信号量和自旋锁的组合,则必须首先获得信号量;在拥有自旋锁时调用down(可导致休眠)是个严重的错误的;)

    3.5、其他类型的spinlock

    rw(read/write)spinlock:

    加锁逻辑:

    1、假设临界区内没有任何的thread,这个时候任何的读线程和写线程都可以键入

    2、假设临界区内有一个读线程,这时候信赖的read线程可以任意进入,但是写线程不能进入;

    3、假设临界区有一个写线程,这时候任何的读、写线程都不可以进入;

    4、假设临界区内有一个或者多个读线程,写线程不可以进入临界区,但是写线程也无法阻止后续的读线程继续进去,要等到临界区所有的读线程都结束了,才可以进入,可见:rw(read/write)spinlock更加有利于读线程;

    seqlock(顺序锁):

    加锁逻辑:

    1、假设临界区内没有任何的thread,这个时候任何的读线程和写线程都可以键入

    2、假设临界区内没有写线程的情况下,read线程可以任意进入;

    3、假设临界区有一个写线程,这时候任何的读、写线程都不可以进入;

    4、假设临界区内只有read线程的情况下,写线程可以理解执行,不会等待,可见:seqlock(顺序锁)更加有利于写线程;

    3.6、spinlock的不足之处

    读写速度CPU > 一级缓存 > 二级缓存 > 内存,因此某一个CPU0的lock修改了,其他的CPU的lock就会失效;那么其他CPU就会依次去L1 L2和主存中读取lock值,一旦其他CPU去读取了主存,就存在系统性能降低的风险;

    4、互斥体(mux)

    mutex用于互斥操作。

    互斥体只能用于一个线程,资源只有两种状态(占用或者空闲)

    4.1、特点

    1、mutex的语义相对于信号量要简单轻便一些,在锁争用激烈的测试场景下,mutex比信号量执行速度更快,可扩展

    性更好,

    2、另外mutex数据结构的定义比信号量小;、

    3、同一时刻只有一个线程可以持有mutex

    4、不允许递归地加锁和解锁

    5、当进程持有mutex时,进程不可以退出。

    • mutex必须使用官方API来初始化。

    • mutex可以睡眠,所以不允许在中断处理程序或者中断下半部中使用,例如tasklet、定时器等

    4.2、常用函数

    常见操作:

    struct mutex mutex_1;

    mutex_init(&mutex_1);

    mutex_lock(&mutex_1)

    临界区代码;

    mutex_unlock(&mutex_1)

    常见函数:

    mutex_lock(struct mutex*)  为指定的mutex上锁,如果不可用则睡眠
    mutex_unlock(struct mutex*)  为指定的mutex解锁
    mutex_trylock(struct mutex*)  尝试获取指定的mutex,如果成功则返回1;否则锁被获取,返回值是0
    mutex_is_lock(struct mutex*)   如果锁已被征用,则返回1;否则返回0
    

    =

  • 相关阅读:
    02-Java 数组和排序算法
    Spring Security 入门
    mysql外键理解
    redis能否对set数据的每个member设置过期时间
    Redis sortedset实现元素自动过期
    mysql之触发器trigger
    一篇很棒的 MySQL 触发器学习教程
    mysql触发器
    云游戏
    mysql触发器个人实战
  • 原文地址:https://www.cnblogs.com/agui125/p/16132406.html
Copyright © 2020-2023  润新知