linux内核同步之信号量、顺序锁、RCU、完成量、关闭中断

一、信号量

1.信号量的概念

信号量也是一种锁,当信号量不可用时,尝试获取信号量的任务将挂起直到它拿到了信号量。由于尝试获取信号量的任务可能挂起,因而中断服务程序以及可延迟函数不能使用信号量。

对于信号量来说需要注意:

  1. 只有对信号量计数值的操作是原子的
  2. 信号量的自旋锁只用于保护信号量的等待队列
  3. 信号量是比较特殊的,其up操作不是必须由down操作的调用者发起。如果把信号量也看作是一把锁,则该锁是很特殊的,它不一定由持有锁的任务释放,任何其它任务都可以做释放动作即调用up
因此信号量的down和up是可以并发执行的。但是由于保护了信号量的计数值和等待队列,因而这种并发并不会导致up和down本身出问题。
因为down操作可能导致调用者休眠,因而不能休眠的场景是不允许调用该函数的,比如中断上下文,而up可以在任意上下文调用。如果要在中断上下文调用可以使用down_trylock,它尝试获取信号量,但是当无法获取时会返回1而不是等待,如果可以获取则返回0。

2.信号量的数据结构和相关API

1.数据结构

信号量用数据结构semaphore表示, 它包含以下域:
  • count:受该信号狼保护的资源的计数值,如果大于0表示资源可用;否则表示资源不可用。对它的操作必须是原子的。如果存在检查并更新的操作,这两个操作的组合必须也是原子的,比如down中的比较并减1。
  • wait_list:等待该信号量保护的资源可用的任务队列的链表。
  • lock:保护等待任务链表的自旋锁。

2.初始化

init_MUTEX( ) 将信号量的count域初始化为1,表示资源当前可用
init_MUTEX_LOCKED( ) 将信号量的count域初始化为0 ,表示资源当前不可用 
DECLARE_MUTEX 完成和 init_MUTEX类似的操作,但是它还多一个静态分配一个信号量的动作
DECLARE_MUTEX_LOCKED 和init_MUTEX_LOCKED类似,但是它还多一个静态分配一个信号量的动作
当然也可以将信号量的初始者设置为其它正值。

3.获取和释放信号量

up()函数用于释放信号量,如果当前信号量的等待队列为空,即没有任务在等待该信号量被释放,则它增加信号量的count值,然后返回,否则它唤醒等待队列上的第一个任务。
down用于获取信号量,如果信号量的值大于0,则它将count的值减1,并返回;否则调用者将被添加到等待队列的尾部,并等待直到被唤醒即该任务获得资源。
void down(struct semaphore *sem)
int down_trylock(struct semaphore *sem)//尝试获取信号量,但是当无法获取时会返回1而不是等待,如果可以获取则返回0
int down_interruptible(struct semaphore *sem)//获取信号量,但是在等待信号量的时候可以被打断,如果在等待过程中被打断,则返回-EINTR
int down_timeout(struct semaphore *sem, long jiffies) //用于获取信号量,但是最多等待jiffies长的时间,如果在指定的时间期限内没有获取信号量,则就返回-ETIME。
void up(struct semaphore *sem)

3. 读写信号量的概念

读写信号量类似于读写自旋锁,它是为读多写少的场景做了优化的信号量,不同于自旋锁的是,在无法获得信号量时,它挂起而不是自旋。
可以有多个任务并发的为读获取读写信号量,但是同一时间点只能有一个任务可以为写获得读写信号量。因此只有没有任何任务为读或写持有该信号量时,新的为写获取信号量的操作才能成功。
内核FIFO的方式存储等待队列中的任务:
  1. 如果读者或写者无法获取读写信号量,就会被添加到等待队列的尾部
  2. 当信号量被释放时,就唤醒等待队列中的第一个任务(先唤醒谁取决于实现采取的策略,代码最能说明问题,最好查阅实际的代码)
  3. 如果唤醒的第一个任务是写,则其它任务继续睡眠,如果唤醒的第一个任务是读,则会唤醒它之后的所有读任务直到碰到了一个写任务,该写任务以及在它之后的所有任务都继续睡眠
由于它还是信号量,因此其应用场景的限制和信号量相同。

4.读写信号量的数据结构

rw_semaphore数据结构用于表示读写信号量,它包含如下的域:
  • activity:0表示没有任务在读或者写,大于0表示有任务正在读,-1表示有一个任务正在写
  • wait_list:存放等待该信号量的任务的链表
  • wait_lock:用于保护等待任务链表的自旋锁
可以用init_rwsem(sem)初始化读写信号量,也可以用DECLARE_RWSEM(name)声明并初始化一个信号量
void down_read(struct rw_semaphore *sem)
int down_read_trylock(struct rw_semaphore *sem)//尝试为读获取信号量,但是当无法获取时会返回1而不是等待,如果可以获取则返回0
void down_write(struct rw_semaphore *sem)
int down_write_trylock(struct rw_semaphore *sem)//尝试为读获取信号量,但是当无法获取时会返回1而不是等待,如果可以获取则返回0
void up_read(struct rw_semaphore *sem)  
void up_write(struct rw_semaphore *sem)  
void downgrade_write(struct rw_semaphore *sem)//它将一个写信号量降格为读信号量,并唤醒等待队列上的读任务。因此它的调用者应该持有写信号量

二、顺序锁

1.顺序锁的概念

使用读写锁时,读锁和写锁的优先级是一样的。2.6内核引入的顺序锁和读写自旋锁相同,区别在于它给写者赋予了更高的优先级:在使用顺序锁时即便读者正在进行读操作,写者也可以进行写动作。读写锁的优点在于写者永远不会由于有读者正在进行读而等待,其缺点在于读者可能需要尝试读好多次才能读到合法的数据。

不是所有的数据类型都能用顺序锁来保护,如果要使用顺序锁,以下原则必须被遵循:

  1. 被保护的数据结构不能包含由写者保护而由读者释放的指针
  2. 读者临界区的代码不能有副作用
  3. 读者临界区应该很小,而且写者应该尽量少获取顺序锁,否则重复的读会造成一些性能损伤

2.数据结构

顺序锁使用数据结构seqlock_t表示,它包含两个域:
  • 自旋锁lock
  • 整数序列号
序列号作为顺序计数器存在。每个读者必须至少读这个值两次,一次在读数据之前,一次在读数据之后,如果两次读获取的序列号相同,则读到了一个合法的值,否则说明在读的过程中有写者更新了数据,因此读者需要重新读取。
有两种方法可以初始化顺序锁:
  1. seqlock_t lock1 = SEQLOCK_UNLOCKED;
  2. seqlock_t lock2; seqlock_init(&lock2);
这两种方法都会把顺序锁初始化未上锁状态。

1.写操作

写者必须先获取锁,再操作,然后再释放锁。
write_seqlock:用于为写获取顺序锁,它会获取顺序锁中的自旋锁,然后将顺序锁的序列号加1
write_sequnlock:用于释放顺序锁,它也会增加顺序锁的序列号,然后释放顺序锁中的自旋锁
这种设计确保了写者正在写数据并且没有完成时序列号为奇数,没有写者在修改数据时,序列号为偶数。

2.读操作

对于读者来说,它需要采取下列形式的操作序列:
    unsigned int seq;
    do {
        seq = read_seqbegin(&seqlock);
        /* ... CRITICAL REGION ... */
    } while (read_seqretry(&seqlock, seq));
read_seqbegin:返回顺序锁当前序列号的值
read_seqretry:如果指定的值和顺序锁的序列号的值不等或者顺序锁的序列号为奇数,则返回1。
需要注意的是,读者并不会关闭内核抢占,而由于写者获取了顺序锁中的自旋锁,因而它会禁止内核抢占。

三、Read-Copy Update (RCU)

RCU是另外一种被设计用来在SMP环境下保护主要操作是读操作的数据同步技术。RCU允许多个读者和多个写者同时并发操作。RCU没使用任何锁也没使用任何由多个CPU共享的计数器;相比于读写自旋锁和顺序锁,这是一个极大地优势。
RCU有极大地优势,但是也有很大的限制,它限制了它所能保护的数据结构:
  • 被保护的资源应当是动态分配的、通过指针来存取的, 并且所有对这些资源的引用必须由原子代码持有
  • 当进入由RCU保护的临界区时不能休眠
RCU操作包括读操作,写操作,以及释放旧版本的操作组成。

1.写操作

它的实现原理是:当数据结构需要改变时,写线程做一个拷贝,改变这个拷贝(这里需要一个内存屏障,以保证更新能被其它CPU看到),接着使相关的指针指向新的版本,当内核确认没有CPU还在引用旧版本时旧的版本就可以被释放.

2.读操作

当内核代码想要读取一个由RCU保护的数据结构时,它
  1. 调用rcu_read_lock(相当于preempt_disable)
  2. 进行读访问
  3. 调用rcu_read_unlock(相当于preempt_enable)
这里需要注意的是在1和3之间的代码不允许休眠。

3.释放旧的版本

在RCU中关键的是旧版本何时释放。由于其它处理器上的代码可能还有对旧的数据的引用,因而不能立即释放它。内核必须在它确保已经没有任何指向旧版本的引用时才能释放旧版本。实际上,只有当所有读者都调用了rcu_read_unlock后才能释放旧的拷贝。内核要求每个读者在开始下列动作之前调用rcu_read_unlock宏:
  • CPU进行进程切换
  • CPU开始在向用户模式转变
  • CPU开始执行idle进程
call_rcu函数由写者调用以清除旧的数据结构。该函数原型如下:
void call_rcu(struct rcu_head *head, void (*func)(void *arg), void *arg);
  • head:其中head是rcu_head类型的数据结构指针,它通常嵌入在受保护的数据结构中。
  • func:在可以释放RCU数据结构时会被调用以释放旧的数据结构
call_rcu函数会在rcu_head描述符中存放回调函数的地址和它的参数,然后将描述符插入到一个每CPU回调链表中。内核会定期检查是否可以释放RCU数据结构了,如果是的话,就会由一个tasklet调用回调函数。

四、完成量(Completions)

1.完成量的概念

内核中常见的一种场景是在当前任务启动另外一个任务,然后等待该任务完成做完某个事情。考虑使用信号量来完成这个工作:
struct semaphore sem;
init_MUTEX_LOCKED(&sem);
start_external_task(&sem);
down(&sem);
当外部完成我们期望的动作时,它调用up(&sem)。
但是信号量并不是特别适合这种场景,信号量对“可用”的情况做了优化,即使用信号量时期望在大部分情况下它是可用的。然而在上述场景中很显然down必然走的是信号量不可用的分支。
completion是用来解决这种问题的一种机制。completion允许一个任务告诉另一个任务工作已经完成。

2.数据结构和相关API

内核使用数据结构completion来表示completion
void init_completion(struct completion *x)
可以使用该函数完成completion的初始化或者通过DECLARE_COMPLETION(work)声明并初始化一个completion,INIT_COMPLETION(x)宏用户初始化completion x。
void wait_for_completion(struct completion *c);
该函数在c上等待,并且不可打断。如果调用了wait_for_completion而没有任何任务调用complete,则wait_for_completion的将永远等待。
wait_for_completion_interruptible(struct completion *x)
该函数在x上等待,但是可能在等待过程中被打断,当由于被打断而返回时,返回值为-ERESTARTSYS;否则返回0
void complete(struct completion *c);
void complete_all(struct completion *c);
这两个函数可用于唤醒在c上等待的任务。区别在于complete只唤醒一个等待的任务,而complete_all唤醒所有的。
completion 机制的典型使用是在模块退出时与内核线程的终止一起. 在这个原型例子里,
void complete_and_exit(struct completion *c, long retval);
唤醒在c上等待的任务,并且以retval退出本任务。

五、关闭本地中断

关闭中断是一种设置临界区的方法。当关闭了中断时,即便是硬件中断也无法打断代码的运行,因而它可以保护同时被中断服务程序访问的数据结构。但是需要注意的是关闭本地中断并不能保护可能被多个CPU访问的数据结构。因此在SMP架构下,往往需要用关闭中断和自旋锁想结合的方式来保护被中断服务程序使用的共享资源。
local_irq_disable() 宏用来在本地CPU关闭中断
local_irq_enable() 宏用来在本地CPU打开中断, which makes use of the of the sti assembly language instruction, enables them. As stated in the 使用这两个函数的问题在于,在需要的时候我们可以简单的关闭中断,但是简单粗暴的打开中断不一定是正确的,因为在我们关闭中断时,中断可能已经是关闭的,这时如果我们简单的打开了中断就可能导致问题。
local_irq_save:关闭中断并且保存中断状态字
local_irq_restore:以指定的中断状态字恢复中断
这两个宏就很好的解决了问题,因为local_irq_restore只是将中断恢复到了我们调用local_irq_save时的状态。

六、使能和关闭可延迟函数

由于可延迟函数在不可预期的时间点被执行,因而被可延迟函数访问的数据结构也需要进行保护。
禁止可延迟函数执行的最简单的办法是关闭本地中断,因为这样中断服务程序就没办法执行了,也就没办法启动可延迟函数。
由于软中断在处于中断状态时不会执行,而tasklet是基于软中断实现的,因而只要禁止本地的软中断就可以在本地CPU上禁止可延迟函数。
local_bh_disable宏用于将本地CPU的软中断计数加1,因而就禁止了本地的软中断。
local_bh_enable宏用于将本地CPU的软中断计数减1,用于打开本地软中断
这两个函数可以都可以被重复调用,但是调用了多少次local_bh_disable,就要相应的调用多少次local_bh_enable才能打开软中断

七、选择同步技术

有很多技术都可用于避免访问共享的数据时出现竞态的手段。但是各种手段对系统性能的影响是不同的。但是作为一条原则,应该使用在该场景下能获得最大并发等级或者说并发数量的技术手段。系统的并发等级取决于:

  • 可以并发操作的I/O设备数
  • 做有效工作的CPU数
为了最大化I/O吞度量,应该使得关闭中断的时间尽可能短。

为了提高CPU效率,应该尽可能避免使用自旋锁。因为它不仅导致自旋的CPU处于忙等状态,而且会对高速缓存造成不利的影响。

取决于访问共享数据的内核任务的类型,需要采用的同步技术也会有区别:

任务 单处理器环境下使用的同步技术 多处理器环境下使用的额外的同步技术
可休眠任务(内线线程,系统调用) 信号量 不需要额外的同步技术
中断 关闭本地中断 自旋锁
可延迟函数 不需要 不需要或者使用自旋锁(取决于不同的tasklet是否会访问相同的数据结构)
可休眠任务+中断 关闭本地中断 自旋锁
可休眠任务+可延迟函数 关闭本地软中断 自旋锁
中断+可延迟函数 关闭本地中断 自旋锁
可休眠任务+中断+可延迟函数 关闭本地中断 自旋锁



你可能感兴趣的:(多任务编程,linux)