Атомность является одним из ключевых понятий в многопоточных программах. Мы говорим, что набор действий является атомарным, если все они выполняются как единая операция неделимым образом. Принятие как должное того, что набор действий в многопоточной программе будет выполняться последовательно, может привести к неверным результатам. Причина заключается в помехах потоков, что означает, что если два потока выполняют несколько шагов с одними и теми же данными, они могут перекрываться.
В следующем примере чередования показано, как два потока выполняют несколько действий (печатает в цикле) и как они перекрываются:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
|
public class Interleaving { public void show() { for ( int i = 0 ; i < 5 ; i++) { System.out.println(Thread.currentThread().getName() + " - Number: " + i); } } public static void main(String[] args) { final Interleaving main = new Interleaving(); Runnable runner = new Runnable() { @Override public void run() { main.show(); } }; new Thread(runner, "Thread 1" ).start(); new Thread(runner, "Thread 2" ).start(); } } |
Когда выполнено, это приведет к непредсказуемым результатам. Например:
01
02
03
04
05
06
07
08
09
10
|
Thread 2 - Number: 0 Thread 2 - Number: 1 Thread 2 - Number: 2 Thread 1 - Number: 0 Thread 1 - Number: 1 Thread 1 - Number: 2 Thread 1 - Number: 3 Thread 1 - Number: 4 Thread 2 - Number: 3 Thread 2 - Number: 4 |
В этом случае ничего плохого не происходит, так как они просто печатают цифры. Однако, когда вам нужно поделиться состоянием объекта (его данных) без синхронизации, это приводит к наличию условий гонки.
Состояние гонки
Ваш код будет иметь состояние гонки, если есть возможность получить неправильные результаты из-за чередования потоков. В этом разделе описываются два типа условий гонки:
- Проверка-то-акт
- Read-модификация-запись
Чтобы устранить условия гонки и обеспечить безопасность потоков, мы должны сделать эти действия атомарными, используя синхронизацию. Примеры в следующих разделах покажут, каковы последствия этих условий гонки.
Проверьте состояние гонок
Это условие гонки появляется, когда у вас есть общее поле и вы хотите последовательно выполнить следующие шаги:
- Получить значение из поля.
- Сделайте что-нибудь на основании результата предыдущей проверки.
Проблема здесь в том, что когда первый поток будет действовать после предыдущей проверки, другой поток мог чередовать и изменять значение поля. Теперь первый поток будет действовать на основе значения, которое больше не является допустимым. Это легче увидеть на примере.
Ожидается , что UnsafeCheckThenAct изменит номер поля один раз. Следующие вызовы метода changeNumber должны привести к выполнению условия else:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
public class UnsafeCheckThenAct { private int number; public void changeNumber() { if (number == 0 ) { System.out.println(Thread.currentThread().getName() + " | Changed" ); number = - 1 ; } else { System.out.println(Thread.currentThread().getName() + " | Not changed" ); } } public static void main(String[] args) { final UnsafeCheckThenAct checkAct = new UnsafeCheckThenAct(); for ( int i = 0 ; i < 50 ; i++) { new Thread( new Runnable() { @Override public void run() { checkAct.changeNumber(); } }, "T" + i).start(); } } } |
Но так как этот код не синхронизирован, это может (нет гарантии) привести к нескольким модификациям поля:
01
02
03
04
05
06
07
08
09
10
11
|
T13 | Changed T17 | Changed T35 | Not changed T10 | Changed T48 | Not changed T14 | Changed T60 | Not changed T6 | Changed T5 | Changed T63 | Not changed T18 | Not changed |
Другой пример этого состояния гонки — ленивая инициализация .
Простой способ исправить это — использовать синхронизацию.
SafeCheckThenAct является поточно- ориентированным, поскольку он удалил условие гонки, синхронизировав все обращения к общему полю.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
public class SafeCheckThenAct { private int number; public synchronized void changeNumber() { if (number == 0 ) { System.out.println(Thread.currentThread().getName() + " | Changed" ); number = - 1 ; } else { System.out.println(Thread.currentThread().getName() + " | Not changed" ); } } public static void main(String[] args) { final SafeCheckThenAct checkAct = new SafeCheckThenAct(); for ( int i = 0 ; i < 50 ; i++) { new Thread( new Runnable() { @Override public void run() { checkAct.changeNumber(); } }, "T" + i).start(); } } } |
Теперь выполнение этого кода всегда будет давать один и тот же ожидаемый результат; только один поток изменит поле:
1
2
3
4
5
6
7
|
T0 | Changed T54 | Not changed T53 | Not changed T62 | Not changed T52 | Not changed T51 | Not changed ... |
В некоторых случаях будут другие механизмы, которые работают лучше, чем синхронизация всего метода, но я не буду обсуждать их в этом посте.
Чтение-изменение-запись условия гонки
Здесь у нас есть другой тип состояния гонки, которое появляется при выполнении следующего набора действий:
- Получить значение из поля.
- Изменить значение.
- Сохраните новое значение в поле.
В этом случае есть еще одна опасная возможность, которая заключается в потере некоторых обновлений в поле. Один из возможных результатов:
1
2
3
4
5
6
7
|
Field’s value is 1. Thread 1 gets the value from the field (1). Thread 1 modifies the value (5). Thread 2 reads the value from the field (1). Thread 2 modifies the value (7). Thread 1 stores the value to the field (5). Thread 2 stores the value to the field (7). |
Как видите, обновление со значением 5 было потеряно.
Давайте посмотрим пример кода. UnsafeReadModifyWrite разделяет числовое поле, которое увеличивается каждый раз:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
public class UnsafeReadModifyWrite { private int number; public void incrementNumber() { number++; } public int getNumber() { return this .number; } public static void main(String[] args) throws InterruptedException { final UnsafeReadModifyWrite rmw = new UnsafeReadModifyWrite(); for ( int i = 0 ; i < 1_000; i++) { new Thread( new Runnable() { @Override public void run() { rmw.incrementNumber(); } }, "T" + i).start(); } Thread.sleep( 6000 ); System.out.println( "Final number (should be 1_000): " + rmw.getNumber()); } } |
Можете ли вы определить составное действие, которое вызывает состояние гонки?
Я уверен, что вы сделали, но для полноты я все равно объясню. Проблема в приращении ( число ++ ). Это может показаться отдельным действием, но на самом деле это последовательность из трех действий (get-increment-write).
При выполнении этого кода мы можем увидеть, что мы потеряли некоторые обновления:
1
|
2014-08-08 09:59:18,859|UnsafeReadModifyWrite|Final number (should be 10_000): 9996 |
В зависимости от вашего компьютера будет очень трудно воспроизвести эту потерю обновления, поскольку нет никакой гарантии того, как будут чередоваться потоки. Если вы не можете воспроизвести приведенный выше пример, попробуйте UnsafeReadModifyWriteWithLatch , который использует CountDownLatch для синхронизации запуска потока и повторяет тест сто раз. Вероятно, вы должны увидеть некоторые недопустимые значения среди всех результатов:
01
02
03
04
05
06
07
08
09
10
11
|
Final number (should be 1_000): 1000 Final number (should be 1_000): 1000 Final number (should be 1_000): 1000 Final number (should be 1_000): 997 Final number (should be 1_000): 999 Final number (should be 1_000): 1000 Final number (should be 1_000): 1000 Final number (should be 1_000): 1000 Final number (should be 1_000): 1000 Final number (should be 1_000): 1000 Final number (should be 1_000): 1000 |
Этот пример можно решить, сделав все три действия атомарными.
SafeReadModifyWriteSynchronized использует синхронизацию при всех обращениях к общему полю:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
public class SafeReadModifyWriteSynchronized { private int number; public synchronized void incrementNumber() { number++; } public synchronized int getNumber() { return this .number; } public static void main(String[] args) throws InterruptedException { final SafeReadModifyWriteSynchronized rmw = new SafeReadModifyWriteSynchronized(); for ( int i = 0 ; i < 1_000; i++) { new Thread( new Runnable() { @Override public void run() { rmw.incrementNumber(); } }, "T" + i).start(); } Thread.sleep( 4000 ); System.out.println( "Final number (should be 1_000): " + rmw.getNumber()); } } |
Давайте посмотрим еще один пример, чтобы удалить это состояние гонки. В этом конкретном случае, так как номер поля не зависит от других переменных, мы можем использовать атомарные переменные.
SafeReadModifyWriteAtomic использует атомарные переменные для хранения значения поля:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
public class SafeReadModifyWriteAtomic { private final AtomicInteger number = new AtomicInteger(); public void incrementNumber() { number.getAndIncrement(); } public int getNumber() { return this .number.get(); } public static void main(String[] args) throws InterruptedException { final SafeReadModifyWriteAtomic rmw = new SafeReadModifyWriteAtomic(); for ( int i = 0 ; i < 1_000; i++) { new Thread( new Runnable() { @Override public void run() { rmw.incrementNumber(); } }, "T" + i).start(); } Thread.sleep( 4000 ); System.out.println( "Final number (should be 1_000): " + rmw.getNumber()); } } |
Следующие посты будут дополнительно объяснять механизмы, такие как блокировка или атомарные переменные.
Вывод
В этом посте объясняются некоторые риски, возникающие при выполнении сложных действий в несинхронизированных многопоточных программах. Для обеспечения атомарности и предотвращения чередования потоков необходимо использовать некоторый тип синхронизации.
- Вы можете взглянуть на исходный код на github .
Ссылка: | Учебник по параллелизму Java — атомарность и условия гонки от нашего партнера по JCG Ксавьера Падро в блоге |