什么是竞态 什么是竞态分析

1. 并发与竞态相关概念

本节以介绍一些概念为主, 主要学习以下内容:

  1. 竞态和并发的概念
  2. 避免出现竞态的一些手段介绍(信号量、互斥体、自旋锁、completion 等)

1.1. 并发与竞态的概念

并发是指系统试图一次完成多个任务。

竞态是指对于共享数据的非控制访问。

为什么会产生并发和竞态呢? 主要有以下几个原因:

  1. 当前的系统是多任务操作系统, 用户空间进程可能会以任何组合来访问我们的代码, 在 SMP(对称多处理器)中, 可能在多个 CPU 上同时执行我们的代码;
  2. Linux 内核是可抢占的, 当前操作可能在未完成的情况下, 丢失了对 CPU 的占有, 而新的获取 CPU 的进程可能会去调用我们的代码;
  3. 设备中断是异步事件, 也可能导致代码并发执行;
  4. Linux 内核中提供了延时机制(例如: workqueue、tasklet、timer 等), 随时都有可能开始执行我们的代码;

那么如何避免并发和竞态的产生呢, 主要有以下几个原则:

  1. 只要有可能, 应该尽量避免资源的共享。在代码中的明显体现是少用全局变量, 使得方法是可重入的;
  2. 必须显式地管理对共享资源的访问。常用的技术称为"锁定"或者"互斥", 确保一次只有一个线程操作共享资源;

1.2. 信号量和互斥体

信号量本质上是一个数值, 用于标记资源的可用数量, 它和一对函数联合使用, 这对函数称为 P 和 V。

当需要使用某个资源的时候, 在相应的信号量上调用 P 函数, 如果信号量的数值大于 0, 则其数值会减 1, 同时, 当前的操作会继续下去, 称为获取信号量; 如果信号量的数值为 0, 则当前操作会进入等待状态。

使用完成后, 使用 V 函数来释放资源, 此时信号量的数值会增加 1, 并唤醒其他等待线程, 称为释放信号量。

如果信号量的数值为 1, 则表示当前只能有一个线程拥有资源, 其他线程都需要等待, 只有等其中拥有资源的权限释放后, 才能去获取资源, 此时的信号量也称为"互斥体"(mutex)。

在 Linux 中, 用于信号量的函数有以下:

#include <linux/semaphore.h>  //书中写的是<asm/semaphore.h>, 我实践中发现是<linux/semaphore.h>

void sema_init(struct semaphore *sem, int val); //初始化信号量, 设定信号量初值为 val
DECLARE_MUTEX(name); //初始化信号量, 初始值为 1
DECLARE_MUTEX_LOCKED(name); //初始化信号量, 初始值为 0
void init_MUTEX(struct semaphore *sem); //运行中初始化信号量, 初始值为 1
void init_MUTEX_LOCKED(struct semaphore *sem); //运行中初始化信号量, 初始值为 0
void down(struct semaphore *sem); //获取信号量(即上面的 P 函数), 未获取到时一直等待, 不可中断
int down_interruptible(struct semaphore *sem); //同 down, 会等待, 但可被中断, 返回 0 表示获取成功, 返回非 0 表示获取失败
int down_trylock(struct semaphore *sem); //同 down, 但不会等待, 而是立即返回, 返回 0 表示获取成功, 返回非 0 表示获取失败
void up(struct semaphore *sem); // 释放信号量(即上面的 V 函数)

需要注意的是, 获取到信号量后, 一定别忘了释放信号量。

1.3. completion 机制

在内核编程中, 经常遇到这种状态: 当前线程需要等待另一个线程完成某个操作或初始化后才能继续运行。

这种情况我们可以使用信号量来完成: 初始化一个初值为 0 的信号量, 当前线程去获取这个信号量, 而在另一个线程中完成操作后释放信号量, 然后让当前线程继续执行下去。这种方法并不是很好的方法, 为此, linux 提供了 completion 接口, 它是一种轻量级的机制, 用于一个线程告诉另外一个线程某个工作已经完成 。

在 Linux 中, completion 机制的常用函数有以下:

#include <linux/completion.h>

DECLARE_COMPLETION(my_completion); //创建一个 completion
init_completion(struct completion *c); // 在运行中创建并初始化 completion
void wait_for_completion(struct completion *c); // 等待 completion, 是不可中断的
void complete(struct completion *c); // 触发 completion, 使得上面的等待线程继续, 只能唤醒一个等待线程
void complete_all(struct completion *c); // 触发 completion, 使得上面等待线程继续, 唤醒所有等待线程

1.4. 自旋锁

信号量对于互斥来讲是一个非常有用的工具, 但并不是唯一的工具。在许多情况下, 更多的是使用自旋锁 (spinlock) 机制来实现互斥。

在信号量中, 获取锁的等待会产生休眠, 因此, 不能用于不能休眠的代码中, 比如中断线程。而自旋锁的获取不会产生休眠, 因此可以用于不能休眠的代码中, 且通常能够提供更高的性能。

自旋锁的工作过程可以简单理解为就是一直检测当前锁的状态, 然后获取, 不断循环, 所以称为自旋。

Linux 中自旋锁的常用函数如下:

#include <linux/spinlock.h>

spinlock_t my_lock = SPIN_LOCK_UNLOCKED; // 编译时初始化自旋锁
void spin_lock_init(spinlock_t *lock); //运行中初始化自旋锁
void spin_lock(spinlock_t *lock); //自旋等待获取锁, 不可中断
void spin_lock_irqsave(spinlock_t *lock, unsigned long flags); // 获取自旋锁前禁止中断, 并将中断状态保存到 flags 中
void spin_lock_irq(spinlock_t *lock); //获取自旋锁前禁止中断, 不跟踪中断状态, 释放自旋锁时需要自己保证启用中断
void spin_lock_bh(spinlock_t *lock); // 获取自旋锁前禁止软件中断, 而硬件中断保存打开
void spin_unlock(spinlock_t *lock); //释放获取的锁, 对应 spin_lock
void spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags); //释放获取的锁, 对应 spin_lock_irqsave
void spin_unlock_irq(spinlock_t *lock); //释放获取的锁, 对应 spin_lock_irq
void spin_unlock_bh(spinlock_t *lock); //释放获取的锁, 对应 spin_lock_bh

int spin_trylock(spinlock_t *lock); //不会等待, 直接返回, 0 表示获取成功, 非 0 表示获取失败
int spin_trylock_bh(spinlock_t *lock); //不会等待, 直接返回, 0 表示成功, 非 0 表示获取失败

在使用自旋锁时, 也要注意以下问题:

  • 拥有自旋锁的代码必须是原子的, 即不能休眠, 不能被打断;
  • 拥有自旋锁时, 需要禁止中断, 防止中断中获取锁, 造成死锁;
  • 拥有自旋锁的时间需要尽可能短, 越短越好;

1.5. 原子变量

如果我们共享的变量是一个简单的整数值, 用一个锁机制对于一个简单的整数值来说显得十分浪费。针对这种情况, Linux 提供了一种原子整数类型, 称为 atomic_t。

atomic_t 中不能记录大于 24 位的整数。其常用的函数如下:

#include <asm/atomic.h>

void atomic_set(atomic_t *v, int i); //运行时初始化原子变量为 i
atomic_t v = ATOMIC_INIT(0); //编译时初始化原子变量为 0
int atomic_read(atomic_t *v); //返回原子变量的值
void atomic_add(int i, atomic_t *v); // 原子变量加上 i
void atomic_sub(int i, atomic_t *v) // 原子变量减去 i
void atomic_inc(atomic_t *v); // 原子变量自加 1
void atomic_decatomic_t *v);

以上是常用的一些函数, 还有一些结果检测、返回等操作其实都可以用上述的操作来完成。需要说明的是, 原子变量只能通过指定的函数来访问, 不能直接使用数学运算法进行赋值和运算。

以上是对并发和竞态的一些介绍, 以及工作和学习中常用到的一些避免竞态的手段。当然, 还有一些其他手段用于避免竞态的发生, 我这边没有介绍, 有兴趣的可以自己去了解。

2. 在并发编程中我们常说的"竞态"是什么?

2.1. 何谓"竞态"

之前在学习一篇文章的时候, 就看到"竞态", 但是不知道什么意思, 文章中也没有对"竞态"做更多的解释, 后来经过一番的探索, 终于弄的差不多明白了, 今天写点总结。

首先, 我们要明白"竞态"是什么。先说我的结论吧, "竞态"就是在多线程的编程中, 你在同一段代码里输入了相同的条件, 但是会输出不确定的结果的情况。我不知道这个解释是不是够清楚, 我们接着往下看, 下面我们用一段代码来解释一下啊。

出现竞态条件的代码:

public class MineRaceConditionDemo {
    
    
    private int sharedValue = 0;
    private final static int MAX = 1000;
    private int raceCondition() {
    
    
        if (sharedValue < MAX) {
    
    
            sharedValue++;
        } else {
    
    
            sharedValue = 0;
        }
        return sharedValue;
    }

    public static void main(String[] args) {
    
    
        MineRaceConditionDemo m = new MineRaceConditionDemo();
        ExecutorService es = new ThreadPoolExecutor(10,
                10,
                5,
                TimeUnit.MINUTES,
                new ArrayBlockingQueue<Runnable>(1000),
                new ThreadPoolExecutor.CallerRunsPolicy());
        ConcurrentHashMap<Integer, Integer> map = new ConcurrentHashMap<>();

        for (int i = 0; i < 1000; i++) {
    
    
            es.execute(() -> {
    
    
                try {
    
    
                //  这是精髓所在啊, 如果没有这个, 那么要跑好几次才会出现竞态条件。
                //  这个用来模拟程序中别的代码的处理时间。
                    Thread.sleep(50);
                } catch (InterruptedException e) {
    
    
                    e.printStackTrace();
                }
                int num = m.raceCondition();
                if (map.get(num) != null) {
    
    
                    System.out.println("the repeat num: " + num);
                    System.out.println("happen.");
                } else {
    
    
                    map.put(num, 0);
                }
            });
        }
        es.shutdown();
    }
}

以上的代码是我自己的设计的一段会出现竞态条件的代码, 比较简陋, 但是可以说明问题了, 你只要运行上面这段代码, 每次的输出的结果级大概率都是不同的, 但是也有以外, 比如你的电脑的性能很强, 这段代码也会出现执行正确的情况, 也就是啥也不输出。
比如有的时候输出这个:

the repeat num: 78
happen.
the repeat num: 229
happen.
the repeat num: 267
happen.
the repeat num: 267
happen.
the repeat num: 498
happen.

有点时候输出这个:

the repeat num: 25
happen.
the repeat num: 157
happen.

当然, 以上的是我的输出的, 你们的输出肯定也是不同的。

对于上面这些, 同一段代码, 对于同样的输出, 但是程序的输出有的时候是正确, 有的时候是错误的, 这种情况, 我们称之为"竞态"。最要命的就是, 代码每次输出不是每次都错误, 而是你不知道他什么时候会正确, 什么时候会错误。

当然, 如果以上的代码执行的情况就是, 啥都不输出, 所有的值都是唯一的。

2.2. "竞态"为什么会发生?

"竞态"的发生主要是因为多个线程都对一个共享变量(比如上面的 sharedValue 就属于共享变量)有读取-修改的操作。在某个线程读取共享变量之后, 进行相关操作的时候, 别的线程把这个变量给改了, 从而导致结果出现了错误。

什么样的代码模式会发生"竞态"

这部分知识主要是来自《Java 多线程编程实战指南 核心篇》。

这里书中提到, 会发生竞态条件就是两个模式: read-modify-write(读-改-写)和 check-than-act(检测而后行动)。
当然, 这里面的都有一个相同的操作过程: 某个有读取这个"共享变量"的操作, 然后别的线程有个修改这个变量的操作。这里有个重点, 在多个线程中, 起码有一个线程有更新操作; 如果所有的线程都是读操作, 那么就不存在什么竞态条件。

总体来说, 就是要 thread1#load - thread2#update。 这种的模式, 起码是是要有两个线程的, 而且其中某个线程肯定是要有更新"共享变量"操作的, 另一个线程不管是读取变量还是更新变量都会出现错误(要么读取脏数据、要么丢失更新结果)。

2.3. 如何消除"竞态"?

单以上面的操作来说, 一般来说有两种解法方式,

2.3.1. 加锁

加上 synchronized 关键字, 保证每次只能有一个线程获取共享变量的使用权。

private synchronized int raceCondition() {
    
    
    if (sharedValue < MAX) {
    
    
        sharedValue++;
    } else {
    
    
        sharedValue = 0;
    }
    return sharedValue;
}

2.3.2. 利用原子操作

利用 java 的工具包里的 AtomicInteger, 代替 int, 利用原子操作消除"竞态"。

private AtomicInteger sharedValue = new AtomicInteger(0);
private final static int MAX = 1000;
private int raceCondition() {
    
    
    if (sharedValue.get() < MAX) {
    
    
        return sharedValue.getAndIncrement();
    } else {
    
    
        sharedValue.set(0);
        return sharedValue.get();
    }
}

以上两种方法的要义就是保证每个线程在操作"共享变量"的都是原子操作。

3. Linux 下竞态分析和避免

3.1. Linux 产生竞态主要三种情况: 中断、抢占、多处理器

一个 cpu 运行在进程上下文或者中断上下文(且这段上下文是临界区域)的时候可能

被中断打断, 且这个中断要访问临界资源;

被其它进程抢占, 且这个进程要访问临界资源;

或者其它的 cpu 也要访问临界区域。

这个时候就会发生竞态。

3.2. cpu 运行在进程上下文的情况

避免中断产生的竞态, 可以提前关闭中断, 处理完成后在开启中断;

避免抢占产生的竞态, 可以使用 spin_lock, 因为自旋锁锁住的临界区域是不可抢占的, 但是要求临界区域尽量的短。另外在关闭中断的情况下抢占的竞态也不会发生, 因为 linux 内核的进程调度也依赖中断实现;

针对多处理器产生的竞态, 通常也是使用 spin_lock, 或者互斥锁也可以, 至于什么时候使用互斥锁, 什么时候使用自旋锁, 参考第 5 条。

(另外 2.6.35 后, 取消了中断嵌套, 所以中断与中断产生的竞态可以不用担心了)

3.3. cpu 运行在中断上下文的情况。

中断和抢占产生的竞态都不需要考虑, 因为, 2.6.35 后, 中断嵌套被取消, 且中断上下文的优先级要高于进程上下文(抢占本身是进程抢占, 运行在进程上下文)。

需要考虑的是多处理器产生的竞态, 参考上一条, 多处理器产生的竞态可以用 spin_lock 或者互斥锁来避免, 但是中断不允许睡眠(互斥锁在拿不到锁的情况下会进入睡眠)所以必须使用 spin_lock。

3.4. spin_lock 在单处理器的情况下, 自动退化为互斥锁(宋宝华说的, 不理解其中原委)。而实际上, spin_lock 在单处理器情况下没有任何意义。

想象一下单处理器运行在进程上下文, spin_lock 锁住的区域可能产生竞态的情况只有中断, 如果中断中也要访问这段临界区域, 假如中断拿不到锁, 那就彻底挂了(因为他会自旋, 且我们只有一个 cpu), 如果顺利拿到锁, 说实在的在单处理器中没有任何意义。

3.5. 什么情况下用自旋锁, 什么情况下用互斥锁?

首先, 在临界区较小, 占用 cpu 时间较短, 或者任务的实时性要求较高的情况下使用自旋锁, 可避免 CPU 频繁调度产生的开销。

其次, 有可能引起阻塞的临界区绝对不可以使用自旋锁, 阻塞意味着进程切换, 切换到的进程如果也要 spin_lock 加锁, 死锁就产生了。

最后, 在中断和软中断中的临界区域, 尽量使用自旋锁(这种情况也符合第一条, 因为中断是实时性要求较高的任务), 如果一定要用互斥锁, 也要 try_lock, 因为中断程序不能睡眠。

猜你喜欢

转载自blog.csdn.net/wan212000/article/details/130707452
今日推荐