很多程序员在面试的时候经常会被问到线程安全相关的问题,比如什么是线程安全,什么又是线程不安全,假如线程不安全,如何解决才能做到线程安全。这时候,往往会出现五花八门的答案,而且大多数都是本末倒置。很多时候,人们经常会用一些现象来回答问题,比如房价高这个问题,很多时候大家就会归结于某些现象:温州炒房团、丈母娘经济、对比国际大城市房价等。但是,我们需要的是“原理性的解释”,比如影响房价的经济学原理如供需关系、不均衡分布等。
再回归到线程安全问题,这是一个非常经典的问题,需要搞懂并发原理,才能搞清楚线程安全。任何事物的发展,都是有因果关系的,就像霍金博士一生孜孜不倦地研究,无非也就是想搞懂人类从哪里来,站在何方,将要去向哪里等大问题。所以针对并发这样的话题,我们学习的思路应该是这样的:
并发到底是什么,如何在系统中产生。
并发会带来什么问题。
如何解决并发带来的问题。
我觉得这个思考方式,应该可以用于大部分技术原理的学习和研究了。只有带着正确的问题出发,才有可能得到你想要的答案。下面我们就根据以上3个问题对并发相关的话题进行探讨,在后续的章节中,我还会反复强调这样的思考方式。
本章先介绍并发原理,再分析 Linux 中的并发相关工具,最后介绍开源软件中的并发问题是如何解决的。
2.1 什么是并发
首先我们需要搞清楚到底什么是并发,它在系统中又是以何种形式存在的。
2.1.1 并发是如何产生的
在操作系统中,一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行,这种情形叫并发。但是,在任一个时刻只有一个程序在处理器上运行。
从这个过程中我们大致可以了解到,并发主要和处理器(CPU)有关,当同时有多个运行中的程序需要占用处理器资源,就形成了并发。图2-1总结了并发的两种场景,第一种场景是多个进程使用同一个处理器内核资源,第二种场景是多个进程使用不同的处理器内核资源。
图2-1 两种并发场景
2.1.2 并发会带来什么问题
针对上面介绍的并发两种场景,会有不同的问题。我们先来分析第一种场景,多个进程同时使用同一个处理器核(core)资源。我们知道一个处理器核在同一时刻只能被一个进程占用,那么,从微观角度讲真正的并发应该不存在,应该不会有任何问题才对呀?很遗憾,事实情况并非如此,为了防止 CPU 资源被同一个进程长期占用,大部分硬件都会提供时钟中断机制,在中断发生的时候,会进行进程的切换,当前进程会让出 CPU,并且让其他进程能获得 CPU 的机会。因为进程切换的存在,假如共享同一个内存变量,就会存在代码临界区,比如 i++ 操作,就不能保证原子性,如图2-2所示。因为 i++ 其实分为两个步骤:
图2-2 多个进程同时使用同一个处理器核的情况
1)add i
2)set i
假设 i=0,当进程1执行完 add i 后,就发生了切换。进程2重新开始执行 add i,那么2个进程都执行完 i++ 之后,结果 i 的值还是1。
所以,在这种情况下,并发带来的问题就是进程切换造成的代码临界区。
我们来分析并发的第二种场景,多个进程同时使用多个 CPU 核。在这种情况下,会引发两种问题。第一种问题和多个进程使用1个 CPU 核引发的问题一样,由于先天就是多个核并行执行多个进程的程序,假如共享同一个变量操作,必然会存在代码临界区。
图2-3 多个进程同时使用多个处理器核的情况
第二种问题如图2-3所示,我们可以发现,因为 CPU 每个核都维护了一个 L2 cache(二级缓存),其目的是为了减少与内存之间的交互,提升数据的访问速度。但是这样,就会造成主存中的数据复制存在多份在各自的 L2 cache 中,导致数据不一致。这就是 CPU 二级缓存和内存之间的可见性问题。
2.1.3 如何解决并发带来的问题
上节分析了并发带来的问题,归根结底就2类:
代码临界区的问题。
主存可见性的问题。
下面我们分别来介绍这两类问题的解决方案。
先说代码临界区问题。孙子曰:“百战百胜,非善之善者也;不战而屈人之兵,善之善者也。”也就是说最好的战争方式,就是不要发动战争,通过谋略让对手投降。杀敌一千,自损八百,很是划不来。所以,处理代码临界区的问题也是一样,最好的方式就是消除临界区。很多时候,临界区是由于自己考虑不周到,代码编写方式不正确造成的,只要设计得当,是有可能消除的。
不过凡事无绝对,假如不能消除临界区,那么我们只能硬着头皮想办法对付了。前面我们分析临界区出现问题是因为多个进程同时进入了临界区,造成了逻辑的混乱。所以,我们可以把临界区作为一个整体,让多个进程串行通过临界区,达到保护临界区的目的。这样的机制我们就叫做同步。同步在技术上一般都是通过锁机制来解决的,后面我们会具体分析 Linux 中的不同锁实现方式。
另外像 i++ 这样的操作,一般都会在硬件级别提供原子操作指令作为解决方案,本章我们也会介绍原子变量的实现方法,一般都会通过 cmpxgl 这样原子指令来支持。
接着来看主存可见性的问题。多个进程依赖同一个内存变量,那么为了保证可见性,可以通过让 L2 cache 强制失效,都去主存中取数据。有时候编译器为了提升程序执行效率,都会对编译后的代码进行优化,让某些指令在上下文中的结果依赖 L2 cache,我们可以通过内存屏障等方式,去除编译器优化,本章后面会具体介绍这种方法。
2.2 操作系统会在哪些场景遇到并发
在互联网时代来临之前,内核虽然生来就被设计成支持多用户的,但是很少面临高并发请求考验,多用户的操作很多时候都是人工来进行的,人敲键盘的速度再快也很难达到秒级的。所以,最开始,并发仅仅针对内核级别,给内核加了一把大内核锁(BKL)。一旦某个用户在使用内核,其他用户则无法获取内核资源。
但是大内核锁太粗暴了,粒度太大。在互联网应用场景就吃不消了。互联网时代,针对不同的细节场景,开发了不同的内核工具来解决相应的问题。图2-4介绍了 Linux 内核不同并发场景提供的工具实现。
图2-4 Linux 内核针对不同并发场景的工具实现
我把操作系统和并发相关的场景归为4类:
1)和 CPU 相关的原子变量(Atomic)和自旋锁(Spin_lock)。
在并发访问的时候,我们需要保证对变量操作的原子性,通过 Atomic 变量解决该问题。其实自旋锁的使用场景和互斥锁类似,都是为了保护临界区资源,但是自旋锁是在 CPU 上进行的忙等,所以暂时就把它和原子变量归为一类了。
2)围绕代码临界区控制的相关工具有:信号量(Semaphore)、互斥(Mutex)、读写锁(Rw-lock)、抢占(Preempt)。
有时候要对多个线程进行精细化控制,就要用到信号量了,下面引用百度百科中的例子:
以一个停车场的运作为例。简单起见,假设停车场只有三个车位,一开始三个车位都是空的。这时如果同时来了五辆车,看门人允许其中三辆直接进入,然后放下车拦,剩下的车则必须在入口等待,此后来的车也都不得不在入口处等待。这时,有一辆车离开停车场,看门人得知后,打开车拦,放入外面的一辆进去,如果又离开两辆车,则又可以放入两辆车,如此往复。在这个停车场系统中,车位是公共资源,每辆车好比一个线程,看门人就是起到了信号量的作用。
互斥从某种角度来讲,可以理解为池子大小为1的信号量,它和信号量的原理类似,都会让无法获取资源的线程睡眠。
很多时候并发的访问往往都是读大于写,为了提高该场景的性能,内核提供了读写锁进行优化访问控制。
3)从 CPU 缓存角度,为优化多核本地访问的性能,内核提供了 per-cpu 变量。
在多核场景,为了解决并发访问内存的问题,经常需要锁住总线,这样效率很低。很多时候并发的最好方案就是没有并发,per-cpu 变量的设计正是基于这样的思路。
4)从内存角度,为提升多核同时访问内存的效率提供了 RCU 机制,另外,为了解决内存访问有序性问题,提供了内存屏障(memory barrier)。假如需要多核同时写同一共享数据,要保证不出问题,我能想到的也就是 Copy On Write 这样的思路,RCU 机制就是基于这个思路的实现。
程序在运行时内存实际的访问顺序和程序代码编写的访问顺序不一定一致,这就是内存乱序访问。内存乱序访问行为出现的理由是为了提升程序运行时的性能。在并发场景下,这种乱序就具有不确定性,内存屏障就是用来消除这种不确定性,保证并发场景的可靠性。
2.3 Linux 中并发工具的实现
通过上一节的介绍,我们大概了解了内核中的并发场景,以及 Linux 提供的相应工具,本节把这些工具的实现简单分析一下。
2.3.1 原子变量
原子变量是在并发场景经常使用的工具,很多并发工具都是基于原子变量来实现的,比如自旋锁。原子变量对其进行的读写操作都必须保证原子性,也就是原子操作。
1.什么是原子操作
对于 i++ 这样的操作,如果要在双核的 CPU 上每核都执行这条指令,假如现在 i=1,那么执行完之后,你希望第一个核执行完之后 i 被设置为2,第二个核执行完之后 i 被设置为3。但是,由于 i++ 这样的执行不是原子操作,所以2个核有可能同时取到 i 的值为1,然后加完之后 i 最终为2。
这种问题是典型的“读-修改-写”场景,避免该场景引发不一致问题就是确保这样的操作在芯片级是原子的。
x86 在多核环境下,多核竞争数据总线的时候,提供了 Lock 指令来进行锁总线的操作,在《Intel 开发者手册》卷 3A,8.1.2.2中说明了 Lock 指令可以影响的指令集:
1)位测试和修改的指令(BTS、BTR 和 BTC)。
2)交换指令(XADD、CMPXCHG 和 CMPXCHG8B)。
3)Lock 前缀会自动加在 XCHG 指令前。
4)单操作数逻辑运算指令:INC、DEC、NOT 和 NEG。
5)双操作数的逻辑运算指令:ADD、ADC、SUB、SBB、AND、OR 和 XOR。
2.原子变量(atomic)的实现
定义如下:
typedef struct {
int counter;
} atomic_t;
add 和 sub 方法:
static __always_inline void atomic_add(int i, atomic_t *v)
{
asm volatile(LOCK_PREFIX "addl %1,%0"
: "+m" (v->counter)
: "ir" (i));
}
static __always_inline void atomic_sub(int i, atomic_t *v)
{
asm volatile(LOCK_PREFIX "subl %1,%0"
: "+m" (v->counter)
: "ir" (i));
}
通过之前分析我们知道 intel 的原子指令保证操作的原子性。并且多核环境下使用 lock 来锁总线,保证串行访问总线。
读取方法为:
static __always_inline int atomic_read(const atomic_t *v)
{
return READ_ONCE((v)->counter);
}
在读的时候为了防止脏读,READ_ONCE 中加上了 volatile 去除编译器优化。
2.3.2 自旋锁
1.为什么使用自旋锁
由于自旋锁(Spin_lock)只是将当前线程不停地执行循环体,而不改变线程的运行状态,所以响应速度更快。但当线程数不断增加时,性能下降明显,因为每个线程都需要执行,占用 CPU 时间。所以它保护的临界区必须小,且操作过程必须短。很多时候内核资源只锁毫秒级别的时间片段,因此等待自旋锁的释放不会消耗太多 CPU 的时间。
2.自旋锁的实现
自旋锁其实是通过一个属性标志来控制访问锁的请求是否能满足,我们先来看一下 spinlock 的定义:
typedef struct spinlock {
union {
struct raw_spinlock rlock;
#ifdef CONFIG_DEBUG_LOCK_ALLOC
# define LOCK_PADSIZE (offsetof(struct raw_spinlock, dep_map))
struct {
u8 __padding[LOCK_PADSIZE];
struct lockdep_map dep_map;
};
#endif
};
} spinlock_t;
去除 debug 的干扰,我们可以看到 spinlock 的核心成员为:
struct raw_spinlock rlock
接着看 raw_spinlock 的结构:
typedef struct raw_spinlock {
arch_spinlock_t raw_lock;
#ifdef CONFIG_GENERIC_LOCKBREAK
unsigned int break_lock;
#endif
#ifdef CONFIG_DEBUG_SPINLOCK
unsigned int magic, owner_cpu;
void *owner;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
} raw_spinlock_t;
可以看到 raw_spinlock 最终依赖与体系结构相关的 arch_spinlock_t 结构,我们以 x86 为例,该结构如下所示:
typedef struct arch_spinlock {
union {
__ticketpair_t head_tail;
struct __raw_tickets {
__ticket_t head, tail;
} tickets;
};
} arch_spinlock_t;
其中 _ticketpair_t 为16位整数,_ticket_t 为8位整数。
通过 spin_lock_init 宏可以初始化自旋锁,init 的过程可以理解为把 head_tail 的值设置为1,并且为未锁住的状态。
下面是获取锁的过程:
static __always_inline int arch_spin_trylock(arch_spinlock_t *lock)
{
arch_spinlock_t old, new;
old.tickets = READ_ONCE(lock->tickets);
if (!__tickets_equal(old.tickets.head, old.tickets.tail))
return 0;
new.head_tail = old.head_tail + (TICKET_LOCK_INC << TICKET_SHIFT);// tail+1
new.head_tail &= ~TICKET_SLOWPATH_FLAG;
// cmpxchg 是一个完全内存屏障(full barrier)
return cmpxchg(&lock->head_tail, old.head_tail, new.head_tail) == old.head_tail;
}
其中:
static inline int __tickets_equal(__ticket_t one, __ticket_t two)
{
return !((one ^ two) & ~TICKET_SLOWPATH_FLAG);
}
_tickets_equal 的过程 one 和 two 先做异或,假如两者一样则返回0,TICKET_SLOW-PATH_FLAG 为0,取反后则变为 OXFF,那么该函数表明假如 one 和 two 相等则返回真;否则返回假。
arch_spin_trylock 的过程为:
1)校验锁的 head 和 tail 是否相等,假如不相等,则获取锁失败,返回0。
2)给 tail+1。
3)比较 lock->head_tail 和 old.head_tail 的值是否相等,如果相等,则把 new.head_tail 赋给 new.head_tail 并且返回1。
接着我们来看释放锁的过程:
static __always_inline void arch_spin_unlock(arch_spinlock_t *lock)
{
if (TICKET_SLOWPATH_FLAG &&
static_key_false(¶virt_ticketlocks_enabled)) {
__ticket_t head;
BUILD_BUG_ON(((__ticket_t)NR_CPUS) != NR_CPUS);
head = xadd(&lock->tickets.head, TICKET_LOCK_INC);
if (unlikely(head & TICKET_SLOWPATH_FLAG)) {
head &= ~TICKET_SLOWPATH_FLAG;
__ticket_unlock_kick(lock, (head + TICKET_LOCK_INC));
}
} else
__add(&lock->tickets.head, TICKET_LOCK_INC, UNLOCK_LOCK_PREFIX);
}
这个函数的关键就在于:
__add(&lock->tickets.head, TICKET_LOCK_INC, UNLOCK_LOCK_PREFIX);
解锁的过程就是给 __add&lock->tickets.head 做+1操作。
接下来看判断是否上锁的条件:
static inline int arch_spin_is_locked(arch_spinlock_t *lock)
{
struct __raw_tickets tmp = READ_ONCE(lock->tickets);
return !__tickets_equal(tmp.tail, tmp.head);
}
从上面的函数我们可以知道,其实就是判断 tail 和 head 是否相等,假如不相等则说明已经上锁了。
最后我们来看一下循环等待获取锁的过程:
static __always_inline void arch_spin_lock(arch_spinlock_t *lock)
{
register struct __raw_tickets inc = { .tail = TICKET_LOCK_INC };
inc = xadd(&lock->tickets, inc);
if (likely(inc.head == inc.tail))
goto out;
for (;;) {
unsigned count = SPIN_THRESHOLD;
do {
inc.head = READ_ONCE(lock->tickets.head);
if (__tickets_equal(inc.head, inc.tail))
goto clear_slowpath;
cpu_relax();
} while (--count);
__ticket_lock_spinning(lock, inc.tail);
}
clear_slowpath:
__ticket_check_and_clear_slowpath(lock, inc.head);
out:
barrier();
}
这个过程步骤如下:
1)tail++。
2)假如 tail++ 之前 tail 和 head 相等,则说明现在已经获得了锁,退出。
3)假如 tail 和 head 不相等,则循环等待,直到相等为止。
图2-5 获取和释放自旋锁的过程
图2-5说明了整个加锁和释放锁的过程,每次上锁都会进行 tail++。解锁进行 head++,当 head==tail 的时候,则说明未上锁。
2.3.3 信号量
通过前面的介绍,我们已经知道信号量(Sema-phore)用于保护有限数量的临界资源,在操作完共享资源后,需释放信号量,以便另外的进程来获得资源。获得和释放应该成对出现。从操作系统的理论角度讲,信号量实现了一个加锁原语,即让等待者睡眠,直到等待的资源变为空闲。
下面我们来分析信号量的实现,其定义如下:
struct semaphore {
raw_spinlock_t lock; // 获取计数器的自旋锁
unsigned int count; // 计数器
struct list_head wait_list; // 等待队列
};
图2-6描述了信号量获取和释放的原理,即 down 和 up 的过程。在 down 的过程中,假如 count>0,则做 count- 操作;否则执行 __down,并且在获取自旋锁的时候保存中断到 eflags 寄存器,最后再恢复中断。
图2-6 信号量获取和释放的原理图
其中 __down 的执行过程为:
1)先把当前 task 的 waiter 放入 wait_list 队列尾部。
2)进入死循环中。
3)假如 task 状态满足 signal_pending_state,则跳出循环,并且从等待队列中删除,返回 EINTR 异常。
4)假如等待的超时时间用完了,则跳出循环,并且从等待队列中删除,返回 ETIME 异常。
5)设置 task 状态为之前传入的 TASK_UNINTERRUPTIBLE(该状态只能被 wake_up 唤醒)。
6)释放 sem 上的 lock。
7)调用 schedule_timeout,直到 timout 后被唤醒,然后重新申请 sem->lock。
8)假如 waiter.up 状态变为 true 了,则说明到了被 up 唤醒的状态了,则返回0。
在 up 的过程中,先获取 sem->lock,并且保存中断。如果 sem->wait_list 为空,则直接做 sem->count++ 操作;否则执行 __up。
其中 __up 的执行过程为:
1)从 sem->wait_list 队列中找到第一个等待的任务。
2)从等待队列中删除该任务。
3)把 waiter->up 设置为 true。
4)尝试唤醒该进程。
2.3.4 互斥锁
互斥锁(Mutex)从功能上来讲和自旋锁类似,都是为了控制同一时刻只能有一个线程进入临界区。从实现上来讲,自旋锁是在 CPU 上实现忙等,而互斥锁则会让无法进入临界区的线程休眠。从某种角度来讲,互斥锁其实就是退化版的信号量。下面是互斥锁的定义:
struct mutex {
// 1: unlocked, 0: locked, 小于0: locked, 在锁上有等待者
atomic_t count;
spinlock_t wait_lock;
struct list_head wait_list;
…
};
可以发现 count 只有两种状态1和0,1为 unlock;0为 locked。其他实现都和信号量类似,大家可以结合代码并且参考信号量的实现来自己分析。
2.3.5 读写锁
在很多时候,并发访问都是读大于写的场景,假如把读者当做写者一样加锁,那么对性能影响较大。所以读写锁(rw-lock)分别对读者和写者进行了处理,来优化解决该场景下的性能问题。
下面我们来看 Linux 对读写锁的实现,首先来看一下在 x86 中对其的定义:
typedef struct qrwlock {
atomic_t cnts;
arch_spinlock_t wait_lock;
} arch_rwlock_t;
其中原子变量 cnts 初始化为0,自旋锁 wait_lock 初始化为未上锁状态。
结合图2-7我们来分析其实现原理:
图2-7 读写锁实现原理
获取读锁的过程如下:
1)如果 cnts 低八位的读部分为0,那么就进入下一步;否则获得锁失败。
2)对高位的读为+1。
3)再进行判断是否写位置为0,如果是则返回1,说明获得了锁。
4)如果写锁被别人获得了,那么就把高位减1,并且返回0,获得读锁失败。
释放读锁的过程只要把 cnts 的高位减1即可。
获取写锁的过程如下:
1)假如 cnts 为0,则 if 条件不满足,说明没有读者和写者;否则要是存在读者或者写者,返回0,获取写锁失败。
2)把 cnts 的低八位写标志设置为 OXFF。
释放写锁则直接把低八位的读标志设置为0。
2.3.6 抢占
我们先回顾一下,一个进程什么时候会放弃 CPU:
时间片用完后调用 schedule 函数。
由于 IO 等原因自己主动调用 schedule。
其他情况,当前进程被其他进程替换的时候。
那么,加入抢占(preempt)的概念后,当前进程就有可能被替换,假如当你按下键盘的时候,键盘中断程序运行之后会让进程 B 替换你当前的工作进程 A,原因是 B 进程优先级比较高,这就是抢占。
内核要完成抢占必然需要打开本地中断,这两者是不可分割的,如图2-8所示。
图2-8 用户键盘输入发生抢占
下面我们来看 Linux 中开启抢占的实现:
#define preempt_enable() \
do { \
barrier(); \
if (unlikely(preempt_count_dec_and_test())) \
__preempt_schedule(); \
} while (0)
假如 _preempt_count-1 之后还是大于0,那么将会执行:_preempt_schedule();
asmlinkage __visible void __sched notrace preempt_schedule(void)
{
if (likely(!preemptible()))
return;
preempt_schedule_common();
}
#define preemptible()(preempt_count() == 0 && !irqs_disabled())
static void __sched notrace preempt_schedule_common(void)
{
do {
preempt_disable_notrace();
__schedule(true);
preempt_enable_no_resched_notrace();
} while (need_resched());
}
preempt_schedule 函数检查是否允许本地中断,以及当前进程的 preempt_count 字段是否为0,如果两个条件都为真,它就调用 schedule()选择另外一个进程来运行。因此,内核抢占可能在结束内核控制路径(通常是一个中断处理程序)时发生,也可能在异常处理程序调用 preempt_enable()重新允许内核抢占时发生。
2.3.7 per-cpu 变量
目前生产环境的服务器大多数跑的都是 SMP(对称多处理器结构),如图2-9所示。因为 SMP 系统多个核心与内存交互的时候,因为 L1 cache 的存在,会出现一致性的问题。所以,最好的方式就是每个核自己维护一份变量,自己用自己的,这样就不会出现一致性问题了。
图2-9 独立 L1 cache 的 SMP 处理器架构
为了解决这个问题,Linux 引入了 per-cpu 变量,可以在编译时声明,也可以在系统运行时动态生成。
首先来感受一下 per-cpu 变量的使用方法。per-cpu 变量在使用之前需要先进行定义,编译期间创建一个 per-cpu 变量:
DEFINE_PER_CPU(int,my_percpu); // 声明一个变量
DEFINE_PER_CPU(int[3],my_percpu_array); // 声明一个数组
使用编译时生成的 per-cpu 变量:
ptr = get_cpu_var(my_percpu);
// 使用ptr
put_cpu_var(my_percpu);
也可以使用下列宏来访问特定 CPU 上的 per-cpu 变量:
per_cpu(my_percpu, cpu_id);
per-cpu 变量导出,供模块使用:
EXPORT_PER_CPU_SYMBOL(per_cpu_var);
EXPORT_PER_CPU_SYMBOL_GPL(per_cpu_var);
动态分配 per-cpu 变量:
void *alloc_percpu(type);
void *__alloc_percpu(size_t size, size_t align);
使用动态生成的 per-cpu 变量:
int cpu;
cpu = get_cpu();
ptr = per_cpu_ptr(my_percpu);
// 使用 ptr
put_cpu();
接下来我们通过 per-cpu 变量的初始化过程来分析其实现原理,系统在启动时会调用 _init setup_per_cpu_areas 为 per-cpu 变量申请内存空间:
void __init setup_per_cpu_areas(void)
{
unsigned int cpu;
unsigned long delta;
int rc;
…
#ifdef CONFIG_X86_64
atom_size = PMD_SIZE;
#else
atom_size = PAGE_SIZE;
#endif
rc = pcpu_embed_first_chunk(PERCPU_FIRST_CHUNK_RESERVE,
dyn_size, atom_size,
pcpu_cpu_distance,
pcpu_fc_alloc, pcpu_fc_free);
…
}
if (rc < 0)
rc = pcpu_page_first_chunk(PERCPU_FIRST_CHUNK_RESERVE,
pcpu_fc_alloc, pcpu_fc_free,
pcpup_populate_pte);
…
/* percpu 区域已初始化并且可以使用 */
delta = (unsigned long)pcpu_base_addr - (unsigned long)__per_cpu_start;
for_each_possible_cpu(cpu) {
per_cpu_offset(cpu) = delta + pcpu_unit_offsets[cpu];
per_cpu(this_cpu_off, cpu) = per_cpu_offset(cpu);
per_cpu(cpu_number, cpu) = cpu;
setup_percpu_segment(cpu);
setup_stack_canary_segment(cpu);
// 下面进行 early init 阶段需要初始化的 per_cpu 数据
#ifdef CONFIG_X86_LOCAL_APIC
per_cpu(x86_cpu_to_apicid, cpu) =
early_per_cpu_map(x86_cpu_to_apicid, cpu);
per_cpu(x86_bios_cpu_apicid, cpu) =
early_per_cpu_map(x86_bios_cpu_apicid, cpu);
#endif
#ifdef CONFIG_X86_32
per_cpu(x86_cpu_to_logical_apicid, cpu) =
early_per_cpu_map(x86_cpu_to_logical_apicid, cpu);
#endif
#ifdef CONFIG_X86_64
per_cpu(irq_stack_ptr, cpu) =
per_cpu(irq_stack_union.irq_stack, cpu) +
IRQ_STACK_SIZE - 64;
#endif
#ifdef CONFIG_NUMA
per_cpu(x86_cpu_to_node_map, cpu) =
early_per_cpu_map(x86_cpu_to_node_map, cpu);
…
}
其中两个关键步骤为:
1)pcpu_page_first_chunk。先分配一块 bootmem 区间 p,作为一级指针,然后为每个 CPU 分配 n 个页,依次把指针存放在 p 中。p[0]..p[n-1]属于 cpu0,p[n]-p[2n-1]属于 CPU2,依次类推。接着建立一个长度为 n×NR_CPUS 的虚拟空间(vmalloc_early),并把虚拟空间对应的物理页框设置为 p 数组指向的 pages。然后把每 CPU 变量 _per_cpu_load 拷贝至每个 CPU 自己的虚拟地址空间中。
2)将 .data.percpu 中的数据拷贝到其中,每个 CPU 各有一份。由于数据从 _per_cpu_start 处转移到各 CPU 自己的专有数据区中了,因此存取其中的变量就不能再用原先的值了,比如存取 per_cpu_runqueues 就不能再用 per_cpu_runqueues 了,需要做一个偏移量的调整,即需要加上各 CPU 自己的专有数据区首地址相对于 _per_cpu_start 的偏移量。在这里也就是 _per_cpu_offset[i],其中 CPU i 的专有数据区相对于 _per_cpu_start 的偏移量为 _per_cpu_offset[i]。
经过这样的处理,.data.percpu 这个 section 在系统初始化后就可以释放了。
其中 _per_cpu_load 被重定向到了 .data..percpu 区域,和 _per_cpu_start 位置是一样的:
#define PERCPU_SECTION(cacheline)
. = ALIGN(PAGE_SIZE);
.data..percpu : AT(ADDR(.data..percpu) - LOAD_OFFSET) {
VMLINUX_SYMBOL(__per_cpu_load) = .;
PERCPU_INPUT(cacheline)
}
图2-10为 per-cpu 变量的初始化流程,我们可以发现,经过 setup_per_cpu_areas 函数,per_cpu 变量被拷贝到了各自的虚拟地址空间。原来的 per_cpu 变量区域,即 _per_cpu_start 和 _per_cpu_end 区域将会被删除。
图2-10 per-cpu 变量的初始化
2.3.8 RCU 机制
在 Linux 中,RCU(Read,Copy,Update)机制用于解决多个 CPU 同时读写共享数据的场景。它允许多个 CPU 同时进行写操作,而且不使用锁,其思想类似于 copy on write 的原理,并且实现垃圾回收器来回收旧数据。
使用 RCU 机制有几个前提条件:
RCU 使用在读者多而写者少的情况。RCU 和读写锁相似。但 RCU 的读者占锁没有任何的系统开销。写者与写者之间必须要保持同步,且写者必须要等它之前的读者全部都退出之后才能释放之前的资源。
RCU 保护的是指针。这一点尤其重要,因为指针赋值是一条单指令,即一个原子操作,因此更改指针指向没必要考虑它的同步,只需要考虑 cache 的影响。
读者是可以嵌套的,也就是说 rcu_read_lock()可以嵌套调用。
读者在持有 rcu_read_lock()的时候,不能发生进程上下文切换;否则,因为写者需要要等待读者完成,写者进程也会一直被阻塞。
下面是 whatisRCU.txt 中使用 RCU 锁的例子:
struct foo {
int a;
char b;
long c;
};
DEFINE_SPINLOCK(foo_mutex);
struct foo *gbl_foo;
void foo_update_a(int new_a)
{
struct foo *new_fp;
struct foo *old_fp;
new_fp = kmalloc(sizeof(*new_fp), GFP_KERNEL);
spin_lock(&foo_mutex);
old_fp = gbl_foo;
*new_fp = *old_fp;
new_fp->a = new_a;
rcu_assign_pointer(gbl_foo, new_fp);
spin_unlock(&foo_mutex);
synchronize_rcu();
kfree(old_fp);
}
int foo_get_a(void)
{
int retval;
rcu_read_lock();
retval = rcu_dereference(gbl_foo)->a;
rcu_read_unlock();
return retval;
}
如上代码中,RCU 用于保护全局指针 struct foo*gbl_foo.foo_get_a()用来从 RCU 保护的结构中取得 gbl_foo 的值。而 foo_update_a()用来更新被 RCU 保护的 gbl_foo 的值。
我们再思考一下,为什么要在 foo_update_a()中使用自旋锁 foo_mutex 呢?
假设中间没有使用自旋锁。那 foo_update_a()的代码如下:
void foo_update_a(int new_a)
{
struct foo *new_fp;
struct foo *old_fp;
new_fp = kmalloc(sizeof(*new_fp), GFP_KERNEL);
old_fp = gbl_foo;
1:-------------------------
*new_fp = *old_fp;
new_fp->a = new_a;
rcu_assign_pointer(gbl_foo, new_fp);
synchronize_rcu();
kfree(old_fp);
}
假设 A 进程在上面代码的----标识处被 B 进程抢点,B 进程也执行了 goo_ipdate_a(),等 B 执行完后,再切换回 A 进程,此时,A 进程所持的 old_fd 实际上已经被 B 进程给释放掉了,此后 A 进程对 old_fd 的操作都是非法的。
RCU API 说明
我们在上面也看到了几个有关 RCU 的核心 API,它们为别是:
rcu_read_lock()
rcu_read_unlock()
synchronize_rcu()
rcu_assign_pointer()
rcu_dereference()
其中:
rcu_read_lock()和 rcu_read_unlock()用来保持一个读者的 RCU 临界区,在该临界区内不允许发生上下文切换。
rcu_dereference():读者调用它来获得一个被 RCU 保护的指针。
rcu_assign_pointer():写者使用该函数来为被 RCU 保护的指针分配一个新的值,这样是为了安全地从写者到读者更改其值,这个函数会返回一个新值。
rcu_dereference:实现也很简单,因为它们本身都是原子操作,因为只是为了 cache 一致性,插上了内存屏障,可以让其他的读者/写者看到保护指针的最新值。
synchronize_rcu:函数由写者来调用,它将阻塞写者,直到所有读执行单元完成对临界区的访问后,写者才可以继续下一步操作。如果有多个 RCU 写者调用该函数,它们将在所有读执行单元完成对临界区的访问后全部被唤醒。
结合图2-11我们来说明 Linux RCU 机制的思路:
1)对于读操作,可以直接对共享资源进行访问,但前提是需要 CPU 支持访存操作的原子化,现代 CPU 对这一点都做了保证。但是 RCU 的读操作上下文是不可抢占的,所以读访问共享资源时可以采用 read_rcu_lock(),该函数的功能是停止抢占。
2)对于写操作,思路类似于 copy on write,需要将原来的老数据做一次拷贝,然后对其进行修改,之后再用新数据更新老数据,这时采用了 rcu_assign_pointer()宏,在该函数中首先通过内存屏障,然后修改老数据。这个操作完成之后,老数据需要回收,操作线程向系统注册回收方法,等待回收。这个思路可以实现读者与写者之间的并发操作,但是不能解决多个写者之间的同步,所以当存在多个写者时,需要通过锁机制对其进行互斥,也就是在同一时刻只能存在一个写者。
3)在 RCU 机制中存在一个垃圾回收的后台进程,用于回收老数据。回收时间点就是在更新之前的所有读者全部退出时。由此可见,写者在更新之后是需要睡眠等待的,需要等待读者完成操作,如果在这个时刻读者被抢占或者睡眠,那么很可能会导致系统死锁。因为此时写者在等待读者,读者被抢占或者睡眠,如果正在运行的线程需要访问读者和写者已经占用的资源,那么将导致死锁。
图2-11 Linux RCU 机制实现原理
那究竟怎么去判断当前的写者已经操作完了呢?我们在之前看到,读者在调用 rcu_read_lock 的时候会禁止抢占,因此只需要判断所有的 CPU 都进过了一次上下文切换,就说明所有读者已经退出了。[1]
2.3.9 内存屏障
程序在运行过程中,对内存访问不一定按照代码编写的顺序来进行。这是因为有两种情况存在:
编译器对代码进行优化。
多 CPU 架构存在指令乱序访问内存的可能。
为解决这两个问题,分别需要通过不同的内存屏障来避免内存乱序。
首先我们来看第一种情况,编译器优化。例如有如下场景:
线程1执行:
while (!condition);
print(x);
线程2执行:
x = 100;
condition = 1;
condition 初始值为0,结果线程1打印出来不一定为100,因为编译器优化后,有可能线程2先执行了 condition=1;后执行 x=100;我们可以在 gcc 编译的时候加上 O2 或者 O3 的选项,就会发生编译器优化。
为了消除该场景下编译器优化带来的不确定性,可以使用内存屏障:
#define barrier() __asm__ __volatile__("" ::: "memory")
x = 100;
barrier()
condition = 1;
另外,可以给变量加上 volatile 来去除编译器优化:
#define ACCESS_ONCE(x) (*(volatile typeof(x) *)&(x))
ACCESS_ONCE(x) = 100;
ACCESS_ONCE(condition) = 1;
接着我们来看多 CPU 运行当中内存访问乱序的问题,图2-12是 Intel CPU 的 P6 微架构,目前大部分的 Inter CPU 都沿用了该架构的思路,其他都是一些小的优化。从图中可以看到,CPU 在处理指令的时候,为了提升性能,减少等待内存中的数据,采用了乱序执行引擎。
注意
很多时候我们并不能保证代码是按照我们书写的顺序来运行的。
假设如下代码:
volatile int x, y, r1, r2;
void start()
{
x = y = r1 = r2 = 0;
}
void end()
{
assert(!(r1 == 0 && r2 == 0));
}
void run1()
{
x = 1;
r1 = y;
}
void run2()
{
y = 1;
r2 = x;
}
图2-12 Intel CPU 的 P6 微架构
代码执行顺序为:
1)start()
2)线程1执行 run1()
3)线程2执行 run2()
4)调用 end()
结果 r1 或者 r2 均有可能为0,原因就是乱序执行引擎的存在。要解决这个问题,在 Pentium 4微处理器中引入了汇编语言指令 lfence、sfence 和 mfence,它们分别有效地实现读内存 barrier、写内存 barrier 和“读-写”内存 barrier:
#define mb() asm volatile("mfence":::"memory")
#define rmb() asm volatile("lfence":::"memory")
#define wmb() asm volatile("sfence" ::: "memory")
可以这样修改:
void run1()
{
x = 1;
mb();
r1 = y;
}
void run2()
{
y = 1;
mb();
r2 = x;
}
[1] 参考 http:// www.ibm.com/developerworks/cn/linux/l-rcu/ 中对 RCU 过程有详细描述。