内核同步讲的比较多了,我也就不太啰嗦了,先说一些概念,然后就是方法。
同步就是避免并发和防止竞争条件。有关临界区的例子我就不举了,随便一本操作系统的书上都有。锁机制的提出也算解决了一些问题,我们待会再说,现在只要知道锁的使用是自愿的,非强制的。linux自身也提供了几种不同的锁机制,区别主要在于当锁被争用时,有些会简单地执行等待,而有些锁会使当前任务睡眠直到锁可用为止,这个后面细说。真正的困难在于发现并辨认出真正需要共享的数据和相应的共享区。先来说一些感性的话:大多数内核数据结构都需要加锁,如果有其他执行线程可以访问这些数据,那么就给这些数据加上某种形式的锁。如果任何其他什么东西能看到它,那么就要锁住它。简而言之,几乎访问所有的内核全局变量和共享数据都需要某种形式的同步方法。有关加锁的细节,在嵌套的锁时,要保证以相同的顺序获取锁,不要重复请求同一个锁,释放时,最好还是以获得锁的相反顺序来释放锁。加锁的粒度用来描述加锁保护的数据规模。接下来就开始讨论真正的同步方法:
1.原子操作。就是指执行过程不被打断的操作,是 不能够被分割的指令。关于这个linux内核提供了两组原子操作接口:原子整数操作和原子位操作。好,先来说说这个原子整数操作。针对整数的原子操作只能对atomic_t类型的数据进行处理。需要说明的是,尽管linux支持的所有机器上的整形数据都是32位的,但是使用atomic_t的代码只能将该类型的数据当作24位来用,原因就不说了。使用原子操作需要的声明在asm/atomic.h中。原子整数操作列表如下;
在编写代码的时候,能使用原子操作的时候,就尽量不要使用复杂的加锁机制,因为大多数或者100%情况下,原子操作比更复杂的同步方法相比较而言,给系统带来的开销小,对高速缓存行(cache-line)的影响也很小。
对应于原子整数操作,还有一种原子操作就是原子位操作,它们是与体系结构相关的操作,定义在文件<asm/bitops.h>,它是对普通的内存地址进行操作的。它的参数是一个指针和一个位号,第0位是给定地址的最低有效位。这里没有想atomic_t一样的数据结构,只要指针指向任何希望的数据,就可以进行操作。原子位操作函数列表如下:
同时,内核还提供了一组与上述操作对应的非原子位函数,操作完全相同,不同在于不保证原子性且名字前缀多了两个下划线,例如与test_bit()对应的非原子形式是__test_bit().如果不需要原子操作,这时这些函数的执行效率可能更高。内核还提供了两个函数用来从指定的地址开始搜索第一个被设置(或未被设置)的位:
int find_first_bit(unsigned long *addr,unsigned int size); int find_first_zero_bit(unsigned long *addr,unsigned int size);
其中,第一个参数是一个指针,第二个参数是要搜索的总位数。返回值分别是第一个被设置的(或没被设置的)位的位号。如果要搜素的范围仅限于一个字,使用_ffs()和__ffz()这两个函数更好,它们只需要给定一个要搜索的地址做参数。
2.自旋锁。临界区远没有我们想象那样的那样简单,有时可能跨越多个函数。自旋锁是linux内核中最常见的锁。如果一个执行线程试图获得一个被争用的(已经被持有)的自旋锁,那么这个线程就会一直进行忙循环----旋转----等待锁重新可用。同一个锁可以用在多个位置,例如,对于给定数据的访问都可以得到保护和同步。上述的自旋过程是很费时间的,所以自旋锁不应该被长时间持有。我们前边所过也可以让请求线程休眠,CPU可以执行其他代码,直到锁可用时在唤醒它,但自旋锁由于忙等待,它是占用CPU的。上面的休眠过程也会带来上下文切换带来的开销,所以持有自旋锁的时间最好小于完成两次上下文切换的时间。自旋锁的实现和体系结构密切相关,代码往往用汇编实现,这些与体系结构相关的部分定义在<asm/spinlock.h>,实际需要用到的接口定义在文件<linux/spinlock.h>中。自旋锁操作列表如下:
自旋锁仅仅被当作一个设置内核抢占机制时候被启用的开关,如果禁止内核抢占,那么在编译时自旋锁会被完全剔除出内核。另外就是linux内核实现的自旋锁是不可递归的。自旋锁可是用在中断处理程序中(使用信号量,会导致睡眠)。在中断处理程序中使用自旋锁,一定要在获取锁之前禁止本地中断(当前处理器上的中断请求)。否则,中断处理程序会打断正持有锁的内核代码,有可能会试图去争用这个已经被持有的自旋锁。这样一来,中断处理程序就会自旋。但锁的持有者在这个中断处理程序执行完毕前不可能运行,这就是双重请求死锁。注意,需要关闭的只是当前处理器上中断,如果中断发生在不同的处理器上,即使中断处理程序在同一个锁上自旋,也不会妨碍锁的持有者(在不同处理器上)最终释放锁。最后两个函数spin_lock_bh()用于获取指定锁,同时它会禁止所有下半部的执行。相应的spin_unlock_bh()函数执行相反的操作。最后,提醒要注意自旋锁和下半部的关系。
3.读--写自旋锁。有时,锁的用途是可以明确分为读取和写入的。当对某个数据结构的操作可以被划分为读/写两种类别时,就可以使用这里说的读---写自旋锁。这种机制为读和写分别提供了不同的锁。一个或多个读任务可以并发的持有读者锁,而写锁只能被一个写任务持有,而且此时不能有并发的读。操作函数列表如下:
事实上,即使一个线程递归地获得同一读锁也是安全的。还是那句话,使用之前要能明确的分清读和写。如果在中断处理程序中只有读操作而没有写操作,那么,就可以混合使用“中断禁止”锁,使用read_lock()而不是read_lock_irqsave()对读进行保护。不过,你还是需要用write_lock_irqsave()禁止有写操作的中断,否则,中断里读操作就有可能锁死在写锁上(假如读者正在进行操作,包含写操作的中断发生了,由于读锁还没有全部被释放,所以写操作会自旋,而读操作只能在包含写操作的中断返回后才能继续,释放读锁,这时死锁就发生了)。最后需要说明的是,这种机制偏向与读锁:当读锁被持有时,写操作为了互斥访问只能等待,但是,读者却可以继续成功地占用锁。而自旋等待的写者在所有读锁释放锁之前是无法获得锁的。所以大量的读者会使挂起的写者处于饥饿状态。
4.信号量。它是一种睡眠锁,实现和体系结构相关的,定义在<asm/semaphore.h>。如果有一个任务试图获得一个已经被占用的信号量时,信号量会将其推进一个等待队列,然后让其睡眠。这时的处理器会重获*,从而去执行其他代码。当持有信号量的进程将信号量释放后,处于等待队列中的那个任务将被唤醒,并获得该信号量。如果需要在自旋锁和信号量中做出选择,应该根据锁被持有的时间长短做判断,如果加锁时间不长并且代码不会休眠,利用自旋锁是最佳选择。相反,如果加锁时间可能很长或者代码在持有锁有可能睡眠,那么最好使用信号量来完成加锁功能。信号量一个有用特性就是它可以同时允许任意数量的锁持有者,而自旋锁在一个时刻最多允许一个任务持有它。信号量同时允许的持有者数量可以在声明信号量时指定,当为1时,成为互斥信号量,否则成为计数信号量。操作函数列表如下:
信号量支持pv操作,我就不说了。
5.读-写信号量。这个和信号量的关系是和自旋锁与读写自旋锁是一样的关系,由rw_semaphore结构表示的。定义在linux/rwsem.h中。所有的读写信号量都是互斥信号量。操作函数有:
DECLARE_RWSEM(name) //声明名为name的读写信号量,并初始化它。 void init_rwsem(struct rw_semaphore *sem); //对读写信号量sem进行初始化。 void down_read(struct rw_semaphore *sem); //读者用来获取sem,若没获得时,则调用者睡眠等待。 void up_read(struct rw_semaphore *sem); //读者释放sem。 int down_read_trylock(struct rw_semaphore *sem); //读者尝试获取sem,如果获得返回1,如果没有获得返回0。可在中断上下文使用。 void down_write(struct rw_semaphore *sem); //写者用来获取sem,若没获得时,则调用者睡眠等待。 int down_write_trylock(struct rw_semaphore *sem); //写者尝试获取sem,如果获得返回1,如果没有获得返回0。可在中断上下文使用 void up_write(struct rw_semaphore *sem); //写者释放sem。 void downgrade_write(struct rw_semaphore *sem); //把写者降级为读者。
其实上面的就是分了两类:信号量和自旋锁,使用时选择的比较如下:
6.完成变量。如果内核中一个任务需要发出信号通知另外一个任务发生了某个特定事件,利用完成变量(complete variables)是使两个任务得以同步的简单方法。如果任务要执行一些工作时,另一个任务就会在完成量上等待,当这个任务完成工作后,会使用完成变量去唤醒在等待的任务,就像信号量一样,它们两者思想是一样的, 它仅仅提供了代替信号量的一个简单地解决方法。它有结构completion表示,定义在linux/completion.h中。操作接口列表如下:
完成变量的通常用法是将完成变量作为数据结构中的一项动态创建,而完成变量的初始化工作的内核代码将调用wait_for_completion()进行等待。初始化完成后,初始化函数调用completion()唤醒在等待的内核任务。
7.BKL(大内核锁)。是一个全局自旋锁,年代有些久远了,使用它主要是为了方便实现从linux最初的SMP过度到细粒度加锁机制,有趣的特性如下:
1.持有BKL的任务可以睡眠。因为当任务无法调度时,所加锁会自动被丢弃,所加锁会自动被丢弃;当任务被调度时,锁又会被重新获得。当然,这不是 说,当任务持有BKL时,睡眠是安全的,仅仅是这样做,因为睡眠不会造成任务死锁。 2.BKL是一种递归锁,一个进程可以多次请求一个锁,并不会像自旋锁那样产生死锁现象。 3.BKL可以在进程上下文中。 4.BKL是有害的。 |
不要奇怪,这种机制在内核中已经几乎不存在了,也不鼓励使用。这里提到这样的思想和接口,是为了万一遇到呢?操作接口(linux/smp_lock.h)列表如下:
BKL在被持有的时候同样会禁止内核抢占。多数情况下,BKL更像是保护代码而不是保护数据。
8.Seq锁。这种锁提供了一种简单机制,用于读写共享数据。实现这种锁主要依靠一个序列计数器。当数据被进行写入操作时,会得到一个锁,并且序列值会增加。在读取数据之前和之后,序列号都被读取。如果读取的序列号值相同,说明在读操作进行的过程中没有被写操作打断过。此外,如果读取的值是偶数,那么就说明写操作没有发生(要明白因为锁的初始值是0,所以写锁会使值为奇数,释放的时候变成偶数)。操作接口如下:
seqlock_t mr_seq_lock = SEQLOCK_UNLOCKED; //定义顺序锁 write_seqlock(&mr_seq_lock); //写操作代码块… write_sequnlock(&mr_seq_lock);
这和普通自旋锁类似,不同的情况发生在读时,写自旋锁有很大不同:
unsigned long seq; do{ seq = read_seqbegin(&my_seq_lock); }while(read_seqretry(&my_seq_lock,seq));
在多个读者和少数读者共享一把锁的时候,seq锁有助于提供一种非常轻量级和具有可扩展性的外观。但是seq锁对写者更有利。只有没有其他写者,写锁总是能够被成功获得。读者不会影响写锁,这点和读写自旋锁及信号量是一样的。另外,挂起的写者会不断地使得读操作循环,知道不再有任何写者锁持有锁为止。
9.禁止抢占:实际情况是这样的,有时我们不需要锁,但同时又不希望进程抢占(内核抢占)的修改某些数据,这时就要关闭内核抢占。可以通过preempt_disable()禁止内核抢占。这时一个可以嵌套调用的函数,可以使用任意次。每次调用都必须有一个相应的preempt_enable()调用,当最后一次preempt_enable()被调用时,内核抢占才重新启用。内核抢占相关操作如下:
抢占技术存放着被持有锁的数量和preempt_disable()的调用次数,当计数为0时,那么内核可以进行抢占。否则不能。为了用更简洁的方法解决每个处理器上的数据访问问题,你可以通过get_cpu()获得处理器编号(假定用这种编号来对每个处理器的数据进行索引的)。这个函数在返回当前处理器号前首先会关闭内核抢占:
int cpu = get_cpu(); ...对每个处理器的数据进行操作 put_cpu();
10.屏障。当处理多处理器之间或硬件设备之间的同步问题时,有时需要在程序代码中以指定的顺序发出读内存(写入)和写内存(存储)指令。在和硬件交互时,时常需要确保一个给定的读操作发生在其他读或写操作之前。另外,在多处理器上,可能需要按写数据的顺序读数据(通常确保后以同样的顺序进行读取)。但是编译器和处理器为了提高效率,可能对读和写重新排序,这样无疑是问题复杂化了。幸好,所有可能重新排序和写的处理器提供了机器指令来确保顺序要求。同样也可以指示编译器不要对给定点周围的指令序列进行重新排序。这些确保顺序的指令叫做屏障(barriers);这样的内存和编译屏障方法列表如下:
最后,说明的是,对于不同的体系结构,屏障的实际效果差别很大。但应该在最坏的情况下使用恰当的内存屏障,这样代码才能在编译时执行针对体系结构的优化。