一文吃透Linux同步管理,让你的系统稳如泰山
在当今的计算机世界中,多任务处理和多核处理器已成为标配。无论是在服务器端处理大量并发请求,还是在嵌入式系统中高效运行多个任务,Linux 操作系统都扮演着至关重要的角色。而在这复杂的多任务、多核环境下,Linux 的同步管理机制就如同精密仪器中的校准装置,对数据一致性和系统稳定性起着关键作用。
想象一下,多个进程或线程如同忙碌的工人,同时对共享资源进行操作。如果没有有效的同步管理,就好比施工现场没有指挥,工人各自为政,必然会导致资源竞争、数据不一致等问题。比如在一个多线程的数据库应用中,若多个线程同时对数据库进行读写操作而没有同步机制,可能会导致数据失、脏读等严重问题,进而影响整个系统的正常运行 。再比如在服务器端,多个用户的请求并发到达,若不能对这些请求进行有序处理,很可能会导致服务器响应混乱,甚至崩溃。
Linux 同步管理机制通过一系列的策略和方法,协调多个进程或线程对共享资源的访问,防止出现竞态条件、死锁等问题。它就像是一位经验丰富的交通警察,在复杂的交通路口指挥车辆有序通行,确保每个 “车辆”(进程或线程)都能安全、高效地到达目的地,从而维护数据的一致性和系统的整体运行秩序。在多核和多处理器系统中,良好的同步机制更是能够高效地管理资源访问,避免缓存一致性问题和伪共享问题,对提升系统性能起着至关重要的作用。接下来,让我们深入探讨 Linux 同步管理的具体方法和实现。
一、Linux 同步管理基础概念
在 Linux 系统中,同步机制是用于实现控制多个执行路径按照一定的规则或顺序访问某些系统资源的机制 。这里所说的执行路径,指的是在 CPU 上运行的代码流。我们知道,CPU 调度的最小单位是线程,它既可以是用户态线程,也可以是内核线程,甚至中断服务程序也包含在内。
简单来说,同步机制就像是一场精心安排的音乐会,每个演奏者(执行路径)都需要按照既定的乐谱(规则)在合适的时间演奏,以确保整个音乐(系统资源访问)和谐有序 。例如,在一个多线程的文件读写程序中,多个线程都可能尝试对同一个文件进行读写操作。如果没有同步机制,这些线程可能会同时修改文件内容,导致数据混乱。而通过同步机制,我们可以确保在同一时刻只有一个线程能够对文件进行写入操作,其他线程需要等待,从而保证文件数据的一致性 。
(1)并发与竞态
并发是指两个以上的执行路径同时被执行,而并发的执行路径对共享资源(硬件资源和软件上的全局变量等)的访问则很容易导致竞态。例如,现在系统有一个 LED 灯可以由 APP 控制,APP1 控制灯亮一秒灭一秒,APP2 控制灯亮 500ms 灭 1500ms。如果 APP1 和 APP2 分别在 CPU1 和 CPU2 上并发运行,LED 灯的行为会是什么样的呢?很有可能 LED 灯的亮灭节奏都不会如这两个 APP 所愿,APP1 在关掉 LED 灯时,很有可能恰逢 APP2 正要打开 LED 灯。很明显,APP1 和 APP2 对 LED 灯这个资源产生了竞争关系。竞态是危险的,如果不加以约束,轻则只是程序运行结果不符合预期,重则系统崩溃。
在操作系统中,更复杂、更混乱的并发大量存在,而同步机制正是为了解决并发和竞态问题。同步机制通过保护临界区(访问共享资源的代码区域)达到对共享资源互斥访问的目的,所谓互斥访问,是指一个执行路径在访问共享资源时,另一个执行路径被禁止去访问。关于并发与竞态,有个生活例子很贴切。假如你和你的同事张小三都要上厕所,但是公司只有一个洗手间而且也只有一个坑。当张小三进入厕所关起门的那一刻起,你就无法进去了,只能在门外侯着。
当小三哥出来后你才能进去解决你的问题。这里,公司厕所就是共享资源,你和张小三同时需要这个共享资源就是并发,你们对厕所的使用需求就构成了竞态,而厕所的门就是一种同步机制,他在用你就不能用了。
总结如下图:
图片
(2)中断与抢占
中断是指计算机在执行程序的过程中,当出现某些紧急事件时,暂时停止当前程序的执行,转去处理这些事件,处理完毕后再返回原来的程序继续执行 。比如,当有新的数据到达网络接口卡时,会产生一个中断信号,通知 CPU 进行处理 。抢占则属于进程调度的概念,Linux 内核从 2.6 版本开始支持抢占调度。通俗地讲,抢占就是一个正在 CPU 上愉快运行的任务(可以是用户态进程,也可以是内核线程)被另一个通常是更高优先级的任务夺去 CPU 执行权 。中断和抢占之间有着密切的关系,抢占依赖中断 。
如果当前 CPU 禁止了本地中断,那么也就意味着禁止了本 CPU 上的抢占。但反过来,禁掉抢占并不影响中断 。例如,在一个实时控制系统中,可能会有一些高优先级的中断任务需要立即处理。当这些中断发生时,CPU 会暂停当前正在执行的任务,转而执行中断处理程序,这就体现了中断对任务执行的影响。而抢占机制则确保了高优先级的任务能够及时获得 CPU 资源,提高系统的响应性能 。
用户态抢占:
从系统调用返回用户空间时;从中断(异常)处理程序返回用户空间时。内核态抢占:
当一个中断处理程序退出,返回到内核态时;task 显式调用 schedule();task 发生阻塞(此时由调度器完成调度)。在 Linux 系统中,为了确保多任务环境下数据的一致性和系统的稳定性,提供了多种同步管理方法,每种方法都有其独特的应用场景和实现原理。下面将详细介绍原子操作、内存屏障、自旋锁、信号量、互斥锁和 RCU(读 - 复制 - 更新)等常见的同步机制。
二、原子操作(Atomic Operation)
所谓原子操作,就是该操作绝不会在执行完毕前被任何其他任务或事件打断,也就说,它的最小的执行单位,不可能有比它更小的执行单位,因此这里的原子实际是使用了物理学里的物质微粒的概念。
原子操作需要硬件的支持,因此是架构相关的,其API和原子类型的定义都定义在内核源码树的include/asm/atomic.h文件中,它们都使用汇编语言实现,因为C语言并不能实现这样的操作。
原子操作主要用于实现资源计数,很多引用计数(refcnt)就是通过原子操作实现的。原子类型定义如下:
volatile修饰字段告诉gcc不要对该类型的数据做优化处理,对它的访问都是对内存的访问,而不是对寄存器的访问。
原子操作API包括:
tomic_read(atomic_t * v);该函数对原子类型的变量进行原子读操作,它返回原子类型的变量v的值。atomic_set(atomic_t * v, int i);该函数设置原子类型的变量v的值为i。void atomic_add(int i, atomic_t *v);该函数给原子类型的变量v增加值i。atomic_sub(int i, atomic_t *v);该函数从原子类型的变量v中减去i。int atomic_sub_and_test(int i, atomic_t *v);该函数从原子类型的变量v中减去i,并判断结果是否为0,如果为0,返回真,否则返回假。void atomic_inc(atomic_t *v);该函数对原子类型变量v原子地增加1。void atomic_dec(atomic_t *v);该函数对原子类型的变量v原子地减1。int atomic_dec_and_test(atomic_t *v);该函数对原子类型的变量v原子地减1,并判断结果是否为0,如果为0,返回真,否则返回假。int atomic_inc_and_test(atomic_t *v);该函数对原子类型的变量v原子地增加1,并判断结果是否为0,如果为0,返回真,否则返回假。int atomic_add_negative(int i, atomic_t *v);该函数对原子类型的变量v原子地增加I,并判断结果是否为负数,如果是,返回真,否则返回假。int atomic_add_return(int i, atomic_t *v);该函数对原子类型的变量v原子地增加i,并且返回指向v的指针。int atomic_sub_return(int i, atomic_t *v);该函数从原子类型的变量v中减去i,并且返回指向v的指针。int atomic_inc_return(atomic_t * v);该函数对原子类型的变量v原子地增加1并且返回指向v的指针。int atomic_dec_return(atomic_t * v);该函数对原子类型的变量v原子地减1并且返回指向v的指针。原子操作通常用于实现资源的引用计数,在TCP/IP协议栈的IP碎片处理中,就使用了引用计数,碎片队列结构struct ipq描述了一个IP碎片,字段refcnt就是引用计数器,它的类型为atomic_t,当创建IP碎片时(在函数ip_frag_create中),使用atomic_set函数把它设置为1,当引用该IP碎片时,就使用函数atomic_inc把引用计数加1。
当不需要引用该IP碎片时,就使用函数ipq_put来释放该IP碎片,ipq_put使用函数atomic_dec_and_test把引用计数减1并判断引用计数是否为0,如果是就释放IP碎片。函数ipq_kill把IP碎片从ipq队列中删除,并把该删除的IP碎片的引用计数减1(通过使用函数atomic_dec实现)。
三、内存屏障(Memory Barrier)
内存屏障是一种重要的同步机制,它主要用于解决内存乱序访问的问题 。在 ARM 架构中,存在 3 类内存屏障指令,分别是数据存储器隔离(Data Memory Barrier,DMB)、数据同步隔离(Data Synchronization Barrier,DSB)和指令同步隔离(Instruction Synchronization Barrier,ISB) 。DMB 指令保证仅当所有在它前面的存储器访问操作都执行完毕后,才提交在它后面的存储器访问操作 。也就是说,DMB 指令确保了在它之前和之后的内存访问操作的顺序性,但不保证内存访问的完成顺序 。
DSB 指令比 DMB 指令更加严格,仅当所有在它前面的存储器访问操作都执行完毕后,才会执行在它后面的指令 。这意味着,任何指令都要等待 DSB 前面的存储访问完成,包括缓存操作等 。ISB 指令则是最严格的同步指令,它会清洗流水线,以保证所有它前面的指令都执行完毕之后,才执行它后面的指令 。在 Linux 内核中,也提供了一些内存屏障函数,如 barrier、mb、rmb、wmb 等 。
barrier 函数用于阻止编译器对内存访问指令进行重排 ,它告诉编译器不要为了性能优化而将 barrier 前后的代码顺序打乱 。mb 函数则同时具备读内存屏障和写内存屏障的功能,它确保了在 mb 之前的内存访问操作都完成后,才会执行在 mb 之后的内存访问操作 。rmb 函数是读内存屏障,保证了在 rmb 之前的读操作都完成后,才会执行在 rmb 之后的读操作 。wmb 函数是写内存屏障,确保了在 wmb 之前的写操作都完成后,才会执行在 wmb 之后的写操作 。
这些内存屏障函数在多处理器系统中,对于保证内存访问的顺序性和数据的一致性起着至关重要的作用 。例如,在多线程编程中,当一个线程修改了共享变量的值后,使用 wmb 函数可以确保其他线程能够正确地读取到这个修改后的值,避免了由于内存乱序访问而导致的数据不一致问题 。
在 Linux 系统中,根据其作用和功能,内存屏障主要分为以下三种类型:
①全屏障(Full Barrier)
全屏障,也称作强内存屏障 ,它的功能最为强大。全屏障可以阻止屏障两边的读写操作进行重排序,确保在屏障之前的所有读写操作,都在屏障之后的读写操作之前完成。在 x86 架构中,全屏障的实现指令是mfence 。当 CPU 执行到mfence指令时,会将之前所有的存储和加载操作都按顺序完成,才会继续执行后面的指令。例如:
在这个例子中,由于mfence全屏障的存在,线程 1 中x = 1的写操作一定会在线程 2 读取y的值之前完成,从而保证了线程 2 在读取y为 2 时,x的值也已经被正确地更新为 1,避免了由于指令重排序导致的数据不一致问题。全屏障在需要严格保证内存操作顺序的场景中非常有用,比如在实现一些关键的同步机制或者对共享资源的复杂操作时。
②读取屏障(Read/Load Barrier)
读取屏障的作用是确保在该屏障之前的所有读取操作,必须在该屏障之后的读取操作之前完成 。它主要用于控制读取操作的顺序,防止读取操作的重排序。在 x86 架构中,读取屏障对应的指令是lfence 。例如:
在上述代码中,lfence读取屏障保证了读操作 A 一定会在读操作 B 之前完成。即使处理器可能有优化策略,也不能将读操作 B 提前到读操作 A 之前执行。读取屏障在多线程环境中,当读取操作的顺序对程序逻辑有重要影响时非常关键。比如在一些依赖于特定读取顺序的算法实现中,或者在读取共享状态变量时,为了确保获取到正确的状态信息,就需要使用读取屏障来保证读取操作的顺序性 。
③写入屏障(Write/Store Barrier)
一个写内存屏障可以提供这样的保证,站在系统中的其它组件的角度来看,在屏障之前的写操作看起来将在屏障后的写操作之前发生。
如果映射到上面的例子来说,首先,写内存屏障会对处理器指令重排序做出一些限制,也就是在写内存屏障之前的写入指令一定不会被重排序到写内存屏障之后的写入指令之后。其次,在执行写内存屏障之后的写入指令之前,一定要保证清空当前CPU存储缓冲中的所有写操作,将它们全部“提交”到缓存中。这样的话系统中的其它组件(包括别的CPU),就可以保证在看到写内存屏障之后的写入数据之前先看到写内存屏障之前的写入数据。
图片
写入屏障用于确保在该屏障之前的所有写入操作,必须在该屏障之后的写入操作之前完成 。它主要关注写入操作的顺序,防止写入操作的重排序。在 x86 架构中,写入屏障的指令是sfence 。例如:
这里,sfence写入屏障确保了写操作 C 一定会在写操作 D 之前完成。无论编译器如何优化或者处理器如何执行指令,都不会改变这两个写操作的顺序。写入屏障在多线程同时修改共享数据时非常重要,它可以保证数据的更新按照预期的顺序进行,避免由于写入顺序混乱导致的数据不一致问题。比如在更新一些关联的共享变量时,使用写入屏障可以确保先更新的变量对其他线程可见后,再进行后续变量的更新 。
四、自旋锁(spinlock)
自旋锁是一种用于多线程同步的机制,其作用是保证在同一时刻只有一个执行单元能够进入临界区,从而确保复杂操作不受其他执行单元的干扰顺利完成 。自旋锁的结构定义较为复杂,在 Linux 内核中,相关结构体层层嵌套 。以较新版本内核为例,spinlock_t 通过 union 关联到 raw_spinlock,而 raw_spinlock 又包含 arch_spinlock_t 。arch_spinlock_t 利用 union 将 u32 类型的 slock 与 struct __raw_tickets 结构体相关联,结构体中的 u16 类型的 owner 和 next 成员用于实现 FIFO(先进先出)机制 。
当一个线程尝试获取自旋锁时,如果锁已经被其他线程占用,它不会立即进入睡眠状态,而是在一个循环中不断检查锁是否可用,即所谓的 “自旋” 。这种方式避免了线程切换的开销,特别适合短时间内的锁竞争场景 。自旋锁的 FIFO 机制保证了等待锁的线程按照先来后到的顺序获取锁,从而实现了公平性 。例如,多个线程同时请求获取自旋锁,先请求的线程会先获取到锁,后请求的线程则需要等待 。
然而,自旋锁也存在一些问题,比如在锁被长时间持有时,等待的线程会持续自旋,浪费 CPU 资源 。为了应对中断死锁问题,自旋锁提供了不同类型的操作函数 。如 spin_lock_irqsave 在获取锁之前会保存当前中断状态并禁用中断,spin_unlock_irqrestore 则会恢复之前保存的中断状态 。这样可以防止在持有自旋锁期间发生中断,从而避免死锁的发生 。
自旋锁的API有:
spin_lock_init(x)该宏用于初始化自旋锁x。自旋锁在真正使用前必须先初始化。该宏用于动态初始化。DEFINE_SPINLOCK(x)该宏声明一个自旋锁x并初始化它。该宏在2.6.11中第一次被定义,在先前的内核中并没有该宏。SPIN_LOCK_UNLOCKED该宏用于静态初始化一个自旋锁。DEFINE_SPINLOCK(x)等同于spinlock_t x = SPIN_LOCK_UNLOCKEDspin_is_locked(x)该宏用于判断自旋锁x是否已经被某执行单元保持(即被锁),如果是,返回真,否则返回假。spin_unlock_wait(x)该宏用于等待自旋锁x变得没有被任何执行单元保持,如果没有任何执行单元保持该自旋锁,该宏立即返回,否则将循环在那里,直到该自旋锁被保持者释放。spin_trylock(lock)该宏尽力获得自旋锁lock,如果能立即获得锁,它获得锁并返回真,否则不能立即获得锁,立即返回假。它不会自旋等待lock被释放。spin_lock(lock)该宏用于获得自旋锁lock,如果能够立即获得锁,它就马上返回,否则,它将自旋在那里,直到该自旋锁的保持者释放,这时,它获得锁并返回。总之,只有它获得锁才返回。spin_lock_irqsave(lock, flags)该宏获得自旋锁的同时把标志寄存器的值保存到变量flags中并失效本地中断。spin_lock_irq(lock)该宏类似于spin_lock_irqsave,只是该宏不保存标志寄存器的值。spin_lock_bh(lock)该宏在得到自旋锁的同时失效本地软中断。spin_unlock(lock)该宏释放自旋锁lock,它与spin_trylock或spin_lock配对使用。如果spin_trylock返回假,表明没有获得自旋锁,因此不必使用spin_unlock释放。spin_unlock_irqrestore(lock, flags)该宏释放自旋锁lock的同时,也恢复标志寄存器的值为变量flags保存的值。它与spin_lock_irqsave配对使用。spin_unlock_irq(lock)该宏释放自旋锁lock的同时,也使能本地中断。它与spin_lock_irq配对应用。spin_unlock(lock)该宏释放自旋锁lock,它与spin_trylock或spin_lock配对使用。如果spin_trylock返回假,表明没有获得自旋锁,因此不必使用spin_unlock释放。spin_unlock_irqrestore(lock, flags)该宏释放自旋锁lock的同时,也恢复标志寄存器的值为变量flags保存的值。它与spin_lock_irqsave配对使用。spin_unlock_irq(lock)该宏释放自旋锁lock的同时,也使能本地中断。它与spin_lock_irq配对应用。spin_unlock_bh(lock)该宏释放自旋锁lock的同时,也使能本地的软中断。它与spin_lock_bh配对使用。spin_trylock_irqsave(lock, flags) 该宏如果获得自旋锁lock,它也将保存标志寄存器的值到变量flags中,并且失效本地中断,如果没有获得锁,它什么也不做。因此如果能够立即获得锁,它等同于spin_lock_irqsave,如果不能获得锁,它等同于spin_trylock。如果该宏获得自旋锁lock,那需要使用spin_unlock_irqrestore来释放。spin_unlock_bh(lock)该宏释放自旋锁lock的同时,也使能本地的软中断。它与spin_lock_bh配对使用。spin_trylock_irqsave(lock, flags) 该宏如果获得自旋锁lock,它也将保存标志寄存器的值到变量flags中,并且失效本地中断,如果没有获得锁,它什么也不做。因此如果能够立即获得锁,它等同于spin_lock_irqsave,如果不能获得锁,它等同于spin_trylock。如果该宏获得自旋锁lock,那需要使用spin_unlock_irqrestore来释放。spin_can_lock(lock)该宏用于判断自旋锁lock是否能够被锁,它实际是spin_is_locked取反。如果lock没有被锁,它返回真,否则,返回假。该宏在2.6.11中第一次被定义,在先前的内核中并没有该宏。获得自旋锁和释放自旋锁有好几个版本,因此让读者知道在什么样的情况下使用什么版本的获得和释放锁的宏是非常必要的。
如果被保护的共享资源只在进程上下文访问和软中断上下文访问,那么当在进程上下文访问共享资源时,可能被软中断打断,从而可能进入软中断上下文来对被保护的共享资源访问,因此对于这种情况,对共享资源的访问必须使用spin_lock_bh和spin_unlock_bh来保护。
当然使用spin_lock_irq和spin_unlock_irq以及spin_lock_irqsave和spin_unlock_irqrestore也可以,它们失效了本地硬中断,失效硬中断隐式地也失效了软中断。但是使用spin_lock_bh和spin_unlock_bh是最恰当的,它比其他两个快。
如果被保护的共享资源只在进程上下文和tasklet或timer上下文访问,那么应该使用与上面情况相同的获得和释放锁的宏,因为tasklet和timer是用软中断实现的。
如果被保护的共享资源只在一个tasklet或timer上下文访问,那么不需要任何自旋锁保护,因为同一个tasklet或timer只能在一个CPU上运行,即使是在SMP环境下也是如此。实际上tasklet在调用tasklet_schedule标记其需要被调度时已经把该tasklet绑定到当前CPU,因此同一个tasklet决不可能同时在其他CPU上运行。
timer也是在其被使用add_timer添加到timer队列中时已经被帮定到当前CPU,所以同一个timer绝不可能运行在其他CPU上。当然同一个tasklet有两个实例同时运行在同一个CPU就更不可能了。
如果被保护的共享资源只在两个或多个tasklet或timer上下文访问,那么对共享资源的访问仅需要用spin_lock和spin_unlock来保护,不必使用_bh版本,因为当tasklet或timer运行时,不可能有其他tasklet或timer在当前CPU上运行。
如果被保护的共享资源只在一个软中断(tasklet和timer除外)上下文访问,那么这个共享资源需要用spin_lock和spin_unlock来保护,因为同样的软中断可以同时在不同的CPU上运行。
如果被保护的共享资源在两个或多个软中断上下文访问,那么这个共享资源当然更需要用spin_lock和spin_unlock来保护,不同的软中断能够同时在不同的CPU上运行。
如果被保护的共享资源在软中断(包括tasklet和timer)或进程上下文和硬中断上下文访问,那么在软中断或进程上下文访问期间,可能被硬中断打断,从而进入硬中断上下文对共享资源进行访问,因此,在进程或软中断上下文需要使用spin_lock_irq和spin_unlock_irq来保护对共享资源的访问。
而在中断处理句柄中使用什么版本,需依情况而定,如果只有一个中断处理句柄访问该共享资源,那么在中断处理句柄中仅需要spin_lock和spin_unlock来保护对共享资源的访问就可以了。
因为在执行中断处理句柄期间,不可能被同一CPU上的软中断或进程打断。但是如果有不同的中断处理句柄访问该共享资源,那么需要在中断处理句柄中使用spin_lock_irq和spin_unlock_irq来保护对共享资源的访问。
在使用spin_lock_irq和spin_unlock_irq的情况下,完全可以用spin_lock_irqsave和spin_unlock_irqrestore取代,那具体应该使用哪一个也需要依情况而定,如果可以确信在对共享资源访问前中断是使能的,那么使用spin_lock_irq更好一些。
因为它比spin_lock_irqsave要快一些,但是如果你不能确定是否中断使能,那么使用spin_lock_irqsave和spin_unlock_irqrestore更好,因为它将恢复访问共享资源前的中断标志而不是直接使能中断。
当然,有些情况下需要在访问共享资源时必须中断失效,而访问完后必须中断使能,这样的情形使用spin_lock_irq和spin_unlock_irq最好。
需要特别提醒读者,spin_lock用于阻止在不同CPU上的执行单元对共享资源的同时访问以及不同进程上下文互相抢占导致的对共享资源的非同步访问,而中断失效和软中断失效却是为了阻止在同一CPU上软中断或中断对共享资源的非同步访问。
五、信号量(semaphore)
Linux内核的信号量在概念和原理上与用户态的System V的IPC机制信号量是一样的,但是它绝不可能在内核之外使用,因此它与System V的IPC机制信号量毫不相干。
信号量在创建时需要设置一个初始值,表示同时可以有几个任务可以访问该信号量保护的共享资源,初始值为1就变成互斥锁(Mutex),即同时只能有一个任务可以访问信号量保护的共享资源。
一个任务要想访问共享资源,首先必须得到信号量,获取信号量的操作将把信号量的值减1,若当前信号量的值为负数,表明无法获得信号量,该任务必须挂起在该信号量的等待队列等待该信号量可用;若当前信号量的值为非负数,表示可以获得信号量,因而可以立刻访问被该信号量保护的共享资源。
当任务访问完被信号量保护的共享资源后,必须释放信号量,释放信号量通过把信号量的值加1实现,如果信号量的值为非正数,表明有任务等待当前信号量,因此它也唤醒所有等待该信号量的任务。
信号量的API有:
DECLARE_MUTEX(name)该宏声明一个信号量name并初始化它的值为0,即声明一个互斥锁。DECLARE_MUTEX_LOCKED(name)该宏声明一个互斥锁name,但把它的初始值设置为0,即锁在创建时就处在已锁状态。因此对于这种锁,一般是先释放后获得。void sema_init (struct semaphore *sem, int val);该函用于数初始化设置信号量的初值,它设置信号量sem的值为val。void init_MUTEX (struct semaphore *sem);该函数用于初始化一个互斥锁,即它把信号量sem的值设置为1。void init_MUTEX_LOCKED (struct semaphore *sem);该函数也用于初始化一个互斥锁,但它把信号量sem的值设置为0,即一开始就处在已锁状态。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的值为非正数,表明有任务等待该信号量,因此唤醒这些等待者。信号量在绝大部分情况下作为互斥锁使用,下面以console驱动系统为例说明信号量的使用。
在内核源码树的kernel/printk.c中,使用宏DECLARE_MUTEX声明了一个互斥锁console_sem,它用于保护console驱动列表console_drivers以及同步对整个console驱动系统的访问。
其中定义了函数acquire_console_sem来获得互斥锁console_sem,定义了release_console_sem来释放互斥锁console_sem,定义了函数try_acquire_console_sem来尽力得到互斥锁console_sem。这三个函数实际上是分别对函数down,up和down_trylock的简单包装。
需要访问console_drivers驱动列表时就需要使用acquire_console_sem来保护console_drivers列表,当访问完该列表后,就调用release_console_sem释放信号量console_sem。
函数console_unblank,console_device,console_stop,console_start,register_console和unregister_console都需要访问console_drivers,因此它们都使用函数对acquire_console_sem和release_console_sem来对console_drivers进行保护。
六、互斥锁(Mutex)
互斥锁在功能上和信号量 count 为 1 时的行为较为相似,都用于保证在同一时刻只有一个线程能够访问共享资源 。然而,由于性能等方面的原因,互斥锁重新进行了实现 。在 Linux 内核中,互斥锁的结构定义包含多个关键成员 。其中,owner 用于记录当前持有锁的线程;count 用于表示锁的状态,0 表示锁被占用,1 表示锁可用;wait_list 同样是一个等待队列,用于存放等待获取锁的线程 。
互斥锁具有一些独特的特点,例如在竞争不激烈的情况下,它会切换为自旋等待 。这是因为在短时间内,锁很可能会被释放,自旋等待可以避免线程切换的开销 。互斥锁相关的 API 主要有 mutex_lock、mutex_unlock 和 mutex_trylock 。mutex_lock 用于获取互斥锁,如果锁已被持有,当前线程将被阻塞;mutex_unlock 用于释放互斥锁,允许其他线程获取;mutex_trylock 则是尝试获取互斥锁,如果锁被持有,它会立即返回失败,而不会阻塞线程 。在实际应用中,比如在一个多线程的数据库连接池管理中,互斥锁可以用于保护连接池的共享资源,确保每次只有一个线程能够获取或释放数据库连接 。
互斥锁的工作原理基于操作系统提供的原子操作和线程调度机制。当一个线程执行到需要访问共享资源的代码段时,它会调用互斥锁的加锁函数(如std::mutex的lock方法)。此时,互斥锁会检查自身的状态,如果当前处于未锁定状态,它会将自己标记为已锁定,并允许该线程进入临界区访问共享资源。这个标记过程是通过原子操作实现的,确保在多线程环境下不会出现竞争条件。例如,在一个多线程的文件读写操作中,当一个线程获取到互斥锁后,就可以安全地对文件进行写入,避免其他线程同时写入导致文件内容混乱。
如果互斥锁已经被其他线程锁定,那么调用加锁函数的线程会被操作系统挂起,放入等待队列中,进入阻塞状态。此时,该线程会让出 CPU 资源,以便其他线程能够继续执行,避免了无效的 CPU 占用。就像在一条单行道上,当一辆车已经在行驶时,其他车辆只能在路口等待,直到前面的车通过。
当持有锁的线程完成对共享资源的访问后,它会调用互斥锁的解锁函数(如std::mutex的unlock方法) 。解锁操作会将互斥锁的状态标记为未锁定,并从等待队列中唤醒一个等待的线程(如果有线程在等待)。被唤醒的线程会重新竞争 CPU 资源,当它获得 CPU 时间片后,会再次尝试获取互斥锁。一旦获取成功,就可以进入临界区访问共享资源。例如,在一个多线程的数据库操作中,当一个线程完成对数据库的更新操作并释放互斥锁后,等待队列中的另一个线程就有机会获取锁,进行查询或其他操作。
七、RCU(Read-Copy-Update)
RCU 是一种高效的读写同步机制,可看作是读写锁的高性能版本 。其核心理念在于读取操作无需加锁,从而大大提高了读取的效率 。这是因为在很多场景下,读取操作不会对数据的一致性造成影响,所以可以允许多个读取操作同时进行 。RCU 的适用场景主要是读多写少的情况 。以文件系统为例,在文件系统中,经常需要进行文件查找等读取操作,而对文件的修改相对较少 。在这种场景下,使用 RCU 可以显著提升系统的性能 。
当进行读取操作时,读者直接访问共享数据,不需要加锁 。而当进行更新操作时,写者会创建新的副本,并在合适的时间替换旧数据 。为了确保数据的一致性,RCU 引入了宽限期的概念 。在宽限期内,所有在更新操作之前开始的读取操作都要完成,之后才能回收旧数据 。下面通过一个简单的代码示例来深入理解 RCU 的工作原理:
在上述代码中,reader 函数表示读取操作,通过 rcu_read_lock 和 rcu_read_unlock 标记 RCU 读临界区 。在临界区内,使用 rcu_dereference 获取共享数据指针,这样可以确保在读取过程中,即使数据被更新,也能读取到有效的数据 。writer 函数表示写入操作,首先分配新的内存空间并初始化数据,然后使用 rcu_assign_pointer 更新共享数据指针 。
接着,通过 synchronize_rcu 等待所有读者完成对旧数据的访问,之后才释放旧数据的内存空间 。通过这个例子可以清晰地看到 RCU 是如何在保证数据一致性的前提下,实现高效的读写操作的 。为了更直观地理解 RCU 的原理,我们来看一个简单的示意图:
在这个示意图中,读者 1 和读者 2 在读取数据时不需要加锁,可以同时进行 。当写者进行数据更新时,会创建新的数据副本并更新指针 。然后进入宽限期,在宽限期内,等待所有读者完成对旧数据的访问 。只有当宽限期结束后,才会回收旧数据 。这样就实现了在多读少写场景下的高效同步 。
八、Linux 同步管理工具
在 Linux 系统中,有许多实用的同步管理工具,其中 rsync 是一款备受青睐的强大工具 。rsync 的全称为 “remote synchronize”,即远程同步 。它不仅支持本地文件系统之间的同步,还能通过网络进行远程文件同步,在数据备份、镜像服务器搭建等场景中应用广泛 。
rsync 的核心优势在于其高效的增量同步功能 。它采用了独特的 “Rsync 算法”,在同步文件时,并非每次都传输整个文件,而是仅传送源文件和目标文件之间的差异部分 。这一特性使得 rsync 在处理大文件或大量文件同步时,能够显著节省带宽和时间,大大提高了同步效率 。例如,在一个包含众多文件的大型项目中,若仅对其中部分文件进行了修改,使用 rsync 进行同步时,它只会传输这些被修改的文件部分,而不会重复传输未改变的文件内容 。
rsync 的使用方式十分灵活,支持多种运行模式 。它可以通过 rsh、ssh 等远程 shell 方式进行文件传输,这种方式利用了系统已有的安全连接机制,确保数据传输的安全性 。同时,rsync 也能以 daemon 模式运行,在这种模式下,rsync server 会打开一个 873 端口,等待客户端连接 。连接时,服务器会检查口令是否相符,通过验证后即可开始文件传输 。这种模式适用于需要长期提供同步服务的场景,比如企业内部的文件服务器 。
下面来看一些 rsync 的基本语法和常见选项用法:
①本地文件同步
将目录 source_dir 同步到 destination_dir,使用归档模式并显示详细输出信息:
在这个命令中,-a表示归档模式,它等同于-rlptgoD,可以保留文件的权限、符号链接、时间戳等属性 。-v表示显示详细的传输信息 。source_dir/表示同步目录下的所有内容(注意尾部斜杠的作用,加上斜杠表示只同步目录内的内容,不包含该目录本身;不加斜杠则会包含整个目录一起同步) 。destination_dir/为目标目录 。
②远程文件同步(通过 SSH)
将本地目录/home/user/documents/同步到远程服务器remote_host的/backup/documents/目录:
这里的-e ssh表示使用 SSH 作为远程传输协议,通过 SSH 连接到远程主机进行文件同步,这对于安全的远程同步非常有用 。
③增量同步并删除目标中多余的文件
使/backup/documents/与/home/user/documents/完全一致,删除/backup/documents/中多余的文件:
--delete选项会在目标路径中删除源路径不存在的文件,确保目标与源保持一致 。
④压缩传输
在传输过程中压缩数据,减少带宽占用,将本地文件同步到远程主机:
-z选项表示在传输过程中压缩数据 。