什么是并发与竞争
当一个程序中有多个执行单位,并且它们共享一段内存,那么就有可能发生同时修改内存中数据的现象,就会导致数据混乱,甚至程序崩溃,这就是并发与竞争。
如何避免竞争:采用原子操作、信号量、自旋锁、互斥量等等 条件来约束对共享内存的访问。这些方式各自有不同的特点,适合不同的场景。
多任务工作方式: 多线程、多进程、中断与任务等等。
共享资源(临界资源): 在多任务的工作模式下有多个执行单位,它们通常可以拥有一段共享空间(比如全局变量、共享内存等等)。
临界区: 在同一时刻只能有一个运行单位执行的一段代码叫临界区。
竞争: 多个运行单位都可以同时访问这一段空间,这时就很容易发生错误,当他们都需要访问这个空间时就会发生竞争,从而导致空间内的数据混乱(多线程任务下极易导致程序崩溃)。
同步: 显然,我们不希望这种情况发生,就有了对临界资源的保护,让每个时间只能有一个进程/线程访问,甚至有序访问,这就叫同步。同步的手段有多种,比如自旋锁、信号量、互斥量等等。
并发无论是在应用层,或是底层写驱动时都需要注意。
原子操作
原子操作: 不可分割的操作。
原子操作通常用于变量或位操作。
内核原子变量操作
c语言中对一个变量赋值仅需要一个语句,比如a = 5;
,这就很容易让我们误解为已经是最精简的操作,其实并不是。
c语言编译首先是需要转化成汇编的,在汇编中对一个变量赋值至少需要3步:
1 ldr r0, =0X30000000 /* 变量 a 地址 */
2 ldr r1, = 3 /* 要写入的值 */
3 str r1, [r0] /* 将 3 写入到 a 变量中 */
那么如果这是一个全局变量,有多个线程对其赋值,就容易发生错误,可能会是下面的结果,也可能是其它的错误结果:
我们需要保证它每次都能被完美的赋值:
在编写驱动时,对全局变量要求保证原子操作可以用 atomic_t 结构体:
typedef struct {
int counter;
} atomic_t;
定义原子变量:atomic_t a;
原子变量赋值:atomic_t b = ATOMIC_INIT(0);
,ATOMIC_INIT(x) 这是一个宏,用于给原子变量赋值。
另外Linux内核还提供了大量对原子变量操作的API,包括读、写、加减乘除等等:
64位系统的原子变量结构体与API与32位的有所不同,不过也类似。
typedef struct {
long long counter;
} atomic64_t; //atomic64_t 64位
原子位操作
同样位操作也是经常使用的操作,内核也提供了原子位操作的API,只不过原子位操作不像原子整形变量那样有个 atomic_t 的数据结构,原子位操作是直接对内存进行操作 :
自旋锁
上面介绍了 原子操作,用于保护变量的访问 ,但是我们在编写程序中,需要操作的不止有变量(结构体、共享内存等等)。
为了同时保护一段区域,就有了各种锁,比如自旋锁。
自旋锁就像它的名字,当多个进程先后对一段内存访问,先拿到锁的人可以访问;没有拿到的只有在循环等待(注意:进程并没有挂起),它会不断查询锁是否释放,然后继续先抢到的人访问临界区,没抢到的等待。(同一时间只允许一个执行单位访问临界资源)
仔细思考它,就可以得到它的优缺点:
优点: 响应快速,当一个线程释放,另一个在等待的线程马上就能获取锁,进入临界区。
缺点: 等待时占用 CPU 时间,降低一些效率。(合理利用就不会)
所以自旋锁适用于对临界区访问 快进快出 的场合,它是 轻量级加锁 。(适用于多核、多线程)
加入一个线程进入临界区,执行的时间很漫长,那等待的线程就一直等待,大大的浪费了cpu 时间。
自旋锁死锁
线程与线程
在自旋锁保护的临界区内,一定不能有引起线程(进程)休眠或阻塞的操作,否则可能引起死锁。
抢占: Linux系统对每个线程(进程)都有优先级分配,默认执行高优先级。
死锁: 线程在获取锁之后阻塞或睡眠,导致多个线程都阻塞。
自旋锁会自动禁止抢占,也就说当线程 A得到锁以后会暂时禁止内核抢占。如果线程 A 在持有锁期间进入了休眠状态,那么线程 A 会自
动放弃 CPU 使用权。线程 B 开始运行,线程 B 也想要获取锁,但是此时锁被 A 线程持有,而且内核抢占还被禁止了!线程 B 无法被调度出去,那么线程 A 就无法运行,锁也就无法释放,好了,死锁发生了!
最简单的死锁: 有时一个程序中使用多把锁,多个线程,手里捏着锁,同时想要获取对方的锁,多个线程直接阻塞。
线程与中断
当中断中也要访问临界区的时候,也可以使用自旋锁,但是在其它线程获取到锁时会关闭中断。
否则,当线程A正在临界区中,中断优先级高于任何线程(它时硬件中断),此时在中断中也想要获取锁,然后中断就阻塞了,其它想要获取锁的线程也一直会阻塞。
自旋锁使用注意事项
- 因为等待资源的线程会一直“自旋”,所以线程持有自旋锁,访问临界区的时间不能太长。
- 临界区中,不能存在任何会引起线程阻塞、睡眠的操作,否者可能引起死锁。
- 不能递归的申请自旋锁。
- 考虑到程序的可移植性,在使用自旋锁时不管你的cpu是单核,还是多核,都当做多核来使用。
自旋锁使用
自旋锁 结构体原型:
64 typedef struct spinlock {
65 union {
66 struct raw_spinlock rlock;
67
68 #ifdef CONFIG_DEBUG_LOCK_ALLOC
69 # define LOCK_PADSIZE (offsetof(struct raw_spinlock, dep_map))
70 struct {
71 u8 __padding[LOCK_PADSIZE];
72 struct lockdep_map dep_map;
73 };
74 #endif
75 };
76 } spinlock_t;
定义自旋锁: spinlock_t lock;
用于操作自旋锁的API:
DEFINE_SPINLOCK(spinlock_t lock) 定义并初始化一个自旋变量。
int spin_lock_init(spinlock_t *lock) 初始化自旋锁。
void spin_lock(spinlock_t *lock) 获取指定的自旋锁,也叫做加锁。
void spin_unlock(spinlock_t *lock) 释放指定的自旋锁。
int spin_trylock(spinlock_t *lock) 尝试获取指定的自旋锁,如果没有获取到就返回 0
int spin_is_locked(spinlock_t *lock) 检查指定的自旋锁是否被获取,如果没有被获取就返回非 0,否则返回 0。
为了防止中断打断持有自旋锁的线程,可以使用以下函数:
void spin_lock_irq(spinlock_t *lock) 禁止本地中断,并获取自旋锁。
void spin_unlock_irq(spinlock_t *lock) 激活本地中断,并释放自旋锁。
void spin_lock_irqsave(spinlock_t *lock,unsigned long flags) 保存中断状态,禁止本地中断,并获取自旋锁。
void spin_unlock_irqrestore(spinlock_t*lock, unsigned long flags) 将中断状态恢复到以前的状态,并且激活本地中断,释放自旋锁。
下半部(BH)也会竞争共享资源,有些资料也会将下半部叫做底半部。如果要在下半部里面使用自旋锁,可以使用以下 API 函数:
void spin_lock_bh(spinlock_t *lock) //关闭下半部,并获取自旋锁。
void spin_unlock_bh(spinlock_t *lock) //打开下半部,并释放自旋锁。
信号量
在不同场景下,还有其它类似自选锁的操作 —— 信号量
相比于自选锁,信号量有以下特点:
- 线程可以访问的临界区比较大,访问资源的时间长,所以临界区中可以调用引起阻塞的操作。
- 等待资源的线程不会一直等待,会让出 cpu 使用权,进入挂起、睡眠状态。(cpu经过调度执行其它线程)
- 正是因为会进入睡眠态,所以不能在中断中使用,同时也不能在自旋锁的临界区中使用。(中断讲究快进快出)
- 在等待资源时CPU 会经历 切换、唤醒进程等等操作,会产生一定的开销。当使用信号量所争取的利益大于这个开销时就不要使用信号量。
二值信号量 与 计数信号量
信号量类似于一个计数器,初始化时会给信号量一个初始值,当线程获取信号量时,信号量的值-1,线程出临界区时区归还信号量,信号量值+1,当信号量值为0,并且还有线程想获取信号量,那么这些线程将会被挂起,直到拿到信号量。
二值信号量就是初始化时信号量的值为1时,此时是为了一个互斥作用,只有一个线程可以进入临界区。
计数信号量:就是当信号量初始化时的值大于1时,此时可以有多个线程进入临界区。
信号量编程
信号量结构体:
struct semaphore {
raw_spinlock_t lock;
unsigned int count;
struct list_head wait_list;
};
定义信号量:struct semaphore sem;
DEFINE_SEAMPHORE(name) 定义一个信号量,并且设置信号量的值为 1。
void sema_init(struct semaphore *sem, int val) 初始化信号量 sem,设置信号量值为 val。
void down(struct semaphore *sem) 获取信号量,因为会导致休眠,因此不能在中断中使用。
int down_trylock(struct semaphore *sem); 尝试获取信号量,如果能获取到信号量就获取,并且返回 0。如果不能就返回非 0,并且不会进入休眠。
int down_interruptible(struct semaphore *sem) 获取信号量,和 down 类似,只是使用 down 进入休眠状态的线程不能被信号打断。而使用此
函数进入休眠以后是可以被信号打断的。
void up(struct semaphore *sem) 释放信号量
互斥体
互斥体就相当于值为1时的信号量,在同样的互斥场景中更推荐使用互斥量。
互斥量的特点:
- mutex 可以导致休眠,因此也不能在中断中使用 mutex,中断中只能使用自旋锁。
- 和信号量一样, mutex 保护的临界区可以调用引起阻塞的 API 函数。(临界区中可以引起阻塞)
- 因为一次只有一个线程可以持有 mutex,因此,必须由 mutex 的持有者释放 mutex。并且 mutex 不能递归上锁和解锁。
互斥量编程
互斥量原型:
struct mutex {
/* 1: unlocked, 0: locked, negative: locked, possible waiters */
atomic_t count;
spinlock_t wait_lock;
};
定义互斥量:struct mutex lock;
有关互斥量的API:
DEFINE_MUTEX(name) 定义并初始化一个 mutex 变量。
void mutex_init(mutex *lock) 初始化 mutex。
void mutex_lock(struct mutex *lock) 获取 mutex,也就是给 mutex 上锁。如果获取不到就进休眠。
void mutex_unlock(struct mutex *lock) 释放 mutex,也就给 mutex 解锁。
int mutex_trylock(struct mutex *lock) 尝试获取 mutex,如果成功就返回 1,如果失败就返回 0。
int mutex_is_locked(struct mutex *lock) 判断 mutex 是否被获取,如果是的话就返回1,否则返回 0。
int mutex_lock_interruptible(struct mutex *lock) 使用此函数获取信号量失败进入休眠以后可以被信号打断。