分类: LINUX
2010-08-19 19:07:51
上一章讨论了竞争条件为何会产生以及怎么去解决。幸运的是,Linux内核提供了一组相当完备的同步方法。这一章就依次讨论这些方法,包括它们的接口、行为和用途。这些方法能使内核开发者们编写出高效而又自由竞争的代码。
9.1 原子操作
原子操作可以保证指令以原子的方式执行——执行过程不被打断。内核提供了两组原子操作接口——一组针对整数进行操作,另一组针对单独的位进行操作。在 Linux支持的所有体系结构上都实现了这两组接口。大多数体系结构要么本来就支持简单的原子操作,要么就为单步执行提供了锁内存总线的指令。
9.1.1 原子整数操作
针对整数的原子操作只能对atomic_t类型的数据进行处理。在这里之所以引入了一个特殊数据类型,而没有直接使用C语言的int类型,主要出于两个原 因:首先,让原子函数只接受atomic_t类型的操作数,可以确保原子操作数只与这种特殊类型数据一起使用。同时也保证了该类型数据不会被传递给其它任 何非原子函数。其次,使用atomic_t类型确保编译器不对相应的值访问优化——这点使得原子操作最终接收到正确的内存地址,而不只是一个别名。最后, 在不同结构体上实现原子操作的时候,使用atomic_t可以屏蔽其间的差异。
使用原子整型操作需要的声明都在
定义一个atomic_t类型的数据:
atomic_t v; /* 定义 v */
atomic_t u = ATOMIC_INIT(0); /*定义u并初始化为0 */
操作也很简单:
atomic_set(&v,4); /* v=4(原子的) */
atomic_add(2, &v) /* v = v+2 = 6 (原子的) */
atomic_inc(&v) /* v=v+1=7(原子的) */
当需要把atomic_t转换成int型时,可以使用atomic_read()来完成:
printk("%d\n", atomic_read(&v)); /* 将打印 “7” */
原子整数操作最常见的用途就是实现计数器(atomic_inc()和atomic_dec())。
应当对标准原子整数操作有一定的了解,某种特定的体系结构上实现的所有操作可以在文件
原子性与顺序性
也许你的代码还有更多的要求,或许读必须在待定的写之前发生。这就不是原子的了,而是顺序的。原子性确保指令执行期间不被打断,要么全部执行,要么根本不执行。而顺序性确保即使两条或多条指令出现在独立的执行线程甚至是独立的处理器上,它们仍然要保持特定的执行顺序。
9.1.2 原子位操作
除了原子整数操作外,内核还提供了一组针对位这一级数据进行操作的函数。没什么好奇怪的,它们是与体系结构相关的操作,定义在
位操作函数是对普通的内存地址进行操作的。它的参数是一个指针和一个位号。
unsigned long word = 0;
set_bit(0, &word); /* 第0位被设置 */
set_bit(1, &word);
printk("%ul\n" , word); /* 打印3 */
9.2 自旋锁
Linux内核中最常见的锁是自旋锁。自旋锁最多只能被一个可执行线程持有。如果一个执行线程试图获得一个被争用的自旋锁,那么该线程就会一直进行忙循环——旋转——等待锁重新可用。在任意时间,自旋锁都可以防止多于一个的执行线程同时进入临界区。
一个被争用的自旋锁使得请求它的线程在等待锁重新可用时自旋(特别浪费时间),这种行为是自旋锁的要点。所以自旋锁不应该被长时间持有。自旋锁的初衷正是 在短期内进行轻量级加锁。还可以采取另外的方式来处理对锁的争用:让请求线程睡眠,直到锁重新可用时再唤醒它。下一节讨论信号量便提供了上述的锁机制,它 使得在发生争用时,等待的线程能投入睡眠,而非旋转。
自旋锁的实现和体系结构密切相关,代码往往通过汇编实现。这些与体系结构相关的代码定义在文件
spinlock_t mr_lock = SPIN_LOCK_UNLOCKED;
spin_lock(&mr_lock);
/* 临界区 */
spin_unlock(&mr_lock);
警告:自旋锁是不可递归的!Linux内核实现的自旋锁是不可递归的,这点不同于自旋锁在其他操作系统上的实现。所以如果你(应改理解为一个执行线程)试图得到一个你正持有的锁,必须自旋,等待自己释放这个锁。
自旋锁可以使用在中断处理程序中(不能使用信号量,会导致睡眠)。在中断处理程序中使用自旋锁时,一定要先禁止本地中断,否则,中断处理程序就会打断正持 有锁的内核代码,有可能会试图去争用这个已经被持有的自旋锁。现在的状况是:中断处理程序在争用锁,持有锁者在等待中断程序结束(才能解锁),死锁!
内核为我们提供了禁止中断同时申请锁的接口,使用起来很方便:
spinlock_t mr_lock = SPIN_LOCK_UNLOCKED;
unsigned long flags;
spin_lock_irqsave(&mr_lock, flags);
/* 临界区 */
spin_unlock_irqrestore(&mr_lock, flags);
其他对自旋锁的操作
spin_lock() 获取指定的自旋锁
spin_lock_irq() 禁止本地中断并获取指定的锁
spin_lock_irqsave() 保存本地中断的当前状态,禁止本地中断,并获取指定的锁
spin_unlock() 释放指定的锁
spin_unlock_irq() 释放指定的锁,并激活本地中断
spin_unlock_irqrestore 释放指定的锁,并让本地中断恢复到以前的状态
spin_lock_init() 动态初始化指定的spinlock_t
spin_try_lock() 试图获得锁,未获得返回非0
spin_is_locked() 如果指定的锁当前正在被获取,则返回非0,否则,返回0
9.2.2 自旋锁和下半部
在与下半部配合使用时,必须小心的使用锁机制。函数spin_lock_bh()用于获取指定锁,同时它会禁止所以下半部的执行。相应的 spin_unlock_bh()函数执行相反的操作。由于下半部可以抢占进程上下文中的代码,所以当下半部和进程上下文共享数据时,必须对进程上下文中 的共享数据进行保护,所以需要加锁的同时还要禁止下半部执行。同样,由于中断处理程序可以抢占下半部,所以如果中断处理程序和下半部共享数据,那么就必须 在获取恰当的锁的同时还要禁止中断。总之,在加锁时,必须禁止所有的一打断代码执行的进程或中断。
9.3 读写自旋锁
有时,锁的用途可以明确地分为读取和写入。当对某个数据结构的操作可以被划分为读/写两种类别时,类似读/写锁这样的机制就很有用了。为此,Linux提 供了专门的读——写自旋锁。这种自旋锁为读和写分别提供了不同的锁。一个或多个读任务可以并发的持有读者锁;相反,用于写的锁最多只能被一个写任务持有; 而且此时不能有并发的读操作。有时把读/写锁叫做共享/排斥锁,或者并发/排斥锁,应为这种锁以共享(对读者而言)和排斥(对写者而言)的形式使用。
9.4 信号量
Linux中的信号量是一种睡眠锁。如果有一个任务试图获得一个已经被占用的信号量时,信号量会将其推进一个等待队列,然后让其睡眠。这时处理器能重获自 由,从而去执行其他代码。当持有信号量的进程将信号量释放后,处于等待队列中的那个任务将被唤醒,并获得该信号量。
我们可以从信号量的睡眠特性得出一些有意思的结论:(1)由于争用信号量的进程在等待锁重新变为可用时会睡眠,所以信号量适用于锁会被长时间持有的情 况。(2)相反,锁被短时间持有时,使用信号量就不太适宜了。因为睡眠、维护等待队列以及唤醒所花费的开销可能比锁被占用的全部时间还要长。(3)由于执 行线程在锁被争用时会睡眠,所以只能在进程上下文中才能获取信号量锁,因为在中断上下文中是不能进行调度的。(4)在占用信号量时,不能占用自旋锁。因为 在你等待信号量时可能会睡眠,而在持有自旋锁时是不允许睡眠的。
信号量的一个有用特性是可以同时允许任意数量的锁持有者,并且这个数量(count)是可以由我们设定的。通常情况下,信号量和自旋锁一样,在一个时刻仅允许有一个锁持有者,这时count为一,这样的信号量被称为互斥信号量。
信号量支持两个原子操作P()和V(),后来的系统把两种操作分别叫做down()和up(),LInux也遵从这zhogn叫法。down()通过对信 号量减1来请求获得一个信号量。如果结果是0或大于0,获得信号量锁,任务就可进入临界区。相反,当临界区中的操作完成后,up()操作用来释放信号量, 它会增加信号量的计数值。
9.4.1 创建和初始化信号量
信号量的实现是与体系结构相关的,具体实现定义在文件
static DECLARE_SEMAPHORE_GENERIC(name, count)
name是信号量变量名,count是信号量的使用者数量。创建互斥信号量:
static DECLARE_MUTEX(name);
更为常见的情况是,信号量作为一个大数据结构的一部分被动态的创建。此时,你只有指向该动态创建的信号量的间接指针,可以使用如下函数对其初始化:
sema_init(sem, count); sem是一个指针。
或init_MUTEX(sem);此时count=1。
9.4.2 使用信号量
函数down_interruptible()试图获取信号量,如果获取失败,他将以TASK_INTERRUPTIBLE(这个状态表示任务是可以被信 号唤醒的)状态进入睡眠。被唤醒后,返回-EINTR。另一个函数down()会让进程在TASK_UNINTERRUPTIBLE(应该是不响应中断)状态下睡眠,这种情况当然不是我们想看到的。因此使用down_interruptible()比使用down()更为普遍。
使用down_trylock()函数,你可以尝试以堵塞方式来获取指定的信号量。在信号量已经被占用时,返回非0,否则,返回0,并且成功地持有信号量锁。下面我们看一个例子:
static DECLARE_MUTEX(mr_sem); /* 定义并声明一个信号量,名字为mr_sem */
if (down_interruptible(&mr_sem)) { /* 信号被接收,信号量还未获取 */ }
/* 临界区 */
up(&mr_sem); /* 释放信号量 */
信号量方法:
sema_init(struct semaphore *, int) 以指定的计数值初始化动态创建的信号量
init_MUTEX(struct semaphore *) 以计数值1初始化动态创建的信号量
init_MUTEX_LOCKED(struct semaphore *) 以计数值0初始化动态创建的信函量(初始为加锁状态)
]down_interruptible(struct semaphore *) 试图获得指定的信号量,如果已被征用,进入可中断睡眠
down(struct semaphore *) 试图获得指定的信号量,如果被争用,进入不可打断睡眠状态
down_trylock(struct semaphore *) 试图获得指定的信号量,被征用则返回非0值
up(struct semaphore *) 释放指定的信号量,如果睡眠队列不为空,则唤醒其中一个
9.5 读——写信号量
读——写信号量在内核中是由rw_semaphore结构表示的,定义在文件
所有的读——写信号量都是互斥信号量。只要没有写者,并发持有读锁的读者数不限(感觉有点前后矛盾)。相反,只有惟一的写者可以获得锁。所有写锁的睡眠都不会被信号打断,所以它只有一个版本的down操作。???????看一个例子:
static DECLARE_RWSEM(mr_rwsem); /* 静态声明一个读——写锁 *?
down_read(&mr_rwsem); /* 试图获取信号量并用于读操作 */
/* 临界区 */
up_read(&mr_rwsem); /* 释放信号量 */
down_write(&mr_rwsem); /* 试图获取信号量用于写 */
/* 临界区 */
up_write(&mr_sem);
与标准信号量一样,读——写信号量也提供了down_read_trylock()和down_write_trylock()方法。这 两个方法都需要一个指向读——写信号量的指针作为参数。如果成功获得了信号量锁,则返回非0,否则返回0。小心了!——这点是和普通信号量的情形完全相 反。另外,值得注意的一点是,使用down_read()和使用down_interruptible()有所差别,后者使用了if语句,而前者直接使 用,我的判断是前者是在TASK_UNINTERRPUTIBLE状态下休眠,这样就可以一直等到获取到信号量为止,似乎有点像自旋锁了。
在中断上下文中只能使用自旋锁,在任务需要睡眠时只能使用信号量。
9.7 完成变量
9.8 seq锁
seq锁是2.6内核才引入的一种新型锁。这种锁提供了一种很简单的机制,用于读写共享数据。实现这种锁主要依靠一个序列计数器。在读数据之前,读取序列 号,然后开始读,若读的过程中曾被写打断,则会使序列号增加,读完后,再次读取该序列号。通过比较前后连个序列号,来确定读操作是否曾被写打断。此外,如 果读取的是偶数,那么就表明写操作没有发生(这里没有发生不等于没有发生过,只是说明当前没有被用作写锁)。例子:
seqlock_t mr_seq_lock = SEQLOCK_UNLOCKED; 定义一个seq锁
write_seqlock(&mr_seq_lock); 获取写锁
/* 临界区 */
write_sequnlock(&_seq_lock);
这和普通自旋锁类似。不同情况发生在读时,与自旋锁有很大不同:
unsigned long seq; 用于存放序列号
do{ seq=read_seqbegin(&mr_seq_lock);
/* 准备读这里的数据 */
} while(read_seqretry(&mr_seq_lock, seq)); 检查前后两次的序列号
在多个读者和少数写者共享一把锁(这种情况似乎很少能够发生)的时候,seq锁对写者更有利。只要没有其它写者,写锁总是可以被成功获得。读者不会影响写锁,这点和读者——写者自旋锁及信号量一样。
9.9 内核抢占
实际上,某些情况下我们并不需要自旋锁,但是仍需要关闭内核抢占。如果数据对每个处理器是唯一的,也就是说数据只能被一个处理器访问,那么这样的数据可能 根本不需要使用锁来保护。如果自旋锁没有被持有,而内核又处于可抢占状态,那么一个新的调度任务就有可能访问同一个变量。在这种情况下,变量很可能会被多 个进程以伪并发的方式访问。为了解决这个问题,可以通过preempt_disable()禁止内核抢占。这是一个可以嵌套调用的函数,可以调用任意次。 每次调用都必须有一个相应的preempt_enable()调用。当最后一次preempt_enable()被调用后,内核抢占才重新启用。
9.10 顺序和屏障
当处理多处理器之间或硬件设备之间的同步问题时,有时需要在你的程序代码中以指定的顺序发出读内存(读入)和写内存(存储)指令。在和硬件交互时,时常需 要确保给定的读操作发生在其它读或写操作之前。另外,在多处理器上,可能需要按写数据的顺序读数据。但是编译器和处理器为了提高效率,可能对读和写重新排 序,这样无疑使问题复杂化了。所有可能重新排序和写的处理器都提供了机器指令来确保顺序要求。同样也可以指示编译器不要对给定点周围的指令序列进行重新排 序。这些确保顺序的指令称作屏障。
rmb()方法提供了一个“读”内存屏障,它确保跨越rmb()的载入动作不会发生重排序。wmb()方法提供了一个“写”内存屏障,功能与rmb()类似,区别仅仅在于它是针对存储而非载入。mb()方法既提供了读屏障也提供了写屏障。