Атомность является одним из ключевых понятий в многопоточных программах. Мы говорим, что набор действий является атомарным, если все они выполняются как единая операция неделимым образом. Принятие как должное того, что набор действий в многопоточной программе будет выполняться последовательно, может привести к неверным результатам. Причина заключается в помехах потоков, что означает, что если два потока выполняют несколько шагов с одними и теми же данными, они могут перекрываться.
В следующем примере чередования показано, как два потока выполняют несколько действий (печатает в цикле) и как они перекрываются:
|
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: 0Thread 2 - Number: 1Thread 2 - Number: 2Thread 1 - Number: 0Thread 1 - Number: 1Thread 1 - Number: 2Thread 1 - Number: 3Thread 1 - Number: 4Thread 2 - Number: 3Thread 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 | ChangedT17 | ChangedT35 | Not changedT10 | ChangedT48 | Not changedT14 | ChangedT60 | Not changedT6 | ChangedT5 | ChangedT63 | Not changedT18 | 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 | ChangedT54 | Not changedT53 | Not changedT62 | Not changedT52 | Not changedT51 | 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): 1000Final number (should be 1_000): 1000Final number (should be 1_000): 1000Final number (should be 1_000): 997Final number (should be 1_000): 999Final number (should be 1_000): 1000Final number (should be 1_000): 1000Final number (should be 1_000): 1000Final number (should be 1_000): 1000Final number (should be 1_000): 1000Final 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 Ксавьера Падро в блоге |