深入理解linux内核——内核同步
来源:互联网 发布:城管打人 知乎 编辑:程序博客网 时间:2024/06/13 03:08
1. 内核可以看做是一个不断对请求进行响应的服务器,这些响应来自CPU上执行的进程,和发出中断请求的外部设备。通过这个类比我们可以看到,内核各部分不是顺序执行的,而是交错interleave执行的。
2. 满足进程和中断,有以下四个原则:
a. 中断到来时,内核空闲,则处理中断(如果CPU在用户态执行,就可以看做空闲);
b. 中断到来时,内核正在被进程占用,内核会停止为进程服务,转而处理中断;
c. 中断到来时,内核正在处理另一个中断,内核停止处理当前的中断,处理完新到的中断,再处理刚刚的中断;
d. 中断可能让内核停止给进程服务,并在中断结束后,也不再处理原来的进程,而是处理新进程。这一条原则关系到linux 2.6的一个新特点,内核抢占,kernel preemption。
3. 首先,如果一个进程执行内核函数时允许发生内核切换,那么这个内核就是抢占的,但实际的情况有些复杂。
4. 无论抢占内核还是非抢占内核,运行在内核态的进程都可以主动放弃CPU,比如等待资源而睡眠时,这种切换称为进化型切换。但抢占式内核在响应引起进程切换的异步事件的方式和非抢占式内核有差别,抢占式内核的这种进程切换被叫做强制性进程切换。
5. 所有进程切换都由宏switch_to完成。但在非抢占内核中,当前进程除非要切换到用户态,否则不可能被替换。
6. 因此总结出抢占内核的一个特征:一个在内核态运行的进程,可能在执行内核函数期间被另一个进程取代。
7. 使内核可抢占的目的是减少用户态进程的分派延迟dispatch latency,即从进程变为可执行状态到它实际开始运行之间的时间间隔。内核抢占可以降低一些需要被及时调度的任务(比如硬件控制器,电影播放器)被另一个运行在内核态的进程延迟的风险。
8. 当被current_thread_info()宏所引用的thread_info描述符的preempt_count字段大于0时,就禁止内核抢占。该字段的编码对应三个不同的计数器,它在如下三种情况下都大于0:
a. 内核正在执行中断服务例程;
b. 可延迟函数被禁止(当内核正在执行软中断或tasklet时经常如此);
c. 通过把抢占计数器设置为正数,而显式的禁用内核抢占。
总结:只有当内核正在执行异常处理程序,而且内核抢占没有被显式的禁用时,才可以抢占内核。而且,本地CPU需要打开本地中断。
9. 内核抢占会引起不容忽视的开销,因此linux2.6内核允许用户在编译内核的时候通过设置选项来禁用或启用内核。
10. 当计算的结果依赖于两个或两个以上的交叉内核控制路径的嵌套方式时,可能出现竞争条件;临界区是一段代码,在其他的内核控制路径能够进入临界区前,进入临界区的内核控制路径必须全部执行完这段代码。一旦临界区被确定,就必须保护它,确保在任意时刻只有一个内核控制路径处于临界区。
11. 单CPU环境下,可以在访问共享数据结构的时候关闭中断,来实现临界区,因为只有开中断的时候才会发生内核控制路径的嵌套。
12. 如果相同的数据结构仅被系统调用服务例程所访问,而且系统中只有一个CPU时,就可以通过禁用内核抢占,来实现临界区。
13. 什么时候同步不必要?先看关于内核同步的一些约束:
a. 所有中断处理程序相应来自PIC的中断并禁用IRQ线,在该中断处理程序结束前,不允许产生相同的中断事件;
b. 中断处理程序、软中断、tasklet不可能被抢占也不可能被阻塞,所以它们不会长时间处于挂起状态;
c. 执行中断处理的内核控制路径不能被执行可延迟函数或系统调用服务例程的内核控制路径中断;
d. 软中断和tasklet不能在一个给定的CPU上交错执行;
e. 同一个tasklet不可能同时在几个CPU上执行。
14. 基于以上约束,这些例子不必进行内核同步处理:
a. 中断处理程序和tasklet不必写成可重入的(因为它们不会被重入,也不会在多个CPU上交错);
b. 仅被软中断和tasklet访问的每CPU变量不需要同步;
c. 仅被一种tasklet访问的数据结构不需要同步。
15. 内核中有如下同步技术:
技术
说明
适用范围
每CPU变量
在CPU之间复制数据结构
所有CPU
原子操作
对一个计数器原子地“读-修改-写”
所有CPU
内存屏障
避免指令重新排序
本地或所有
自旋锁
加锁时忙等
所有
信号量
加锁时阻塞等(睡眠)
所有
顺序锁
基于访问计数器的锁
所有
禁止本地中断
本地CPU
禁止本地软中断
本地CPU
读-拷贝-更新RCU
通过指针,而不是锁,来访问数据
所有
16. 最好的同步技术,是设计不需要同步的内核。任何显式的同步原语都有不可忽视的开销。
17. 每CPU变量per-cpu variable:建立NR_CPU长度的数组,每个CPU对应一个元素。
18. 每CPU变量为来自不同CPU的并发提供保护,但对异步函数的访问无法保护,比如中断处理程序和可延迟函数。
19. 无论单核多核,访问每CPU变量要禁用抢占:如果一个内核控制路径获得了它的每CPU变量本地副本的地址,然后它被抢占,转移到另一个CPU上,但仍然引用原来的CPU元素的地址。
20. 优化和内存屏障:编译器会进行优化,会重新编排汇编语言指令以使寄存器以最优的方式使用,导致指令不会严格的按它们在源代码中出现的顺序执行。此外,现代CPU通常并行执行若干条指令,且可能重新安排内存访问。
但是,处理同步必须避免指令重新排序,比如如果同步原语之后的指令如果放在同步原语之前执行了,就糟了。
21. 优化屏障optimization barrier原语保证编译程序不会混淆放在愿与操作之前和之后的指令。在Linux中,优化屏障就是barrier()宏,它展开为
asm volatile(“ ”:::”memory”);
asm指令表示要插入汇编语言片段。volatile关键字禁止编译器把asm指令与其他指令重新组合。memory关键字强制编译器假定RAM中的所有内存单元已经被汇编语言指令修改,因此编译器不能使用存放在CPU寄存器中的内存单元的值来优化asm指令前的代码。
22. 优化屏障并不保证当前CPU不会把汇编语言指令混在一起执行——这是内存屏障的工作。内存屏障memory barrier原语确保,在原语之后的操作开始执行之前,原语之前的操作已经完成。因此内存屏障类似于防火墙,让任何汇编语言指令都不能通过。
23. 在80x86处理器中,以下汇编指令是串行的,它们起内存屏障的作用:
a. 对IO端口进行操作的所有指令;
b. 有lock前缀的所有指令;
c. 写控制寄存器、系统寄存器或调试寄存器的所有指令(比如cli,sti,用于修改eflags寄存器的IF标志的状态)。
d. 在Pentinum4微处理器中引入的汇编语言指令lfence、sfence和mfence,分别有效地实现内存屏、写内存屏障和读写内存屏障;
e. 少数专门的汇编指令,比如总之中断处理程序或异常处理程序的iret指令。
24. Linux有6个内存屏障原语,分别是mb()、rmb()、wmb()、smp_mb()、smp_rmb()和smp_wmb()。它们也可以当做优化屏障,因为必须保证编译程序不在屏障前后移动汇编语言指令。读内存屏障(rmb)仅作用于读内存的指令,写内存屏障(wmb)只作用于写内存的指令,带smp的只用在多处理器系统中,单处理器中它们什么也不做。
25. 原子操作:若干汇编指令具有“读-修改-写”类型。这样会访问存储器单元两次,第一次读原值,第二次写新值。可想而知,两个内核控制路径,通过执行非原子操作来同时“读-修改-写”同一存储器单元,就有问题了。而避免这种“读-修改-写”指令引起竞争的最好方法,是确保这样的操作在芯片级是原子的。
26. 原子操作atomic operations以单个指令执行,中间不能中断,且避免其他的CPU访问同一存储器单元。这些原子操作可以建立在其他更灵活机制的基础之上创建临界区。
27. 对80x86指令分一下类:
a. 进行0次或1次对齐内存访问的汇编指令是原子的。对齐:当数据项的地址是以字节为单位的整数倍时,数据项在内存中被对齐。比如对齐的int的地址是4的整数倍。
b. 如果再读操作之后,写操作之前没有其他处理器占用内存总线,那么从内存中读取数据、更新数据并把更新后的数据写回内存中的这些“读-修改-写”指令(如INC和DEC)是原子的。
c. 操作码前缀是“lock”字节的(0xf0)的“读-修改-写”指令,即使在多核中也是原子的。因为当控制单元检测到这个前缀的时候,就会锁定内存总线,让别的处理器无法访问这个内存单元。
d. 操作码前缀是一个rep字节(0xf2,0xf3)的指令不是原子的,这个指令是让控制单元重复执行相同指令,每次执行新的循环之前,要检查挂起的中断。
28. 编译器并不保证把a = a + 1或者a++这样的搞基语言编成一个原子指令,因此linux内核专门提供了atomic_t类型和一些函数和宏,在多处理器系统中,每条这样的指令都有lock字节前缀。
29. 加锁locking,当内核控制路径必须访问共享数据结构或进入临界区的时候,就需要为自己获取一把“锁”。就相当于一间只允许一个人进入的房间,某人进入的时候就把门锁上,让别人无法进入。
30. 自旋锁spin lock是在多处理器环境中工作的一种特殊的锁,内核控制路径会忙等,也就说如果这个内核控制路径发现锁被其他CPU上的内核控制路径持有的时候,它会一直在周围旋转,一直循环,直到锁被释放,它获取到锁。
31. 一般来说,自旋锁保护的临界区都是禁止内核抢占的。在单核系统,自旋锁不起锁的作用,自旋锁原语仅仅能禁止或启用内核抢占。
32. 自旋锁忙等期间,内核抢占依然有效,忙等中的进程,可能被更高优先级的进程替代。
33. linux中自旋锁用spinlock_t结构表示,包含两个字段:slock,自旋锁状态,为1表示未加锁,0或负数表示加锁状态;break_lock,表示进程正在忙等自旋锁。
34. 具有内核抢占的spin_lock宏,该宏会获取自旋锁的地址slp作为参数:
I. 调用preempt_disable()禁用内核抢占;
II. 调用函数_raw_spin_trylock(),它对自旋锁的slock字段执行原子性的测试和设置,它会首先:
movb $0, %alxchgb %al, slp->slock
也就是,给slp->slock赋值为0,并把它本来的值取出,然后判断,如果旧值是证书,函数返回1,否则返回0;
III. 如果旧值是正数,宏结束,否则
IV. 否则内核控制路径无法获得自旋锁,必须循环等待自旋锁被释放。调用preempt_enable()递减在第I步被增加了的抢占计数器。如果在执行spin_lock宏之前内核抢占被启用,那么其他进程此时可以取代等待自旋锁的进程。
V. 如果break_lock字段等于0,则设置为1。通过检查该字段,拥有锁并在其他CPU上运行的进程可以知道是否有其他进程在等待这个锁。如果进程把持这个自旋锁时间过长,可以提前释放。
VI. 执行循环等待:
while(spin_is_locked(slp) && slp->break_lock) cpu_relax();
宏cpu_relax()简化为Pause汇编语言指令,是在Pentinum4模型中引入的。早先80x86,它对应rep;nop指令。
VII. 跳回第I步,再次试图获得自旋锁。
35. 非抢占式内核中的spin_lock宏:这种情况下,宏生成一个汇编语言程序片段,等价于:
1: lock; decb slp->slock; 原子性递减slock的值 jns 3f; 如果slock等于0了,说明自旋锁是1,未锁状态,就执行向前执行3. ; 程序会正常执行下去2: pause; 否则,pause cmpb $0, slp->slock; 看slock为0了吗 jle 2b; 小于等于0,回头执行2,继续pause jmp 1b; 否则回头执行1,试图获得锁3: ;走入这里说明成功进入了临界区
36. spin_unlock宏释放以前获得的自旋锁,本质就是:
movb $1, slp->slock; 给slock赋值为1
然后调用preempt_enbale()(非抢占内核的话,这个函数就啥也不做)。因为80x86微处理器总是原子地执行内存中的只写访问,所以不需要lock字节。
37. 读写自旋锁:引入读写自旋锁是为了提高内核的并发能力。读写自旋锁允许多个内核控制路径同时读同一个数据结构。如果有内核控制路径要对这个结构进行写操作,必须首先获取读写锁的写锁。
38. 每个读写自旋锁都是一个rwlock_t结构,其lock字段是一个32位的字段,分为两个不同的部分:
a. 24位计数器,表示对受保护的数据结构并发地进行读操作的内核控制路径的数目。这个计数器的二进制补码存放在这个字段的0~23位。
b. “未锁”标志字段,当没有内核控制路径在读或写时设置该位,否则清0。这个未锁标志存放在lock字段的第24位。
39. 注意,如果自旋锁为空(设置了未锁标志而且没有读者),那么lock字段的值为0x0100 0000;如果写者已经获得自旋锁(未锁标志清0且无读者),那么lock字段的值是0x0000 0000;如果一个两个或者多个进程因为读获得了自旋锁,那么lock字段的值为0x00ff ffff,0x00ff fffe等(未锁标志清0,读者个数的二进制补码在0-23位上)。
40. (与spinlock_t结构一样)rwlock_t结构也有break_lock字段。
41. rwlock_init宏会把读写自旋锁的lock字段初始化为0x01000000,把break_lock初始化为0。
42. 获取读锁的read_lock宏作用于读写自旋锁的地址rwlp,如果开启了内核抢占选项,read_lock宏执行与spin_lock很相似,不同是该宏会通过_raw_read_trylock()函数在第二步有效地获取读写自旋锁(spinlock_t通过slock字段):
int _raw_read_trylock(rwlock_t *lock){ atomic_t *count = (atomic_t *)lock->lock; atomic_dec(count); if(atomic_read(count) >= 0) { /*因为本函数不是原子的,在判断完count的值之后,返回1之前,*/ /*count有可能改变。但是这不影响函数正常工作*/ /*因为*/ return 1; } atomic_inc(count); return 0;}
43. 如果没有开启内核抢占,read_lock等价于如下汇编代码:
movl $rwlp->lock, %eax lock; subl $1, (%eax) jns 1f call __read_lock_failed1:
其中,__read_lock_failed()是下列汇编:
__read_lock_failed: lock; incl (%eax)1: pause cmpl $1, (%eax) js 1b lock; decl (%eax) js__read_lock_failed ret
44. 释放读锁read_unlock宏只需要增加lock字段的计数器就好:
lock; incl rmlp->lock
然后调用preempt_enable()重启内核抢占。
45. 获取写锁的write_lock宏实现方式和spin_lock()和read_lock()类似。禁用内核抢占后,调用_raw_write_trylock()获得锁,返回0说明锁被占用:
int _raw_write_trylock(rwlock_t *lock){ atomic_t *count = (atomic_t*)lock->lock; if(atomic_sub_and_test(0x01000000,count)) { /*给count字段减去0x01000000,从而清除第24位的未锁标志*/ /*如果减完产生了0,说明没有读者,就返回1,得到锁*/ return 1; } /*否则,把0x01000000加回来*/ atomic_add(0x01000000, count); return 0;}
46. 释放写锁的write_unlock宏只需要用汇编语言指令
lock; addl $0x01000000, $rwlp
把lock的未锁置位,然后调用preempt_enable()就行了。
47. 顺序锁:读写自旋锁中的读锁和写锁具有相同的优先权。读者需要等待写者写完,写者也要等待读者读完。于是Linux2.6中引入了顺序锁seqlock,它与读写自旋锁相似,但给写者更高的优先级。
48. 顺序锁允许读者读的时候,写者可以继续运行。这种策略使得写者永远不会等待读者,它只会等待另一个写者。缺点是读者可能需要多次读相同的数据,直到它获得有效的副本。
49. 每个顺序锁都是包括两个字段的seqlock_t结构,一个类型为spinlock_t的lock字段,和一个整型的sequence字段。sequence是一个顺序计数器,每个读者在读数据之前和之后都要取顺序计数器的值,如果两次的值不相同,说明新的写者已经开始写并增加了计数器,所以读者刚刚独到的数据是无效的。
50. 写者通过write_seqlock()和write_sequnlock()获取和释放顺序锁。获取的时候会增加顺序计数器,释放的时候会再次增加顺序计数器,这样保证写者在写的时候,顺序计数器一定是奇数,而没有写者在写的时候,计数器是偶数。
51. 读者会这样读:
unsigned int seq;do{ seq =read_seqbegin(&seqlock);/*返回顺序锁的当前顺序号*/ /*临界区*/} while (read_seqretry(&seqlock, seq));/*再读顺序号,如果和seq不匹配,就返回1*/
52. 读者进入临界区不必禁用内核抢占;由于读者获取自旋锁,所以它进入临界区的时候自动禁用内核抢占。
53. 一般满足下述条件才用顺序锁:
a. 被保护的数据结构不包括被写者修改和被读者间接引用的指针(否则写者可能直接把读者写挂);
b. 读者的临界区代码没有副作用,多个读者的操作和单独读者操作,不能有不同的结果;
另外读者单独临界区代码应该简短,写者不应该经常获取顺序锁(引起反复读的话,开销很大)。
54. 读-拷贝-更新RCU是为了保护在多数情况下被多个CPU读的数据结构而设计的一种同步技术。RCU允许多个读者和写者并发执行。而且,RCU是一种非锁方案。
55. RCU关键思想:a. RCU只保护被动态分配并通过指针引用的数据结构;b. 在被RCU保护的临界区中,内核控制路径不可以睡眠。
56. 当内核控制路径要读取被RCU保护的数据结构时,执行宏rcu_read_lock(),它等同于preempt_disable()。接下来,读者通过间接引用该数据结构指针所指向的内存单元来读这个数据结构。最后,用宏rcu_read_unlock()标记临界区的结束,同理,这个宏等同于preempt_enable()。
57. 写者要更新数据的时候,它间接引用指针并生成整个数据结构的副本。然后修改这个副本。然后写者改变指向数据结构的指针,指向被修改后的副本。修改指针是原子操作,所以新旧副本对读者写者都可见,不会引起数据崩溃。但还是要用内存屏障来保证只有在数据结构被修改之后才对其他CPU可见。
58. RCU技术的困难之处在于:写者修改指针时不能立即释放数据结构的副本。写者修改时,可能有读者正在读旧副本。只有当CPU上所有读者都执行完rcu_read_unlock()之后,才可以释放旧副本。
59. 内核会要求所有潜在的读者在下面的操作之前执行rcu_read_unlock():
a. CPU执行进程切换
b. CPU开始在用户态执行
c. CPU执行空循环
对于上述每种情况,我们说CPU已经经过了静止状态quiescentstate。
60. 写者调用call_rcu()函数来释放旧副本。当所有CPU都通过静止状态之后,call_rcu()接受rcu_head描述符(通常在要被释放的数据结构中)的地址和将要调用的回调函数的地址作为参数。回调函数执行时,通常释放数据结构的旧副本。
61. 函数call_rcu()把回调函数和其参数地址放在rcu_head描述符中,然后把描述符插入回调函数的每CPU链表中。内核每经过一个时钟滴答,就检查本地CPU是否经过一个静止状态。如果所有CPU都经过静止状态,本地tasklet就执行链表中的所有回调函数。
62. RCU用在网络层和虚拟文件系统中。
63. 信号量:它本质上实现了一个加锁原语,让等待者睡眠,直到等待的资源变为空闲。
64. Linux提供两种信号量:内核信号量,由内核控制路径使用;System V IPC信号量,由用户态进程使用。本章讨论内核信号量。
65. 信号量会阻止内核控制路径继续进行,当内核控制路径试图获取内核信号量所保护的资源时,相应进程会被挂起,当资源被释放时,进程再次变为可运行的。因此只有可以睡眠的函数才能获取内核信号量。中断处理程序和可延迟函数不能使用信号量。
66. 内核信号量是一个semaphore的结构体,包含字段:
字段
描述
count
atomic_t类型,当该值大于零,说明资源空闲;等于0说明忙;
小于0,说明资源不可用,且有进程在等待资源。
wait
存放等待队列链表的地址。当前等待资源的所有睡眠进程都在其中;
如上所述,count大于等于0时,这个表是空的。
sleepers
存放一个标志,表示是否有进程在信号量上睡眠。
67. 信号量可以初始化为0,也可以初始化为1,也可以初始化为任意正数n(表示有n个进程可以并发访问)。
68. 希望进程释放信号量的时候,就调用up()函数,其等价于:
movl $sem->count, %ecx ;取count字段的值 lock; incl (%ecx) ;自加1 jg 1f ;判断加1后的count是否大于0, ;不是的话就执行__up()函数,否则跳到1处,什么也不做。 lea %ecx, %eax ;__up()函数从eax寄存器接受参数 pushl %edx ;这样的函数用__attribute__和regparm关键字修饰 pushl %ecx call __up ;这个函数会唤醒一个等待的进程。 popl %ecx popl %edx 1:
__up()函数如下:
__attribute__((regparm(3))) void __up(struct semaphore*sem){ wake_up(&sem->wait);}
69. 进程要获取信号量时调用down()函数,其本质是:
down: movl $sem->count, %ecx ;和up()一样,先读count, lock; decl (%ecx) ;不过这里是减一 jns 1f ;当count大于等于0,什么也不做,走到1处正常执行 lea %ecx, %eax ;否则进程必须挂起, pushl %edx ;保存一些寄存器的内容到栈里 pushl %ecx ; call __down ;之后调用__down() popl %ecx popl %edx 1:
这里是__down()函数,它负责把进程状态从RUNNING变为UNINTERRUPTTIBLE,并把进程放在信号量的等待队列:
__attribute__((regparm(3))) void __down(struct semaphore*sem){ DECLARE_WAITQUEUE(wait, current); unsigned long flags; current->state =TASK_UNINTERRUPTIBLE; spin_lock_irqsave(&sem->wait.lock.flags); /*访问信号量等待队列的时候,要加锁并且禁止中断*/ add_wait_queue_exclusive_locked(&sem->wait,&wait); /*__locked表示假设调用等待队列函数之前已经获得了自旋锁*/ sem->sleepers++; for(;;){ if(!atomic_add_negative(sem->sleepers-1,&sem->count)){ /*sleepers减1后加到count里,如果为负数会返回1,否则执行下面的*/ /*1. 如果信号量打开,count为1,且sleepers本来等于0的时候*/ /*函数什么也不做,直接跳出了*/ /*2. 如果信号量关闭,没有睡眠进程,此时sleeper等于0*/ /*这里的判定完全依赖于count的值,count不是负数,就执行下面的代码*/ /*把sleepers置为0,然后跳出*/ /*3. 如果信号量关闭,有其他睡眠进程(sleepers是1),且count不为负数,*/ /*这里就相当于给count字段加1,然后把sleepers置0*/ sem->sleepers = 0; break; } /*走到这里说明sleepers-1+ count是负数,那么肯定count是负数,信号量关闭*/ /*会把sleepers置成1,执行schedule让出cpu*/ sem->sleepers= 1; spin_unlock_irqrestore(&sem->wait.lock,flags); schedule(); spin_lock_irqsave(&sem->wait.lock,flags); current->state= TASK_UNINTERRUPTIBLE; } /*执行完循环,说明等待中的进程可以得到信号量了,*/ /*就把进程从等待队列中移除,并且唤醒,往下执行*/ /*由于等待队列中的睡眠进程都是互斥的,这里一次只唤醒一个进程*/ remove_wait_queue_locked(&sem->wait,&wait); wake_up_locked(&sem->wait); spin_unlock_irqrestore(&sem->wait.lock,flags); current->state = TASK_RUNNING;}
70. 因为进程通常发现信号量处于打开状态,因此可以优化信号量函数,如果信号量等待队列为空,up()函数就不执行跳转指令;同样,如果信号量是打开的,down函数就不执行跳转指令。信号量实现的复杂性是由于极力在执行流的主分支上避免费时的指令 造成的。
71. 读写信号量:类似于读写自旋锁,不同点就是等待进程会挂起,而不会自旋。
72. 内核以严格的FIFO顺序处理等待读写信号量的所有进程。如果读者或写者进程发现信号量关闭,这些进程就被插入到信号量等待队列链表的末尾。当信号量被释放时,就检查处于等待队列链表的第一个位置的进程,如果是写者进程,等待队列上的其他进程就继续睡;如果是读者进程,紧跟着这个进程的其他读者进程也会唤醒并获得锁,但是在写者进程之后的读者进程还是会睡。
73. 读写信号量是由rw_semaphore结构描述的,包含以下字段:
字段
描述
count
两个16位计数器,其中高16位以二进制补码的形式,
存放非等待写者进程的总数和等待的写内核路径控制数。
低16位计数器存放非等待的读者和写者进程的总数。
wait_list
指向等待进程的链表。每个元素都是rwsem_waiter结构
这个结构包含一个指向等待进程的指针,和一个标志,
用于说明该进程是为读需要信号量,还是为写。
wait_lock
用户保护等待队列链表和rw_semaphore结构本身的自旋锁。
74. 补充原语:2.6内核里还有另一种类似于信号量的原语:补充completion。引入这种原语是为了解决多处理器系统上发生的一种微妙的竞争关系。当A进程分配了一个临时信号量,并初始化为关闭的MUTEX,并把这个信号量的地址传给进程B,然后A中调用了down(),进程A想在唤醒的时候撤销这个信号量,而此时另一cpu上的进程B可能还在对信号量进程up(),这样会造成up()访问一个不存在的数据结构。
75. 当然了,对up()和down()也做同步保护就行了,禁止他们并发执行,但是需要额外的指令,而且有函数如果要频繁使用信号量,这样也很麻烦,所以就用补充来解决。completion数据结构如下:
struct completion{ unsigned int done; wait_queue_head_t wait; };
76. 补充原语和信号量的差别在于如何使用等待队列中包含的自旋锁。补充原语中,自旋锁用来确保相当于up的complete()和相当于down的wait_for_completion()不会并发执行。在信号量中,自旋锁用于避免并发执行的down()函数弄乱信号量的数据结构。
77. 内核避免竞争实例——引用计数器reference counter,是一个atomic_t计数器,与特定的资源,如内存页,模块,或文件相关,广泛的应用于避免由于资源的并发分配和释放而产生的问题中。当内核控制路径开始使用资源时就原子减少,用完资源时就原子加1。当引用计数器为0时,说明资源未被使用,如有必要则释放这个资源。
78. 内核避免竞争实例——大内核锁big kernel block,BKL,2.6内核中BKL用于保护旧的代码,大多数是和VFS和几个文件系统相关的函数,用一个叫做kernel_sem的信号量来实现大内核锁。
每个进程描述符有lock_depth字段,允许同一个进程几次获取BKL,如果进程未获得锁,这个字段-1,否则+1。可以用lock_kernel()和unlock_kernel()内核函数来获得和释放BKL。
79. 内核避免竞争实例——内存描述符读写信号量。mm_struct类型的每个内存描述符在mmap_sem字段中都包含了自己的信号量。由于几个轻量级进程之间可以共享一个内存描述符,因此,信号量保护这个描述符避免可能产生的竞争条件。
例如,内核必须为某一个进程创建一个内存区的时候,内核调用do_mmap()函数分配一个新的vm_area_struct数据结构。分配过程中,如果没有可用的空闲内存,而共享同一内存描述符的另外一个进程可能在运行,那么当前进程可能被挂起。如果没有信号量,那么需要访问内存描述符的第二个进程的任何操作都可能会导致数据崩溃。
用读写信号量,因为一些内核函数只需要扫描内存描述符(比如缺页异常处理函数)。
80. 内核避免竞争实例——slab高速缓存链表的信号量。slab高速缓存描述符链表通过cache_chain_sem信号量保护,这个信号量允许互斥的访问和修改该链表。当kmem_cache_create()在链表中增加一个元素,而kmem_chche_shrink()和kmem_chche_reap()顺序的扫描整个链表时,可能产生竞争。但是处理中断时这些函数不会被调用,在访问链表时它们也从不阻塞。由于内核是支持抢占的,因此这种信号量在多处理器系统和单处理系统中都起作用。
81. 内核避免竞争实例——索引节点信号量。Linux把磁盘文件的信息存放在一种叫做索引节点inode的的内存对象中。相应的数据结构也包括有自己的信号量,存放在i_sem字段中。
文件系统中有很多竞争条件,因为磁盘上的每个文件都实际上是用户的一种共有资源,所有进程都可能访问文件,修改文件。Linux中用索引节点信号量来保护目录文件,避免竞争带来的问题。
- 深入理解linux内核——内核同步
- 深入理解Linux内核-内核同步
- 深入理解Linux内核day04--内核同步
- 《深入理解Linux内核》--第五章 内核同步:读书笔记
- 深入理解Linux内核个人小结5---内核同步
- 《深入理解Linux 内核》chap 5 内核同步
- 深入理解linux内核——进程
- 深入理解LINUX内核
- 深入理解 Linux 内核
- 深入理解linux内核
- 深入理解 Linux 内核
- 深入理解Linux内核
- linux内核学习——内核同步
- 《深入理解Linux内核》学习笔记——第一章
- 《深入理解LINUX内核》学习笔记——内存管理
- 深入理解Linux内核——内存管理
- 深入理解linux内核——内存寻址
- 深入理解linux内核学习
- 7-8 使用原子 和 子表结构 ,求广义表 的深度
- 51nod五级题小记
- volatile和synchronized的比较
- vue-cli(详解)
- caffe代码data_transform
- 深入理解linux内核——内核同步
- Java结束线程的三种方法
- neuq 1202: 人民币问题
- setInterval
- jq代码学习3--某网站品牌列表的效果 fl ch2 p53
- Polynomial addition Operation using C++[2.65]
- SQL多表查询
- Python计算上个月最后一天和第一天
- 关于React-Native使用immutable(redux环境下)的一点用法