并发和竞态
--读书笔记
竞态发生的原因有:
硬件中断服务。
多个用户空间进程在运行。
SMP系统能够同时多处理器执行代码。
内核代码是可抢占的。
热插拔设备的使用
5.1 scull中的缺陷
假设有 A B 两个进程独立的试图写入同一个schull设备的相同偏移.每个进程都会决定分配内存,
并且每个都会复制结果指针给dptr->datat[s_pos],后赋值的进程会覆盖之前的数据.因此之前的指针消失了,结果内存泄露了。
5.2 并发及其管理
1 竞争情况来自对资源的共享存取的结果.即当多个线程有机会操作同一个数据结构(或者硬件资源)时。
2 第一个原则:只要可能,就要避免资源的共享.
如果你将一个资源能被多个线程执行,则应必须考虑竞态。
第二个原则:对象尚不能正常工作时,不能将其对内核可用。
3 常用的管理技术是加‘锁’或‘互斥’。
为此,我们必须建立临界区:在任何给定时间只有一个线程可以执行的代码.
5.3 信号量和互斥
信号量的本质是一个整型值,结合有一对函数使用,称为P和V.
想进入临界区的进程调用相关的P,如其值大于零,该值递减1 进程继续.相反,其值是<=0,进程须等待别人释放该信号量.
解锁信号量通过调用V完成,此函数递增信号量的值,如果需要,会唤醒等待的进程.
当信号量用作互斥(阻止多个进程同时在同一个临界区内运行)将其初始化为1.此时在任何时间内只回有一个进程有资源.
此信号量有时称为一个互斥锁,即"互斥"。在Linux内核信号量大多都是用作互斥.
5.3.1 信号量的实现
头文件:<asm/semaphore.h>
类 型:struct semaphore;
声明和初始化:
方法1:是直接创建一个信号量,接着使用sema_init来初始化
struct semaphore sem;
void sema_init(struct semaphore *sem, int val);
val:给旗标的初始值.
方法2:用做互斥时,可以使用下面的宏
struct semaphore name;
DECLARE_MUTEX(name); 初始化为0
DECLARE_MUTEX_LOCKED(name); 初始化为1
方法3:动态分配互斥(在运行时分配)
void init_MUTEX(struct semaphore *sem);
void init_MUTEX_LOCKED(struct semaphore *sem);
使用:
P 函数称为 down。down即此函数递减信号量,还可能会使进程休眠等待资源被释放
有3个版本:
void down(struct semaphore *sem);
递减信号量,必要时会一直等到。将建立不可杀进程。
int down_interruptible(struct semaphore *sem);
同上,而且其操作可中断。在用户空间等待的进程,可以被用户程序中断。(推荐)。
注意:如果被中断,会返回非0值,并且不会获得该信号量。所以应该检查返回值。
int down_trylock(struct semaphore *sem);
此函数会立即还回,若非0值,则未取得信号量。
v 函数,取得信号量后,必须释放,特别是在出错的情况下,函数如下:
void up(struct semaphore *sem);
一旦up,调用者就不再拥有该信号量。
5.3.2. 在scull中使用信号量
定义如下,可以在每个scull单独使用信号量。
struct scull_dev
{ ...
struct semaphore sem; /* mutual exclusion semaphore */
};
初始化:
在scull_init中,指针scull_devices分配存储空间后:
for (i = 0; i < scull_nr_devs; i++)
{ ...
init_MUTEX(&scull_devices[i].sem); 必须scull_setup_cdev之前初始化
scull_setup_cdev(&scull_devices[i], i); 因为此句后,系统就可以使用该设备了。
}
在write中的使用:(在read中的方法相同)
if (down_interruptible(&dev->sem))
return -ERESTARTSYS;//返回此值后,高层要么从头重启这个调用,要么返回这个错误给用户。
....
out: //申请信号量后,都必须释放。
up(&dev->sem);
return retval;
5.3.3 读写信号量
1 允许多个并发读 常常是可能的,只要没有写操作.这样能够显著提高性能;
2 只读的任务可以并行进行它们的工作而不必等待其他读者退出临界区.
3 Linux提供一个特殊的信号量类型称为rwsem,在驱动中的使用相对较少。
头文件:<linux/rwsem.h>
结 构:struct rw_semaphore;
须显式初始化:
void init_rwsem(struct rw_semaphore *sem);
4 读写
只读接口如下:
void down_read (struct rw_semaphore *sem); //其他读者可能地并发地存取, 可能将调用进程置为不可中断的睡眠
int down_read_trylock(struct rw_semaphore *sem); //若读存取是不可用时不会等待; 读成功返回非零, 否则是0.
void up_read (struct rw_semaphore *sem); //down_read 获取的rwsem 必须用up_read 释放
写的接口如下:行为类似读操作。
void down_write (struct rw_semaphore *sem);
int down_write_trylock(struct rw_semaphore *sem);
void up_write (struct rw_semaphore *sem);
void downgrade_write (struct rw_semaphore *sem);
5 说明
写者有优先权,当一个写者试图进入临界区,就不会允许读者进入知道写完成.
如果有大量的写操作,可能会使读操作饿死。
5.4. Completions 完成接口
任务同步
等待硬件响应或线程之间的同步,可以使用信号量来同步。
同步示例代码如下:
struct semaphore sem;
init_MUTEX_LOCKED(&sem);
start_external_task(&sem);
down(&sem);
up(&sem); 外部任务完成时调用
在实际中,锁定时大多数时间资源可用,down时很多时候都要等待,造成性能下降,于是2.4.7 版本出现了Completions接口
Completions 在2.6 版本还有吗? 暂时略。
5.5 自旋锁
自旋锁概念
1 一个自旋锁是一个互斥设备,只能有2个值:‘锁定’和‘解锁’,常用1个位实现。
2 想获得锁的代码测试该位,若锁可用,则"测试并置位",然后代码进入临界区。
相反,如果这个锁不可用,代码将循环检查该锁,直到它变为可用。此循环就是"自旋"。
自旋锁的特点
1 这个"测试并置位"操作必须以原子方式进行,以便只有一个线程能够获得锁.
2 等待的处理器在自旋时,不会作有用的工作。
3 最初是为多处理而设计的,单处理器的多线程也类似SMP。若单处理器的非抢占式进入自旋状态则会永远自旋下去(除了中断改变状态)。
自旋锁基本API
头文件:<linux/spinlock.h>
类 型:spinlock_t
初始化:
spinlock_t my_lock = SPIN_LOCK_UNLOCKED; //方式一:定义时初始化
void spin_lock_init(spinlock_t *lock); //方式一:在运行时初始化
使 用:
void spin_lock (spinlock_t *lock); //进入临界前使用。有些可能使不可中断的。
void spin_unlock (spinlock_t *lock); //释放锁
5.5.2 自旋锁和原子上下文
1 任何代码在持有自旋锁时都必须是原子的。它不能睡眠,不能丢掉处理器,除了中断。
反面情景:一个驱动请求一个自旋锁并且进入临界区,不小心失去了处理器(可能调用了copy_from_user使进程进入睡眠,可能任务切换)
另一个线程想获得同一个锁,在最好的情况下:会自旋很长时间,最坏的情况下:系统可能完全死锁.
2 持有自旋锁的时间内,抢占应该被禁止.
这可以避免竞态,即使在单核处理器上也是如此。
3 持有自旋锁的时间内,是否可用休眠
很难做到不休眠,比如copy_from_user可能休眠。所以:在此期间必须注意说调用的每一个函数。
4 持有自旋锁的时间内,是否允许中断
情景: 当持有这个锁时,设备发出一个中断,此中断必须获得锁,中断可能永远自旋。
这是自旋锁操作不能睡眠的其中一个理由.
有各种自旋锁函数会提供禁止中断功能。
5 自旋锁必须尽可能短时间的持有.
持有一个锁越长,另一个进程可能等待它的时间越长,它自旋的机会越大.
也阻止了当前处理器调度,意味着高优先级进程可能得等待.
5.5.3 自旋锁API
加锁一个自旋锁:
void spin_lock (spinlock_t *lock);
void spin_lock_irqsave(spinlock_t *lock, unsigned long flags);
在获得自旋锁之前禁止中断(只在本地处理器)。之前的中断状态保存在flags里
void spin_lock_irq (spinlock_t *lock);
释放时将打开中断。
void spin_lock_bh (spinlock_t *lock);
在获取锁之前禁止软件中断, 但是硬件中断留作打开的。
释放一个自旋锁 与获取严格对应。
void spin_unlock(spinlock_t *lock);
void spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags);
void spin_unlock_irq(spinlock_t *lock); void spin_unlock_bh(spinlock_t *lock);
void spin_unlock_bh(spinlock_t *lock);
非阻塞的自旋锁:
int spin_trylock(spinlock_t *lock); 成功时返回非零(获得了锁)。
int spin_trylock_bh(spinlock_t *lock); 成功时返回非零(获得了锁)。
5.5.4. 读者/写者自旋锁
这些锁允许任何数目的读者同时进入临界区,但是写者必须是排他的存取.
定义在 <linux/spinlokc.h>
类 型 rwlock_t
声明和初始化:
rwlock_t my_rwlock = RW_LOCK_UNLOCKED; /* Static way */
rwlock_t my_rwlock;
rwlock_init(&my_rwlock); /* Dynamic way */
函数有:
void read_lock (rwlock_t *lock);
void read_lock_irqsave(rwlock_t *lock, unsigned long flags);
void read_lock_irq (rwlock_t *lock);
void read_lock_bh (rwlock_t *lock);
void read_unlock (rwlock_t *lock);
void read_unlock_irqrestore(rwlock_t *lock, unsigned long flags);
void read_unlock_irq(rwlock_t *lock);
void read_unlock_bh (rwlock_t *lock);
void write_lock (rwlock_t *lock);
void write_lock_irqsave(rwlock_t *lock, unsigned long flags);
void write_lock_irq (rwlock_t *lock);
void write_lock_bh (rwlock_t *lock);
int write_trylock (rwlock_t *lock);
void write_unlock (rwlock_t *lock);
void write_unlock_irqrestore(rwlock_t *lock, unsigned long flags);
void write_unlock_irq(rwlock_t *lock);
void write_unlock_bh (rwlock_t *lock);
5.6 锁陷阱
基于经验,概览下可能出错的地方。
5.6.1 不明确的规则
1 加锁应当在开始时就安排好,事后更改会是很难的事情,会让调试变得容易很多。
2 不论信号量还是自旋锁都不允许一个持锁者第2次请求锁或调用请求锁的函数,否则系统将挂死。
3 对外函数加锁会显示地声明。
对内用作测试的,或静态函数,若假定获得了锁,也要显示的声明此假定,为以后方便查阅。
4 例 在scull中,内部函数都假定获得了锁。
5.6.7 加锁顺序规则
在需要获得多个锁的系统中,要按相同的顺序获取,否则可能出现死锁。
反面示例:有2个锁,为Lock1和Lock2,代码需要同时都获取。假设一个线程锁住Lock1
而另一个同时获得Lock2.接着每个线程试图得到它没有的那个.2个线程都会死。
5.6.8 细粒度锁,粗粒度锁的对比
粗粒度锁
第一个支持多处理器系统的Linux内核是2.0;它只含有一个自旋锁.这个大内核锁将整个内核变为一个大的临界区;
好处:这个锁足够好地解决了并发问题以允许内核开发者从事所有其他的开发SMP所包含的问题。
坏处:系统可能花费大量的时间来等待这个大内核锁.一个4个处理器的系统的性能甚至不接近4个独立的机器的性能.
细粒度锁
一个现代的内核能包含几千个锁,每个保护一个小的资源。
好处:此锁可能对伸缩性是很好的,它允许每个处理器在它自己特定的任务上工作而不必竞争其他处理器使用的锁.
坏处:如此多的锁,都不知道要用那些,该用什么样顺序。
lockmeter工具可以帮助定位锁问题。
5.7 代替锁的一些方法
5.7.1 一是重新设计算法,从根本上避免锁的使用。
二是使用循环缓冲区,需要一个数组和2个索引值。当索引相等时,表明空。 写在读索引后面时,表明满。
5.7.1 原子变量
当有某个共享资源是整形变量时,如n_nop, 即使做n_nop++, 也要加锁,显得浪费。
内核提供了原子锁,也适用于SMP系统。他们的速度很快。
定义:<asm/atomic.h>.
类型:atomic_t
初始化
void atomic_set(atomic_t *v, int i);
atomic_t v = ATOMIC_INIT(0);
使用方法:
int atomic_read(atomic_t *v); 返回v的当前值.
void atomic_add(int i, atomic_t *v); *v += i;
void atomic_sub(int i, atomic_t *v); *v -= i;
void atomic_inc(atomic_t *v); *v递增
void atomic_dec(atomic_t *v); *v递减
int atomic_inc_and_test(atomic_t *v); //
int atomic_dec_and_test(atomic_t *v); //
int atomic_sub_and_test(int i, atomic_t *v); //在操作后,原子值是0,返回值是真;否则,它是假。
int atomic_add_negative(int i, atomic_t *v); //*v += i; 如果结果是负值返回值是真,否则为假.
int atomic_add_return(int i, atomic_t *v); //
int atomic_sub_return(int i, atomic_t *v); //
int atomic_inc_return(atomic_t *v); //
int atomic_dec_return(atomic_t *v); //类似的,它们返回原子变量的新值给调用者.
当有多个原子时,如下操作时,麻烦可能在2个操作之间的短暂时间内产生。
atomic_sub(amount, &first_atomic);
atomic_add(amount, &second_atomic);
5.7.3 位操作
原子位操作非常快,因为它们使用单个机器指令来进行操作,任何时候都不用禁止中断。即便在SMP计算机上。
定义:<asm/bitops.h>
类型:int、 unsigned int
使用方法:
void set_bit(nr, void *addr); 将*addr的nr位置1
void clear_bit(nr, void *addr); 将*addr的nr位清0
void change_bit(nr, void *addr); 翻转位.
test_bit(nr, void *addr); 返回这个位的当前值.
int test_and_set_bit(nr, void *addr); //类似前面的函数,并返回之前的值
int test_and_clear_bit(nr, void *addr);
int test_and_change_bit(nr, void *addr);
示例:略
5.7.4 seqlock 锁
1 要保护的资源小,简单,并且常常被读取,并且很少写入,且写入很快是使用。
2 本质上,它们通过允许读者对资源的只有访问,但是要求读取时检查与写入是否有冲突,若有则重新读取。
3 常不用于包含指针的数据结构。
定义:<linux/seqlock.h>.
类型:seqlock_t
初始化:
seqlock_t lock1 = SEQLOCK_UNLOCKED; 或
seqlock_t lock2;
seqlock_init(&lock2);
使用:
暂略。
示例:
unsigned int seq;
do{
seq = read_seqbegin(&the_lock);
/* Do what you need to do */
} while read_seqretry(&the_lock, seq);
5.7.5. 读取-拷贝-更新
读取-拷贝-更新(RCU)是一个高级的互斥方法, 也能获得较高的性能.它在驱动中的使用很少也不是没人知道。
详情略。