Параллелизм в Java: изменчивое ключевое слово

Введение Многопоточность - частая причина головной боли программистов. Поскольку люди, естественно, не привыкли к такому «параллельному» мышлению, разработка многопоточной программы становится гораздо менее простой задачей, чем написание программного обеспечения с одним потоком выполнения. В этой статье мы рассмотрим некоторые распространенные проблемы многопоточности, которые можно решить с помощью ключевого слова volatile. Мы также рассмотрим некоторые более сложные проблемы, в которых volatile недостаточно, чтобы исправить si.

Вступление

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

В этой статье мы рассмотрим некоторые распространенные проблемы многопоточности, которые можно решить с помощью ключевого слова volatile

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

Переменная видимость

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

Если один поток обновляет переменную / объект, мы не можем точно знать, когда именно это изменение станет видимым для другого потока. Причина, по которой это происходит, заключается в кэшировании ЦП .

Каждый поток, использующий переменную, делает локальную копию (т. Е. Кеш) своего значения на самом ЦП. Это позволяет выполнять операции чтения и записи более эффективно, поскольку обновленное значение не должно «перемещаться» полностью в основную память, а вместо этого может быть временно сохранено в локальном кеше:

Кеширование процессора вJava{.ezlazyload}
[Изображение предоставлено: Jenkov Tutorials]{.small}

Если поток 1 обновляет переменную, он обновляет ее в кеше, а поток 2 все еще имеет устаревшую копию в своем кеше. Работа потока 2 может зависеть от результата потока 1 , поэтому работа с устаревшим значением даст совершенно другой результат.

Наконец, когда они хотят зафиксировать изменения в основной памяти, значения полностью отличаются, и одно перекрывает другое.

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

Короче говоря, ваше приложение сломается .

Неустойчивое ключевое слово

volatile отмечает переменную как изменчивую . Таким образом, JVM гарантирует, что результат каждой операции записи записывается не в локальную память, а в основную память.

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

Подобное, но не идентичное поведение может быть достигнуто с помощью ключевого слова synchronized.

Примеры

Давайте посмотрим на несколько примеров использования ключевого слова volatile

Простая общая переменная

В приведенном ниже примере кода мы видим класс, представляющий зарядную станцию для ракетного топлива, которая может использоваться несколькими космическими кораблями. Ракетное топливо представляет собой общий ресурс / переменную (что-то, что может быть изменено "извне"), а космические корабли представляют собой потоки (вещи, которые изменяют переменную).

Давайте теперь продолжим и определим RocketFuelStation . Каждый Spaceship будет иметь RocketFuelStation в качестве поля, поскольку они назначены ему, и, как и ожидалось, fuelAmount является static . Если космический корабль забирает топливо со станции, это должно отразиться и на экземпляре, принадлежащем другому объекту:

 public class RocketFuelStation { 
 // The amount of rocket fuel, in liters 
 private static int fuelAmount; 
 
 public void refillShip(Spaceship ship, int amount) { 
 if (amount <= fuelAmount) { 
 ship.refill(amount); 
 this.fuelAmount -= amount; 
 } else { 
 System.out.println("Not enough fuel in the tank!"); 
 } 
 } 
 // Constructor, Getters and Setters 
 } 

Если amount мы хотим залить в корабль, превышает количество fuelAmount оставшееся в баке, мы уведомляем пользователя, что залить такое количество невозможно. Если нет, мы с радостью заправляем корабль и уменьшаем количество, оставшееся в баке.

Теперь, поскольку каждый Spaceship будет работать в другом Thread , нам придется extend класс:

 public class Spaceship extends Thread { 
 
 private int fuel; 
 private RocketFuelStation rfs; 
 
 public Spaceship(RocketFuelStation rfs) { 
 this.rfs = rfs; 
 } 
 
 public void refill(int amount) { 
 fuel += amount; 
 } 
 
 // Getters and Setters 
 
 public void run() { 
 rfs.refillShip(this, 50); 
 } 

Здесь следует отметить несколько моментов:

  • RocketFuelStation передается конструктору, это общий объект.
  • Класс Spaceship расширяет Thread , что означает, что нам нужно реализовать метод run() .
  • Как только мы создадим экземпляр Spaceship и вызовем start() , метод run() также будет выполнен.

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

И, наконец, давайте запустим этот код, чтобы проверить его:

 RocketFuelStation rfs = new RocketFuelStation(100); 
 Spaceship ship = new Spaceship(rfs); 
 Spaceship ship2 = new Spaceship(rfs); 
 
 ship.start(); 
 ship2.start(); 
 
 ship.join(); 
 ship2.join(); 
 
 System.out.println("Ship 1 fueled up and now has: " + ship.getFuel() + "l of fuel"); 
 System.out.println("Ship 2 fueled up and now has: " + ship2.getFuel() + "l of fuel"); 
 
 System.out.println("Rocket Fuel Station has " + rfs.getFuelAmount() + "l of fuel left in the end."); 

Поскольку мы не можем гарантировать, какой поток будет запущен первым в Java, System.out.println() располагаются после запуска join() в потоках. Метод join() ожидает завершения потока, поэтому мы знаем, что распечатываем результаты после фактического завершения потоков. В противном случае мы можем столкнуться с неожиданным поведением. Не всегда, но это возможно.

new RocketFuelStation() состоит из 100 литров топлива. Как только мы запустим оба корабля, у обоих должно быть по 50 литров топлива, а на станции должно остаться 0 литров топлива.

Посмотрим, что произойдет, когда мы запустим код:

 Ship 1 fueled up and now has: 0l of fuel 
 Rocket Fuel Station has 0l of fuel left 
 Rocket Fuel Station has 50l of fuel left 
 Ship 2 fueled up and now has: 50l of fuel 
 Rocket Fuel Station has 0l of fuel left in the end. 

Это не правильно. Давайте снова запустим код:

 Ship 1 fueled up and now has: 0l of fuel 
 Ship 2 fueled up and now has: 0l of fuel 
 Rocket Fuel Station has 50l of fuel left 
 Rocket Fuel Station has 0l of fuel left 
 Rocket Fuel Station has 100l of fuel left in the end. 

Сейчас оба пусты, включая заправочную станцию. Попробуем еще раз:

 Ship 1 fueled up and now has: 50l of fuel 
 Rocket Fuel Station has 0l of fuel left 
 Rocket Fuel Station has 50l of fuel left 
 Ship 2 fueled up and now has: 50l of fuel 
 Rocket Fuel Station has 0l of fuel left in the end. 

Сейчас у обоих по 50 литров, а станция пуста. Но это чистая удача.

Давайте продолжим и обновим класс RocketFuelStation

 public class RocketFuelStation { 
 // The amount of rocket fuel, in liters 
 private static volatile int fuelAmount; 
 
 // ... 

Единственное, что мы меняем, - это сообщить JVM, что fuelAmount является изменчивым и что она должна пропустить этап сохранения значения в кеше и зафиксировать его прямо в основной памяти.

Мы также изменим класс Spaceship

 public class Spaceship extends Thread { 
 private volatile int fuel; 
 
 // ... 

Так как fuel тоже может кэшироваться и обновляться некорректно.

Теперь, когда мы запускаем предыдущий код, мы получаем:

 Rocket Fuel Station has 50l of fuel left 
 Rocket Fuel Station has 0l of fuel left 
 Ship 1 fueled up and now has: 50l of fuel 
 Ship 2 fueled up and now has: 50l of fuel 
 Rocket Fuel Station has 0l of fuel left in the end. 

Идеально! У обоих кораблей по 50 литров топлива, а станция пуста. Попробуем еще раз, чтобы убедиться:

 Rocket Fuel Station has 50l of fuel left 
 Rocket Fuel Station has 0l of fuel left 
 Ship 1 fueled up and now has: 50l of fuel 
 Ship 2 fueled up and now has: 50l of fuel 
 Rocket Fuel Station has 0l of fuel left in the end. 

И опять:

 Rocket Fuel Station has 0l of fuel left 
 Rocket Fuel Station has 0l of fuel left 
 Ship 1 fueled up and now has: 50l of fuel 
 Ship 2 fueled up and now has: 50l of fuel 
 Rocket Fuel Station has 0l of fuel left in the end. 

Если мы столкнемся с подобной ситуацией, где начальным оператором будет «У Rocket Fuel Station осталось 0 fuelAmount -= amount до того, как первый поток System.out.println() строка в этом выражении if

 if (amount <= fuelAmount) { 
 ship.refill(amount); 
 fuelAmount -= amount; 
 System.out.println("Rocket Fuel Station has " + fuelAmount + "l of fuel left"); 
 } 

Хотя это, казалось бы, дает неправильный результат - это неизбежно, когда мы работаем параллельно с этой реализацией. Это происходит из-за отсутствия взаимного исключения при использовании ключевого слова volatile Подробнее об этом в разделе «Недостаточность волатильности» .

Важно то, что конечный результат - 50 литров топлива в каждом космическом корабле и 0 литров топлива на станции.

Произойдет до гарантии

Теперь предположим, что наша зарядная станция немного больше и у нее две ТРК вместо одной. Мы ловко назовем количество топлива в этих двух баках « fuelAmount1 и « fuelAmount2 .

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

 public class RocketFuelStation { 
 private static int fuelAmount1; 
 private static volatile int fuelAmount2; 
 
 public void refillFuel1(Spaceship ship, int amount) { 
 // Perform checks... 
 ship.refill(amount); 
 this.fuelAmount1 -= amount; 
 } 
 
 public void refillFuel2(Spaceship ship, int amount) { 
 // Perform checks... 
 ship.refill(amount); 
 this.fuelAmount2 -= amount; 
 } 
 
 // Constructor, Getters and Setters 
 } 

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

 station.refillFuel1(spaceship1, 41); 
 station.refillFuel2(spaceship1, 42); 

Затем топливные переменные будут обновлены внутри как:

 fuelAmount1 -= 41; // Non-volatile write 
 fuelAmount2 -= 42; // Volatile write 

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

Гарантия Happens-Before Guarantee гарантирует , что все обновленные переменные (включая энергонезависимые) будут записаны в основную память вместе с изменчивыми переменными.

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

Недостаточность летучих

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

Взаимное исключение

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

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

Давайте проиллюстрируем эту проблему на конкретном примере, чтобы понять, почему условия гонки нежелательны:

Представьте, что два потока используют счетчик. Поток A считывает текущее значение счетчика ( 41 ), добавляет 1 , а затем записывает новое значение ( 42 ) обратно в основную память. Тем временем (т.е. пока поток A добавляет 1 к счетчику), поток B делает то же самое: считывает (старое) значение из счетчика, добавляет 1 , а затем записывает его обратно в основную память.

Поскольку оба потока читают одно и то же начальное значение ( 41 ), окончательное значение счетчика будет 42 вместо 43 .

В таких случаях использования volatile недостаточно, потому что это не гарантирует взаимного исключения . Это именно тот случай, о котором говорилось выше - когда оба потока достигают fuelAmount -= amount до того, как первый поток достигает оператора System.out.println()

Вместо этого здесь можно использовать ключевое слово synchronized, потому что оно обеспечивает как видимость, так и взаимное исключение , в отличие от volatile которое обеспечивает только видимость .

Почему бы тогда не использовать synchronized всегда?

Не переусердствуйте из-за снижения производительности. Если вам нужно и то , и synchronized . Если вам нужна только видимость, используйте volatile .

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

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

Заключение

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

volatile можно рассматривать как более легкую версию synchronized поскольку он не обеспечивает взаимного исключения, поэтому его не следует использовать в качестве замены.

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

В конце концов, все сводится к конкретной ситуации, с которой нужно справиться. Если производительность не является проблемой, то наличие полностью поточно-ориентированной программы со всем synchronized не повредит. Но если приложению требуется быстрое время отклика и низкие накладные расходы, то необходимо выделить время и определить критические части программы, которые должны быть особо безопасными, и те, которые не требуют таких строгих мер.

comments powered by Disqus