内核同步 (来自chinaunix总结)

内核同步
  • 内核服务请求的方式
    •   老板(硬件中断),客人(用户态发出的系统调用,或异常)
    •   不顾客人顾老板
    •   顾完新老板,再顾旧老板
    •   顾完老板,可能顾旧客人,也可能顾新客人

  • 抢占和并发
    •   抢占
      •    抢占式内核的特定:进程切换的时机多了。
        •     与抢占无关的切换:
          •      当前进程自愿放弃CPU(如:睡等资源)
          •      进程由内核态切回用户态时(一般是中断处理后,从中断返回的那一刻)。
          •      内核线程结束,或者调用调度函数的时候都会切换。
        •     抢占式内核特有的切换:
          •      在内核执行路径内部(甚至在执行一个内核函数过程中),也有可能被切换。
            •       例如:处理异常式,如果时间片用完,也会立即切换进程(非抢占式内核中,要等待异常处理完成。)
      •    优缺点:
        •     抢占的好处:减少被正运行的内核态进程延误的时间。
        •     正在执行不可重入的KCP时,不可抢占: 中断服务程序(但某些异常服务程序中,可以被抢占), softirq, tasklet
      •    禁用、使能、检查使能
        •     用pd.thread_info.preempt_count表示
        •     检查抢占使能:preempt_count
        •     抢占禁用(增加计数):get_cpu, preempt_disable
        •     抢占使能(减少计数): put_cpu(_noresched),preempt_enable(_noresched)
      •    抢占发生时机:
        •     中断/异常处理结束
          •      要恢复到用户空间前的进程切换:不是抢占特有的
          •      要恢复到内核空间前的进程切换:是抢占特有的
        •     抢占使能后,调用preempt_schedule函数:
          •      检查条件(preempt_count, 本地中断可用)
          •      preempt_count 设置成“被抢占”
          •      调度
          •      (再次获得CPU以后)preempt_count = 0 (恢复)

    •   KCP并发
      •    造成KCP并发执行的三个原因:中断嵌套,抢占,多CPU同时访问,多个内核线程。各个因素会综合作用。
      •    并发影响的方面:中断处理、异常处理、可延时函数、内核线程。
      •    减少并发的设计方案。
        •     为了使中断处理不需同步:
          •      同类(同一根IRQ)中断不嵌套 (向PIC发ACK同时禁用此根IRQ线)
          •      中断处理, 可延迟函数(softirq、tasklet) 都要禁止抢占,并且要快速执行完(要求不能阻塞)
          •      硬件中断处理函数,不能被可延时函数或系统调用中断。
        •     为了使可延函不需同步:
          •      可延时函数不能并发。
          •      同一个可延时函数不能在多个CPU上同时执行。
          •      但不同可延函可以在不同CPU同时执行。

指令级的同步
  • per-CPU变量(实际是一个数组)
    •   原理:
      •    同一变量,每个CPU一份拷贝,只访问自己的,不访问别人的。
      •    per-CPU 变量数组的元素要内存对齐,每个数据进入不同的cache块中; 以便能同时入cache, 而且同一个cache块没有不同CPU的数据.
    •   局限:
      •    能保证CPU,不能保证中断handle的同步。
      •    容易形成竞争条件。所以访问per-CPU变量时,不能抢占。
        •     如果获得本地per-CPU变量的地址,被抢占,然后迁移到其他CPU上执行。地址就是原来的CPU的数据地址,不是当前的
    •   相关接口:
      •    静态:
        •     定义per-CPU变量: DEFINE_PER_CPU(type, name),
        •     获取:per_cpu(name, cpu-id), 获取本地CPU变量并禁止抢占:get_cpu_var(name)
        •     释放:单纯使能抢占:put_cpu_var(name)
      •    动态:alloc_precpu (type), free_percpu (pointer) per_cpu_ptr(pointer, cpu-id)

  • 原子操作: 一条汇编指令,完成“读,修改,写回内存”,不能被中断。
    •   一般的“读改写”指令: (例如:inc dec)只适用于单CPU,不适用与多CPU
      •    内存不识别原子操作。例如:多CPU,如果同时执行“读,修改,写回内存”,内存操作会被内存仲裁成“读,读,写,写”。
    •   操作码有“lock”字节: 执行时锁住内存总线,其他CPU不能访问;适用于多CPU系统。
      •    x86汇编,操作码加lock字节,执行该条指令时锁住内存。
      •    linux中的c代码:
        •     原子计数器:类型是atomic_t;访问的函数、宏以atomic_开头。
        •     原子位操作(参数是地址):test_bit, set_bit, clear_bit, .....

  • 优化屏障和内存屏障
    •   优化屏障 - 编译器相关。防止屏障前后的指令,在优化时混合。用barrier()宏
      •    实现:asm volatile("":::"memory")
        •     volatile关键字: 禁止编译器将内联汇编指令与其他指令调整。
        •     memory关键字: 强制编译器认为内联汇编会改变RAM中所有的存储单元。所以编译器认为:asm以后的指令不能通过CPU寄存器,访问asm之前时的变量的值。所有不会优化。
      •    缺点:它不能保证CPU不会乱序执行。
    •   内存屏障 - CPU流水,乱序执行相关。前面的指令执行完毕后,再执行后面的指令。
      •    本身带有屏障作用(只能串行执行)的指令:
        •     操作I/O口的
        •     有lock字节的指令
        •     操作控制寄存器、系统寄存器、调试寄存器的的
        •     屏障(fence)指令:lfence(load), sfence(save), mfence(读写)
        •     一些特定的汇编指令,如iret
      •    C代码中的宏:
        •     单CPU,多CPU共用: mb rmb wmb
          •      CPU提供了屏障指令:内联屏障指令实现。
          •      CPU没提供屏障指令:有优化屏障的,有lock字节的指令。
        •     仅多CPU用的:加smp_前缀.
          •      多CPU时:就是mb rmb wmb
          •      但CPU时:就是barrier宏
稍复杂的同步
  • 用自旋同步
    •   自旋锁
      •    特点:
        •     一般lock很短时间,多CPU时用
        •     自旋锁的临界区内,禁止抢占。否则会造成长时间占用锁。
        •     等待锁(自旋)时,可以抢占。
      •    数据结构:spinlock_t
        •     slock: 锁的状态。1-打开状态; 0-锁上状态
        •     break_lock: 是否有进程正在忙等该锁。(抢占式内核才会用到break_lock)
          •      长时间占据锁的进程,会查看break_lock,暂时放弃锁给忙等进程机会
          •      如果用spin_unlock + schedule + spin_lock, 会调用两次schedule函数; 所以提供了cond_resched_lock
            •       如果有忙等进程,解一下锁。(配置了SMP才会有该段代码)
              •        清break_lock, unlock, pause, lock
            •       如果需要调度,打开抢占,调度一下。
              •        开锁(_raw_spin_unlock),
              •        使能抢占(_no_resched),
              •        调度(__cond_resched),
              •        上锁。
      •    spin_unlock: 简单给slock置1 (x86的写操作都是原子的),并使能抢占。
      •    抢占式内核的:spin_lock宏 (源代码:BUILD_LOCK_OPS)
        •     禁止抢占。
        •     _raw_spin_trylock: 测试并设置”slock,如果返回-1,说明得到锁。
          •      用0来设置:如果原来是1,变成0;如果原来是0,结果仍是0。
        •     设置break_lock, 使能抢占。
        •     不断执行pause指令,直到锁被打开 或 break_lock变成0(只有cond_resched_lock会把break_lock清0)
        •     返回开始。
      •    非抢占式内核的spin_lock
        •     slock原子减1,如果非负则得到锁
        •     如果小于0,pause等待,直到slock大于0 (被spin_unlock置1)
        •     返回开始。
    •   读写自旋锁 rwlock_t:结构与操作方式与spinlock类似,区别在于:
      •    lock代替slock: 24bit(1-有写者;0-无写着位); 23-0bit(0x01000000减去读者数)
        •     这样lock的值从小到大,表示能进入的进程数 (读者占1,写者占极大值0x01000000)
          •      0:有写者, 一个也进不来
          •      中间:有读者,读者可进入
          •      0x01000000: 锁空闲
      •    _raw_read_trylock, _raw_write_trylock 代替 _raw_spin_trylock。
        •     锁read时,lock减1;锁write时,lock减0x01000000。开锁时加上相应值
        •     锁read时,如果lock变成负数,则恢复0,忙等到1,再减1。

  • 利用重复(指令重复执行、数据多分拷贝)的同步
    •   指令重复执行:读时可写锁:seqlock (写者唯一)
      •    原理
        •     spinlock_t: 写者互斥锁
        •     sequence:写前加,写后再加1。偶数说明当前没写者;奇数说明当前有写者
        •     读前保存sequence(函数read_seqbegin), 读后比较(函数read_seqretry)。如果不一致就重读
      •    局限:
        •     不能出现:读者用指针引用数据,写者该指针。
        •     适用情况:读任务量小(临界区小),重读次数少(写不频繁)
    •   数据多分拷贝:RCU (读,拷贝更新)
      •    原理:
        •     数据通过指针引用。
        •     写时:拷贝数据,修改拷贝,新数据的指针替换原来数据的指针。
        •     旧数据,会在特定时刻,通过tasklet释放。
      •    接口:
        •     读数据:rcu_read_lock rcu_read_unlock 仅仅禁止、使能抢占。
          •      读数据的临界区不能有睡眠
          •      kernel进入静止状态之前必须unlock。
            •       进程切换。
            •       返回到用户空间。
            •       开始idle。
        •     写数据完成后,调call_rcu(rcu_head). rcu_head一般内嵌在数据
          •      call_rcu会把rcu_head插入一个链表中。
        •     每个tick, 会检查本地CPU是否进入静止状态。如果进入了,本地的rcu_tasklet会调用rcu_head内的函数释放数据。
          •      rcu_tasklet是个per-CPU变量

  • 用等待列队同步
    •   信号量
      •    代码分析:
        •     阻塞时以独占方式插入列队,唤醒时只唤醒一个
        •     由于__up中的wake_up不操作列队; 所以多个__up时,wake_up的仅仅是第一个进程。
          •      这样__down中,本进程进入临界区前,再调一次wake_up_locked, 多唤醒一个进程(我们称为后继进程)。
          •      多唤醒的进程需要重试,才知道能不能真正唤醒。所以用for循环检查count,
          •      所以:睡眠时恢复原来的count. 唤醒后重新count--,测试count>=0;
        •     由于__up需要根据count来判断,是否有阻塞的进程
          •      所以,只有有睡眠的进程(无论多少),count就必须减一次(变成-1)。
          •      只要有__up, count就变成0了。第一个被唤醒的进程调wake_up_locked, 第二个进程就需要把count减1,以维持上述状态。
          •      所以就用sleepers, 表示有没有__up改变了上述状态。
      •    还原代码:
        •     for 中的条件判断(原子完成:改值,判断非负):if (!atomic_add_negative(sem->sleepers-1, &sem->count))
          •      1、如果有__up, 被唤醒的进程要count-1, 否则count不变: count -= have_uped
          •      2、新进程来时:如果已经有等待的进程, count要恢复成down()以前的值; 否则不用恢复:count += sleepers
          •      结合以上两者:将have_uped替换成(1-sleepers).
            •       情况1变成:count += (sleepers - 1)
            •       为了满足情况2,新来的进程,进入for前,sleeper++;
            •       经过if判断后(无论是新来的,还是被唤醒的),根据情况设置sleeper: 0(不在“有睡眠的,count为-1”状态), 1(表示已进入上述状态)。
              •        当前进程被唤醒(或唤醒失败),就能推出有无__up.
      •    spin_lock的作用:
        •     为了保护sem内数据。
        •     down()之后,互斥锁没有锁上,所以也没有禁止抢占
    •   读写信号量
      •    严格的FIFO,每次都从列队头开始唤醒
        •     列队头是写锁:不再唤醒
        •     列队头是读锁:唤醒与之连续的所有读者,直到遇见一个写者。
      •    结构
        •     count:
          •      高两字节:是否有写者进入 与 所有的等待数目
          •      低两字节:已进入的总数(无论读者或写者)
          •      TODO: 具体编码形式:
        •     wait_list: 等待列队,每一个元素都一个标识, 说明读者还是写者。
        •     wait_lock: 互斥锁
      •    接口
        •     down_read down_read_trylock up_read
        •     down_write down_write_trylock up_write
        •     down_grade_write: 把锁写直接变成锁读
    •   完成
      •    需求:A阻塞自己,等待B完成某些工作后唤醒自己。
      •    结构:completion:
        •     uint done; 表示是否完成。正数:完成。负数:没有
          •      0:未完成,或已经跳过wait_for_completion
          •      1: 完成且还没有wait_for_completion.
        •     wait_queue_head_t wait; 等待列队
      •    接口:
        •     complete(): done++, 唤醒一个进程
        •     wait_for_completion():
          •      如果done为0, 循环等待直到done非零,done减1。
          •      联系complete,可知done只能为0或1。不可能有其他值

系统级的同步
    •   本地中断禁用:维持本地CPU的KCP同步
      •    保护中断处理中的数据
      •    禁用、使能:local_irq_disable, local_irq_enble (cli, sli指令修改eflags的IF标识)
      •    禁用、恢复:local_irq_save, local_irq_restore
    •   延时函数禁用
      •    禁止中断后,可延迟函数没有了启动机会,自然就被禁止了
      •    单纯禁止可延迟函数:local_bh_disable: 修改preempt_count中的,softirq计数器部分。
      •    使能可延迟函数:local_bh_enable“:softirq计数器减1, 并使长时间等待的进程尽快执行
        •     检查是否能启动可延迟函数;如果能,调用soft_irq()启动
        •     检查TIF_NEED_RESCHED, 调preempt_schedule() 调度
    •   如何选取同步方式:高层并发,底层同步,且能少用就少用
      •    例如:链表操作:要插入的节点上先挂入后继节点上,再把前驱连上要插入的节点(需要加内存屏障)
      •    处理方式选择:考虑谁被谁打断,禁止前者
公用数据的指令单一处理器多处理器附加的机制
异常信号量(产生同步问题的异常一般时系统调用。大多是资源相关,用等待列队方便。抢占不会产生问题,唯一需要禁止抢占的是访问per-CPU变量的时候。)
中断中断禁用(只有中断禁用可以胜任)自旋锁
可延时函数(softirqs)无(单一CPU上,可延时函数只能串行执行。)自旋锁(同一softirq可以同时在不同CPU上执行)
可延时函数(同一个tasklet)无(同上)无(因为同一个tasklet不能同时在不同CPU上执行,所以没有同步问题)
可延时函数(不同tasklet共用)无(同上)自旋锁(不用tasklet同一时间可在不同CPU上执行)
异常+中断处理中断禁止(处理异常时可能来中断,但中断处理不可能被异常打断)自旋锁(关注其他CPU上的中断和异常。快速处理完的中断,用自旋锁。系统调用的异常,用信号量好)
异常+可延迟函数可延迟函数禁止(可延迟函数类似与异常,所以该处类似"异常+中断处理")自旋锁
中断+可延迟函数中断禁用(可延迟函数内可能有产生中断,反之不成立。所以,可延迟函数内禁止中断即可)自旋锁
异常+中断+可延迟函数中断禁用(异常处理、可延函都被中断打断,反之不成立)自旋锁
  • 示例
    •   引用计数: 用原子类型(atomic_t)
    •   大内核锁:
      •    得不到就阻塞等待:用信号量实现
      •    得到锁的进程可以重复上锁;但上锁、开锁要匹配。
        •     用lock_depth表示锁的层次, 0表示要上锁;
        •     上锁时先加1,遇到0才真正上锁(down(sem))
        •     开锁时先减1, 小于0才真正开锁( up(sem) )
      •    自己调schedule时,要放弃锁;但被抢占时,不能放弃锁
        •     schedule中:
          •      if (prev_pd->lock_depth>=0) 释放锁(up信号量)
          •      if (next_pd->lock_depth>=0) 申请锁(down信号量)
        •     preempt_schedule_irq中:
          •      先把lock_depth置为-1,schedule()就不会释放锁了,其他的进程也就得不到大内核锁
          •      schedule()返回后,再恢复原值
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值