深入理解Linux内核-并发-内核同步

内核如何为不同的请求提供服务

把内核看作必须满足两种请求的侍者:一种来自顾客,另一种来自数量有限的几个不同的老板。对不同的请求,侍者采用如下策略:
(1). 老板提出请求时,如侍者空闲,则开始为其服务。
(2). 如老板提出请求时,侍者正在为顾客服务,则侍者停止为顾客服务,开始为老板服务。
(3). 如一个老板提出请求时,侍者正在为另一个老板服务,则侍者停止为第一个老板提供服务,开始为第二个老板服务,服务完毕,再继续为第一个老板服务。
(4). 一个老板可能命令侍者停止正在为顾客提供的服务。侍者在完成对老板最近请求的服务后,可能会暂时不理会原来的顾客,而去为新选中的顾客服务。

侍者提供的服务对应于CPU处于内核态时所执行的代码。如CPU在用户态执行,则侍者被认为处于空闲。老板的请求相当于中断,顾客的请求相当于用户态进程发出的系统调用或异常。请求内核服务的用户态进程需发出一个适当的指令,在80x86上是int $0x80sysenter。这些指令引起一个异常,迫使CPU从用户态切换到内核态。

内核抢占

(1). 无论在抢占内核还是非抢占内核,运行在内核态的进程都可自动放弃CPU
原因可能是,进程由于等待资源而睡眠。将这种进程切换称为计划性进程切换。抢占式内核在响应进程切换的异步事件的方式上与非抢占的内核是有差别的,把这种进程切换称作强制性进程切换。
(2). 所有的进程切换都由宏switch_to完成。在抢占内核和非抢占内核中,当进程执行完某些具有内核功能的线程,且调度程序被调用后,发生进程切换。抢占内核的主要特点是:一个在内核态运行的进程,可能在执行内核函数期间被另外一个进程取代。

举例1
在进程A执行异常处理程序时,一个有较高优先级的进程B变为可执行。如发生了中断请求,且相应的处理程序唤醒了进程B。如内核是抢占的,就发生强制性进程切换,让B取代A。异常处理程序的执行被暂停,直到调度程序再次选择进程A时才恢复它的执行。如内核是非抢占的,在进程A完成异常处理程序的执行前是不会发生进程切换的。除非进程A自动放弃CPU。此时异常处理执行完毕返回时,将发生切换.

举例2
考虑一个执行异常处理程序的进程已经用完了它的时间配额的情况,如内核是抢占的,进程可能会立即被取代,但如内核是非抢占的,进程继续运行直到它执行完异常处理程序或自动放弃CPU。使能内核可抢占的目的是减少用户态进程的分派延迟,即从进程变为可执行状态到它实际开始运行间的间隔。使Linux 2.6内核具有可抢占的特性,无需对支持非抢占的旧内核做太大改变。curren_thread_info所引用的thread_infopreempt_count大于0时,禁止内核抢占。该字段的编码对应三个不同的计数器,它在如下任何一种情况发生时,取值都大于0
(1). 内核正在执行中断服务例程
(2). 可延迟函数被禁止(内核正执行软中断或tasklet时经常如此)
(3). 通过把抢占计数器设置为正数而显式禁用内核抢占。
上面的原则告诉我们,只有当内核正执行异常处理程序(尤其是系统调度),且内核抢占没被显式禁用时,才可能抢占内核。本地的CPU必须打开本地中断,否则,无法完成内核抢占。preempt_enable宏递减抢占计数器,检查TIF_NEED_RESCHED。此时,进程切换请求是挂起的,故宏调preempt_schedule,它本质上执行下面代码:

if(!current_thread_info->preempt_count && !irqs_disabled())
{
	current_thread_info->preempt_count = PREEMPT_ACTIVE;
	schedule();
	current_thread_info->preempt_count = 0;
}

函数检查是否允许本地中断,及当前进程的preempt是否为0,如两个都是真,就调用schedule选择另外一个进程运行。故,内核抢占可能在结束内核控制路径(通常是一个中断处理程序)时发生,也可能在异常处理程序调preempt_enable重新允许内核抢占时发生。内核抢占也可能发生在启用可延迟函数时候。内核抢占会引起不容忽视的开销。

什么时候同步是必须的

当计算的结果依赖于两个或两个以上的交叉内核控制路径的嵌套方式时,可能出现竞争条件。必须小心的识别出异常处理程序,中断处理程序,可延迟函数(软中断),内核线程中的临界区。一旦临界区被确定,就必须对其采用适当的保护措施,以确保任意时刻只有一个内核控制路径处于临界区。

多处理系统中,情况更加复杂。由于许多CPU可能同时执行内核路径,因此内核开发者不能假设只要禁用内核抢占功能,且中断,异常,软中断处理程序都没访问过该数据结构,就能保证这个数据结构能安全地被访问。

什么时候同步是不必要的

(1). 所有的中断处理程序响应来自PIC的中断并禁用IRQ线。此外,在中断处理程序结束前,不允许产生相同的中断事件。
(2). 中断处理程序,软中断,tasklet既不可被抢占,也不能被阻塞,所以,它们不可能长时间处于挂起状态。最坏情况下,它们的执行将有轻微的延迟,因为在其执行的过程中可能发生其他的中断。
(3). 执行中断处理的内核控制路径不能被执行可延迟函数或系统调用服务例程的内核控制路径中断。
(4). 软中断和tasklet不能在一个给定的CPU上交错执行。
(5). 同一个tasklet不可能同时在几个CPU上执行。
以上的每一种设计选择,都可以被看成是一种约束。它能使一些内核函数的编码变得更容易。

下面是一些可能简化了的例子:
(1). 中断处理程序和tasklet不必编写成可重入的函数。
(2). 仅被软中断和tasklet访问的每CPU变量不需要同步。
(3). 仅被一种tasklet访问的数据结构不需要同步。

同步原语

考察在避免共享数据之间的竞争条件时,内核控制路径是如何交错执行的。

技术说明适用范围
每CPU变量在CPU之间复制数据结构所有CPU
原子操作对一个计数器原子地"读-修改-写"的指令所有CPU
内存屏障避免指令重新排序本地CPU或所有CPU
自旋锁加锁时忙等所有CPU
信号量加锁时阻塞等待(睡眠)所有CPU
顺序锁基于访问计数器的锁所有CPU
本地中断的禁止禁止单个CPU上的中断处理本地CPU
本地软中断的禁止禁止单个CPU上的可延迟函数处理本地CPU
读-拷贝-更新通过指针而不是锁来访问共享数据结构所有CPU

每CPU变量

最好的同步技术是把设计不需要同步的内核放在首位。事实上每一种显式的同步原语都有不容忽视的性能开销。最简单也是最重要的同步技术包括把内核变量声明为每CPU变量,每CPU变量主要是数据结构的数组,系统中每个CPU对应数组的一个元素。一个CPU不应访问与其他CPU对应的数组元素,另外,它可随意读或修改它自己的元素而不担心出现竞争条件,因为它是唯一有资格这么做的CPU。但,这也意味着每CPU变量基本上只能在特殊情况下使用,即当它确定在系统的CPU上的数据在逻辑上是独立的时候。每CPU的数组元素在主存中被排列,以使每个数据结构存放在硬件高速缓存的不同行,因此,对每CPU数组的并发访问不会导致高速缓存行的窃用和失效。

虽然,每CPU变量为来自不同CPU的并发访问提供保护,但对来自异步函数(中断处理程序和可延迟函数)的访问不提供保护,在这种情况下,需要另外的同步原语。此外,在单处理器和多处理器系统中,内核抢占都可能使每CPU变量产生竞争条件。总的原则是内核控制路径应在禁止抢占的情况下访问每CPU变量。

原子操作

若干汇编语言指令具有"读-修改-写"类型,即访问存储器单元两次,第一次读原值,第二次写新值。单独的读内存,写内存,由于存储器仲裁器(对访问RAM芯片的操作进行串行化的硬件电路)插手,单独的读,写都是串行的。避免由于"读-修改-写"指令引起的竞争条件的最容易办法,就是确保这样的操作在芯片级是原子的。任何一个这样的操作都必须以单个指令执行,中间不能中断,且避免其他的CPU访问同一存储单元。这些很小的原子操作,可建立在其他更灵活机制的基础上以创建临界区。

当数据项的地址可被其尺寸整除时,称数据项在内存中被对齐。一般来说,非对齐的内存访问不是原子的。对80x86
(1). 进行零次或一次对齐内存访问的汇编指令是原子的。
(2). 如在读操作之后,写操作之前没有其他处理器占用内存总线,则从内存中读取数据,更新数据,并把更新后的数据写回内存中的这些"读-修改-写"汇编语言指令(如inc,dec)是原子的。当然,在单处理系统中,永远不会发生内存总线窃用的情况。
(3). 操作码前缀是lock字节的"读-修改-写"汇编语言指令即使在多处理系统中也是原子的。当控制单元检测到这个前缀时,就"锁定"内存总线,直到这条指令执行完成为止。因此,当加锁的指令执行时,其他处理器不能访问这个内存单元。
(4). 操作码前缀是一个rep字节的汇编语言指令不是原子的,这条指令强行让控制单元多次重复执行相同的指令。控制单元在执行新的循环之前要检查挂起的中断。

在编写C代码程序时,不能保证编译器会为a=a+1或甚至像a++这样的操作使用一个原子指令。因此,Linux内核提供了一个专门的atomic_t类型(一个原子访问计数器)和一些专门的函数或宏,这些函数和宏作用于atomic_t类型的变量,并当作单独的,原子的汇编语言指令来使用。在多处理系统中,每条这样的指令都有一个lock字节的前缀。

优化和内存屏障

当使用优化的编译器时,千万不要认为指令会严格按它们在源代码中出现的顺序执行。如,编译器可能重新安排汇编语言指令以使寄存器以最优的方式使用。此外,现代CPU通常并行地执行若干条指令,且可能重新安排内存访问。这种重新排序可极大加速程序的执行。然而, 当处理同步时, 必须避免指令重新排序。如放在同步原语之后的一条指令在同步原语本身之前执行,事情很快会变得失控。事实上,所有的同步原语其优化和内存屏障的作用。

优化屏障原语保证编译程序不会混淆放在原语操作之前的汇编语言指令和放在原语操作之后的汇编语言指令,这些汇编语言指令在C中都有对应的语句。在Linux中,优化屏障就是barrier宏,它展开为asm volatile("":::"memory")。指令asm告诉编译程序要插入汇编语言片段(这种情况下为空)。volatile关键字强制编译器假定RAM中的所有内存单元已经被汇编语言指令修改;因此,保证编译器不会混淆存放在同步操作之前的汇编语言指令和存放在同步操作之后的汇编语言指令。

内存屏障原语确保,在原语之后的操作开始执行之前,原语之前的操作已经完成。在80x86处理器中,下列种类的汇编语言指令是"串行的",因为它们其内存屏障的作用:
(1). 对I/O端口进行操作的所有指令
(2). 有lock前缀的所有指令
(3). 写控制寄存器,系统寄存器或调试寄存器的所有指令(如cli,sti,修改eflags指令)
(4). 在Pentium 4微处理器中引入的汇编语言指令lfence, sfence, mfence,它们分别有效地实现读内存屏障,写内存屏障,读-写内存屏障。
(5). 少数专门的汇编语言指令,终止中断处理程序或异常处理程序的iret指令是其中的一个。

Linux使用六个内存屏障原语。这些原语也被当作优化屏障,因为我们必须保证编译过程不在屏障前后移动汇编语言指令。当内存屏障应防止仅出现于多处理器系统上的竞争条件时,就使用smp_xxx;其他的内存屏障防止出现在单处理器和多处理器上的竞争条件。

说明
mb()适用于MP,UP的内存屏障
rmb()适用于MP,UP的读内存屏障
wmb()适用于MP,UP的写内存屏障
smp_mb()适用于MP的内存屏障
smp_rmb()适用于MP的读内存屏障
smp_wmb()适用于MP的写内存屏障

内存屏障原语的实现依赖于系统的体系结构。在80x86微处理器上,如CPU支持lfencermb就展开为asm volatile("lfence")。否则就展开为asm volatile("lock;addl $0,0(%%esp)":::"memory")asm指令告诉编译器插入一些汇编指令并起优化屏障的作用。

Intel上的wmb宏实际上更简单,因为它展开为barrier。这是因为Intel处理器从不对写内存访问重新排序,因此,没必要再在代码中插入一条串行化汇编指令。不过,这个宏禁止编译器重新组合指令。

关于lfence,sfence,mfence,lock的补充解释:sfence之前的写操作一定在sfence完成且全局可见。这包含了顺序性,硬件缓存一致性两种保证。lfence之前的读操作一定在lfence完成。这包含了顺序性保证。

mfence之前的读操作一定在lfence完成,之前的写操作一定在sfence完成且全局可见。lock,自带mfence效果。且锁内存总线。使得期间内存只能被单个cpu访问。

自旋锁

自旋锁的循环指令表示"忙等"。即使等待的内核控制路径无事可做,它也在CPU上保持运行。一般来说,由自旋锁所保护的每个临界区都是禁止内核抢占的。在单处理器系统上,这种锁本身并不起锁的作用,自旋锁原语仅仅是禁止或启用内核抢占。

注意,在自旋锁忙等期间,内核抢占还是有效的。因此,等待自旋锁是否的进程有可能被更高优先级的进程替代。在Linux中,每个自旋锁都用spinlock_t表示, 其中包含两个字段:
(1). slock
该字段表示自旋锁的状态:1表示"未加锁",任何负数和0都表示"加锁"状态
(2). break_lock
表示进程正在忙等自旋锁(只在内核支持SMP和内核抢占下使用该标志)

说明
spin_lock_init()把自旋锁置为1(未锁)
spin_lock()循环,直到自旋锁变为1(未锁),然后,把自旋锁置为0(锁上)
spin_unlock()把自旋锁置为1(未锁)
spin_unlock_wait()等待,直到自旋锁变为1(未锁)
spin_is_locked()如自旋锁被置为1(未锁),返回0;否则,返回1
spin_trylock()把自旋锁置为0(锁上),如原来锁的值是1,则返回1;否则,返回0

具有内核抢占的spin_lock宏

下面的描述都是针对支持SMP系统的抢占式内核。该宏获取自旋锁的地址slp作为它的参数,并执行下面的操作:
(1). 调preempt_disable禁用内核抢占。
(2). 调_raw_spin_trylock,它对自旋锁的slock字段执行原子性测试和设置。

movb $0, %al
xchgb %al, slp->slock

汇编语言指令xchg原子性地交换8位寄存器%al(存0)和slp->slock指示的内存单元的内容。随后,如存放在自旋锁中的旧值(在xchg指令执行后存放在%al中)是正数,函数就返回1,否则返回0
(3). 如自旋锁中旧值是正数,结束:内核控制路径已经获得自旋锁。
(4). 否则,执行循环一直到在其他CPU上运行的内核控制路径释放自旋锁。调preempt_enable递减在第一步递增了的抢占计数器。如在执行spin_lock之前内核抢占被启用,则其他进程此时可能取代等待自旋锁的进程。
(5). 如break_lock字段为0,将其设置为1。通过检测该字段,拥有锁并在其他CPU上运行的进程就可知道是否有其他进程在等待这个锁。如进程把持某个自旋锁的时间太长,它可提前释放锁以使等待相同自旋锁的进程能继续向前运行。
(6). 执行等待循环

while(spin_is_locked(slp) && slp->break_lock)
	cpu_relax();

cpu_relax简化为一条pause汇编语言指令。在Pentiun 4中引入此指令以优化自旋锁循环的执行。pause与早先的80x86微处理器模型是向后兼容的,因为它对应rep;nop
(7). 跳转回1,再次试图获取自旋锁

非抢占式内核中的spin_lock宏

1: lock; decb slp->slock
	jns 3f
2: pause
	cmpb $0, slp->slock
	jle 2b
	jmp 1b
3:

汇编语言指令decb递减自旋锁的值,指令是原子的,因为它带有lock前缀。

spin_unlock宏

movb $1, slp->slock

在随后调preempt_enable(如不支持内核抢占,preempt_enable什么都不干)。因为现在的80x86微处理器总是原子地执行内存中的只写访问,所以不使用lock字节。

读/写自旋锁

每个读/写自旋锁都是一个rwlock_t,其lock字段是一个32位的字段,分为两个不同的部分:
(1). 24位计数器,表示对受保护的数据结构并发地进行读操作的内核控制路径的数目。这个计数器的二进制补码存放在这个字段的0~23位。
(2). "未锁"标志字段,当没有内核控制路径在读或写时设置该位,否则,清0。这个未锁标志存放在lock字段第24位。

注意,如自旋锁为空(设置了"未锁"标志且无读者),则lock字段的值为0x01000000;如写者已经获得自旋锁(未锁标志清0且无读者),则lock字段的值为0x00000000;如果一个,两个或多个进程因为读获取了自旋锁,则lock字段的值为0x00ffffff,0x00fffffe等。

这里的"补码"含义看着是,给定一个目标数值。补码为=0x1000000-目标数值(未锁标志清0,读者个数的二进制补码在0~23位上)。与spinlock_t结构一样,rwlock_t也包括break_lockrwlock_init把读/写自旋锁的lock字段初始化为0x01000000,把break_lock初始化为0

为读获取和释放一个锁

如编译内核时选择了内核抢占选项,read_lock执行与spin_lock相似的操作。只有一点不同:该宏执行_raw_read_trylock以在第2步有效地获取读/写自旋锁。

int _raw_read_trylock(rwlock_t *lock)
{
	atomic_t *count = (atomic_t*)lock->lock;
	atomic_dec(count);
	if(atomic_read(count) >= 0)
		return 1;
	atomic_inc(count);
	return 0;
}

计数器等于0x01000000表示没有任何进程占用锁,等于0x00ffffff表示有一个读者,等于0x00000000表示有一个写者。如编译内核时没有选择内核抢占选项,read_lock宏产生下面的汇编语言代码:

	movl $rwlp->lock, %eax
	lock; subl $1, (%eax)
	jns 1f
	call __read_lock_failed
1:

这里,__read_lock_failed是下列汇编语言函数:
(1). 恢复rwlp->lock
(2). 循环等待rwlp->lock变为>=1
(3). 再次尝试递减rwlp->lock,若递减后结果是负数,再次转入__read_lock_failed。否则,成功获取读锁且更新了读者数。

__read_lock_failed:
	lock; incl (%eax)
1: pause
	cmpl $1, (%eax) # AT&T汇编,(%eax)<1时,SF被设置
	js 1b
	
	lock; decl (%eax)
	js __read_lock_failed
	ret

释放读自旋锁是相当简单的,read_unlock只需用汇编语言指令简单地增加lock字段的计数器。隐含了使用限制:必须先取得读锁后,才能执行释放读锁。

lock; incl rwlp->lock

然后调preempt_enable重新启用内核抢占

为写获取和释放一个锁

write_lock实现的方式与spin_lockread_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,若结果为0,返回true。否则,返回false。
		return 1;
	atomic_add(0x01000000, count);
	return 0;
}

释放写自旋锁同样非常简单,因为write_unlock只需使用汇编语言指令lock; addl $0x01000000, rwlp
lock字段中的"未锁"标识置位,再调用preempt_enable即可。

顺序锁

使用读/写自旋锁时,内核控制路径发出的执行read_lockwrite_lock操作的请求具有相同的优先权:读者必须等待,直到写操作完成。同样的,写者也必须等待,知道读操作完成。

Linux 2.6引入了顺序锁,它与读/写自旋锁相似,只是它为写者赋予了较高的优先级:事实上,即使在读者正在读的时候也允许写者继续运行。这种策略的好处是写者永远不会等待(除非另一个写者正在写),缺点是有些时候读者不得不反复多次读相同的数据直到它获得有效的副本。

每个顺序锁都包括两个字段的seqlock_t结构:
一个类型为spinlock_tlock字段,一个整型的sequence字段,字段2是一个顺序计数器。

每个读者都必须在读数据前后两次读顺序计数器,并检查两次读到的值是否相同。如不同,说明新的写者已经开始写并增加了顺序计数器,因此,暗示读者刚读到的数据是无效的。通过把SEQLOCK_UNLOCKED赋给变量seqlock_t或执行seqlock_init宏,把seqlock_t变量初始化为"未上锁"。

写者通过调write_seqlockwrite_sequnlock获取和释放顺序锁。 保证写者在写的过程中,计数器的值是奇数,且当没有写者在改变数据的时候,计数器的值是偶数。读者执行下面的临界区代码:

unsigned int seq;
do {
	seq = read_seqbegin(&seqlock);
	/* ... */
} while(read_seqretry(&seqlock, seq));

read_seqbegin返回顺序锁的当前顺序号;如局部变量seq的值是奇数(写者在read_seqbegin被调用后,正更新数据结构),或seq的值与顺序锁的顺序计数器的当前值不匹配(当读者正执行临界区代码时,写者开始工作),read_seqretry就返回1

注意,当读者进入临界区时,不必禁用内核抢占;另一方面,由于写者获取自旋锁,所以它进入临界区时自动禁用内核抢占。并不是每一种资源都可使用顺序锁来保护。

一般,必须在满足下述条件时才能使用顺序锁:
(1). 被保护的数据结构不包括被写者修改和被读者间接引用的指针(否则,写者可能在读者的眼鼻下就修改指针)
(20. 读者的临界区代码没副作用(否则,多个读者的操作会与单独的读操作有不同的结果)此外,读者的临界区代码应简短,且写者应不常获取顺序锁。否则,反复的读访问会引起严重的开销。Linux 2.6中,使用顺序锁的典型例子包括保护一些与系统时间处理相关的数据结构。

读-拷贝-更新–实现细节待定

为了保护在多数情况下被多个CPU读的数据结构而设计的另一种同步技术。RCU允许多个读者和写者并发执行(相对于只允许一个写者执行的顺序锁有了改进)。且,RCU是不使用锁的,即它不使用被所有CPU共享的锁或计数器,这一点上与读/写自旋锁和顺序锁(由于高速缓存行窃用和失效而有很高的开销)相比,RCU有更大的优势。

RCU是如何不使用共享数据结构而令人惊讶地实现多个CPU同步呢?
关键的思想包括限制RCU的范围,如下:
(1). RCU只保护被动态分配并通过指针引用的数据结构
(2). 在被RCU保护的临界区中,任何内核控制路径都不能睡眠。当内核控制路径要读取被RCU保护的数据结构时,执行rcu_read_lock,它等同于preempt_disable。接下来,读者间接引用该数据结构指针所对应的内存单元并开始读这个数据结构。如前面所强调的,读者在完成对数据结构的读操作之前,是不能睡眠的。用等同于preempt_enable的宏rcu_read_unlock标记临界区的结束。可想象,由于读者几乎不做任何事情来防止竞争条件的出现,所以写者不得不做的更多一些。事实上,当写者要更新数据结构时,它间接引用指针并生成整个数据结构的副本。接下来,写者修改这个副本。一旦修改完毕,写者改变指向数据结构的指针,以使它指向被修改后的副本。由于修改指针值的操作是一个原子操作,所以旧副本和新副本对每个读者或写者都是可见的,在数据结构中不会出现数据崩溃。

尽管如此,还需内存屏障来保证:
只有在数据结构被修改后,已更新的指针对其他CPU才是可见的。如把自旋锁与RCU结合以禁止写者的并发执行,就隐含地引入了这样的内存屏障。

然后,使用RCU的真正困难在于:写者修改指针时不能立即释放数据结构的旧副本。实际上,写者开始修改时,正在访问数据结构的读者可能还在读旧副本。只有在CPU上的所有(潜在的)读者都执行完宏rcu_read_unlock后,才可释放旧副本。内核要求每个潜在的读者在下面的操作之前执行rcu_read_unlock
(1). CPU执行进程切换
(2). CPU开始在用户态执行
(3). CPU执行空循环
对上述每种情况,我们说CPU已经经过了静止状态。写者调用函数call_rcu来释放数据结构的旧副本。当所有的CPU都通过静止状态之后,call_rcu接受rcu_head描述符(通常嵌在要被释放的数据结构中)的地址和将要调用的回调函数的地址作为参数。一旦回调函数被执行,它通常释放数据结构的旧副本。函数call_rcu把回调函数和其他参数的地址存放在rcu_head描述符,然后把描述符插入回调函数的每CPU链表中。

内核每经过一个时钟滴答就周期性地检查本地CPU是否经过了一个静止状态。如所有CPU都经过了静止状态,本地tasklet(它的描述符存放在每CPU变量rcu_tasklet中)就执行链表中的所有回调函数。RCULinux 2.6中新加的功能,用在网络和虚拟文件系统中。

信号量

实际上,Linux提供两种信号量:
(1).内核信号量,由内核控制路径使用;
(2).System V IPC信号量,由用户态进程使用。内核信号量类似于自旋锁,锁关闭时,不允许内核控制路径继续进行。当内核控制路径试图获取内核信号量所保护的忙资源时,相应的进程被挂起。只有在资源被释放时,进程才再次变为可运行的。因此,只有可睡眠的函数才能获取内核信号量;中断处理程序和可延迟函数都不能使用内核信号量。内核信号量是struct semaphore类型的对象,包含下面这些字段:a. count
存放atomic_t类型的一个值。如该值大于0,则资源就是空闲的。如count等于0,则信号量是忙的,但没进程等待这个被保护的资源。如count为负数,则资源是不可用的,并至少有一个进程等待资源。
b. wait
存放等待队列链表的地址,当前等待资源的所有睡眠进程都放在这个链表中。
c. sleepers
存放一个标志,标识是否有一些进程在信号量上睡眠。

可用init_MUTEXinit_MUTEX_LOCKED来初始化访问所需的信号量:这两个宏分别把count字段设置成1(互斥访问的资源空闲)和0(对信号量进行初始化的进程当前互斥访问的资源忙)。宏DECLARE_MUTEXDECLARE_MUTEX_LOCKED完成同样的功能,但它们也静态分配semaphore结构的变量。注意,也可把信号量中的count初始化为任意的正数值n,这种情况下,最多有n个进程可并发地访问这个资源。

获取和释放信号量

进程希望释放内核信号量锁时,就调用up函数。函数本质等价于

	movl $sem, %ecx
	lock; incl (%ecx)
	jg 1f
	lea %ecx, %eax
	pushl %edx
	pushl %ecx
	call __up
	popl %ecx
	popl %edx
1:

这里的__up是下列c函数:

__attribute__((regparm(3))) void __up(struct semaphore* sem)
{
	wake_up(&sem->wait);
}

当进程希望获取内核信号量锁时,就调用downdown实现比较复杂,但本质上等价于下列代码:

down:
	movl $sem, %ecx
	lock; decl (%ecx);
	jns 1f
	lea %ecx, %eax
	pushl %edx
	pushl %ecx
	call __down
	popl %ecx
	popl %edx
1:

这里的__down是下列C函数:

__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);
	sem->sleepers++;
	for(;;)
	{
		if(!atomic_add_negative(sem->sleepers - 1, &sem->count))
		{
			sem->sleepers = 0;
			break;
		}
		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;
}

要牢记,如没进程在信号量等待队列上睡眠,则信号量的sleepers字段通常置为0,否则置为1。考虑几种典型情况
(1). MUTEX信号量打开(count等于1sleepers等于0
down仅仅把count置为0,并跳到主程序的下一条指令;
(2). MUTEX信号量关闭,没有睡眠进程(count等于0sleepers等于0
downcount并将count字段置为-1,且sleepers置为0来调用__down。在循环体的每次循环中,该函数检查count字段是否为负。(因为当调用atomic_add_negative时,sleepers等于0。因此,atomic_add_negative不改变count字段)
a.如count字段为负,__down就调用schedule挂起当前进程。count字段仍置为-1sleepers字段置为1。随后,进程在这个循环内恢复自己的运行并又进行测试。
b. .如count字段不为负,则把sleepers置为0,并从循环退出。__down试图唤醒信号量等待队列中的另一个进程,并终止保持的信号量。在退出时,count字段和sleepers字段都置为0,这表示信号量关闭且没有进程等待信号量。
(3). MUTEX信号量关闭,有其他睡眠进程(count等于-1sleepers等于1
downcount并将count字段置为-2,且sleepers字段置为1来调用__down函数。该函数暂时把sleepers置为2,然后通过把sleepers-1加到count来取消由down执行的减操作。同时,该函数检查count是否依然为负(在__down进入临界区之前,持有信号量的进程可能正好释放了信号量)。
a. 如count字段为负,__downsleepers重新置为1,并调schedule挂起当前进程。count字段还是置为-1,而sleepers字段置为1
b. 如count字段不为负,__downsleepers置为0,试图唤醒信号量等待队列上的另一个进程,并退出持有的信号量。退出时,count字段置为0sleepers字段置为0。考虑下,在等待队列上的另一个进程已被唤醒,这个进程进行循环体的另一次循环;atomic_add_negativecount中减去1count重新变为-1;唤醒的进程在重新回去睡眠前,把sleepers重新置为1

考虑下,__down中的wake_up至多唤醒一个进程,因为等待队列中的睡眠进程是互斥的。只有异常处理程序,特别是系统调用服务例程,才可调down函数。中断处理程序或可延迟的函数不必调down,因为当信号量忙时,这个函数挂起进程。由于此,Linux提供了down_trylock,前面提及的异步函数可安全地使用down_trylock。该函数和down函数除了对资源繁忙情况的处理有所不同外,其他是相同的。在资源繁忙时,该函数会立即返回,而不是让进程去睡眠。系统中还定义了一个略有不同函数,down_interruptible。该函数广泛地用在设备驱动程序中,如进程接受了一个信号但在信号量上被阻塞,就允许进程放弃"down"。如睡眠进程在获取所需的资源之前被一个信号唤醒,则该函数会增加信号量的count字段的值并返回-EINTR。另一方面,如down_interruptible正常结束并得到了所需的资源,就返回0。因此,在返回值是-EINTR时,设备驱动程序可放弃I/O操作。最后,因为进程通常发现信号量处于打开状态,因此,就可优化信号量函数。尤其是,如信号量等待队列为空,up就不执行跳转指令;同样,如信号量是打开的,down就不执行跳转指令。信号量实现的复杂性是由于极力在执行流的主分支上避免费时的指令造成的。

考虑:
初始数量为1的信号量sem
(1). A执行P(sem),进入临界区。sem->count变为0
(2). B执行P(sem)。加入等待链表。此前sem->sleepers0,故而sem->count变为-1sem->sleepers变为1。放弃调度。
(3). C执行P(sem)。加入等待链表。此前sem->sleepers1,故而sem->count变为-1sem->sleepers变为1。放弃调度。
(4). D执行P(sem)。加入等待链表。此前sem->sleepers1,故而sem->count变为-1sem->sleepers变为1。放弃调度。
(5). A执行V(sem)sem->count变为0,执行__up唤醒BB被唤醒后,再次循环。
此时sem->sleepers1sem->count0,执行检测后,满足唤醒条件。故而sem->sleepers变为0,将B从等待链表移除。继续唤醒等待链表的CB进入临界区运行。C被唤醒后,再次循环。此时sem->sleepers0sem->count0,执行检测后,不满足唤醒条件。故而,而sem->count变为-1sem->sleepers变为1。放弃调度。综合上述流程下,信号量可正常履行其职责。

信号量互斥保护部分,保证了。存在执行体阻塞等待此信号量时,sem->count变为-1sem->sleepers变为1。当信号量使用者释放信号量引起sem->count变为0,将唤醒一个等待者。其余等待者继续等待。

读/写信号量–实现细节待定

读/写信号量类似于前面"读/写自旋锁",有一点不同:在信号量再次变为打开之前,等待进程挂起而不是自旋。很多内核控制路径为读可以并发地获取读/写信号量。但,任何写者内核控制路径必须有对被保护资源的互斥访问。故,只有在没有内核控制为读访问或写访问持有信号量时,才可为写获取信号量。读/写信号量可提高内核中的并发度,并改善整个系统的性能。每个读/写信号量都是由rw_semaphore结构描述的,它包含下列字段:

(1). count
存放两个16位的计数器。其中最高16位计数器以二进制补码形式存放非等待写者进程的总数(01)和等待的写内核控制路径数。最低16位计数器存放非等待的读者和写者进程的总数。
(2). wait_list
指向等待进程的链表。链表中的每个元素都是一个rwsem_waiter结构,该结构包含一个指针和一个标志,指针指向睡眠进程的描述符,标志表示进程是为读需要信号量还是为写需要信号量。
(3). wait_lock
一个自旋锁。用于保护等待队列链表和rw_semaphore结构本身。

init_rwsem初始化rw_semaphoredown_readdown_write分别为读或写获取读/写信号量。同样,up_readup_write为读或写释放以前获取的读/写信号量。down_read_trylock,down_write_trylock分别类似于down_read,down_write,但在信号量忙时,它们不阻塞进程。downgrade_write自动把写锁转换成读锁。

补充原语

struct completion{
	unsigned int done;
	wait_queue_head_t wait;
};

void fastcall __sched wait_for_completion(struct completion *x)
{
	might_sleep();
	spin_lock_irq(&x->wait.lock);
	if (!x->done) {
		DECLARE_WAITQUEUE(wait, current);
		wait.flags |= WQ_FLAG_EXCLUSIVE;
		__add_wait_queue_tail(&x->wait, &wait);
		do {
			__set_current_state(TASK_UNINTERRUPTIBLE);
			spin_unlock_irq(&x->wait.lock);
			schedule();
			spin_lock_irq(&x->wait.lock);
		} while (!x->done);
		__remove_wait_queue(&x->wait, &wait);
	}
	x->done--;
	spin_unlock_irq(&x->wait.lock);
}

void fastcall complete(struct completion *x)
{
	unsigned long flags;
	spin_lock_irqsave(&x->wait.lock, flags);
	x->done++;
	__wake_up_common(&x->wait, TASK_UNINTERRUPTIBLE | TASK_INTERRUPTIBLE,1, 0, NULL);
	spin_unlock_irqrestore(&x->wait.lock, flags);
}

禁止本地中断

禁止本地中断并不保护运行在另一个CPU上的中断处理程序对数据结构的并发访问,因此,多处理器系统上,禁止本地中断经常与自旋锁结合使用。宏local_irq_disable使用cli关闭本地CPU上的中断。宏local_irq_enable使用sti打开被关闭的中断。保持和恢复eflags的内容是分别通过local_irq_savelocal_irq_restore来实现。local_irq_saveeflags的内容拷贝到一个局部变量中,随后用cliIF标志清0。在临界区的末尾,宏local_irq_restore恢复eflags内容。

禁止和激活可延迟函数

必须保护可延迟函数访问的数据结构使其避免竞争条件。禁止可延迟函数在一个CPU上执行的一种简单方式就是禁止在那个CPU上的中断。因为,没有中断处理程序被激活,软中断操作就不能异步地开始。然而,内核有时需只禁止可延迟函数而不禁止中断。通过操纵当前thread_infopreempt_count字段中存放的软中断计数器,可以在本地CPU上激活或禁止可延迟函数。如软中断计数器是正数,do_softirq就不会执行软中断,且,因为tasklet在软中断之前被执行,把这个计数器设置为大于0的值,由此禁止了在给定CPU上的所有可延迟函数和软中断的执行。

local_bh_disable给本地CPU的软中断计数器加1local_bh_enable从本地CPU的软中断计数器中减掉1。递减软中断计数器之后,local_bh_enable执行两个重要的操作以助于保证适时地执行长时间等待的线程:
(1). 检查本地CPUpreempt_count字段中硬中断计数器和软中断计数器。
如这两个计数器的值都等于0且有挂起的软中断要执行,就调do_softirq来激活这些软中断。
(2). 检查本地CPUTIF_NEED_RESCHED标志是否被设置。
如是,说明进程切换请求是挂起的,因此调preempt_schedulepreempt_schedule 是一个 Linux 内核中的函数,用于在抢占式调度(preemptive scheduling)中执行调度操作。

对内核数据结构的同步访问

通常,内核开发者采用下述由经验得到的法则:把系统中并发度保持在尽量高的程度。系统中的并发度取决于两个主要因素:
(1). 同时运转的I/O设备数
(2). 进行有效工作的CPU数。

为了使I/O吞吐量最大化,应使中断禁止保持在很短的时间。当中断被禁止时,由I/O设备产生的IRQPIC暂时忽略,因此,就没有新的活动在这种设备上开始。为有效地利用CPU,应尽可能避免使用基于自旋锁的同步原语。更糟糕的是:由于自旋锁对硬件高速缓存的影响而使其对系统的整体性能产生不利影响。

让我们举例在下列两种情况下,既可维持较高的并发度,也可达到同步:
(1). 共享的数据结构是一个单独的整数值。
可把它声明为atomic_t类型并使用原子操作对其更新。 原子操作比自旋锁和中断禁止都快,只有在几个内核控制路径同时访问这个数据结构时速度才会慢下来。
(2). 把一个元素插入到共享链表的操作决不是原子的。
不过内核有时并不用锁或禁止中断就可执行这种插入操作。我们把这种操作的工作机制作为例子来进行说明。考虑一种情况,系统调用服务例程把新元素插入到一个简单链表中,而中断处理程序或可延迟函数异步地查看该链表。

C语言中,插入是通过下列指针赋值实现的:

new->next = list_element->next;
list_element->next = new;

在汇编语言中,插入简化为两个连续的原子指令。第一条指令建立new元素的next指针,但不修改链表。因此,如中断处理程序在第一条指令和第二条指令执行的中间查看这个链表,看到的就是没有新元素的链表。如该处理程序在第二条指令执行后查看链表,看到的就是有新元素的链表。关键是,任何一种情况下,链表都是一致且处于未损坏状态。然而,只有在中断处理程序不支持修改链表下才能确保这种完整性。如修改了链表,则在new元素内刚刚设置的next指针就可能变为无效的。然而,开发者必须确保两个赋值操作的顺序不被编译器或CPU控制单元搅乱;否则,如中断处理程序在两个赋值之间中断了系统调用服务例程,处理程序就会看到一个损坏的链表。因此,就需写一个内存屏障原语:

new->next = list_element->next;
wmb();
list_element->next = new;

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

一般来说,同步原语的选取取决于访问数据结构的内核控制路径的种类。记住,只要内核控制路径获得自旋锁(还有读/写锁,顺序锁,RCU),就禁止本地中断或本地软中断,自动禁用内核抢占。

访问数据结构的内核控制路径单处理器保护多处理器保护
异常信号量
中断本地中断禁止自旋锁
可延迟函数无或自旋锁
异常与中断本地中断禁止自旋锁
异常与可延迟函数本地软中断禁止自旋锁
中断与可延迟函数本地中断禁止自旋锁
异常,中断,可延迟函数本地中断禁止自旋锁

保护异常所访问的数据结构

当一个数据结构仅由异常处理程序访问时,竞争条件可通过信号量避免。因为信号量原语允许进程睡眠到资源变为可用。注意,信号量的工作方式在单处理器系统和多处理器系统上完全相同。内核抢占不会引起太大的问题。如一个拥有信号量的进程是可以被抢占的,运行在同一个CPU上的新进程就可能试图获得这个信号量。这种情况下,让新进程处于睡眠状态,且原来拥有信号量的进程最终会释放信号量。只有在访问每CPU变量的情况下,必须显式地禁用内核抢占。因为内核抢占可能让一个每cpu变量先后被两个执行体写。前一执行体恢复执行后,看到的将不是自己初始写的内容。

保护中断所访问的数据结构

假定一个数据结构仅被中断处理程序的"上半部分"访问。中断处理程序本身不能同时多次运行。因此,访问数据结构就无需任何同步原语。但是,如多个中断处理程序访问同一个数据结构,情况就不同了。一个处理程序可中断另一个处理程序,不同的中断处理程序可在多处理器系统上同时运行。在单处理器系统上,必须通过在中断处理程序的所有临界区上禁止中断来避免竞争条件。多处理器系统的要求甚至更加苛刻。不能简单地通过禁止本地中断来避免竞争条件。事实上,即使在一个CPU上禁止了中断,中断处理程序还可在其他CPU上执行。避免竞争条件最简单的方法是禁止本地中断,并获取保护数据结构的自旋锁、读/写自旋锁。

Linux内核使用了几个宏,把本地中断激活/禁止与自旋锁结合起来。单处理器系统上,这些宏仅激活或禁止本地中断和内核抢占。

保护可延迟函数所访问的数据结构

首先,在单处理器系统上不存在竞争条件。这是因为可延迟函数的执行总是在一个CPU上串行进行。即一个可延迟函数不会被另一个可延迟函数中断。多处理器系统上,竞争条件的确存在,因为几个可延迟函数可并发执行。

访问数据结构的可延迟函数保护
软中断自旋锁
一个tasklet
多个tasklet自旋锁

保护由异常和中断访问的数据结构

现在考虑下由异常处理程序(例如系统调用服务例程)和中断处理程序访问的数据结构。单处理器系统上,竞争条件的防止是简单的。因为中断处理程序不是可重入且不能被异常中断。只要内核以本地中断禁止访问数据结构,内核访问数据结构过程就不会被中断。

多处理器系统上,必须关注异常和中断在其他CPU上的并发执行。本地中断禁止还必须加自旋锁,强制并发的内核控制路径进行等待,直到访问数据结构的处理程序完成自己的工作。有时,用信号量代替自旋锁可能更好。

保护由异常和可延迟函数访问的数据结构

可延迟函数本质上是由中断的出现激活的,而可延迟函数执行时不可能产生异常。因此,把本地中断禁止和自旋锁结合就够了。异常处理程序可通过local_bh_disable禁止可延迟函数,而不禁止本地中断。仅禁止可延迟函数比禁止中断更可取,因为中断还可继续在CPU上得到服务。在每个CPU上可延迟函数的执行都被串行化,因此,不存在竞争条件。多处理器系统上,要用自旋锁确保任何时候,只有一个内核控制路径访问数据结构。

保护由中断和可延迟函数访问的数据结构

可延迟函数运行时可能产生中断,但是,可延迟函数不能阻止中断处理程序。因此,需通过可延迟函数执行期间禁用本地中断来避免竞争。不过,中断处理程序可随意访问被可延迟函数访问的数据结构而不用关中断,前提是没有其他的中断处理程序访问这个数据结构。在多处理器系统上,还是需自旋锁禁止对多个CPU上数据结构的并发访问。

保护由异常,中断,可延迟函数访问的数据结构

禁止本地中断,获取自旋锁。当中断处理程序终止执行时,可延迟函数才能被实质激活。

避免竞争条件实例

引用计数器

当内核控制路径开始使用资源时就原子地减少计数器的值,
当内核控制路径使用完资源时就原子地增加计数器。
当引用计数器变为0时,如有必要,就释放资源。

大内核锁

每个进程描述符都含有lock_depth,这个字段允许同一个进程几次获取大内核锁。因此,对大内核锁两次连续的请求不挂起处理器。如进程未获得过锁,则这个字段值为-1;否则,这个字段值加1,表示已经请求了多少次锁。如没有这个字段,在当前进程已经拥有大内核锁的情况下,任何试图获得这个锁的异步函数都可能产生死锁。lock_kernelunlock_kernel用来获得和释放大内核锁。前一个等价于:

depth = current->lock_depth + 1;
if(depth == 0)
{
	down(&kernel_sem);
}

current->lock_depth = depth;

后者等价于

if(--current->lock_depth < 0)
{
	up(&kernel_sem);
}

允许一个持有大内核锁的进程调shedule,从而放弃CPU。不过,schedule检查被替换进程的lock_depth,如它的值是0或正数,就自动释放kernel_sem信号量。因此,不会有显式调用schedule的进程在进程切换前后都保持大内核锁。但,当schedule再次选择这个进程来执行时候,将为该进程重新获得大内核锁。

如一个持有大内核锁的进程被另一个进程抢占,情况就不同了。现在大内核锁的实现是基于信号量的,且不会由于获得它而自动禁用内核抢占。实际上,在被大内核锁保护的临界区内允许内核抢占是改变大内核锁实现的主要原语。其次,这对于系统的响应时间会产生有益的影响。

当一个持有大内核锁的进程被抢占时,schedule一定不能释放信号量。因为在临界区内执行代码的进程没主动触发进程切换。所以,如释放大内核锁,则另一个进程就可获得它,并破坏由被抢占进程所访问的数据结构。

为避免被抢占进程失去大内核锁,preempt_schedule_irq临时把进程的lock_depth字段置为-1。观察这个字段的值,schedule假定被替换的进程不拥有kernel_sem信号量,也就不释放它。结果,被抢占的进程就一直拥有kernel_sem信号量。一旦这个进程再次被调度程序选中,preempt_schedule_irq就恢复lock_depth字段原来的值,并让进程在被大内核锁保护的临界区中继续运行。

内存描述符读/写信号量

mm_structmmap_sem字段中都包含了自己的信号量。由于几个轻量级进程间可共享一个内存描述符,因此,信号量保护这个描述符以避免可能产生的竞争条件。

slab高速缓存链表的信号量

slab高速缓存描述符链表通过cache_chain_sem保护的,这个信号量允许互斥地访问和修改该链表。

索引节点的信号量

程序使用了两个或多个信号量时,为避免死锁,信号量的请求需按预先确定的地址顺序进行。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
Preface The Audience for This Book Organization of the Material Level of Description Overview of the Book Background Information Conventions in This Book How to Contact Us Safari? Enabled Acknowledgments Chapter 1. Introduction Section 1.1. Linux Versus Other Unix-Like Kernels Section 1.2. Hardware Dependency Section 1.3. Linux Versions Section 1.4. Basic Operating System Concepts Section 1.5. An Overview of the Unix Filesystem Section 1.6. An Overview of Unix Kernels Chapter 2. Memory Addressing Section 2.1. Memory Addresses Section 2.2. Segmentation in Hardware Section 2.3. Segmentation in Linux Section 2.4. Paging in Hardware Section 2.5. Paging in Linux Chapter 3. Processes Section 3.1. Processes, Lightweight Processes, and Threads Section 3.2. Process Descriptor Section 3.3. Process Switch Section 3.4. Creating Processes Section 3.5. Destroying Processes Chapter 4. Interrupts and Exceptions Section 4.1. The Role of Interrupt Signals Section 4.2. Interrupts and Exceptions Section 4.3. Nested Execution of Exception and Interrupt Handlers Section 4.4. Initializing the Interrupt Descriptor Table Section 4.5. Exception Handling Section 4.6. Interrupt Handling Section 4.7. Softirqs and Tasklets Section 4.8. Work Queues Section 4.9. Returning from Interrupts and Exceptions Chapter 5. Kernel Synchronization Section 5.1. How the Kernel Services Requests Section 5.2. Synchronization Primitives Section 5.3. Synchronizing Accesses to Kernel Data Structures Section 5.4. Examples of Race Condition Prevention Chapter 6. Timing Measurements Section 6.1. Clock and Timer Circuits Section 6.2. The Linux Timekeeping Architecture Section 6.3. Updating the Time and Date Section 6.4. Updating System Statistics Section 6.5. Software Timers and Delay Functions Section 6.6. System Calls Related to Timing Measurements Chapter 7. Process Scheduling Section 7.1. Scheduling Policy Section 7.2. The Scheduling Algorithm Section 7.3. Data Structures Used by the Scheduler Section 7.4. Functions Used by the Scheduler Section 7.5. Runqueue Balancing in Multiprocessor Systems Section 7.6. System Calls Related to Scheduling Chapter 8. Memory Management Section 8.1. Page Frame Management Section 8.2. Memory Area Management Section 8.3. Noncontiguous Memory Area Management Chapter 9. Process Address Space Section 9.1. The Processs Address Space Section 9.2. The Memory Descriptor Section 9.3. Memory Regions Section 9.4. Page Fault Exception Handler Section 9.5. Creating and Deleting a Process Address Space Section 9.6. Managing the Heap Chapter 10. System Calls Section 10.1. POSIX APIs and System Calls Section 10.2. System Call Handler and Service Routines Section 10.3. Entering and Exiting a System Call Section 10.4. Parameter Passing Section 10.5. Kernel Wrapper Routines Chapter 11. Signals Section 11.1. The Role of Signals Section 11.2. Generating a Signal Section 11.3. Delivering a Signal Section 11.4. System Calls Related to Signal Handling Chapter 12. The Virtual Filesystem Section 12.1. The Role of the Virtual Filesystem (VFS) Section 12.2. VFS Data Structures Section 12.3. Filesystem Types Section 12.4. Filesystem Handling Section 12.5. Pathname Lookup Section 12.6. Implementations of VFS System Calls Section 12.7. File Locking Chapter 13. I/O Architecture and Device Drivers Section 13.1. I/O Architecture Section 13.2. The Device Driver Model Section 13.3. Device Files Section 13.4. Device Drivers Section 13.5. Character Device Drivers Chapter 14. Block Device Drivers Section 14.1. Block Devices Handling Section 14.2. The Generic Block Layer Section 14.3. The I/O Scheduler Section 14.4. Block Device Drivers Section 14.5. Opening a Block Device File Chapter 15. The Page Cache Section 15.1. The Page Cache Section 15.2. Storing Blocks in the Page Cache Section 15.3. Writing Dirty Pages to Disk Section 15.4. The sync( ), fsync( ), and fdatasync( ) System Calls Chapter 16. Accessing Files Section 16.1. Reading and Writing a File Section 16.2. Memory Mapping Section 16.3. Direct I/O Transfers Section 16.4. Asynchronous I/O Chapter 17. Page Frame Reclaiming Section 17.1. The Page Frame Reclaiming Algorithm Section 17.2. Reverse Mapping Section 17.3. Implementing the PFRA Section 17.4. Swapping Chapter 18. The Ext2 and Ext3 Filesystems Section 18.1. General Characteristics of Ext2 Section 18.2. Ext2 Disk Data Structures Section 18.3. Ext2 Memory Data Structures Section 18.4. Creating the Ext2 Filesystem Section 18.5. Ext2 Methods Section 18.6. Managing Ext2 Disk Space Section 18.7. The Ext3 Filesystem Chapter 19. Process Communication Section 19.1. Pipes Section 19.2. FIFOs Section 19.3. System V IPC Section 19.4. POSIX Message Queues Chapter 20. Program ExZecution Section 20.1. Executable Files Section 20.2. Executable Formats Section 20.3. Execution Domains Section 20.4. The exec Functions Appendix A. System Startup Section A.1. Prehistoric Age: the BIOS Section A.2. Ancient Age: the Boot Loader Section A.3. Middle Ages: the setup( ) Function Section A.4. Renaissance: the startup_32( ) Functions Section A.5. Modern Age: the start_kernel( ) Function Appendix B. Modules Section B.1. To Be (a Module) or Not to Be? Section B.2. Module Implementation Section B.3. Linking and Unlinking Modules Section B.4. Linking Modules on Demand Bibliography Books on Unix Kernels Books on the Linux Kernel Books on PC Architecture and Technical Manuals on Intel Microprocessors Other Online Documentation Sources Research Papers Related to Linux Development About the Authors Colophon Index
Linux内核中的无(lock-free)技术主要用于实现高效的并发数据结构,以提高系统的性能和吞吐量。其中,无环形缓冲区(lock-free ring buffer)是一种常用的数据结构,它可以高效地实现在多个线程之间传递数据的功能。 无环形缓冲区的实现原理如下: 1. 环形缓冲区的数据结构:无环形缓冲区由一个固定大小的环形数组和两个指针构成,一个是读指针,一个是写指针。读指针指向下一个将要读取的元素,写指针指向下一个将要写入的元素。 2. 原子操作:无环形缓冲区的实现依赖于原子操作(atomic operations),这些操作是在单个CPU指令中执行的,不会被其他线程中断。在Linux内核中,原子操作是通过宏定义实现的,如“atomic_add()”、“atomic_sub()”等。 3. 写入数据:当一个线程想要写入数据时,它首先需要检查缓冲区是否已满。如果缓冲区已满,则写入操作失败。如果缓冲区未满,则该线程会使用原子操作将数据写入缓冲区,并更新写指针。 4. 读取数据:当一个线程想要读取数据时,它首先需要检查缓冲区是否为空。如果缓冲区为空,则读取操作失败。如果缓冲区不为空,则该线程会使用原子操作将数据从缓冲区中读取,并更新读指针。 5. 线程同步:无环形缓冲区的实现不依赖于任何机制,因此可以避免竞争和死等问题。不过,在多个线程并发读写的情况下,需要使用一些同步机制来保证线程安全,如使用原子操作或者memory barrier等技术。 总的来说,无环形缓冲区是一种高效的并发数据结构,能够在多个线程之间高效地传递数据,提高系统的性能和吞吐量。在Linux内核中,无环形缓冲区的实现依赖于原子操作和线程同步技术,可以避免竞争和死等问题。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

raindayinrain

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值