资源访问受限--引论

资源访问受限--引论

线程与任务文中,虽然创建了多线程,并且线程之间出现了一些不可预测的CPU调度,但是由于线程之间是相互隔离的——线程没有访问共同的资源,尽管在执行任务的过程可能被CPU剥夺运行权,但是当它们再次获得运行权时对运行结果并没有影响,它们是安全的。

实际上,上篇文章通过join()方法演示了 一种安全访问共享资源的方法

考虑一种情况,如果多个线程访问同一资源,并对资源内容进行修改,会发生什么情况?

对于非原子性操作,多线程下会出现竞争条件。例如,对于操作accounts[to] += amount,可以被拆分为多个CPU指令:

  1. 加载accounts[to]到寄存器
  2. 增加amount
  3. 将结果写回acounts[to]

上述3个步骤中,线程执行到任一步骤时都可能被剥夺运行权。

如此一来,最后的结果就变得不可预测。

考虑一个经典的“转账”示例:

 1public class UnsynchronizedTransfer {
 2
 3    public static void main(String[] args) {
 4        double INITIAL_MONEY = 1000;
 5        int ACCOUNTS = 100;
 6        Bank bank = new Bank(ACCOUNTS, INITIAL_MONEY);
 7				// 可以增加循环次数观察“出错”的概率提升
 8        for (int i = 0; i < 2; i++) {
 9          	// 多个线程使用同一个bank资源
10            Thread t = new Thread(new TransferTask(bank));
11            t.start();
12        }
13    }
14
15    static class TransferTask implements Runnable {
16        private Bank bank;
17        private int size;
18        private double maxAmount = 1000;
19
20        public TransferTask(Bank bank) {
21            this.bank = bank;
22            this.size = bank.size();
23        }
24
25        @Override
26        public void run() {
27            try {
28                int from = (int) (size * Math.random());
29                int to = (int) (size * Math.random());
30                double amount = maxAmount * Math.random();
31                bank.transfer(from, to, amount);
32                Thread.sleep((long) (size * Math.random()));
33            }catch (InterruptedException e){
34                // e.printStackTrace();
35            }
36        }
37    }
38
39    static class Bank {
40        private final double[] accounts;
41
42        public Bank(int accountCount, double money) {
43            // initialize bank account
44            accounts = new double[accountCount];
45            Arrays.fill(accounts, money);
46        }
47
48        public void transfer(int from, int to, double amount) {
49            if (accounts[from] < amount) return;
50            if (from == to) return;
51            // transfer
52            accounts[from] -= amount;
53          	// 这句打印语句增加了调度器剥夺线程运行权的风险
54            System.out.println(Thread.currentThread() + " move away");
55            accounts[to] += amount;
56            System.out.printf("%s: %10.2f from %d to %d, Total Balance: %10.2f%n",
57                Thread.currentThread(),
58                amount,
59                from,
60                to,
61                totalBalance());
62        }
63
64        private double totalBalance() {
65            double sum = 0;
66            for (double a : accounts) {
67                sum += a;
68            }
69            return sum;
70        }
71
72        int size() {
73            return accounts.length;
74        }
75    }
76}
77/* output(sample):
78Thread[Thread-1,5,main] move away
79Thread[Thread-0,5,main] move away
80Thread[Thread-1,5,main]:     217.65 from 30 to 20, Total Balance:   99445.52
81Thread[Thread-0,5,main]:     554.48 from 55 to 53, Total Balance:  100000.00
82*///:~

上例中,使用多个线程访问了Bank类的资源,在Bank类的transfer()方法中,额外增加了一句控制台输出,这是为了增加线程被调度的可能性1 (如果注释这句,会发现程序异常的概率会变小)。Bank类初始化时分配100个“账户”,每个账户1000元,然后不断转账,观察所有账户总额的变化。

仔细观察输出(循环2次,出现的概率较小),我们看到:

  1. 线程1在输出 move away 之后被剥夺运行权;

  2. 接着线程0在 move away 之后也被剥夺运行权;

  3. 线程1继续运行,此时问题就出现了,总金额不是100000:

    在计算总额时,线程1获取账户55的余额时少了554.48元,这正是第2步中线程0的accounts[from] -= amount将账户55的余额减少的金额。

实际上CPU的调度过程比上述分析复杂得多,在Bank类的transfer()方法中,每一行代码在运行时都可能被剥夺运行权,值得一提的是,上例输出操作的还不是相同的“账户”,若是操作同样的“账户”,情况将变得更复杂。

所以说线程不安全是一种不确定性,在有限的线程时,它可能发生也可能不发生,比如main()方法里只循环1次时就不会发生,循环100次就极大概率会发生。并发编程就是要消除这种不确定性

接下来的示例,演示一个生成偶数的工具类,在多线程条件下调用生成偶数的方法并加以判断,若发现不是偶数则退出程序:

 1public class UnSynchronizedEvenGenerator {
 2    public static void main(String[] args) {
 3        System.out.println("press Ctrl-C to exit");
 4        EvenGenerator evenGenerator = new EvenGenerator();
 5        ExecutorService executorService = Executors.newCachedThreadPool();
 6        for (int i = 0; i < 3; i++) {
 7           executorService.execute(new Thread(new EvenTask(evenGenerator)));
 8        }
 9        executorService.shutdown();
10    }
11
12    static abstract class AbstractIntGenerator {
13        // 此处使用了volatile关键字
14        private volatile boolean canceled = false;
15
16        public abstract int next();
17
18        public void cancel() {
19            canceled = true;
20        }
21
22        public boolean isCanceled() {
23            return canceled;
24        }
25    }
26
27
28    static class EvenGenerator extends AbstractIntGenerator {
29        private int even = 0;
30
31        @Override
32        public int next() {
33            ++even;  // danger here!
34            ++even;
35            return even;
36        }
37    }
38
39    static class EvenTask implements Runnable {
40        private EvenGenerator evenGenerator;
41
42        public EvenTask(EvenGenerator evenGenerator) {
43            this.evenGenerator = evenGenerator;
44        }
45
46        @Override
47        public void run() {
48            while (!evenGenerator.isCanceled()) {
49                int next = evenGenerator.next();
50                if (next % 2 != 0) {
51                    System.out.println(Thread.currentThread().toString() + next + " not even!");
52                    evenGenerator.cancel();
53                }
54            }
55        }
56    }
57}
58/* output: (sample)
59press Ctrl-C to exit
60Thread[pool-1-thread-2,5,main]1427 not even!
61Thread[pool-1-thread-1,5,main]1425 not even!
62Thread[pool-1-thread-3,5,main]1429 not even!
63*///:~

上例中,使用for循环开启了多个线程,并使用同一个evenGenerator对象作为构造器参数:

1for (int i = 0; i < 3; i++) {
2   executorService.execute(new Thread(new EvenTask(evenGenerator)));
3}

当循环次数为1(只有一个线程)时,程序会一直执行,直到按下Ctrl-C手动结束任务;

而当循环次数大于1时,无论其运行多长时间,其总会结束。

AbstractIntGenerator类中的canceled标志是基本数据类型,而Java内存模型规定,所有原始类型对象(除了double和long)的读写都是原子的2并且由volatile修饰,说明其是可见的,因此当发生错误时,所有线程都能读取到cancel信息而退出

这个表述没错,程序确实也退出了,但是不够严谨。

查看示例输出可以看到,有3个线程的输出信息,按照输出顺序可以作如下推测:

  1. 线程2发现奇数,修改cancel为true

  2. 线程1发现奇数,修改cancel为true

嗯?为什么线程1还会执行?根据volatile的语义,线程1不是应该“发现”线程1对cancel的改动么?

实际上volatile的语义只能保证在线程2之后执行的语句能够发现对cancel的改动。

但是由于run()方法没有任何同步,所以线程2可能是在线程1while执行之后剥夺线程1的运行权而运行的。

2022.05.11注:实际上是volotile关键字的特性,其能保证可见性,但是不能保证有序性。

EvenGenerator类中通过两次自增运算获取下一个偶数,但是自增运算也不是原子性操作,其仍可被拆分为多个CPU指令3,并且被调度器剥夺运行权,在多线程下问题就会显现。

如何确定自增运算不是原子性的呢?

以下是javap -c -v UnSynchronizedEvenGenerator\$EvenGenerator输出的字节码(部分)

 1public int next();
 2 descriptor: ()I
 3 flags: ACC_PUBLIC
 4 Code:
 5   stack=3, locals=1, args_size=1
 6      0: aload_0
 7      1: dup
 8      2: getfield      #2                  // Field even:I
 9      5: iconst_1
10      6: iadd
11      7: putfield      #2                  // Field even:I
12     10: aload_0
13     11: dup
14     12: getfield      #2                  // Field even:I
15     15: iconst_1
16     16: iadd
17     17: putfield      #2                  // Field even:I
18     20: aload_0
19     21: getfield      #2                  // Field even:I
20     24: ireturn

可以看到,一个自增操作被拆分为至少43个步骤:

  1. get字段even
  2. add修改even
  3. put设置even

在未同步的情况下,其中执行到其中任何一步的时候都可能被CPU剥夺运行权。

如何解决多线程下共享资源的竞争条件呢?

基本上所有的并发模式在解决线程冲突问题时,都采用序列化访问共享资源的方式。即同一时刻只允许某一个线程访问资源,其他线程被阻塞。通常是通过在代码前面加上一条锁语句来实现的,由于锁产生了一种互斥的效果,这种机制也被称为互斥量mutex )。


  1. 一般看来,任务越耗时,其被CPU调度剥夺运行权的几率越大。 ↩︎

  2. https://docs.oracle.com/javase/specs/jls/se15/html/jls-17.html#jls-17.7 ↩︎

  3. java文件编译的字节码会对Java代码进行拆分 ↩︎

  4. 尚不清楚前面aload_0以及dup的意义。 ↩︎