内核同步
内核同步解决并发带来的问题,多个线程对同一数据进行修改,数据会出现不一致的情况,同步用于保护共享数据等资源。
有两种形式的并发:
- 同时进行式并发,在不同cpu上执行的进程同时访问共享数据
- 二次进入式并发,某进程读写一段数据时,中断触发,在中断处理函数中再次修改之前进程读写的内容
访问共享数据的那部分代码被称为临界区。
原子操作
不可打断的操作为原子操作,一条汇编指令不可被中断,其为原子操作。在内核代码中,我们可以看到类似atomic64_add这样的函数,使用它们完成加减运算,而不是简单地使用”+”、”-“运算符。
x86_64架构下,访问一个对齐的long型是原子操作:
- volatile unsigned long value_;
- value_=191987987;
以上赋值语句是原子的,即使多线程同时访问以上value_亦不需要加锁,所有线程要么看到旧值,要么看到新值。
但value_++;这条自增语句不是原子的,它需要读内存、改值、写内存三条指令:
- 4004ca: 48 8b 45 f8 mov -0x8(%rbp),%rax
- 4004ce: 48 83 c0 01 add $0x1,%rax
- 4004d2: 48 89 45 f8 mov %rax,-0x8(%rbp)
gcc等编译器,会针对这种操作,提供内建的原子方法,如上面的value_++可以修改为:
- __sync_fetch_and_add(&value_, 1);
对应__sync_fetch_and_add的汇编如下:
- 4004bc: 48 8d 45 f8 lea -0x8(%rbp),%rax
- 4004c0: f0 48 83 00 01 lock addq $0x1,(%rax)
以上lock前缀用于锁定总线,保证后面一条指令对内存的独占访问。gcc提供了一组原子方法,更多可以参看gcc手册。
根据需要保护的数据的粒度、等待锁时进程是否可休眠等不同应用场景,锁有很多种类,下面我们来看内核代码中几种常用的锁。
原子锁
像以上介绍的atomic64_add就是一个原子锁,其用于保护一个整型值,在内核代码中由一条汇编语句实现:
- static __inline__ void atomic64_add(long i, atomic64_t *v)
- {
- __asm__ __volatile__(
- LOCK "addq %1,%0"
- :"=m" (v->counter)
- :"ir" (i), "m" (v->counter));
- }
以上代码中,同样用到lock进行内存保护。
自旋锁
在我们编写应用程序的时候,常使用c库中的pthread_mutex_lock对临界区进行加锁,pthread_mutex_lock底层使用futex系统调用实现。若锁变量mutex已被其他线程占用,则后续申请锁的进程将进入休眠,当mutex被释放时,后续的进程被唤醒。
不同于pthread_mutex_lock获取不到锁的进程将进入休眠,使用自旋锁(spin lock)的进程,若锁已被其他进程占用,则一直占用cpu,重复检查锁的状态,直到该锁可用为止。
自旋锁是为多处理器的使用而设计的,对于运行可抢占内核的单处理器,其行为类似于多处理器。因而,自旋锁对多处理器和使用可抢占内核的单处理器都适用,均可用于临界区保护。
但自旋锁对使用不可抢占内核的单处理器没有意义,因为当cpu处于自旋状态时,它做不了任何有用的工作,非抢占式单处理器系统上通过禁止中断实现临界区的保护,自旋锁被实现为空操作。
在同一个cpu上,自旋锁不可递归获取。
下面是自旋锁的一个具体使用例子:
- SYSCALL_DEFINE1(close, unsigned int, fd)
- {
- struct file * filp;
- struct files_struct *files = current->files;
- struct fdtable *fdt;
- int retval;
- spin_lock(&files->file_lock);
- fdt = files_fdtable(files);
- filp = fdt->fd[fd];
- rcu_assign_pointer(fdt->fd[fd], NULL);
- FD_CLR(fd, fdt->close_on_exec);
- __put_unused_fd(files, fd);
- spin_unlock(&files->file_lock);
- retval = filp_close(filp, files);
- return retval;
- }
以上是close系统调用的实现代码(截取了自旋锁相关的部分)。可以看到操作文件结构、文件描述符前,先调用spin_lock获取当前文件对应的files_struct结构中的file_lock,之后修改临界区,完成清除标志位、把文件描述符fd放入未使用列表等工作,最后调用spin_unlock释放file_lock自旋锁。
读写自旋锁
对于读操作而言,其实并不需要加锁,因而我们可以对读和写区别对待:
- 没有写操作时,可以进行多个读操作
- 多个读操作进行时,写操作需要等待
使用读写自旋锁,在读得多,写得少的场景下,有很大的效率提升。
内核中读写自旋锁的类型为rwlock_t,相关的操作函数有read_lock、write_lock等。
信号量
信号量(semaphore),类似于c库中的pthread_mutex_lock。进程1申请的信号量若被进程2占用,则进程1进入休眠状态,这时允许进程调度,进程1被切换后,cpu可以进行其他工作。
内核中信号量用semaphore结构表示,获取信号量的函数为down(),释放信号量的函数为up()。
使用自旋锁时进程一直占用cpu,而使用信号量时进程可休眠,但进程休眠时发生切换将带来一定cpu开销。根据以上两种锁的特点,自旋锁与信号量适用于不同场景:
- Requirement Recommended Lock
- Low overhead locking Spin lock is preferred
- Short lock hold time Spin lock is preferred
- Long lock hold time Semaphore is preferred
- Need to lock from interrupt contex Spin lock is required
- Need to sleep while holding lock Semaphore is required
读写信号量
与自旋锁分读写自旋锁类似,信号量也分读写信号量。读写信号量由rw_semaphore表示,相关的操作函数有down_read/up_read、down_write/up_write。
下面来看进程获取信号量,进入休眠,唤醒并获取信号量的具体实现过程:
- 进程调用down_read获取一个读信号量
down_read调用__down_read,在__down_read函数中,调用set_task_state设置进程状态,将获取读信号量的请求加入请求队列中,在获取不到锁的情况下,调用schedule进行进程切换
- 进程调用up_read释放一个读信号量
up_read调用__up_read,__up_read函数调用rwsem_wake,该函数调用__rwsem_do_wake,__rwsem_do_wake函数中,获取请求队列中的下一个请求,调用wake_up_process函数唤醒发起下一个请求的进程,wake_up_process调用try_to_wake_up,try_to_wake_up调用activate_task,activate_task调用enqueue_task,将进程加入可运行队列
BKL
大内核锁(Big kernel lock, BKL),是一个全局可见的锁,它的出现是为了解决SMP出现后的并发问题。
获取BKL之后,内核态被上锁,同一时刻只能有一个cpu能运行内核代码,无法发挥多处理器的威力,BKL正逐渐地被其他更细粒度的锁替代。
Reference: Chapter 9 and chapter 10, Linux kernel development.3rd.Edition