并发及管理
竞态的产生:
用户空间多线程访问
SMP在不同处理器上同时执行
内核代码抢占,驱动程序可能在任何时候丢失对处理器的独占
设备中断
内核延迟机制
热插拔设备可能会在正在使用时消失
避免竞态,使用内核的并发控制原语
竞态通胀作为对资源共享访问结果而产生。
设计驱动程序第一个原则,只要可能就应该避免资源的共享,最明显的应用时避免使用全局变量。但在实际过程中,共享是难以避免的,共享就是现实的生活。
资源共享的硬原则:
在单个执行线程之外共享硬件或者软件资源的任何时候,因为另外一个线程可能产生对该资源的不一致观察,因此必须现实的管理对该资源的访问。访问管理的常见技术称为锁定或者互斥—确保一次只有一个执行线程可操作共享资源。
另外一个原则,当内核代码创建一个可能和其他内核部分共享的对象时,该对象必须在还有其他组件引用自己时保持存在。也就是引用计数。
信号量和互斥体
信号量的PV操作,进入临界区调用P操作,如果信号量的值大于0,则该值会减一,进程继续,相反值小于等于0,进程等待。V操作,增加信号量的值,并在必要时唤醒等待进程。
互斥体,信号量用于互斥,初始值设置为1.
linux信号量的实现
sema_init初始化一个信号量,需要指定初值
用于互斥的信号量,也就是互斥提,通过宏DECLARE_MUTEX 和DECLARE_MUTEX_LOCKED
init_MUTEX,和init_MUTEX_LOCKED,传入的都是struct semaphore* 类型
down操作(P操作)
down 不可中断
down_interruptible 可中断,通常规则,我们不应该使用非中断操作。除非没有其他可变通的方法。
down_trylock 永远不会休眠,不可获得返回非零值
up操作(V操作)
up
donw_interruptible中断时的处理
这个函数返回非零值,表示操作被中断,可以返回的错误有:-ERESTARTSYS和-EINTER。
如果返回的是-ERESTRARTSYS,内核高层代码可能会从头重新启动该调用,也可能是把错误返回给用户,因此,需要在返回这个错误码之前,必须撤销已经做出的任何用户可见的修改。如果无法撤销,则只能返回-EINTER
读取者/写入者信号量
struct rw_semaphore
down_read
dow_read_trylock 比较特别的是,授予访问时返回非零
up_read
down_write
dow_write_trylock
up_write
downgrade_write 当某个快速修改改变获得了写入者锁,而其后是更长时间的只读访问的话,我们可以在结束修改之后调用downgrade_write来允许其他读者访问。
问题:写入者优先级高于读取者,如果大量写入请求,会导致读取饿死
适用场景:很少需要写访问且写入这只会短期拥有信号量时使用rwsem
completion
使用场景:在当前线程之外初始化某个活动,然后等待该活动结束。
一种错误的方式:
struct semaphore sem;
init_MUTEX_LOCKED(&sem);
start_external_task(&sem);
down(&sem);
在external_task任务结束后调用up(&sem);
这种方式有一些问题,
通常使用中,试图锁定某个信号量的代码会发现信号量机会总是可用(什么意思?)
如果存在对信号量的严重竞争,性能会受到影响。
completion是一种轻量级的机制。
DECLARE_COMPLETION
init_completion
wait_for_completion 非中断的等待。
complete 唤醒一个
complete_all 唤醒所有
completion通常是一个单次设备,使用一次然后丢弃。
如果需要复用,没有调用complete_all的情况下,可以直接再次调用wait_for_completion,如果调用过了complete_all,那么就必须使用INIT_COMPLETION宏来重新初始化
completion的典型使用场景,模块退出时等待内核线程终止。
自旋锁
自旋锁的使用场景:通常是用在不能休眠的代码中,可以提供比信号量更高的性能。
自旋锁的实现,通过原子操作,测试并设置相关的位
超线程处理器,可以实现多个虚拟的CPU,他们共享单个处理器核心及缓存。当存在自旋锁时,等待执行忙循环的处理器做不了任何有用的工作。
非抢占式单个处理器系统进入自旋状态,则会永远自旋下去。
spinlock_t
spin_lock_init
spin_lock
spin_unlock
自旋锁和原子上下文
避免自旋锁等待很长时间,核心原则是:任何拥有自旋锁的代码都必须是原子的,他不能休眠,事实上他不鞥因为任何原因放弃处理器。
内核抢占由自旋锁代码本身处理。
避免休眠有时候很难做到,所以谨慎对待自旋锁下调用的函数。
中断的处理,如果设备驱动程序在运行过程中拥有锁,此时产生了中断,中断函数中要申请锁,此时无法获得。因此在拥有自旋锁时禁止中断(仅在本地CPU上)
使占用锁的时间最短,避免影响CPU的调度。
自旋锁函数
spin_lock
spin_lock_irqsave 禁止中断,保存中断标记
spin_lock_irq 禁止中断,不跟踪标志,
spin_lock_bh 禁止软件中断,硬件中断会打开, 以tasklet运行的代码可能产生软件中断
如果锁会被运行在中断上下文中,则必须使用某个禁止中的形式,软件中断(例如,以tasklet运行的代码)
spin_unlock
spin_unlock_irqstore
spin_unlock_irq
spin_unlock_bh
非阻塞
spin_trylock
spin_trylock_bh (意义是啥?都非阻塞了,为什么要)
读取者/写入者自旋锁
和上面的API差不多,但是没有try形式。和rwsem类似有读取者饥饿的问题,另外竞争会导致性能变得很低
锁陷阱
不明确的规则
如果某个获得锁的函数需要调用的其他函数同样试图获取这个锁,那么系统会挂起。无论是信号量还是自旋锁,都不允许锁拥有者第二次获取这个锁。
设计决策是:提供给外部调用的函数必须显示地处理锁,内部函数假定锁和信号量已经被正确获得。
锁的顺序原则
最好是避免出现多个锁的情况。
避免死锁,始终以相同顺序来处理。
局部锁,和一个属于内核中心位置的锁,应该先获取局部锁
信号量和自旋锁,应该先获取信号量
细粒度锁和粗粒度锁
粗粒度锁的问题是扩展性
细粒度锁的问题是复杂性,维护性
设备驱动程序中通常的做法,应该最初使用粗粒度锁(整个驱动程序或者每个设备)在确认是锁导致的原因下,再使用细粒度锁。 通过lockmeter工具来度量内核话费在锁上的时间。
除了锁之外的办法
无锁算法
生产者/消费者模式下,如果只有一个生产者,可以使用无锁循环缓冲区(具体实现?)
原子变量
某些处理器可以以原子的方式执行++操作,但不是可移植的。
位操作
内核提供了一组可原子地修改和测试单个位的函数
函数使用的参数是依赖具体架构的。nr(int 或者unsigned long)
使用位操作来管理一个锁变量以控制对某个共享变量的访问,则相对复杂并值得讨论。大多数现代代码不会以这种方式使用位操作(尽管内核中因为历史原因还有这种写法)。而应该使用自旋锁。
seqlock
适用的场景:保护的资源很小、很简单、会频繁被访问而且写入访问很少发生且必须快速时。通常不能用于保护含有指针的数据结构,因为在写入者修改该数据结构的同时,读取者可能会追随一个无效的指针。
场景样例
unsigned int seq;
do {
seq = read_seqbegin(&this_lock);
/完成要保护的工作/
} while read_seqretry(&this_loc, seq);
write_seqlock_irqsave
write_seqlock_irq
write_seqlock_bh
write_unseqlock_irqrestore
write_unseqlock_irq
write_unseqlock_bh
RCU(read-copy-update)
很少在驱动程序中使用
RCU使用时的一些限定:
多读少写
被保护的资源通过指针访问
对这些资源的引用必须仅由原子代码拥有。
rcv_read_lock 只是禁止内核抢占,不会等待
在需要修改该数据结构时,写入线程首先复制,最后修改副本,之后用新的版本替换相关指针。当内核确信老的版本没有其他引用时,就可以释放老的版本。
设置回调函数,受rcu保护的结构,必须分配一个struct rcu_head结构
void call_rcu(struct rcu_head *head, void(*func)(void *arg), void *arg);
修改完资源后,应该调用该函数,在可安全释放资源时,func会被调用,func唯一工作就是kfree