Linux内核学习——内核同步方法

内核同步方法

原子操作

原子操作的意义在于要么就完整地执行所有的指令,要么就都不执行。在内核中提供了两组原子操作的接口——原子整数和原子位

原子整数操作

原子整数操作提供了一种新的数据类型,以避免原子操作函数与非原子操作函数使用相同的函数,同时也能屏蔽不同体系结构之间的差异:

1
2
3
typedef struct {
volatile int counter;
} atomic_t;

<asm/atomic.h>中提供了一系列原子整数操作的接口:

1
2
3
4
5
6
atomic_t v;
atomic_t u = ATOMIC_INIT(0);

atomic_set(&v, 4); //v=4
atomic_add(2, &v); //v = v+4
atomic_inc(&v); //v = v+1

原子整数操作最常见的应用就是计数器,例如可以在c++的智能指针中增加原子整数作为计数器。能用原子操作就尽量不用锁机制,增加效率。

64位原子操作

随着64位系统的发展,内核开发者也引入了64位的atomic64_t。

1
2
3
typedef struct {
volatile long counter;
} atomic64_t;

原子位操作

与原子整数操作不同,原子位操作是在普通的内存地址上进行操作,因此原子位操作没有特定的类型,针对的只是普通指针类型。

1
2
3
unsigned long word = 0;
set_bit(0, &word);//设置第0位
set_bit(1, &word);//设置第1位

自旋锁

临界区的情况可能非常复杂,不能像上面那样只是简单声明原子变量就可以保证同步,因此就引入更为复杂的同步方法——锁。

Linux内核中最常见的锁是自旋锁(spin lock)——这种锁最多只能被一个可执行线程持有。一个可争用的自旋锁会使得请求线程busy wait,不断消耗处理器的时间。当然有方法可以让线程先行睡眠,待锁可用时再唤醒。但考虑到上下文切换的消耗,如果持有锁的时间比较短,自旋锁是一个比较好的选择。

如果持有自旋锁的时间小于两次上下文切换,那么可以选用自旋锁。

自旋锁方法

自旋锁的实现是和体系结构相关的,代码往往通过汇编来实现。基本的使用形式如下:

1
2
3
4
DEFINE_SPINLOCK(mr_lock);
spin_lock(&mr_lock);
/*临界区*/
spin_unlock(&my_lock);

自旋锁不可递归,如果一个线程试图去获取一个自己已经拥有的自旋锁,就会陷入死锁

其它针对自旋锁的操作

可以使用spin_lock_init()来初始化动态创建的自旋锁(此时返回一个指向spinlock_t类型的指针)。spin_try_lock()会试图获取某个特定的自旋锁,而spin_is_locked()方法则只是判断特定锁是否被占用。

自旋锁和下半部

与下半部配合使用时,我们在获取锁的时候需要禁止所有下半部的执行,因为下半部可以抢占进程上下文中的代码,此时可以使用方法——spin_lock_bh()

读-写自旋锁

由于锁的用途可以明确分为读取和写入两个场景,因此提供了读-写自旋锁进行保护。一个或者多个任务可以并发地拥有读者锁,但写者锁只能被一个写任务拥有。

1
2
3
4
5
6
7
8
9
DEFINE_SPINLOCK(mr_rwlock);
read_lock(&mr_rwlock);
/*临界区,只读*/
read_unlock(&mr_rwlock);

/*写者分支*/
write_lock(&mr_rwlock);
/*临界区*/
write_unlock(&mr_rwlock);

注意这样的一种死锁情况,写者锁不断自旋,等待读者锁释放

1
2
read_lock(&mr_lock);
write_lock(&mr_lock);

信号量

linux中的信号量实际上是一种睡眠锁,则任务试图获得一个不可用的信号量时,就会进入一个等待队列,然后处于休眠状态,此时处理器就可以释放。与自旋锁不同,信号量在等待锁时会睡眠,所以信号量适用于锁被长时间占有的情况。如果占有锁时间较短,那么维护队列,切换上下文的开销就不值得了。另外,在占有信号量时,不能同时占用自旋锁,因为拥有自旋锁时不允许睡眠。

计数信号量和二值信号量

信号量可以允许持有计数器,如果一个时刻只允许一个锁持有者,那就是二值信号量,如果计数可以大于1,那就是计数信号量。

由于引入了计数机制,因此信号量支持两个原子操作P()和V()。一个将信号量计数减一来请求一个信号量,另一个则是讲信号加一来释放信号量。

创建和初始化信号量

1
2
3
4
struct semaphore name;
sema_init(&name, count);
//动态创建信号量
init_MUTEX(sem);

使用信号量

函数down_interruptible()会试图获取指定信号量,如果信号量不可用,它会将进程置成TASK_INTERRUPTLE状态,进入睡眠。

读-写信号量

读写信号量与读写自旋锁差别不大,但是读写信号量相比读写自旋锁多一种特有的操作——downgrade_write(),这个函数可以动态地将获取的写锁转换为读锁。

互斥体

互斥体(mutex)是指任何可以睡眠的强制互斥锁,就像计数是1的信号量。互斥体在内核中对应的数据结构为mutex:

1
2
3
4
5
6
7
//静态创建
DEFINE_MUTEX(name);
//动态
mutex_init(&mutex);
//加锁、解锁
mutex_lock(&mutex);
mutex_unlock(&mutex);

相比信号量,mutex的限制更多:

  • 只有一个任务可以拥有mutex;
  • 给mutex上锁的任务必须负责解锁;
  • 不能递归地上锁和解锁;
  • mutex不能在中断或者下半部使用;
  • mutex只能通过官方API管理;

尽量使用mutex,除非mutex的限制影响了你;除非是低开销的加锁、或者短期锁定,又或者需要在中断上下文加锁,某人都用mutex;

顺序锁

在2.6版本内核中引进了一种新型锁,顺序锁(seq),通过一个序列计数器,在有数据写入的时候,就会得到一个锁,并且序列值增加。在读取数据之前和之后,会比较序列号,相同才证明数据没有被修改。

1
2
3
4
unsigned long seq;
do{
seq = read_seqbegin(&mr_seq_lock);
}while (read_seqretry(&mr_seq_lock, seq));

seq锁对于写者更有利,如果有写者,那么读数据就会不断进入循环。

禁止抢占

由于内核是抢占性的,内核中的进程在任何时刻都可能停下了,另一个进程运行,这就可能出现临界区有两个进程。自旋锁可以避免这个问题,但某些情况不需要自旋锁,可以通过禁止抢占来实现:

1
2
3
preempt_disable();
/*抢占被禁止...*/
preempt_enable();

顺序和屏障

当处理多处理器之间的同步问题时,我们可能需要在代码中指定读内存和写内存的指令,这种确保顺序的指令就叫屏障(barriers)。

  • rmb()提供了读内存屏障,它确保跨越rmb()的载入动作不会发送重排;
  • wmb()提供的是写内存屏障;