Linux同步互斥1

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() 等。

三、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、基本原子操作实现流程

  1. 高级接口 atomic_add():

    • 用户代码首先调用内核提供的 atomic_add() 函数,进行原子加法操作。
  2. raw_atomic_add():

    • 内核将调用 raw_atomic_add() 函数,它是一个架构无关的中介层函数,负责将调用转发到架构特定的 arch_atomic_add()
  3. arch_atomic_add():

    • 这是架构特定的原子加法实现。对于 ARM 架构,通常会通过 ARM 的内存屏障和原子操作指令来实现。

3.2、ARM 架构的原子加法实现

在 ARM 架构下,atomic_add() 通常是通过 LDREXSTREX 指令实现的。这些指令支持在单一操作中读取和修改内存,从而确保操作的原子性。

ARM 原子操作指令
  • LDREX (Load-Exclusive): 从指定内存位置加载数据并开始一个"独占"(exclusive)操作。
  • STREX (Store-Exclusive): 将数据写回指定内存位置,并检查是否仍然保持独占状态。

这两个指令常常用来实现原子操作,例如加法、减法等。

ARM 中的 atomic_add 实现

对于 ARM 架构,atomic_add() 实际上会调用 arch_atomic_add(),并且在 arch_atomic_add() 中使用 LDREXSTREX 来确保原子操作。

实现代码(简化版)

以下是一个简化的 ARM 原子加法实现,基于 LDREXSTREX 指令:

#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);
}
解释:
  1. LDREXSTREX

    • LDREX 从内存加载值,并为接下来的操作设置独占状态。
    • STREX 尝试将新值写入内存,并检查是否存在其他修改。如果内存被其他处理器或线程修改,STREX 会失败。
  2. 循环重试

    • 如果 STREX 失败,表示在 LDREXSTREX 之间的时间窗口内其他 CPU 修改了 v->counter,因此需要重新加载并重试。
  3. 原子操作保证

    • 通过 LDREXSTREX 的组合,ARM 确保了原子性:要么成功地将新值写入内存,要么失败并重试,直到操作成功。

3.3、ARM v7 和 ARMv8 的差异

  • ARMv7ARMv8 支持 LDREX/STREX,它们也支持 CAS(Compare-and-Swap)等原子操作指令。
  • 对于较新的 ARMv8 及更高版本,可能会使用 LDAXPSTLXP 等更高效的指令。

完整实现(与 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() 操作通过 LDREXSTREX 指令来实现原子性。内核提供了一个高层的 atomic_add() 函数,最终通过架构特定的 arch_atomic_add() 函数来执行原子加法。使用这种方式,Linux 内核能够确保在 ARM 系统上执行安全、高效的原子操作。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值