Чи безпечна нитка! = Перевірити?


140

Я знаю, що такі складні операції, як, наприклад i++, не є безпечними для потоків, оскільки вони включають кілька операцій.

Але чи перевірка посилання на себе є потоком безпечної роботи?

a != a //is this thread-safe

Я спробував це запрограмувати і використовувати кілька потоків, але це не вийшло з ладу. Я думаю, я не міг імітувати гонку на своїй машині.

Редагувати:

public class TestThreadSafety {
    private Object a = new Object();

    public static void main(String[] args) {

        final TestThreadSafety instance = new TestThreadSafety();

        Thread testingReferenceThread = new Thread(new Runnable() {

            @Override
            public void run() {
                long countOfIterations = 0L;
                while(true){
                    boolean flag = instance.a != instance.a;
                    if(flag)
                        System.out.println(countOfIterations + ":" + flag);

                    countOfIterations++;
                }
            }
        });

        Thread updatingReferenceThread = new Thread(new Runnable() {

            @Override
            public void run() {
                while(true){
                    instance.a = new Object();
                }
            }
        });

        testingReferenceThread.start();
        updatingReferenceThread.start();
    }

}

Це програма, яку я використовую для перевірки безпеки потоку.

Дивна поведінка

Оскільки моя програма починається між деякими ітераціями, я отримую значення вихідного прапора, а це означає, що !=перевірка посилання не вдається на одній посиланні. АЛЕ після деяких ітерацій вихід стає постійним значенням, falseа потім виконання програми протягом тривалого часу не генерує єдиного trueвиходу.

Як показує вихід після деяких n (нефіксованих) ітерацій, вихід здається постійним значенням і не змінюється.

Вихід:

Для деяких ітерацій:

1494:true
1495:true
1496:true
19970:true
19972:true
19974:true
//after this there is not a single instance when the condition becomes true

2
Що ви маєте на увазі під "потоком безпечних" у цьому контексті? Ви запитуєте, чи гарантовано завжди повертати помилкове?
JB Nizet

@JBNizet так. Ось що я думав.
Narendra Pathai

5
Він навіть не завжди повертає false в однопотоковому контексті. Це може бути NaN ..
harold

4
Ймовірно пояснення: код був вчасно складений, і компільований код завантажує змінну лише один раз. Це очікується.
Марко Топольник

3
Друк індивідуальних результатів - поганий спосіб перевірки на перегони. Друк (як форматування, так і запис результатів) порівняно затратний у порівнянні з вашим тестом (і іноді ваша програма закінчиться блокуванням запису, коли пропускна здатність з'єднання з терміналом або самим терміналом повільна). Крім того, IO часто містить власні мутекси, які перетворюють порядок виконання ваших потоків (зверніть увагу, що ваші окремі рядки 1234:trueніколи не розбивають один одного ). Тест на перегони потребує більш жорсткої внутрішньої петлі. Надрукуйте резюме в кінці (як це робив хтось нижче з одиничною тестовою рамкою).
Бен Джексон

Відповіді:


124

За відсутності синхронізації цей код

Object a;

public boolean test() {
    return a != a;
}

може виробляти true. Це байт-код дляtest()

    ALOAD 0
    GETFIELD test/Test1.a : Ljava/lang/Object;
    ALOAD 0
    GETFIELD test/Test1.a : Ljava/lang/Object;
    IF_ACMPEQ L1
...

як ми бачимо, що він завантажує поле aдо локальних змін двічі, це неатомна операція, якщо aзмінити між ними іншим потоком порівняння може призвести false.

Також тут проблема актуальності пам’яті пам’яті, немає жодної гарантії, що зміни, aвнесені іншим потоком, будуть помітні в поточному потоці.


22
Хоча байткод насправді не є доказом. Це повинно бути десь і в JLS ...
Марко Тополник

10
@Marko Я згоден з вашим мисленням, але не обов'язково з вашим висновком. Для мене байт-код вище - це очевидний / канонічний спосіб реалізації !=, який передбачає завантаження LHS та RHS окремо. І тому якщо JLS не згадує нічого конкретного щодо оптимізацій, коли LHS та RHS синтаксично однакові, застосовується загальне правило, яке означає завантаження aвдвічі.
Анджей Дойл

20
Насправді, якщо припустити, що згенерований байт-код відповідає JLS, це доказ!
proskor

6
@ Adrian: По-перше: навіть якщо це припущення є недійсним, достатньо наявності одного компілятора, де він може оцінити "істинний", достатньо, щоб продемонструвати, що він може іноді оцінювати як "істинне" (навіть якщо специфікація забороняє це - що це не). По-друге: Java чітко визначена, і більшість компіляторів чітко її відповідають. У цьому відношенні є сенс використовувати їх як орієнтир. По-третє: ви використовуєте термін "JRE", але я не думаю, що це означає, що ви думаєте, що це означає. . .
ruakh

2
@AlexanderTorstling - "Я не впевнений, чи цього достатньо для виключення оптимізації для одного зчитування." Це недостатньо. Насправді, за відсутності синхронізації (а додаткові відносини "трапляються до", що нав'язує), оптимізація діє,
Стівен C

47

Чи a != aбезпечна нитка перевірки ?

Якщо aпотенційно можна оновити іншим потоком (без належної синхронізації!), Тоді Ні.

Я спробував це запрограмувати і використовувати декілька потоків, але не провалився. Я думаю, не міг імітувати гонку на моїй машині.

Це нічого не означає! Проблема полягає в тому, що якщо виконання, в якому aоновлений іншим потоком, дозволено JLS, код не є безпечним для потоків. Той факт, що ви не можете спричинити те, що стан перегонів відбувається з певним тестовим випадком на певній машині та конкретною реалізацією Java, не перешкоджає цьому відбуватися за інших обставин.

Чи означає це, що a! = A може повернутися true.

Так, теоретично, за певних обставин.

Крім того, a != aможна повернутись, falseхоча aзмінювався одночасно.


Щодо "дивної поведінки":

Оскільки моя програма починається між деякими ітераціями, я отримую значення вихідного прапора, що означає, що посилання! = Check не вдається в одній і тій же посилання. АЛЕ після деяких ітерацій висновок стає постійним значенням false, а потім виконання програми протягом тривалого часу не генерує єдиного справжнього виводу.

Ця "дивна" поведінка відповідає наступному сценарію виконання:

  1. Програма завантажується і JVM починає інтерпретувати байт-коди. Оскільки (як ми бачили з результатів javap) байт-код робить два навантаження, ви (мабуть) час від часу бачите результати стану гонки.

  2. Через деякий час код складається компілятором JIT. Оптимізатор JIT помічає, що два навантаження одного і того ж гнізда пам'яті ( a) близько один до одного, і оптимізує друге. (Насправді є ймовірність, що він повністю оптимізує тест ...)

  3. Тепер умова гонки вже не проявляється, бо більше немає двох навантажень.

Зауважте, що це все відповідає тому, що JLS дозволяє виконувати Java.


@kriss прокоментував так:

Це виглядає так, що це може бути те, що програмісти C або C ++ називають "Невизначена поведінка" (залежність від реалізації). Здається, у Java на таких кутових випадках, як у цьому, може бути кілька UB.

Модель пам'яті Java (вказана в JLS 17.4 ) визначає набір передумов, згідно з якими один потік гарантовано бачить значення пам'яті, записані іншим потоком. Якщо один потік намагається прочитати змінну, написану іншою, і ці передумови не виконуються, то може бути ряд можливих виконань ... деякі з яких, ймовірно, будуть неправильними (з точки зору вимог програми). Іншими словами, визначено набір можливих способів поведінки (тобто набір "добре сформованих страт"), але ми не можемо сказати, яка з цих форм поведінки відбудеться.

Компілятору дозволено комбінувати та упорядковувати навантаження та зберігати (та робити інші речі) за умови, що кінцевий ефект коду однаковий:

  • коли виконується однією ниткою, і
  • при виконанні різними потоками, які правильно синхронізуються (відповідно до моделі пам'яті).

Але якщо код не синхронізується належним чином (і, отже, відносини «трапляються раніше» недостатньо обмежують набір добре сформованих виконань), компілятору дозволяється переупорядковувати навантаження та зберігати таким чином, що дасть «неправильні» результати. (Але це насправді просто говорить про те, що програма неправильна.)


Чи означає це, що a != aможе повернути правду?
proskor

Я мав на увазі, що, можливо, на своїй машині я не міг імітувати, що наведений вище код не є безпечним для потоків. Тож, можливо, за цим є теоретичні міркування.
Нарендра Патай

@NarendraPathai - Не існує теоретичної причини, по якій ви не можете продемонструвати це. Можливо, є якась практична причина ... а може, вам просто не пощастило.
Стівен C

Будь ласка, перевірте мою оновлену відповідь із програмою, яку я використовую. Чек повертає справді іноді, але, схоже, є дивна поведінка у висновку.
Narendra Pathai

1
@NarendraPathai - Дивіться моє пояснення.
Стівен C

27

Доведено тест-нг:

public class MyTest {

  private static Integer count=1;

  @Test(threadPoolSize = 1000, invocationCount=10000)
  public void test(){
    count = new Integer(new Random().nextInt());
    Assert.assertFalse(count != count);
  }

}

У мене 2 помилки на 10 000 викликів. Так ні , це НЕ безпечно для потоків


6
Ви навіть не перевіряєте рівність ... Random.nextInt()частина зайва. Ви могли так само пройти тестування new Object().
Марко Топольник

@MarkoTopolnik Будь ласка, перевірте мою оновлену відповідь із програмою, якою я користуюся. Чек повертає справді іноді, але, схоже, є дивна поведінка у висновку.
Narendra Pathai

1
Побічна примітка: Випадкові об'єкти зазвичай мають на увазі повторне використання, а не створюються кожен раз, коли вам потрібен новий int.
Саймон Форсберг

15

Ні, це не так. Для порівняння Java VM повинна поставити два значення для порівняння на стек і виконати інструкцію порівняння (яке залежить від типу "a").

Java VM може:

  1. Прочитайте «а» два рази, покладіть кожен на стек, а потім і порівняйте результати
  2. Прочитайте "a" лише один раз, покладіть його на стек, повторіть його (інструкція "дублювання") та запустіть порівняння
  3. Виключіть вираз повністю і замініть його false

У першому випадку інший потік може змінити значення для "а" між двома зчитуваннями.

Яка стратегія буде обрана, залежить від компілятора Java та Java Runtime (особливо компілятора JIT). Це навіть може змінитися під час виконання вашої програми.

Якщо ви хочете переконатися в тому, як доступ до змінної, ви повинні зробити її volatile(так званий "бар'єр наполовину пам'яті") або додати повний бар'єр пам'яті ( synchronized). Ви також можете використовувати API більш високого рівня (наприклад, AtomicIntegerяк згадував Юнід Ахасан).

Докладніше про безпеку потоку читайте в JSR 133 ( Модель пам'яті Java ).


Оголошення aяк volatileби все ще передбачає два чітких читання, з можливістю зміни між ними.
Холгер

6

Це все добре пояснив Стівен С. Для задоволення ви можете спробувати запустити той самий код із такими параметрами JVM:

-XX:InlineSmallCode=0

Це повинно запобігти оптимізації JIT (це робиться на сервері Hotspot 7), і ви побачите trueназавжди (я зупинився на 2 000 000, але я вважаю, що це продовжується і після цього).

Для інформації нижче - код JIT'ed. Якщо чесно, я не читаю складання досить вільно, щоб знати, чи тест справді зроблений чи звідки беруться два вантажі. (рядок 26 - випробування, flag = a != aа рядок 31 - дужка закриття while(true)).

  # {method} 'run' '()V' in 'javaapplication27/TestThreadSafety$1'
  0x00000000027dcc80: int3   
  0x00000000027dcc81: data32 data32 nop WORD PTR [rax+rax*1+0x0]
  0x00000000027dcc8c: data32 data32 xchg ax,ax
  0x00000000027dcc90: mov    DWORD PTR [rsp-0x6000],eax
  0x00000000027dcc97: push   rbp
  0x00000000027dcc98: sub    rsp,0x40
  0x00000000027dcc9c: mov    rbx,QWORD PTR [rdx+0x8]
  0x00000000027dcca0: mov    rbp,QWORD PTR [rdx+0x18]
  0x00000000027dcca4: mov    rcx,rdx
  0x00000000027dcca7: movabs r10,0x6e1a7680
  0x00000000027dccb1: call   r10
  0x00000000027dccb4: test   rbp,rbp
  0x00000000027dccb7: je     0x00000000027dccdd
  0x00000000027dccb9: mov    r10d,DWORD PTR [rbp+0x8]
  0x00000000027dccbd: cmp    r10d,0xefc158f4    ;   {oop('javaapplication27/TestThreadSafety$1')}
  0x00000000027dccc4: jne    0x00000000027dccf1
  0x00000000027dccc6: test   rbp,rbp
  0x00000000027dccc9: je     0x00000000027dcce1
  0x00000000027dcccb: cmp    r12d,DWORD PTR [rbp+0xc]
  0x00000000027dcccf: je     0x00000000027dcce1  ;*goto
                                                ; - javaapplication27.TestThreadSafety$1::run@62 (line 31)
  0x00000000027dccd1: add    rbx,0x1            ; OopMap{rbp=Oop off=85}
                                                ;*goto
                                                ; - javaapplication27.TestThreadSafety$1::run@62 (line 31)
  0x00000000027dccd5: test   DWORD PTR [rip+0xfffffffffdb53325],eax        # 0x0000000000330000
                                                ;*goto
                                                ; - javaapplication27.TestThreadSafety$1::run@62 (line 31)
                                                ;   {poll}
  0x00000000027dccdb: jmp    0x00000000027dccd1
  0x00000000027dccdd: xor    ebp,ebp
  0x00000000027dccdf: jmp    0x00000000027dccc6
  0x00000000027dcce1: mov    edx,0xffffff86
  0x00000000027dcce6: mov    QWORD PTR [rsp+0x20],rbx
  0x00000000027dcceb: call   0x00000000027a90a0  ; OopMap{rbp=Oop off=112}
                                                ;*aload_0
                                                ; - javaapplication27.TestThreadSafety$1::run@2 (line 26)
                                                ;   {runtime_call}
  0x00000000027dccf0: int3   
  0x00000000027dccf1: mov    edx,0xffffffad
  0x00000000027dccf6: mov    QWORD PTR [rsp+0x20],rbx
  0x00000000027dccfb: call   0x00000000027a90a0  ; OopMap{rbp=Oop off=128}
                                                ;*aload_0
                                                ; - javaapplication27.TestThreadSafety$1::run@2 (line 26)
                                                ;   {runtime_call}
  0x00000000027dcd00: int3                      ;*aload_0
                                                ; - javaapplication27.TestThreadSafety$1::run@2 (line 26)
  0x00000000027dcd01: int3   

1
Це хороший приклад виду коду, який насправді буде виробляти JVM, коли у вас є нескінченний цикл, і все можна більш-менш підняти. Справжній "цикл" - це три інструкції від 0x27dccd1до 0x27dccdf. Цикл jmpу циклі є безумовним (оскільки цикл нескінченний). Єдині два вказівки в циклі add rbc, 0x1- нарощування countOfIterations(незважаючи на те, що цикл ніколи не вийде, тому це значення не буде прочитано: можливо, воно знадобиться у випадку, якщо ви потрапите в нього на відладці), .. .
BeeOnRope

... і дивна testінструкція, яка насправді існує лише для доступу до пам'яті (зауважте, що eaxнавіть у методі навіть не встановлено!): це спеціальна сторінка, яка налаштована на нечитабельність, коли JVM хоче запустити всі потоки щоб досягти безпечної точки, тому він може виконати gc або якусь іншу операцію, яка вимагає, щоб усі потоки були у відомому стані.
BeeOnRope

Більше того, JVM повністю зняв instance. a != instance.aпорівняння з циклу, і виконує його лише один раз, перш ніж цикл буде введений! Він знає, що не потрібно перезавантажувати instanceабо aоскільки вони не оголошені мінливими, і немає іншого коду, який може змінити їх на одній нитці, тому він просто передбачає, що вони однакові протягом усього циклу, що дозволено пам'яттю модель.
BeeOnRope

5

Ні, a != aне безпечна нитка. Цей вираз складається з трьох частин: завантажувати a, aзнову завантажувати та виконувати !=. Можливо, щоб інший потік набув внутрішнього блокування на aбатьківському батьківщині та змінив значення aміж двома операціями навантаження.

Ще одним фактором є те, чи aє місцевим. Якщо aлокальний, то жоден інший потік не повинен мати доступ до нього, і тому він повинен бути безпечним для потоків.

void method () {
    int a = 0;
    System.out.println(a != a);
}

також слід завжди друкувати false.

Визначення aяк volatileби не вирішило проблему, якщо вона aє staticабо інстанцією. Проблема не в тому, що потоки мають різні значення a, а в тому, що одна нитка завантажується aдвічі з різними значеннями. Це на самому ділі може зробити так менше потокобезпечна .. Якщо aНЕ volatileтоді aможе кешувати і зміна в іншому потоці не впливатиме на кешированниє значення.


Ваш приклад з synchronizedневірно: для цього коду гарантована печаткою false, все методи, набір a повинні бути synchronizedтеж.
ruakh

Чому так? Якщо метод синхронізований, як би будь-який інший потік набув внутрішнього блокування aбатьківського батька під час виконання методу, необхідного для встановлення значення a.
DoubleMx2

1
Ваші приміщення неправильні. Ви можете встановити поле об'єкта, не отримуючи його внутрішнього блокування. Java не вимагає потоку для придбання внутрішнього блокування об'єкта перед встановленням його полів.
ruakh

3

Щодо дивної поведінки:

Оскільки змінна aне позначена як volatile, в якийсь момент вона може мати значення aкешування потоком. Тоді обидва as a != aє кешованою версією і, таким чином, завжди є однаковою (сенс flagтепер є завжди false).


0

Навіть просте читання не є атомним. Якщо aє longта не позначено як, volatileто на 32-бітних JVM long b = aне є безпечними для потоків.


летючі та атомність не пов'язані між собою. навіть якщо я
позначу летючу речовину,

Присвоєння летючого довгого поля завжди атомне. Інших операцій, таких як ++, немає.
ЖекаКозлов
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.