本博客将深入探讨Linux中的并发与同步机制,从原理到实践,逐步揭示其中的奥秘。我们将介绍Linux中常见的并发编程概念,例如进程、线程、锁,以及它们在实际应用中的使用技巧和最佳实践。
并发访问,指的是多个内核代码路径同时访问和操作数据,这个代码可执行路径有以下几种情况:
- 内核执行路径
- 中断处理程序
- 内核线程
临界区指的是访问和操作共享数据的代码段,其中的资源不能被多个执行线程访问。为了防止并发访问,就需要保证访问临界区的原子性,即在临界区内不能有多个并发源同时执行。
在内核中产生并发访问的并发源主要有以下几种,但是分单处理器系统和多处理器:
1.中断和异常:中断发生后,中断处理程序和被中断的进程之间可能产生并发访问。
- 单核:中断处理程序可以打断软中断和tasklet的执行。
- 多核:同一类型的中断处理程序不会并发执行,但是不同类型的中断可能送达不同的cpu,不同类型的中断处理程序可能会并发执行。
2.软中断和tasklet:软中断和tasklet随时可能被调度,从而打断当前正在执行的进程上下文。
- 单核:软中断和tasklet之间不会并发执行,但是可以打断进程上下文的执行。
- 多核:同一类型的软中断会在不同的CPU上并发执行,同一类型的tasklet是串行执行的,不会在多个CPU上并发执行。
3.内核抢占:调度器支持抢占,会导致进程和进程之间的并发访问。
- 单核:只有在支持抢占的内核中,进程上下文会产生并发。
- 多核:不同CPU上的进程上下文会并发执行。
思考清楚哪些地方是临界区,该使用什么机制来保护临界区。
1.1 原子操作
顾名思义,即指令以原子的方式执行,执行过程不会被打断。
经典的例子就是两个CPU并发的访问同一个静态变量对其同时加一,从CPU的角度来看,其过程是:①读取变量的值并存储到通用寄存器中②在通用寄存器里做i++运算③写会变量所在的内存。如果上述操作同时进行,那么就会发生并发访问。
要怎么解决这种情况?
很多人都会想到加锁,但是加锁操作会导致较大的开销。故内核提供了atomic_t类型的原子变量,其实现依赖于不同的架构:
#ifdef CONFIG_64BIT
typedef struct {
s64 counter;
} atomic64_t;
#endif
在内核中,原子函数就像一条汇编语句,能够原子的完成上述的“读-修改-回写”机制。
1.2 内存屏障
内存屏障,确保多线程环境下的内存访问顺序正确,是CPU或者编译器在对内存随机访问的操作中的一个同步点,只有在此点之前的所有读写操作都执行后才可以执行此点之后的操作。
能够实现高效的无锁数据结构,提高多线程程序的性能表现。
1.2.1为什么会出现内存屏障?
在学习内存屏障之前,先了解一下有序处理器和无序处理器的指令处理过程:
- 有序处理器严格按照指令的顺序执行,这意味着每条指令都必须等待前一条指令完成所有阶段后才能开始。这种设计简单但效率较低,因为处理器在cache miss时会等很久。
- 无序处理器允许指令在操作数可用时立即执行,而不必等待前面的指令完成。
如果CPU需要读取的地址中的数据已经已经缓存在了cache line中,即使是cpu需要对这个地址重复进行读写,对CPU性能影响也不大,但是一旦发生了cache miss,如果是有序处理器,CPU在从其他CPU获取数据或者直接与主存进行数据交互的时候需要等待不可用的操作对象,这样就会非常慢,非常影响性能。举个例子:
假设CPU0发起对某个内存地址的写操作,但是该地址的数据存放在CPU1的缓存中。
- CPU0写操作:
- CPU0准备写某个内存地址,但该地址的数据不在CPU0的缓存中,而是在CPU1的缓存中。
- 发送无效化消息(Invalidate Message):
- CPU0会发出一个无效化消息(invalidate message),使其他CPU(包括CPU1)的缓存中该地址的数据无效。这是为了确保写操作时数据的一致性。
- 等待无效化完成:
- 在有序处理器中,CPU0必须等待所有相关CPU完成无效化操作后,才能进行写操作。
- 在乱序处理器中,CPU0不需要等待所有无效化完成。相反,它会将无效化消息放入无效化队列(invalidate queues),并继续执行其他指令,提高了CPU的并行处理能力和整体性能。
但也带来了一个问题,就是程序执行过程中,可能会由于乱序处理器的处理方式导致内存乱序,程序运行结果不符合我们预期的问题。所以我们需要内存屏障来解决。
1.2.2 内存屏障的分类
内存屏障能够让编译器或CPU在内存上访问有序,主要包括两类:
编译器内存屏障
Linux 内核提供函数 barrier()
用于让编译器保证其之前的内存访问先于其之后的完成。
#define barrier() __asm__ __volatile__("" ::: "memory")
由于编译器对代码进行优化时,可能会改变实际执行指令的顺序。避免这种行为的办法就是使用编译器屏障(又叫优化屏障)。例如:
#include <stdio.h>
int x,y,r;
void f ()
{
x = r;
y= 1;
}
我们使用gcc -O2 -S test.c,优化编译,得到其相关编译代码如下:
movl r(%rip), %eax
movl $1, y(%rip)
movl %eax, x(%rip)
可以清楚地看到经过编译器优化之后,movl $1, y(%rip)先于movl %eax, x(%rip)执行,这意味着,编译器优化导致了内存乱序访问,现在将内存屏障加在两句代码之间:
#include <stdio.h>
int x,y,r;
void f ()
{
x = r;
__asm__ __volatile__( "" : : : "memory" );
y= 1;
}
编译过的代码就不会出现内存乱序访问了。
CPU内存屏障
*/
#define mb() asm volatile("lock; addl $0,0(%%esp)" ::: "memory")
#define rmb() asm volatile("lock; addl $0,0(%%esp)" ::: "memory")
#define wmb() asm volatile("lock; addl $0,0(%%esp)" ::: "memory")
#elif defined(__x86_64__)
#define mb() asm volatile("mfence" ::: "memory")
#define rmb() asm volatile("lfence" ::: "memory")
#define wmb() asm volatile("sfence" ::: "memory")
#define smp_rmb() barrier()
#define smp_wmb() barrier()
#define smp_mb() asm volatile("lock; addl $0,-132(%%rsp)" ::: "memory", "cc")
#endif
以上是对内存屏障的定义,如果是SMP架构,smp_mb定义为mb(),不是SMP架构时,直接使用编译器屏障,运行时内存乱序访问并不存在。
- SFENCE:确保写操作按顺序执行。SFENCE 之前的写操作会在 SFENCE 之后的写操作之前完成。这意味着在 SFENCE 之前的写操作会先被系统看到。
- LFENCE:确保读操作按顺序执行。LFENCE 之前的读操作会在 LFENCE 之后的读操作之前完成。这意味着在 LFENCE 之前的读操作会先被系统看到。
- MFENCE:确保读写操作按顺序执行。MFENCE 之前的所有读和写操作会在 MFENCE 之后的读和写操作之前完成。这意味着在 MFENCE 之前的所有操作会先被系统看到。
使用场景
- 写屏障 (SFENCE):用于确保数据的写入顺序正确,但需要与读屏障一起使用以确保读操作的正确性。
- 读屏障 (LFENCE):用于确保数据的读取顺序正确,但需要与写屏障一起使用以确保写操作的正确性。
- 全局屏障 (MFENCE):用于确保读写操作都按顺序执行,适用于需要严格顺序的场景。
由于每个CPU都存在Cache,当一个特定的数据第一次被其他CPU获取时,这个数据不在对应CPU的Cache中(这就是Cache Miss)。这意味着CPU要从内存中快速获取数据(这个过程需要CPU等待几百个周期),这个数据会被加载到CPU的Cache中,这样后续就可以直接从Cache上访问。当某个CPU进行写时操作修改时,他必须确保其他CPU已将数据从他们的Cache中移除。显然,存在多个Cache时,必须通过一个缓存一致性协议来避免数据不一致的问题,而这个通信的过程就可能导致乱序访问的出现,甚至运行时内存乱序访问,例如:
#include <stdio.h>
int x,y,r1,r2;
//线程1
void run1 ()
{
x = 1 ;
r1 = y;
}
//线程2
void run2 ()
{
y = 1 ;
r2=x;
}
变量x、y、r1、r2均被初始化为0,run1和run2运行在不同的线程中。如果run1和run2在同一个cpu下执行完成,那么就如我们所料,r1和r2的值不会同时为0,而假设run1而run2在不同的CPU下执行完成后,由于存在内存乱序访问的可能,那么r1和r2可能同时为0。我们可以利用CPU内存屏障来运行时避免内存乱序访问:
#include <stdio.h>
int x, y, r1, r2;
// 线程1
void* run1(void* arg)
{
x = 1;
asm volatile("mfence" ::: "memory"); // 全局内存屏障
r1 = y;
return NULL;
}
// 线程2
void* run2(void* arg)
{
y = 1;
asm volatile("mfence" ::: "memory"); // 全局内存屏障
r2 = x;
return NULL;
}
mfence
指令用于在 x86_64 架构上实现全局内存屏障。这确保了在执行 mfence
之前的所有内存操作完成后,才会执行 mfence
之后的操作。
1.3 经典自旋锁
上述的原子变量用来解决临界区中只有一个变量的问题,但是大多情况下,临界区有一个数据操作的集合。常见的例子就是临界区中有链表的操作,这个过程就需要用锁机制来完成,自旋锁是内核中最常见的锁机制。主要特点:
- 忙等待的锁机制。操作系统中锁的机制分为两类,一类是忙等待,另一类是睡眼等待自旋锁属于前者,当无法获取自旋锁时会不断尝试,直到获取锁为止。
- 同一时刻只能有一个内核代码路径可以获得该锁。
- 要求自旋锁持有者尽快完成临界区的执行任务。如果临界区中的执行时间过长,在锁外面忙等待的 CPU比较浪费,特别是自旋锁临界区里不能睡眠。
- 自旋锁可以在中断上下文中使用。
1.3.1自旋锁的实现
数据结构定义如下:
typedef struct spinlock {
struct raw_spinlock rlock;
} spinlock_t;
typedef struct raw_spinlock {
arch_spinlock_t raw_lock;
#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;
spinlock 数据结构的定义既考虑到了不同处理器架构的支持和实时性内核的要求,还定义了raw_spinlock和 arch_spinlock_t数据结构,其中 arch_spinlock_t数据结构和架构有关。在Linux2.6.25内核之前,spinlock 数据结构就是一个简单的无符号类型变量。若 slock值为 1,表示锁未被持有;若为 0,表示锁被持有。之前的自旋锁机制比较简洁,特别是在没有锁争用的情况下;但也存在很多问题,尤其是在很多 CPU 争用同一个自旋锁时,会导致严重的不公平,由于刚刚释放了锁的CPU的高速缓存中存储了该锁,所以它比别的CPU更快获得该锁。
自旋锁的原型定义:
static __always_inline void spin_lock(spinlock_t *lock)
{
raw_spin_lock(&lock->rlock);
}
static inline void __raw_spin_lock(raw_spinlock_t *lock)
{
preempt_disable();
spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
}
spin_lock函数最终调用__raw_spin_lock函数来实现,首先关闭内核抢占,这是因为假设在临界区中发生了中断,中断返回时会检查是否要抢占调度,抢占调度会导致持有锁的进程睡眠,且若是抢占调度进程申请了自旋锁,就会进入盲等待状态,发生死锁。
1.3.2自旋锁的变体
假设这样一种情况,在某个驱动程序里有一个链表,在驱动中有很多操作都要访问和更新链表,但如果在临界区发生了外部中断,系统去执行中断处理程序,但如果中断处理程序也要操作该链表,故会使用自旋锁对链表进行保护。中断处理程序试图获取该自旋锁,但因为它已经被其他CPU持有了,于是中断处理程序进入忙等待状态或者睡眠状态。在中断上下文中出现忙等待或者睡眼状态是致命的,中断处理程序要求“短”和“快”,自旋锁的持有者因为被中断打断而不能尽快释放锁,而中断处理程序一直在忙等待该锁,从而导致死锁的发生。内核的自旋锁的变体spin_lock_irq()函数通过在获取自旋锁时关闭本地 CPU 中断,可以解决该问题。
static __always_inline void spin_lock_irq(spinlock_t *lock)
{
raw_spin_lock_irq(&lock->rlock);
}
static inline void __raw_spin_lock_irq(raw_spinlock_t *lock)
{
local_irq_disable();//关闭本地cpu中断,其他CPU依然可以响应
preempt_disable();//关闭抢占
spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
}
spin_lock_irq函数主要防止本地中断处理程序和自旋锁持有者之间产生锁的争用。
1.3.3 spin_lock()和raw_spin_lock()
在绝对不允许抢占和睡眠的临界区,应该使用raw_spin_lock函数,否则使用spin_lock函数。
1.4 MCS锁
1.4.1 背景
1.4.1.1 SMP
即对称多处理器结构,在这种架构中,一台计算机由多个CPU组成,并共享内存和其他资源,所有的CPU都可以平等地访问内存、I/O和外部中断。虽然同时使用多个CPU,但是从管理的角度来看,它们的表现就像一台单机一样。操作系统将任务队列对称地分布于多个CPU之上,从而极大地提高了整个系统的数据处理能力。但是随着CPU数量的增加,每个CPU都要访问相同的内存资源,共享资源可能会成为系统瓶颈,导致CPU资源浪费。
1.4.1.2 NUMA
非一致存储访问,将CPU分为CPU模块,每个CPU模块由多个CPU组成,并且具有独立的本地内存、I/O槽口等,模块之间可以通过互联模块相互访问,访问本地内存(本CPU模块的内存)的速度将远远高于访问远地内存(其他CPU模块的内存)的速度,这也是非一致存储访问的由来。NUMA较好地解决SMP的扩展问题,当CPU数量增加时,因为访问远地内存的延时远远超过本地内存,系统性能无法线性增加。
MCS锁,一种基于FIFO队列的自旋锁,提供先来先服务的公平性。确保无饥饿性。
1.4.2 核心思想
每个线程(或处理器)都维护一个本地的节点(MCS节点),这些节点构成一个队列,用来表示获取锁的等待顺序。当一个线程想要获取锁时,它需要将自己的节点加入到队列尾部,并等待直到轮到它获取锁。
节点结构:每个线程需要一个节点来表示它在队列中的位置。这个节点通常包含一个指向下一个节点的指针和一个布尔值来表示锁的状态(locked == true 表示节点处于加锁状态或等待加锁状态,locked == false 表示节点处于解锁状态)。
加锁过程:当一个线程想要获取锁时,它会创建一个节点并将其加入到队列的尾部。如果队列为空,则将当前节点作为队列的唯一节点。否则,线程会在自己的节点上自旋,等待直到轮到它获取锁。自旋的条件是基于前一个节点的锁值,如果前一个节点的锁值为true(即前一个节点处于加锁状态或等待加锁状态),则线程继续自旋。如果前一个节点的锁值为false(即前一个节点处于解锁状态),则线程可以将自己的节点的锁值设置为true,并且获得锁。
解锁过程:线程释放锁时,将自己的节点从队列中移除,并将后继节点的锁值设置为false,以允许后继节点获得锁。
1.4.3 mcs锁代码实现
#include <stdio.h>
#include <stdlib.h>
#include <stdatomic.h>
#include <pthread.h>
// 定义节点结构
typedef struct MCSNode {
_Atomic struct MCSNode *next; // 指向下一个节点的指针
_Atomic int locked; // 节点的锁值,0表示解锁状态,1表示锁定状态
} MCSNode;
// 定义MCS锁
typedef struct MCSLock {
_Atomic MCSNode *tail; // 队列的尾节点
} MCSLock;
// 初始化MCS锁
void MCS_init(MCSLock *lock) {
lock->tail = NULL;
}
// 加锁操作
void MCS_acquire(MCSLock *lock, MCSNode *my_node) {
// 将当前节点加入到队列的尾部
MCSNode *pred = atomic_exchange(&lock->tail, my_node);
if (pred != NULL) {
// 如果前一个节点不为空,则将前一个节点的next指针指向当前节点
atomic_store_explicit(&pred->next, my_node, memory_order_relaxed);
// 在前一个节点上自旋,直到前一个节点释放锁
while (atomic_load_explicit(&my_node->locked, memory_order_relaxed))
;
}
}
// 解锁操作
void MCS_release(MCSLock *lock, MCSNode *my_node) {
MCSNode *succ = atomic_load_explicit(&my_node->next, memory_order_relaxed);
if (succ == NULL) {
// 如果当前节点没有后继节点,则尝试原子地更新尾节点,释放锁
if (atomic_compare_exchange_strong(&lock->tail, &my_node, NULL)) {
// 释放成功,直接返回
return;
}
// 释放失败,等待后继节点的锁
while (succ == NULL) {
succ = atomic_load_explicit(&my_node->next, memory_order_relaxed);
}
}
// 设置后继节点的锁值为解锁状态,允许后继节点获得锁
atomic_store_explicit(&succ->locked, 0, memory_order_relaxed);
}
// 测试用例
#define NUM_THREADS 4
#define NUM_ITERATIONS 1000000
MCSLock lock;
long counter = 0;
void *worker(void *arg) {
for (int i = 0; i < NUM_ITERATIONS; ++i) {
MCSNode node = { .next = NULL, .locked = 0 };
MCS_acquire(&lock, &node);
counter++;
MCS_release(&lock, &node);
}
return NULL;
}
int main() {
pthread_t threads[NUM_THREADS];
MCS_init(&lock);
for (int i = 0; i < NUM_THREADS; ++i) {
pthread_create(&threads[i], NULL, worker, NULL);
}
for (int i = 0; i < NUM_THREADS; ++i) {
pthread_join(threads[i], NULL);
}
printf("Counter: %ld\n", counter);
return 0;
}
未完待续…