Статьи

Java Concurrency Tutorial — Атомность и условия гонки

Атомность является одним из ключевых понятий в многопоточных программах. Мы говорим, что набор действий является атомарным, если все они выполняются как единая операция неделимым образом. Принятие как должное того, что набор действий в многопоточной программе будет выполняться последовательно, может привести к неверным результатам. Причина заключается в помехах потоков, что означает, что если два потока выполняют несколько шагов с одними и теми же данными, они могут перекрываться.

В следующем примере чередования показано, как два потока выполняют несколько действий (печатает в цикле) и как они перекрываются:

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

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

Состояние гонки

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

  1. Проверка-то-акт
  2. Read-модификация-запись

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

Проверьте состояние гонок

Это условие гонки появляется, когда у вас есть общее поле и вы хотите последовательно выполнить следующие шаги:

  1. Получить значение из поля.
  2. Сделайте что-нибудь на основании результата предыдущей проверки.

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

Ожидается , что 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. Сохраните новое значение в поле.

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

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 .