第10章 内核同步方法
10.1 原子操作
竞争条件造成不确定的后果原因是一条指定编译后是多条机器指令,在执行多个机器指令的时候会被打断,如果我一条指令编译后只有一个机器指令,执行一个机器指令的过程中是不会被打断的,这样不会被打断的操作称为原子操作。Linux提供了两组原子操作接口,一组是针对整数进行操作,一组针对单独的位进行操作。
10.1.1 原子整数操作
针对整数的原子操作只能对atomic_t类型是数据进行操作,不能对一般的int类型进行操作。其结构体定义如下,在他的定义中我们发现了老朋友volatile。
typedef struct{ volatile int counter }atomic_t;
对atomic_t类型的数据增减都有特定的函数来实现,下面实现了声明一个原子整数,对其赋值并增加2。
atomic_t v; atomic_set(&v ,4); atomic_add(2,&v);
原子整数的操作如何实现的呢?如何保证其原子性?原子操作通常是内联函数,通过内嵌汇编指令来实现。另外,如果一个函数本身就是原子的,那么该函数就会被定义成一个宏,如读取一个字的函数,读取一个字本身就是一种原子操作,不会被打断,所以不存在读一个字的时候被打断,也就不存在在读一个字的时候同时对该字执行写操作。
10.1.2 64位原子操作
上述的atomic_t是32位的,在64位的处理机上要重新定义一种64位的atmoic64_t,这两种类型的操作都是相同的。
10.1.3 原子位操作
用于对bit级别的数进行操作,这一操作的针对内存地址的。其参数是一个内存地址即一个指针和一个用于指定对哪一位进行操作的位号。暂时还不太理解这一操作的应用场景。
10.2 自旋锁
并不是所有对临界区的限制都通过内核提供的原子操作来实现,在许多复杂的情况下需要使用别的保护机制。比如自旋锁,自旋锁首先是一种锁,锁住了临界区,当一个执行线程企图进入临界区的时候,他必须要获得锁才能执行,锁只有一把,保证了任意时刻即使有多个执行线程有可能进入临界区,最终也只能有一个执行线程会进入临界区。再者它是“自旋”的,即一个线程如果没有获得锁他会“自旋”即不放弃处理机,而是忙等待。前面提到过对不同的锁的区分是根据他们在无法获得锁的时候的行为,与自旋相对的是睡眠,即一个执行线程没有获得锁就去睡眠等待相关信号去唤醒他。这两种做法各有利弊,自旋会一直占据处理机,是一种浪费。睡眠就必然会让另外的线程上处理机,这种进程上下文的切换也会消耗一定的资源。
10.2.1 自旋锁方法
自旋锁的实现和体系结构相关,是通过汇编代码实现。使用也非常简单。
DEFINE_SPINLOCK(mr_lock); spin_lock(&mr_lock); /*临界区*/ spin_unlock(&mr_lock);
在Linux里自旋锁不可以递归的获取,加入你现在持有了一个自旋锁,然后你再次获取锁,这样会带来死锁。
自旋锁可以使用在中断处理程序中,在中断处理程序中获得自旋锁的时候,一定要先禁止本地中断,否则可能会带来死锁。比如一个中断程序打断了正在持有锁的代码,然后中断处理程序试图获得锁,由于无法获得他回自旋等待,但是持有锁的线程必须等待中断处理程序执行完毕才能继续执行才有可能释放锁,这就是双重请求死锁。
10.2.2 其他针对自旋锁的操作
自旋锁的所有的操作,其中有一个试图获得锁的操作,如果获得成功就返回0,如果失败就返回非0,让我想到了test_set操作。
10.2.3 自旋锁和下半部
10.3 读-写自旋锁
锁的场景可以明确的分为读操作和写操作,写操作的时候是完全互斥的,不允许其余线程并发的执行写或者读操作。而读操作没有这么严格的限制,在进行读的时候,可以并发的读但是不可以写。所以很多时候对一个数据结构的操作可以明确的划分为上述两种情况,因而Linux提供了读-写自旋锁,这种锁为读和写分别提供了锁,多个读任务可以并发的持有读锁,但是写锁只能有一个任务来持有,这种锁又被称为共享/排斥锁,或者并发/排斥锁。
读-写锁的使用情景:首先主进程获得一个读-写锁,然后主进程开启一个或多个读进程,在读进程里对锁进行读操作的加锁解锁,与此同时主进程也开启多个写进程,在写进程里进程对锁执行写操作的加锁解锁。
DEFINE_RWLOCK(mr_rwlock); /*读进程*/ read_lock(&mr_rwlock); /*读临界区*/ read_unlock(&mr_rwlock); /*写进程*/ write_lock(&mr_rwlock); /*写临界区*/ write_unlock(&mr_rwlock);
读-写锁必须应用在读进操作和写操作能明确的分开在两段代码里的,不允许出现把一个读锁升级为写锁的情景,如下所示。这样会导致死锁,write_lock在无法获得锁的时候回自旋等待,所以这个进程在自旋等待自己释放锁。
read_lock(&mr_rwlock);
write_lock(&mr_rwlock);
10.4 信号量
信号量是一种睡眠锁,当一个进程无法获得锁的时候,他不是自旋等待而是去睡眠,放弃处理机并把自己加入一个等待队列。当持有锁的进程放弃锁的时候,会按照某种策略从等待队列里选择一个进行并唤醒。相较于自旋锁,睡眠锁能够更好的利用处理机资源,因为不存在自旋白白等待的情况,但是进程的上下文切换也会带来新的开销。
- 如果锁的持有时间比较短,适合使用自旋锁,因为使用信号量会带来上下文的切换这也是需要代价的,当然如果锁的持有时间比较长,那么适合使用信号量机制,因为等待锁时候的自旋会浪费处理机资源
- 执行线程在锁被争用的时候会睡眠,所以只能在进程上下文中才能获得信号量锁,因为中断上下文是不允许睡眠的
- 可以在持有信号量的时候去睡眠,因为睡眠完了之后还是会继续执行的
- 占用信号量的时候不能同时占用自旋锁,因为等待信号量时可能会睡眠,而在持有自旋锁时是不允许睡眠的
- 信号量不同于自旋锁,他不会禁止内核抢占,所以持有信号量的代码可能会被抢占
- 在需要和用户空间同步的时候,代码需要睡眠,信号量是唯一的选择
10.4.1 计数信号量和二值信号量
信号量可以在初始化的时候进行设置,从而允许任意个进程同时持有锁,这个设置的值称为数量。如果数量设置为1,即同时只允许一个进程持有锁,此时被称为互斥信号量。如果同时允许多个进程持有锁,把数量设置为相应的值,此时被称为计数信号量。
信号量支持两个操作:P操作和V操作。P操作是测试操作,V操作是增加操作,后来为了便于记忆改成为down操作和up操作。down操作用于把信号量减一,同时也是试图获取一个信号量,如果down操作的返回值是0或者大于0的值,代表当前进程成功的获取了一个锁,他可以进入临界区,否则就没有成功获得锁。up操作用于对信号量增加,在一个进程执行完临界区的代码后需要执行up操作对信号量增加,如果此时有进程带等待,那么就唤醒相应的进程。
10.4.2 创建和初始化信号量
创建信号量的时候需要指出count值的大小,或者直接创建互斥信号量。
10.4.3 使用信号量
获取信号量有三个函数,最常用的是down(),试图获取信号量,如果失败就进入睡眠,并在锁被释放的时候被唤醒。如果使用down_interruptible(),那么在睡眠的时候无法被唤醒。使用down_trylock(),如果无法获得锁就不会进入睡眠状态。
10.5 读-写信号量
自旋锁里有读-写锁,信号量里也有读写信号量。具体的情景也是相似的,可以有多个读者获取锁,但只能有一个写者获取锁。
10.6 互斥体
mutex,更多情况下被翻译成互斥锁,类似于互斥信号量,即counter为一的信号量。和信号量相同,允许在获得锁的时候睡眠,相较于技术量为1的信号量,互斥锁更加高效。它的高效性源于它的受限性,即它的应用场景比信号量要小。
- 任何时候只能有一个线程持有mutex
- 给mutex上锁的线程必须负责解锁,即加锁和解锁必须在一个上下文里,所以他不适合内核和用户空间进行同步的复杂的场景
- 递归的上锁和解锁是不允许的
- 不能在中断或者下半部使用
10.7 完成变量
内核中一个任务完成后需要通知另一个等待他的任务,此时使用完成变量是一种简单的方法。需要等待的任务调用wait_for_completion()来等待某个事件的发生,产生事件的任务调用complete()来发送信号唤醒正在等待的任务。
10.8 BLK:大内核锁
全局自旋锁。
10.9 顺序锁