Почему эта Java-программа завершается, несмотря на то, что, по-видимому, она не должна (и не должна)?

Чувствительная операция в моей лаборатории сегодня пошла совершенно не так. Привод электронного микроскопа вышел за его пределы, и после цепочки событий я потерял оборудование на 12 миллионов долларов. Я сузил более 40 тысяч строк в неисправном модуле до этого:

import java.util.*;

class A {
    static Point currentPos = new Point(1,2);
    static class Point {
        int x;
        int y;
        Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }
    public static void main(String[] args) {
        new Thread() {
            void f(Point p) {
                synchronized(this) {}
                if (p.x+1 != p.y) {
                    System.out.println(p.x+" "+p.y);
                    System.exit(1);
                }
            }
            @Override
            public void run() {
                while (currentPos == null);
                while (true)
                    f(currentPos);
            }
        }.start();
        while (true)
            currentPos = new Point(currentPos.x+1, currentPos.y+1);
    }
}

Некоторые образцы вывода, которые я получаю:

$ java A
145281 145282
$ java A
141373 141374
$ java A
49251 49252
$ java A
47007 47008
$ java A
47427 47428
$ java A
154800 154801
$ java A
34822 34823
$ java A
127271 127272
$ java A
63650 63651

Поскольку здесь нет никакой арифметики с плавающей запятой, и мы все знаем, что целые числа со знаком хорошо ведут себя при переполнении в Java, я думаю, что в этом коде нет ничего плохого. Однако, несмотря на вывод, указывающий, что программа не достигла условия выхода, она достигла условия выхода (и оно было достигнуто и не достигнуто?). Почему?


Я заметил, что в некоторых средах этого не происходит. Я использую OpenJDK 6 в 64-разрядной версии Linux.


person Dog    schedule 23.04.2013    source источник
comment
12 миллионов оборудования? мне действительно любопытно, как это могло произойти... почему вы используете пустой блок синхронизации: synchronized(this) {}?   -  person Martin V.    schedule 23.04.2013
comment
Это даже отдаленно не потокобезопасно.   -  person Matt Ball    schedule 23.04.2013
comment
@MattBall, и это сокращенный код. Очевидно, что запись в currentPos не happen-before чтение, но я не понимаю, как это может быть проблемой.   -  person Dog    schedule 23.04.2013
comment
@МартинВ. потому что это единственный способ воспроизвести поведение. Зацикливание на чем угодно делает то же самое.   -  person Dog    schedule 23.04.2013
comment
Интересно отметить: добавление квалификатора final (который не влияет на создаваемый байт-код) в поля x и y решает ошибку. Хотя это не влияет на байт-код, поля помечаются им, что наводит меня на мысль, что это побочный эффект оптимизации JVM.   -  person Niv Steingarten    schedule 23.04.2013
comment
@Dog Также результат очень «ожидаемый». Два независимых потока, работающих с общими данными без какой-либо синхронизации, невозможно определить результат этого. Это может даже никогда не закончиться   -  person Eugene    schedule 23.04.2013
comment
@Eugene: Это не должно заканчиваться не. Вопрос в том, почему это заканчивается?. Создается Point p, удовлетворяющий p.x+1 == p.y, затем в поток опроса передается ссылка. В конце концов поток опроса решает выйти, потому что считает, что условие не выполняется для одного из полученных им Point, но затем вывод консоли показывает, что оно должно было быть выполнено. Отсутствие volatile здесь просто означает, что поток опроса может застрять, но проблема явно не в этом.   -  person Erma K. Pizarro    schedule 23.04.2013
comment
@ErmaK.Pizarro, если эти две строки: if (px+1!= py) System.out.println(px+ +py); приходят друг за другом, это не значит, что они (вместе) атомарны. Между ними может произойти все что угодно. Пока мы не знаем, чего он хочет, невозможно ответить   -  person Eugene    schedule 23.04.2013
comment
@ Юджин, я не думаю, что ты читаешь код. p.x и p.y изменить нельзя, они устанавливаются только в конструкторе Point. Единственный способ получить Point — это вызвать конструктор, который не возвращает значение до тех пор, пока не будут установлены p.x и p.y. Ясно, что никакой код здесь не изменяет уже существующий Point.   -  person Dog    schedule 23.04.2013
comment
Я очень старался упростить это. См. stackoverflow.com/questions/16178020/   -  person Dog    schedule 23.04.2013
comment
@MattBall: похоже, вы мало знаете о параллелизме Java. Эту программу можно сделать потокобезопасной, просто сделав currentPos volatile. Это обеспечит запись в конструкторе happen-before записью currentPos. Другой способ - просто сделать поля в Point final, что будет работать из-за раздела семантики финального поля JLS. Синхронизированный блок - это просто noop, который OP поставил для воспроизведения проблемы. Даже не зная модели памяти Java, большинство людей предположило бы, что эта программа является потокобезопасной.   -  person L̲̳o̲̳̳n̲̳̳g̲̳̳p̲̳o&#x    schedule 29.06.2013
comment
Думаю, это пример того, почему тестирование считается более чем желательным.   -  person John Nicholas    schedule 15.08.2013
comment
@JohnNicholas: Настоящий код (который, очевидно, не является этим) имел 100% покрытие тестами и тысячи тестов, многие из которых проверяли вещи в тысячах различных порядков и перестановок ... Тестирование волшебным образом не находит каждый пограничный случай, вызванный недетерминированным JIT/кэш/планировщик. Настоящая проблема заключается в том, что разработчик, написавший этот код, не знал, что конструирование не происходит до использования объекта. Заметили, как удаление пустого synchronized устраняет ошибку? Это потому, что мне приходилось писать код случайным образом, пока я не нашел такой, который детерминистически воспроизводил бы это поведение.   -  person Dog    schedule 15.08.2013
comment
круто, вы должны обновить свой вопрос с этой информацией о синхронизированных кстати: D Лично я нахожу это захватывающим, когда оно разворачивается.   -  person John Nicholas    schedule 15.08.2013
comment
В тех случаях, когда сбой программного обеспечения может вызвать события с Плохими последствиями, вы должны всегда иметь какую-либо независимую функцию контроля. Об этом знают медицинские, аэрокосмические, автомобильные, железнодорожные и ядерные технологии.   -  person Jason S    schedule 16.08.2013
comment
Почему бы не использовать аппаратную блокировку! мы ничему не научились с 1985 года? (Therac-25): codingninja.co.uk/trust-me -я-программист   -  person Darknight    schedule 16.08.2013
comment
@Dog Это news.nationalpost.com/2013/08/16/ вы?   -  person Eric des Courtis    schedule 16.08.2013
comment
+1 к Темной ночи относительно Терака-25. По крайней мере, этот инцидент привел только к повреждению оборудования, а не к гибели людей.   -  person Stuart Marks    schedule 17.08.2013


Ответы (5)


Очевидно, что запись в currentPos не происходит до чтения, но я не понимаю, как это может быть проблемой.

currentPos = new Point(currentPos.x+1, currentPos.y+1); делает несколько вещей, в том числе записывает значения по умолчанию в x и y (0), а затем записывает их начальные значения в конструкторе. Поскольку ваш объект не опубликован безопасно, эти 4 операции записи могут быть свободно переупорядочены компилятором/JVM.

Таким образом, с точки зрения потока чтения допустимо чтение x с его новым значением, но y со значением по умолчанию, например, 0. К тому времени, когда вы достигаете оператора println (который, кстати, синхронизирован и, следовательно, влияет на операции чтения), переменные имеют свои начальные значения, и программа выводит ожидаемые значения.

Пометка currentPos как volatile обеспечит безопасную публикацию, поскольку ваш объект фактически неизменяем - если в вашем реальном случае использования объект мутирует после создания, гарантий volatile будет недостаточно, и вы снова можете увидеть несогласованный объект.

Кроме того, вы можете сделать Point неизменяемым, что также обеспечит безопасную публикацию, даже без использования volatile. Чтобы добиться неизменности, вам просто нужно отметить x и y окончательными.

В качестве примечания, как уже упоминалось, synchronized(this) {} может рассматриваться JVM как отсутствие операции (я понимаю, что вы включили его, чтобы воспроизвести поведение).

person assylias    schedule 01.05.2013
comment
Я не уверен, но не будет ли создание x и y final иметь тот же эффект, избегая барьера памяти? - person Michael Böckling; 15.08.2013
comment
Более простой проект — это неизменяемый точечный объект, который проверяет инварианты построения. Таким образом, вы никогда не рискуете опубликовать опасную конфигурацию. - person Ron; 15.08.2013
comment
@BuddyCasino Да, действительно, я добавил это. Честно говоря, я не помню всего обсуждения 3 месяца назад (в комментариях было предложено использовать final, поэтому не уверен, почему я не включил его в качестве опции). - person assylias; 15.08.2013
comment
Неизменяемость сама по себе не гарантирует безопасную публикацию (если бы x и y были закрытыми, но доступными только для геттеров, все равно существовала бы та же проблема с публикацией). final или volatile гарантирует это. Я бы предпочел окончательный, а не изменчивый. - person Steve Kuo; 16.08.2013
comment
@SteveKuo Неизменяемость требует final - без final лучшее, что вы можете получить, - это эффективная неизменность, которая не имеет той же семантики. - person assylias; 16.08.2013
comment
Я не знаю никакого java. Проблема в том, что static class Point заставляет все new совместно использовать один и тот же адрес переменной/памяти, поэтому в середине конструктора он может писать в одну переменную, а другой поток читает другую? (без блокировки/синхронизации). Будет ли это страдать от ошибки, если static class не используется? Моя интуиция подсказывает мне, основываясь на числах, что проблема в том, что p.x читается, ctor блокируется (это функция?) и назначает x, y, а затем происходит + p.y. Заявление if, которое он использует, странно, почему .x+1==.y кажется, что 141373 141373 должно произойти - person ; 16.08.2013
comment
В блоке while, когда я извлекаю currentPos.x+1 и currentPos.y+1 как две локальные переменные, ошибка исчезает... почему? - person grape_mao; 13.02.2015

Поскольку currentPos изменяется вне потока, его следует пометить как volatile:

static volatile Point currentPos = new Point(1,2);

Без volatile поток не гарантирует чтение обновлений currentPos, которые выполняются в основном потоке. Таким образом, новые значения продолжают записываться для currentPos, но поток продолжает использовать предыдущие кэшированные версии из соображений производительности. Поскольку только один поток изменяет currentPos, вы можете обойтись без блокировок, что повысит производительность.

Результаты выглядят совсем по-другому, если вы читаете значения только один раз в потоке для использования при сравнении и последующем их отображении. Когда я делаю следующее, x всегда отображается как 1, а y варьируется от 0 до некоторого большого целого числа. Я думаю, что его поведение в этот момент несколько неопределенно без ключевого слова volatile, и возможно, JIT-компиляция кода способствует этому. Кроме того, если я закомментирую пустой блок synchronized(this) {}, тогда код тоже сработает, и я подозреваю, что это связано с тем, что блокировка вызывает достаточную задержку, чтобы currentPos и его поля перечитывались, а не использовались из кеша.

int x = p.x + 1;
int y = p.y;

if (x != y) {
    System.out.println(x+" "+y);
    System.exit(1);
}
person Ed Plese    schedule 23.04.2013
comment
Да, и я также мог бы просто поставить замок вокруг всего. Какова твоя точка зрения? - person Dog; 23.04.2013
comment
Я добавил дополнительное объяснение использования volatile. - person Ed Plese; 23.04.2013

У вас есть обычная память, ссылка «currentpos» и объект Point и его поля за ним, разделенные между двумя потоками без синхронизации. Таким образом, не существует определенного порядка между операциями записи, происходящими в этой памяти в основном потоке, и операциями чтения в созданном потоке (назовем его Т).

Основной поток выполняет следующие записи (игнорирование начальной настройки точки приведет к тому, что px и py будут иметь значения по умолчанию):

  • to p.x
  • to p.y
  • to currentpos

Поскольку в этих операциях записи нет ничего особенного с точки зрения синхронизации/барьеров, среда выполнения может позволить потоку T видеть, как они происходят в любом порядке (основной поток, конечно, всегда видит операции записи и чтения, упорядоченные в соответствии с порядком программы), и происходит в любой момент между чтениями в T.

Итак, Т делает:

  1. читает currentpos в p
  2. читать p.x и p.y (в любом порядке)
  3. сравните и возьмите ветку
  4. прочитать px и py (в любом порядке) и вызвать System.out.println

Учитывая, что между операциями записи в main и чтениями в T нет упорядоченных отношений, очевидно, что есть несколько способов, которыми это может привести к вашему результату, поскольку T может увидеть запись main в currentpos до записи в currentpos.y или currentpos.x:

  1. Сначала он читает currentpos.x, до того, как произошла запись x — получает 0, затем читает currentpos.y до того, как произошла запись y — получает 0. Сравните evals с true. Записи становятся видимыми для T. Вызывается System.out.println.
  2. Он сначала читает currentpos.x, после того как произошла запись x, затем читает currentpos.y до того, как произошла запись y, и получает 0. Сравните evals с истинным. Записи становятся видимыми для T... и т.д.
  3. Сначала он считывает currentpos.y до того, как произойдет запись y (0), затем читает currentpos.x после записи x и возвращает значение true. и Т. Д.

и так далее... Здесь есть ряд гонок данных.

Я подозреваю, что ошибочное предположение здесь заключается в том, что записи, полученные в результате этой строки, становятся видимыми во всех потоках в порядке выполнения программы потока, выполняющего ее:

currentPos = new Point(currentPos.x+1, currentPos.y+1);

Java не дает такой гарантии (это было бы ужасно для производительности). Необходимо добавить что-то еще, если вашей программе требуется гарантированный порядок операций записи относительно операций чтения в других потоках. Другие предлагали сделать поля x,y окончательными или, в качестве альтернативы, сделать currentpos изменчивым.

  • Если вы сделаете поля x,y окончательными, то Java гарантирует, что запись их значений будет замечена до возврата конструктора во всех потоках. Таким образом, поскольку присваивание currentpos выполняется после конструктора, поток T гарантированно увидит записи в правильном порядке.
  • Если вы сделаете currentpos volatile, то Java гарантирует, что это точка синхронизации, которая будет полностью упорядочена по отношению к другим точкам синхронизации. Так как в main записи в x и y должны происходить до записи в currentpos, то любое чтение currentpos в другом потоке должно также видеть записи x, y, которые произошли раньше.

Использование final имеет то преимущество, что оно делает поля неизменяемыми и, таким образом, позволяет кэшировать значения. Использование volatile приводит к синхронизации при каждой записи и чтении currentpos, что может снизить производительность.

Подробности см. в главе 17 спецификации языка Java:http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html

(Первоначальный ответ предполагал более слабую модель памяти, так как я не был уверен, что гарантированной изменчивости JLS было достаточно. Ответ отредактирован, чтобы отразить комментарий от assylias, указывая на то, что модель Java сильнее - происходит - до транзитивности - и поэтому изменчивости на currentpos также достаточно. ).

person paulj    schedule 15.08.2013
comment
Это лучшее объяснение, на мой взгляд. Большое спасибо! - person skyde; 16.08.2013
comment
@skyde, но ошибся в семантике volatile. volatile гарантирует, что при чтении volatile-переменной будет показана последняя доступная запись volatile-переменной, а также любая предыдущая запись. В этом случае, если currentPos сделать изменчивым, назначение обеспечивает безопасную публикацию объекта currentPos, а также его членов, даже если они сами не являются изменчивыми. - person assylias; 16.08.2013
comment
Ну, я говорил, что я не мог сам увидеть, как именно JLS гарантировал, что volatile образует барьер с другими, нормальными операциями чтения и записи. Технически, я не могу ошибаться в этом ;). Когда дело доходит до моделей памяти, разумно предположить, что порядок не гарантируется и будет неправильным (вы все еще в безопасности), чем наоборот, и будет неправильным и небезопасным. Это здорово, если volatile предоставляет такую ​​гарантию. Можете ли вы объяснить, как это обеспечивает глава 17 JLS? - person paulj; 16.08.2013
comment
Короче говоря, в Point currentPos = new Point(x, y) у вас есть 3 записи: (w1) this.x = x, (w2) this.y = y и (w3) currentPos = the new point. Порядок программы гарантирует, что hb(w1, w3) и hb(w2, w3). Далее в программе вы читаете (r1) currentPos. Если currentPos не является изменчивым, между r1 и w1, w2, w3 нет hb, поэтому r1 может наблюдать любой из них (или ни один). С volatile вы вводите hb(w3, r1). И отношение hb является транзитивным, поэтому вы также вводите hb(w1, r1) и hb(w2, r1). Это резюмировано в Java Concurrency на практике (3.5.3. Идиомы безопасной публикации). - person assylias; 16.08.2013
comment
Ах, если hb транзитивно таким образом, то это достаточно сильный «барьер», да. Должен сказать, нелегко определить, что 17.4.5 JLS определяет hb как обладающее этим свойством. Его точно нет в списке свойств, приведенном в начале 17.4.5. Транзитивное замыкание упоминается только ниже после некоторых пояснительных примечаний! В любом случае, приятно знать, спасибо за ответ! :). Примечание. Я обновлю свой ответ, чтобы отразить комментарий assylias. - person paulj; 16.08.2013
comment
@paulj в 17.4.5: Если hb(x, y) и hb(y, z), то hb(x, z). - person assylias; 16.08.2013
comment
@assylias Groan... Теперь очевидно, что ты указал мне на это. :). Спасибо. - person paulj; 16.08.2013

Вы можете использовать объект для синхронизации операций записи и чтения. В противном случае, как уже говорили другие, запись в currentPos произойдет в середине двух операций чтения p.x+1 и p.y.

new Thread() {
    void f(Point p) {
        if (p.x+1 != p.y) {
            System.out.println(p.x+" "+p.y);
            System.exit(1);
        }
    }
    @Override
    public void run() {
        while (currentPos == null);
        while (true)
            f(currentPos);
    }
}.start();
Object sem = new Object();
while (true) {
    synchronized(sem) {
        currentPos = new Point(currentPos.x+1, currentPos.y+1);
    }
}
person Germano Fronza    schedule 15.08.2013
comment
На самом деле это делает работу. В моей первой попытке я поместил чтение в синхронизированный блок, но позже я понял, что это на самом деле не нужно. - person Germano Fronza; 16.08.2013
comment
-1 JVM может доказать, что sem не используется совместно, и рассматривать синхронизированный оператор как недействующий... Тот факт, что он решает проблему, - чистая удача. - person assylias; 16.08.2013
comment
Я ненавижу многопоточное программирование, слишком многое работает благодаря удаче. - person Jonathan Allen; 18.08.2013

Вы обращаетесь к currentPos дважды и не даете никаких гарантий, что он не обновится между этими двумя доступами.

Например:

  1. x = 10, y = 11
  2. рабочий поток оценивает p.x как 10
  3. основной поток выполняет обновление, теперь x = 11 и y = 12
  4. рабочий поток оценивает p.y как 12
  5. рабочий поток замечает, что 10+1 != 12, поэтому печатает и завершает работу.

По сути, вы сравниваете две разные точки.

Обратите внимание, что даже создание currentPos volatile не защитит вас от этого, так как это два отдельных чтения рабочим потоком.

Добавить

boolean IsValid() { return x+1 == y; }

метод к вашему классу очков. Это гарантирует, что при проверке x+1 == y используется только одно значение currentPos.

person user2686913    schedule 15.08.2013
comment
currentPos читается только один раз, его значение копируется в p. p читается дважды, но всегда будет указывать на одно и то же место. - person Jonathan Allen; 16.08.2013