LDD读书笔记_并发和竞态

来源:互联网 发布:网址域名注册 编辑:程序博客网 时间:2024/05/16 18:07
其实这部分的内容除了LDD的第五章外, 还包括对ULK第五章的一点理解.

•自旋锁和信号量的选用:

需求                                                   建议的加锁方法

低开销加锁                                       优先使用自旋锁
短期锁定                                           优先使用自旋锁
长期加锁                                           优先使用信号量
中断上下文中加锁                           使用自旋锁
持有锁是需要睡眠、调度               使用信号量

•在自旋锁,信号量及中断禁止之间的选择

仅异常时的数据访问保护
        单处理器
                 以最常见的系统调用为例, 此时的CPU运行在内核态为用户进程提供服务, 此竟争条件可通过信号量避免.
        多处理器
                 与单处理器相同

        特别注意, 访问per-cpu变量时, 禁用抢占.
       
仅中断时的数据访问保护

        单处理器
                 只有一个中断的“上半部”访问时, 中断都相对自己串行地执行, 无需同步.
                 多个中断的“上半部”访问时, 要关中断.
        多处理器
                 只有一个中断的“上半部”访问时, 中断都相对自己串行地执行, 无需保护.
                 多个中断可访问时, 要关中断, 并加上自旋锁

仅可延迟函数(软中断和tasklet)中的数据访问保护
        单处理器
                可延迟函数在单cpu上是串行的执行的, 所以不存在竞争问题
        多处理器
                软中断访问的数据使用自旋锁, 防止多cpu竟争, 因为软中断可以在多个CPU上并发执行
               仅由一种tasklet访问的数据结构不需要保护, 因为同一个类型的 tasklet 不会在多个CPU上同时运行
               被多种tasklet访问需要用自旋锁保护

异常和中断时的数据访问保护(只考虑一种中断与一种异常)

        单处理器
               在异常的访问中关闭中断, 中断的访问中不需要保护. 因为中断能抢占异常, 但是异常不能抢占中断.
        多处理器
               在异常的访问中关闭本地中断,并加上自旋锁. 中断的访问中加上自旋锁即可
               ( 有时, 可以用信号量替代自旋锁. 可用紧循环和down_trylock代替自旋的功能, 提高并发度).

异常和可延迟函数中的数据访问保护(只考虑一种异常与一种可延迟函数)
        单处理器
               在异常访问中关闭软中断(禁止可延迟函数更合适).  可延迟函数无需保护.
        多处理器
               在异常访问中关闭中断(禁止可延迟函数更合适)并加上自旋锁. 可延迟函数加上自旋锁.

中断和可延迟函数中的数据访问保护(只考虑一种中断与一种可延迟函数)
        单处理器
              可延迟函数访问中关闭中断, 中断的访问中不需要保护. 因为中断能抢占可延迟函数, 可延迟函数不能抢占中断.
        多处理器
              可延迟函数访问中关闭本地中断, 并加上自旋锁.  中断的访问中加上自旋锁

异常, 中断和可延迟函数中的数据访问保护(只考虑一种中断一种可延迟函数一种异常)
        单处理器
               在异常访问中关闭中断. 可延迟函数访问中关闭中断. 中断的访问中不需要保护
        多处理器
              在异常访问中关闭中断并加上自旋锁. 可延迟函数访问中关闭本地中断并加上自旋锁 . 中断的访问中加上自旋锁

锁的使用中, 很重要的注意点是注意加锁的粒度(granularity).
第五章原文中有句话, "作为通常的规则, 我们应该在最初使用粗粒度的锁, 除非有真正原因相信竞争会导致问题".
As a general rule, you should start with relatively coarse locking unless you have a real reason to believe that contention could be a problem.


===================================================================================================================================
以下是LDD的笔记
===================================================================================================================================

• 基本概念
    –临界区
    –加锁
    –死锁
• 内核同步方法
    –PerCPU变量
    –原子操作
    –自旋锁
    –读写自旋锁
    –大内核锁
    –信号量
    –读写信号量
   –完成变量
    –seqlock
    –RCU

1. 几个基本概念
• 所谓临界区(critical region)就是访问和操作共享数据的代码段。
    –多个执行线程并发访问同一资源通常是不安全的。
    –如果两个执行进程有可能处于同一个临界区中,那么这就是程序包含的一个bug,这种情况称为竞争条件(race condition)。
    –避免并发和防止竞争条件被称为同步。

• 加锁
    –锁提供一种机制:它如同一把门锁,门后的房间可想象成一个临界区。
    –是什么造成并发执行:
       • 中断
       • 软中断和tasklet
       • 内核抢占
       • 睡眠
       • 对称多处理器
    –提供锁来保护不难做到,真正的困难是发现潜在并发执行的可能,并有意识的采取措施来防止,并在设计代码的早期就做。
    –要保护些什么
        •如果有其他执行进程可以访问这些数据,那么就给这些数据加上某种形式的锁;

• 死锁
    –避免死锁的简单规则:
        • 加锁顺序
        • 防止发生饿死
        • 不要重复请求同一个锁
        • 越复杂的加锁方案越可能造成死锁

2. 内核同步方法
• PerCPU变量
      很多人都忘记了这个选择. 其实最好的同步技术是把设计不需要同步的内核放在首位.
      最简单也是最重要的同步技术包括把内核变量声明为per cpu变量. 每个cpu访问的是数组中自己对应的那个元素, 不用担心别的cpu会去访问这个元素.
      这也就意味着, 只有当确定在系统上的cpu上的数据时逻辑上独立的时候, 才可以使用.
      虽然提供了多cpu之间的保护, 但是对同一个cpu上的并发并没有提供保护, 在这种情况下, 还是需要有同步操作, 比如禁止

• 原子操作(需要硬件架构汇编指令级别的支持)
    –两个原子操作绝对不可能并发地访问同一个变量。
    –内核提供两组原子操作接口--针对整数和单独的位。
    –在编写代码时,能使用原子操作的时候,就尽量不要使用复杂的加锁机制。
    –使用:
       •atomic_t v; //定义
       •atomic_t u = ATOMIC_INIT(0);//定义并初始化
       •常用操作
           –atomic_set(&v,4);
           –atomic_add(2,&v);
           –atomic_inc(&v);
           –atomic_read(&v);
           – ...
•原子位操作
    –位号0-31
    –例:
        •unsigned long word = 0;
        •set_bit(0,&word);
        •clear_bit(1,&word);

•抢占控制
    – 内核是抢占性的,内核中的进程在任何时刻都可能停下来以便另一个具有更高优先级的进程运行。
       这意味着一个任务与被抢占的任务可能会在同一个临界区内运行. 所以推出禁止内核抢占的功能.
    – 操作
         preempt_disable()
         preempt_enable()
         这是一个可以嵌套调用的函数,可以调用任意次。每次调用都必须有一个相应的preempt_enable()调用。
         当最后一次preempt_enable()被调用后,内核抢占才重新启用。
        其实就是操作thread_info结构体中的preempt_count, 只有这个值为0的时候, 才表示是 pre-emptable 的.
        否则preempt_schedule 操作就什么也不做, 也就是说当前的thread不会被调度出去.


        此外要注意, 这个preempt_count 其实是分3段的, 前面的接口其实操作的是bit 0-7 .
        * - bits 0-7 are the preemption count (max preemption depth: 256)
        * - bits 8-15 are the softirq count (max # of softirqs: 256)
        *
        * The hardirq count can be overridden per architecture, the default is:
        *
        * - bits 16-27 are the hardirq count (max # of hardirqs: 4096)
        * - ( bit 28 is the PREEMPT_ACTIVE flag. )

•自旋锁(自旋锁是专为防止多处理器并发而引入的一种锁)
    – 临界区可能跨越多个函数,显然简单的原子操作无能为力。所以需要更为复杂的锁来保护.
    – linux 常见的锁:自旋锁(spin lock)。如果代码试图获得一个已经被持有的自旋锁,那么会一直忙循环旋转直到锁重新可用(不会做进程调度)。
    – 因为是忙等待, 所以自旋锁不应该被长时间持有。而是适合于短期内轻量级加锁。
    – 使用:
       •spinlock_t mr_lock = SPIN_LOCK_UNLOCKED;
       •spin_lock(&mr_lock);
       •//临界区
       •spin_unlock(&mr_lock);
    – 是为多处理器提供了防止并发访问所需的保护机制。
       在单处理器上,编译时不会加入自旋锁,而仅仅被当作一个设置内核抢占机制是否被启用的开关(就是preempt_disable)。如果系统又不支持抢占, 那就什么都不做.
    – 自旋锁不可递归。
    – 自旋锁可以使用在中断处理程序中(内核中断处理部分大量使用)。但在获得锁之前,首先要禁止本地中断。持有自旋锁的任务不允许休眠(可能引起死锁)。
    – 内核提供禁止中断同时请求锁的接口:
        •spinlock_t mr_lock = SPIN_LOCK_UNLOCKED;
        •unsigned long flags;
        •spin_lock_irqsave(&mr_lock,flags);
        •//临界区
        •spin_unlock_irqrestore(&mr_lock,flags);
        •spin_lock_bh(lock) /spin_unlock_bh(lock)   该宏在得到自旋锁的同时失效本地软中断,反之, 释放锁,使能本地软中断.
           其实还是操作前面的preempt_count, 对应的是bits 8-15.
           而剩下的bit 16-27, 对应在在irq_enter中会增加, 在irq_exit中会减少.


•读写自旋锁
    –可以多个读任务并发的持有读者锁,但是用于写的锁最多只能被一个写任务持有。
           如果读写锁当前没有读者,也没有写者,那么写者可以立刻获得读写锁,否则它必须自旋在那里,直到没有任何写者或读者。
           如果读写锁没有写者,那么读者可以立即获得该读写锁,否则读者必须自旋在那里,直到写者释放该读写锁。
    –大量读者可能会使挂起的写者处于饥饿状态,这是设计锁时要注意的.
    –使用:
        •初始化:rwlock_t mr_rwlock = RW_LOCK_UNLOCKED;
        •只读代码使用:read_lock(&mr_rwlock);read_unlock(&mr_rwlock);
        •读写代码使用:write_lock(&mr_rwlock);write_unlock(&mr_rwlock);
    –相关接口:
        •
read_lock(lock) / read_unlock(lock)
         读者要访问被读写锁lock保护的共享资源,需要使用该宏来得到读写锁lock。
         如果能够立即获得,它将立即获得读写锁并返回,否则,将自旋在那里,直到获得该读写锁。
       •write_lock(lock) /write_unlock(lock)
         写者要想访问被读写锁lock保护的共享资源,需要使用该宏来得到读写锁lock。
         如果能够立即获得,它将立即获得读写锁并返回,否则,将自旋在那里,直到获得该读写锁。.
       •read_lock_irq(lock) /read_unlock_irq(lock)
        读者也可以用它来获得读写锁,与read_lock不同的是,该宏还同时失效了本地中断。该宏与read_lock_irqsave的不同之处是,它没有保存标志寄存器。
       •write_lock_irq(lock) /write_unlock_irq(lock)
         写者也可以用它来获得锁,与write_lock不同的是,该宏还同时失效了本地中断.
       •read_lock_bh(lock) /read_unlock_bh(lock)
         读者也可以用它来获得读写锁,与与read_lock不同的是,该宏还同时失效了本地的软中断。 
       •write_lock_bh(lock) /write_unlock_bh(lock)
         写者也可以用它来获得读写锁,与write_lock不同的是,该宏还同时失效了本地的软中断。

大内核锁(Big Kernel Lock)
    •大内核锁本质上也是自旋锁,自旋锁是不可以递归获得锁的,因为那样会导致死锁。但大内核锁可以递归获得锁。
    •大内核锁用于保护整个内核,而自旋锁用于保护非常特定的某一共享资源。
    •进程保持大内核锁时可以发生调度。
    •整个内核只有一个大内核锁.
    •大内核锁是历史遗留,内核中用的非常少,一般保持该锁的时间较长(我们基本不用)

•信号量(semaphore)
    –如果加锁时间不长且代码不会睡眠,自旋锁是最佳选择。反之,最好使用信号量。
    –信号量和自旋锁在使用上的差异:
        •如果代码需要睡眠——使用信号量是唯一的选择。信号量不同于自旋锁,它不会关闭内核抢占,所以持有信号量的代码可以被抢占。
         这意味着信号量不会对影响调度反应  时间带来负面影响。信号量只能适用于进程上下文。
    –信号量允许任意数量的锁持有者。持有者数量在声明时指定。当计数为1时,称为互斥量。一般互斥量使用较多。
    –创建和初始化:
        •静态声明:
             –static DECLARE_SEMAPHORE_GENERIC(name,count);
             –互斥量:static DECLARE_MUTEX(name);
       •动态创建:
            –sema_init(sem,count);
            –互斥量:init_MUTEX(sem);
            –使用:
                  •static DECLARE_MUTEX(mr_sem);//declare
                  •if(down_interruptible(&mr_sem)){
                  •return ;
                  •}
                  •//临界区
                  •up(&mr_sem);
    –相关接口:
           • void down(struct semaphore * sem);
           该函数用于获得信号量sem,它会导致睡眠,因此不能在中断上下文(包括IRQ上下文和softirq上下文)使用该函数。
           该函数将把sem的值减1,如果信号量sem的值非负,就直接返回,否则调用者将被挂起,直到别的任务释放该信号量才能继续运行。
           • int down_interruptible(struct semaphore * sem);
           该函数功能与down类似,不同之处为,down不会被信号(signal)打断,但down_interruptible能被信号打断,因此该函数有返回值来区分是正常返回还是被信号中断,
           如果返回0,表示获得信号量正常返回,如果被信号打断,返回-EINTR。
           • int down_trylock(struct semaphore * sem);
           该函数试着获得信号量sem,如果能够立刻获得,它就获得该信号量并返回0,否则,表示不能获得信号量sem,返回值为非0值。
           因此,它不会导致调用者睡眠,可以在中断上下文使用.
           • void up(struct semaphore * sem);
           该函数释放信号量sem,即把sem的值加1,如果sem的值为非正数,表明有任务等待该信号量,因此唤醒这些等待者。

•读写信号量
    类似于前面的读写锁.
    –静态声明:static DECLARE_RWSEM(name);
    –动态创建:init_rwsem(struct rw_semaphore *sem);
    –使用:static DECLARE_RWSEM(mr_rwsem);
        •只读:down_read(&mr_rwsem);up_read(&mr_rwsem);
        •读写:down_write(&mr_rwsem);up_write(&mr_rwsem);
    –和读写自旋锁一样,除非代码中的读和写可以明白无误地分割开来,否则使用一般的信号量。

•完成变量
    –内核中一个任务需要发出信号通知另一任务,可以使用完成变量(completion variable)来完成任务间同步
    –使用:
        •静态创建:DECLARE_COMPLETION(mr_comp);
        •动态创建:init-completion(mr_comp);
        在一个指定的完成变量上,需要等待的任务调用wait_for_completion()来等待特定事件。
        当特定事件发生后,产生事件的任务调用 completion()来发送信号唤醒正在等待的任务。

•Seqlock
    –当要保护的资源很小很简单,会频繁访问且写入访问很少发生,还必须快速,可以使用seqlock。
    –初始化方法:
        •1. seqlock_t lock1 = SEQLOCK_UNLOCKED;
        •2. seqlock_t lock2; seqlock_init(&lock2);
    –写入时:
        •write_seqlock(&lock1);
        •//读写
        •/write_sequnlock(&lock1);
    –读取时:
        •unsigned long seq;
        •do{
        •    seq = read_seqbegin(&lock1);
        •    //读取
        •}while(read_seqretry(&lock1,seq));
        •读取时获得一个值进入临界区,退出时值和当前值比较,不相等则重读。

    顺序锁也是对读写锁的一种优化,对于顺序锁,读者绝不会被写者阻塞,也就说,读者可以在写者对被顺序锁保护的共享资源
进行写操作时仍然可以继续读,
    而不必等待写者完成写操作,写者也不需要等待所有读者完成读操作才去进行写操作。但
是,写者与写者之间仍然是互斥的,即如果有写者在进行写操作,
    其他写者
必须自旋在那里,直到写者释放了顺序锁。

这种锁有一个限制,它必须要求被保护的共享资源不含有指针,因为写者可能使得指针失效,但读者如果正要访问该指针,将导致OOPs

• RCU (read -copy update)
    –对于被RCU保护的共享数据结构,读者不需要获得任何锁就可以访问它,但写者在访问它时首先拷贝一个副本,然后对副本进行修改,
      最后使用一个回调(callback)机制
在适当的时机把指向原来数据的指针重新指向新的被修改的数据.这个时机就是所有引用该数据的CPU都退出对共享数据的操作.
    –是读写锁的高性能版本,RCU既允许多个读者同时访问被保护的数据,又允许多个读者和多个写者同时访问被保护的数据
      (
是否可以有多个写者并行访问取决于写者之间使用的同步机制).
    –读者没有任何同步开销,写者的同步开销则取决于使用的写者间同步机制。
    –如果写比较多时,对读者的性能提高不能弥补写者导致的损失。
    –相关接口
        •rcu_read_lock() /rcu_read_unlock()  
           读者在读取由RCU保护的共享数据时使用该函数标记它进入读端临界区。反之用以标记读者退出读端临界区。
        •synchronize_rcu()
           该函数由RCU写端调用,它将阻塞写者,直到经过grace period后,即所有的读者已经完成读端临界区,写者才可以继续下一步操作。
           如果有多个RCU写端调用该函数,他们将在一个grace period之后全部被唤醒。
       •void fastcall call_rcu(struct rcu_head *head, void (*func)(struct rcu_head *rcu))
           函数call_rcu也由RCU写端调用,它不会使写者阻塞,因而可以在中断上下文或softirq使用。该函数将把函数func挂接到RCU回调函数链上,然后立即返回。
           一旦所有的CPU都已经完成端临界区操作,该函数将被调用来释放删除的将绝不在被应用的数据。参数head用于记录回调函数func,一般该结构会作为被RCU保护的
           数据结构的一个字段,以便省去单独为该结构分配内存的操作。需要指出的是,函数synchronize_rcu的实现实际上使用函数call_rcu。
       •void fastcall call_rcu_bh(struct rcu_head *head, void (*func)(struct rcu_head *rcu))
           函数call_ruc_bh功能几乎与call_rcu完全相同,唯一差别就是它把softirq的完成也当作经历一个quiescent state,因此如果写端使用了该函数,
          在进程上下文的读端必须使用rcu_read_lock_bh。 
       •static inline void list_add_rcu(struct list_head *new, struct list_head *head)
          该函数把链表项new插入到RCU保护的链表head的开头。使用内存栅保证了在引用这个新插入的链表项之前,新链表项的链接指针的修改对所有读者是可见的。
       •hlist_for_each_rcu(pos, head)  
          该宏用于遍历由RCU保护的哈希链表head,只要在读端临界区使用该函数,它就可以安全地和其它_rcu哈希链表操作函数(如hlist_add_rcu)并发运行。


1 0
原创粉丝点击