Linux同步互斥1(基于Linux6.6)---原子操作
一、概述
在 Linux 内核中,同步和互斥是多线程或多进程环境中确保数据一致性、避免竞争条件(race condition)以及确保系统资源正确管理的核心概念。由于 Linux 是一个支持并发和并行的操作系统,理解同步和互斥机制对于编写高效且稳定的内核代码至关重要。
1. 互斥(Mutual Exclusion)
互斥是一种机制,确保同一时间内只有一个线程或进程能够访问共享资源。通过互斥可以防止多个并发执行的进程或线程同时修改共享资源,从而避免数据竞争和不一致的问题。
在 Linux 中,常见的互斥机制包括:
1.1 自旋锁(Spinlock)
- 定义:自旋锁是一种低开销的锁机制,适用于锁的持有时间非常短的场景。当一个线程请求锁时,如果锁被其他线程持有,它不会被阻塞,而是会不断“自旋”(即循环检查锁的状态),直到锁被释放。
- 使用场景:适用于临界区代码非常短,锁持有时间非常短的情况,因为自旋锁在获取不到锁时会消耗 CPU 时间。
- 函数:
spin_lock(spinlock_t *lock)
:获取自旋锁。spin_unlock(spinlock_t *lock)
:释放自旋锁。
1.2 互斥锁(Mutex)
- 定义:互斥锁是一种更强的同步机制,它不仅提供了互斥性(确保只有一个线程访问临界区),而且会阻塞试图访问锁的线程,直到锁被释放。与自旋锁相比,互斥锁适用于临界区较长的情况。
- 使用场景:适用于锁持有时间较长,或者可能涉及到 I/O 操作的场景。
- 函数:
mutex_lock(mutex_t *lock)
:获取互斥锁,若锁已被持有,则会阻塞当前线程。mutex_unlock(mutex_t *lock)
:释放互斥锁。
1.3 读写锁(Read-Write Lock)
- 定义:读写锁允许多个线程同时读共享资源,但在写操作时,只有一个线程能够访问该资源,且该线程排他性地持有锁。读写锁适用于读操作频繁而写操作较少的情况。
- 使用场景:适用于资源读取多而写入少的情况。
- 函数:
read_lock(rwlock_t *lock)
:获取读锁。write_lock(rwlock_t *lock)
:获取写锁。read_unlock(rwlock_t *lock)
:释放读锁。write_unlock(rwlock_t *lock)
:释放写锁。
2. 同步(Synchronization)
同步是确保多个进程或线程按照正确的顺序执行的机制。同步机制主要用于解决多个线程在执行时的协调问题,确保它们按照预期的顺序访问共享资源或完成任务。
2.1 信号量(Semaphore)
- 定义:信号量是一种计数器,用于控制多个进程或线程对共享资源的访问。信号量可以分为两种类型:
- 二值信号量(Binary Semaphore):只有两种状态,通常用于互斥锁。
- 计数信号量(Counting Semaphore):允许指定的资源数量,并控制同时可以访问的线程数。
- 使用场景:用于控制访问有限资源的数量。
- 函数:
down(semaphore_t *sem)
:获取信号量,若信号量的值为 0,则阻塞当前线程。up(semaphore_t *sem)
:释放信号量,增加信号量的值。
2.2 条件变量(Condition Variable)
- 定义:条件变量用于线程之间的通信,线程可以等待某个条件的发生,然后继续执行。通常和互斥锁一起使用。线程可以在条件变量上等待,直到另一个线程通知它条件满足。
- 使用场景:适用于等待某个条件满足才能继续执行的场景,例如,生产者-消费者问题。
- 函数:
wait(condition_variable_t *cond, mutex_t *lock)
:在条件变量上等待,释放互斥锁并等待条件满足。signal(condition_variable_t *cond)
:通知一个线程条件已满足。broadcast(condition_variable_t *cond)
:通知所有等待该条件的线程。
2.3 读写条件变量(Read-Write Condition Variable)
- 定义:读写条件变量类似于普通的条件变量,但它针对读写锁的情况进行了优化,使得多个读线程可以同时等待,只有写线程能独占条件变量。
- 使用场景:适用于需要读取和写入协调的复杂同步场景。
3. 内核级同步机制
- Linux 内核中还提供了一些高级同步原语,用于更复杂的并发场景:
- 序列锁(Seqlock):用于解决多读少写的场景,能够提高性能。读操作不需要互斥,但写操作需要加锁,保证写时不被中断。
- RCU(Read-Copy-Update):一种针对读操作非常频繁的场景的同步机制,能够在不锁定的情况下保证并发读和并发写的一致性。
4. 死锁与避免
- 死锁(Deadlock):当多个线程或进程相互等待对方释放资源,导致它们都无法继续执行的情况。死锁可能发生在使用多个锁时。
- 避免策略:
- 锁顺序:确保所有线程获取锁的顺序相同,以避免循环依赖。
- 超时:为锁操作设置超时时间,防止长时间等待。
二、问题与对策
2.1、问题
程序逻辑经常遇到这样的操作序列:
1、读一个位于memory中的变量的值到寄存器中
2、修改该变量的值(也就是修改寄存器中的值)
3、将寄存器中的数值写回memory中的变量值
在多CPU体系结构中,运行在两个CPU上的两个内核控制路径同时并行执行上面操作序列,有可能发生下面的场景:
CPU1上的操作 | CPU2上的操作 |
读操作 | |
读操作 | |
修改 | 修改 |
写操作 | |
写操作 |
多个CPUs和memory chip是通过总线互联的,在任意时刻,只能有一个总线master设备(例如CPU、DMA controller)访问该Slave设备(在这个场景中,slave设备是RAM chip)。因此,来自两个CPU上的读memory操作被串行化执行,分别获得了同样的旧值。完成修改后,两个CPU都想进行写操作,把修改的值写回到memory。但是,硬件arbiter的限制使得CPU的写回必须是串行化的,因此CPU1首先获得了访问权,进行写回动作,随后,CPU2完成写回动作。在这种情况下,CPU1的对memory的修改被CPU2的操作覆盖了,因此执行结果是错误的。
不仅是多CPU,在单CPU上也会由于有多个内核控制路径的交错而导致上面描述的错误。一个具体的例子如下:
系统调用的控制路径 | 中断handler控制路径 |
读操作 | |
读操作 | |
修改 | |
写操作 | |
修改 | |
写操作 |
系统调用的控制路径上,完成读操作后,硬件触发中断,开始执行中断handler。这种场景下,中断handler控制路径的写回的操作被系统调用控制路径上的写回覆盖了,结果也是错误的。
2.2、对策
对于那些有多个内核控制路径进行read-modify-write的变量,内核提供了一个特殊的类型atomic_t,具体定义如下:include/linux/types.h
typedef struct {
int counter;
} atomic_t;
从上面的定义来看,atomic_t实际上就是一个int类型的counter,不过定义这样特殊的类型atomic_t是有其思考的:内核定义了若干atomic_xxx的接口API函数,这些函数只会接收atomic_t类型的参数。这样可以确保atomic_xxx的接口函数只会操作atomic_t类型的数据。同样的,如果你定义了atomic_t类型的变量(你期望用atomic_xxx的接口API函数操作它),这些变量也不会被那些普通的、非原子变量操作的API函数接受。
Linux 中常见的原子操作类型:
-
原子加减操作(Atomic Addition/Subtraction)
atomic_add(int i, atomic_t *v)
:对v
加上i
。atomic_sub(int i, atomic_t *v)
:对v
减去i
。
-
原子比较与交换(Atomic Compare and Swap)
atomic_cmpxchg(atomic_t *v, int old, int new)
:将v
的值与old
比较,若相等,则将v
的值修改为new
。此操作是原子性的,可以用于实现高效的锁-free 数据结构。
-
原子测试并设置(Atomic Test and Set)
atomic_test_and_set_bit(int bit, unsigned long *addr)
:将指定的位设置为 1,并返回该位原来的值。这种操作通常用于标志位的管理。
-
原子交换(Atomic Exchange)
atomic_xchg(atomic_t *v, int new)
:将v
的值替换为new
,并返回原来的值。
-
原子操作的一致性保证
- 内存屏障:原子操作通常会涉及到内存屏障(memory barrier),它是一种保证 CPU 和编译器不会对操作进行重新排序的机制。Linux 提供了几种内存屏障函数,如
smp_mb()
、smp_rmb()
、smp_wmb()
等。
- 内存屏障:原子操作通常会涉及到内存屏障(memory barrier),它是一种保证 CPU 和编译器不会对操作进行重新排序的机制。Linux 提供了几种内存屏障函数,如
三、ARM中的实现
以atomic_add为例,描述linux kernel中原子操作的具体代码实现细节:
include/linux/atomic/atomic-instrumented.h
static __always_inline void
atomic_add(int i, atomic_t *v)
{
instrument_atomic_read_write(v, sizeof(*v));
raw_atomic_add(i, v);
}
include/linux/atomic/atomic-arch-fallback.h
static __always_inline void
raw_atomic_add(int i, atomic_t *v)
{
arch_atomic_add(i, v);
}
include/asm-generic/atomic.h
#define arch_atomic_add generic_atomic_add
在 Linux 6.x 内核中,ARM 架构的 atomic_add
操作会通过一系列的层次化调用来实现,最终依赖于 ARM 特定的原子操作。具体的实现会依赖于 ARM 架构的内存模型和处理器的原子操作指令。
3.1、基本原子操作实现流程
-
高级接口
atomic_add()
:- 用户代码首先调用内核提供的
atomic_add()
函数,进行原子加法操作。
- 用户代码首先调用内核提供的
-
raw_atomic_add()
:- 内核将调用
raw_atomic_add()
函数,它是一个架构无关的中介层函数,负责将调用转发到架构特定的arch_atomic_add()
。
- 内核将调用
-
arch_atomic_add()
:- 这是架构特定的原子加法实现。对于 ARM 架构,通常会通过 ARM 的内存屏障和原子操作指令来实现。
3.2、ARM 架构的原子加法实现
在 ARM 架构下,atomic_add()
通常是通过 LDREX
和 STREX
指令实现的。这些指令支持在单一操作中读取和修改内存,从而确保操作的原子性。
ARM 原子操作指令
- LDREX (Load-Exclusive): 从指定内存位置加载数据并开始一个"独占"(exclusive)操作。
- STREX (Store-Exclusive): 将数据写回指定内存位置,并检查是否仍然保持独占状态。
这两个指令常常用来实现原子操作,例如加法、减法等。
ARM 中的 atomic_add
实现
对于 ARM 架构,atomic_add()
实际上会调用 arch_atomic_add()
,并且在 arch_atomic_add()
中使用 LDREX
和 STREX
来确保原子操作。
实现代码(简化版)
以下是一个简化的 ARM 原子加法实现,基于 LDREX
和 STREX
指令:
#include <asm/atomic.h>
#define arch_atomic_add(i, v) __arch_atomic_add(i, v)
static inline void __arch_atomic_add(int i, atomic_t *v)
{
unsigned int old_val, new_val;
int ret;
do {
// 读取原子变量的值,开始一个独占操作
old_val = v->counter;
// 执行加法操作
new_val = old_val + i;
// 尝试将新值写入原子变量,如果内存没有被其他处理器修改
ret = __ldrex(&v->counter); // 加载原子变量的值
if (ret == old_val) {
__strex(new_val, &v->counter); // 尝试将新值写入
break; // 如果成功,跳出循环
}
// 如果失败,重新加载并尝试
} while (1);
}
解释:
-
LDREX
和STREX
:LDREX
从内存加载值,并为接下来的操作设置独占状态。STREX
尝试将新值写入内存,并检查是否存在其他修改。如果内存被其他处理器或线程修改,STREX
会失败。
-
循环重试:
- 如果
STREX
失败,表示在LDREX
和STREX
之间的时间窗口内其他 CPU 修改了v->counter
,因此需要重新加载并重试。
- 如果
-
原子操作保证:
- 通过
LDREX
和STREX
的组合,ARM 确保了原子性:要么成功地将新值写入内存,要么失败并重试,直到操作成功。
- 通过
3.3、ARM v7 和 ARMv8 的差异
- ARMv7 和 ARMv8 支持
LDREX
/STREX
,它们也支持CAS
(Compare-and-Swap)等原子操作指令。 - 对于较新的 ARMv8 及更高版本,可能会使用
LDAXP
和STLXP
等更高效的指令。
完整实现(与 Linux 6.x 内核一致)
在 Linux 6.x 内核中,atomic_add()
和其他原子操作通常会通过 arch_atomic_add()
来调用 ARM 特定的实现。最终的实现会使用 ARM 的原子操作指令,确保在多核处理器上进行安全的并发修改。
arch_atomic_add()
的实现大致如下:
static inline void arch_atomic_add(int i, atomic_t *v)
{
int ret;
unsigned long flags;
local_irq_save(flags); // 保存中断标志并禁用中断
do {
// 使用 LDREX 获取原子变量的当前值
ret = __ldrex(&v->counter);
if (ret == old_val) {
// 使用 STREX 尝试写入新的值
__strex(new_val, &v->counter);
break;
}
} while (1);
local_irq_restore(flags); // 恢复中断
}
ARM 架构的 atomic_add()
操作通过 LDREX
和 STREX
指令来实现原子性。内核提供了一个高层的 atomic_add()
函数,最终通过架构特定的 arch_atomic_add()
函数来执行原子加法。使用这种方式,Linux 内核能够确保在 ARM 系统上执行安全、高效的原子操作。