【问题标题】:Real world example of Memory Consistency Errors in multi-threading?多线程中内存一致性错误的真实示例?
【发布时间】:2019-12-16 11:36:48
【问题描述】:

在java多线程的tutorial中,给出了内存一致性错误的例子。但我无法重现它。有没有其他方法可以模拟内存一致性错误

教程中提供的示例:

假设定义并初始化了一个简单的 int 字段:

int counter = 0;

计数器字段在两个线程 A 和 B 之间共享。假设线程 A 递增计数器:

counter++;

然后,不久之后,线程 B 打印出计数器:

System.out.println(counter);

如果这两个语句在同一个线程中执行,则可以安全地假设打印输出的值为“1”。但是如果这两个语句在不同的线程中执行,打印出来的值很可能是“0”,因为不能保证线程 A 对计数器的更改对线程 B 是可见的——除非程序员已经在这两个语句。

【问题讨论】:

  • 你写了什么来重现它?仅仅counter++; 不足以看出不一致。
  • “不保证”是双向的。不能保证一个线程所做的更新会被另一个线程看到,但也不能保证它不会,所以 Memory Consistency Errors 很难强制执行,尤其是在那样的短线。
  • 这很难测试。你怎么知道counter++ 甚至发生在println(counter) 之前?几乎你可以做的所有事情来确保这将强制这两个事件之间的“发生在之前”的关系。

标签: java multithreading


【解决方案1】:

不久前我回答了一个关于 Java 5 中的错误的问题。Why doesn't volatile in java 5+ ensure visibility from another thread?

鉴于这段代码:

public class Test {
    volatile static private int a;
    static private int b;

    public static void main(String [] args) throws Exception {
        for (int i = 0; i < 100; i++) {
            new Thread() {

                @Override
                public void run() {
                    int tt = b; // makes the jvm cache the value of b

                    while (a==0) {

                    }

                    if (b == 0) {
                        System.out.println("error");
                    }
                }

            }.start();
        }

        b = 1;
        a = 1;
    }
}

a 的 volatile 存储发生在 b 的正常存储之后。所以当线程运行看到a != 0时,由于JMM中定义的规则,我们必须看到b == 1

JRE 中的错误允许线程进入error 行并随后得到解决。如果您没有将a 定义为volatile,这肯定会失败。

【讨论】:

  • 我只是删除变量b,虽然主线程将1分配给a,但循环从未停止,我认为足以证明存在内存一致性错误。
【解决方案2】:

这可能会重现问题,至少在我的计算机上,我可以在循环后重现它。

  1. 假设你有一个Counter 类:

    class Holder {
        boolean flag = false;
        long modifyTime = Long.MAX_VALUE;
    }
    
  2. thread_A设置flagtrue,节省时间到 modifyTime
  3. 让另一个线程,比如说thread_B,读取Counterflag。如果thread_B 仍然得到false,即使它晚于modifyTime,那么我们可以说我们已经重现了这个问题。

示例代码

class Holder {
    boolean flag = false;
    long modifyTime = Long.MAX_VALUE;
}

public class App {

    public static void main(String[] args) {
        while (!test());
    }

    private static boolean test() {

        final Holder holder = new Holder();

        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(10);
                    holder.flag = true;
                    holder.modifyTime = System.currentTimeMillis();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }).start();

        long lastCheckStartTime = 0L;
        long lastCheckFailTime = 0L;
        while (true) {
            lastCheckStartTime = System.currentTimeMillis();
            if (holder.flag) {
                break;
            } else {
                lastCheckFailTime = System.currentTimeMillis();
                System.out.println(lastCheckFailTime);
            }
        }

        if (lastCheckFailTime > holder.modifyTime 
                && lastCheckStartTime > holder.modifyTime) {
            System.out.println("last check fail time " + lastCheckFailTime);
            System.out.println("modify time          " + holder.modifyTime);
            return true;
        } else {
            return false;
        }
    }
}

结果

last check time 1565285999497
modify time     1565285999494

这意味着thread_BCounterflag 获得false 1565285999497,甚至thread_A 已将其设置为true 1565285999494(3 毫秒前) .

【讨论】:

  • 您的测试非常好,但它没有达到您所声称的:'这意味着 thread_B 在时间 1565285999497 提交的 Counter 的标志中为 false'。标志的检查可能发生得更早,例如1565285999493,分配给lastCheckTime 或者更确切地说是调用System.currentTimeMillis() 可能在三毫秒后发生。您需要将分配给lastCheckTime标志检查之前,在while循环的最开始。
  • @ciamej 谢谢,你是对的。但是如果我把它放在 while 循环的最开始,那么 lastCheckTime 不再代表 thread_B 仍然从标志中读取 false .你有什么想法可以同时实现吗?
  • 你不能同时拥有它,因为你不能原子地执行两个操作。但是,如果您在循环开始时记录时间,并且确实通过System.out.println 打印了该时间,那么这意味着即使在花费时间之后holder.flag 也必须是false。当然,这一切只有在编译器没有对操作重新排序的情况下才有意义,但我们以操作的顺序执行为假设。
  • @ciamej 我引入了一个新的时间戳lastCheckStartTime 并编辑了答案。现在是不是对了?
  • 你有没有运行代码并发现一些不一致的地方?因为时间戳看起来一样......现在代码肯定是正确的。
【解决方案3】:

使用的示例太糟糕了,无法演示内存一致性问题。让它发挥作用将需要脆弱的推理和复杂的编码。然而你可能看不到结果。多线程问题是由于不幸的时机而发生的。如果有人想增加观察问题的机会,我们需要增加倒霉时机的机会。 下面的程序就实现了。

public class ConsistencyIssue {

    static int counter = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(new Increment(), "Thread-1");
        Thread thread2 = new Thread(new Increment(), "Thread-2");
        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println(counter);
    }

    private static class Increment implements Runnable{

        @Override
        public void run() {
            for(int i = 1; i <= 10000; i++)
                counter++;
        }

    }
}

执行 1 输出:10963, 执行2输出:14552

最终计数应该是 20000,但它比这少。原因是count++是多步操作, 1.阅读次数 2.递增计数 3. 保存

两个线程可能一次读取计数 1,将其增加到 2。然后写出 2。但如果是串行执行,则应该是 1++ -> 2++ -> 3。

我们需要一种方法来使所有 3 个步骤都原子化。即一次只能由一个线程执行。

解决方案 1:同步 用 Synchronized 包围增量。由于计数器是静态变量,因此您需要使用类级同步

@Override
        public void run() {
            for (int i = 1; i <= 10000; i++)
                synchronized (ConsistencyIssue.class) {
                    counter++;
                }
        }

现在输出:20000

解决方案 2:AtomicInteger

public class ConsistencyIssue {

    static AtomicInteger counter = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(new Increment(), "Thread-1");
        Thread thread2 = new Thread(new Increment(), "Thread-2");
        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();
        System.out.println(counter.get());
    }

    private static class Increment implements Runnable {

        @Override
        public void run() {
            for (int i = 1; i <= 10000; i++)
                counter.incrementAndGet();
        }

    }
}

我们可以使用信号量,也可以使用显式锁定。但是对于这个简单的代码 AtomicInteger 就足够了

【讨论】:

  • 谢谢。这可能是因为内存一致性错误。但这也可能是因为线程干扰。docs.oracle.com/javase/tutorial/essential/concurrency/…
  • 这是两个线程对单个变量的不协调访问的示例 - 内存一致性问题是内存操作的顺序在不同线程中的不同、不一致的顺序中可见。
【解决方案4】:

有时当我尝试重现一些真正的并发问题时,我会使用调试器。 在打印上打一个断点,在增量上打一个断点,然后运行整个事情。 以不同的顺序释放断点会得到不同的结果。

也许很简单,但它对我有用。

【讨论】:

    【解决方案5】:

    请再看看您的源代码中是如何引入该示例的。

    避免内存一致性错误的关键是理解happens-before关系。这种关系只是保证一个特定语句的内存写入对另一个特定语句可见。要了解这一点,请考虑以下示例。

    这个例子说明了多线程不是确定性的事实,因为您无法保证不同线程的操作将执行的顺序,这可能会导致不同的观察结果跨越几次运行。 但这并不能说明内存一致性错误!

    要了解内存一致性错误是什么,您需要先了解内存一致性。 Lamport 在 1979 年引入了最简单的内存一致性模型。这是最初的定义。

    任何执行的结果都是一样的,就好像所有进程的操作都按某种顺序执行,并且每个单独进程的操作按照其程序指定的顺序出现在这个顺序中

    现在,考虑这个示例多线程程序,请看一下这张来自最近关于顺序一致性的研究论文的图片。它说明了真正的内存一致性错误可能是什么样子。

    要最终回答您的问题,请注意以下几点:

    1. 内存一致性错误始终取决于底层内存模型(特定的编程语言可能允许更多行为用于优化目的)。什么是最好的内存模型仍然是一个开放的研究问题。
    2. 上面给出的例子给出了一个顺序一致性冲突的例子,但不能保证你可以用你最喜欢的编程语言观察它,原因有两个:它取决于编程语言确切的内存模型,并且由于不确定性,您无法强制执行特定的错误执行。

    内存模型是一个广泛的话题。要获取更多信息,例如,您可以查看苏黎世联邦理工学院的 Torsten Hoefler 和 Markus Püschel 课程,从中我了解了大部分概念。

    来源

    1. Leslie Lamport. How to Make a Multiprocessor Computer That Correctly Executes Multiprocessor Programs, 1979
    2. Wei-Yu Chen, Arvind Krishnamurthy, Katherine Yelick, Polynomial-Time Algorithms for Enforcing Sequential Consistency in SPMD Programs with Arrays, 2003
    3. Design of Parallel and High-Performance Computing course, ETH Zürich

    【讨论】:

      猜你喜欢
      • 2011-02-15
      • 1970-01-01
      • 2013-01-10
      • 1970-01-01
      • 1970-01-01
      • 2012-01-07
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多