Многопоточное программирование похоже на болото со всевозможными ловушками между ними. Большинство разработчиков тратят большую часть своего времени на разработку приложений верхнего уровня, и им не нужно слишком вникать в основные детали. Но в многопоточном программировании или параллельном программировании есть много ловушек, скрытых в основных деталях. Если вы не владеете этими базовыми знаниями, вы можете совершенно не знать, что в программе есть лазейки в процессе написания, и трудно выяснить конкретную причину и устранить лазейку даже после того, как лазейка возникла. Хотя лазейки, упомянутые выше, звучат пугающе, я верю, что благодаря нашей пошаговой зачистке мы сможем освоить большое количество практических инструментов, которые помогут нам решить эти проблемы и создать надежные параллельные программы.
Чтобы прочитать эту статью, вам необходимо понять основные концепции параллелизма и основы Java многопоточных программирования. Читатели, которые этого не знают, могут ссылаться на следующие две статьи:
- Основная концепция параллелизма -Когда мы говорим «параллелизм, многопоточность», что мы имеем в виду?
- Основы многопоточного программирования на Java —На этот раз давайте полностью разберемся с многопоточностью Java.
проблема гонки данных
Чтобы понять, в чем заключаются подводные камни многопоточных программ, давайте сначала посмотрим на кусок кода:
public class AccumulateWrong {
private static int count = 0;
public static void main(String[] args) throws Exception {
Runnable task = new Runnable() {
public void run() {
for (int i = 0; i < 1000000; ++i) {
count += 1;
}
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
Основная функция этого кода состоит в том, чтобы накапливать целое число один миллион раз в двух потоках, поэтому наш ожидаемый результат должен быть в сумме равен двум миллионам. Но результат прогона на моем компе всего 1799369 и каждый раз разный.Я верю что пробежав на вашем компе получится другой результат, но он точно не дойдет до 2 миллионов.
Причина этого кода в том, что есть проблема, которую мы выполнилиcount += 1;С этой строкой кода на ЦП фактически выполняется несколько инструкций:
- Получить текущее значение переменной count
- Вычислить значение count + 1
- Сохраните значение результата count + 1 в переменной count
Таким образом, возможна следующая последовательность выполнения:
| t1 | t2 |
|---|---|
| Полученное значение count равно 100 | |
| 100 + 1 = 101 Расчет | |
| Полученное значение count равно 100 | |
| Сохраните 101 в переменной count | |
| Вычислить 100 + 1 = 101 | |
| Сохраните 101 в переменной count |
После завершения такого раунда операций, хотя мы накопили count по одному разу в каждом из двух потоков, в сумме два раза значение count увеличилось только на 1, и есть проблема с результатом. Эта ситуация конкурирующего доступа к общим данным между несколькими потоками называетсягонка данныхможно понимать, что ситуация, когда одновременный доступ к общим данным может вызвать проблемыгонка данных.
Итак, как мы можем решить что-то вроде этогогонка данныхПроблема?
синхронизированное ключевое слово
Я считаю, что большинство читателей должны знатьsynchronizedЭто ключевое слово, которое можно использовать в определении метода или структуре блока, в конце концов, какую роль оно может играть? Ставим в виде блочной структурыcount += 1;Окружите его предложениями.
for (int i = 0; i < 1000000; ++i) {
synchronized (this) {
count += 1;
}
}
После запуска вы можете увидеть, что на этот раз результат составляет целых два миллиона. это здесь,synchronizedРоль состоит в том, чтобы позволить двум потокам выполнять взаимоисключающиеcount += 1;утверждение. Так называемое взаимное исключение означает, что одновременно может выполняться только один поток.Если другой поток хочет выполниться в то же время, он должен дождаться, пока предыдущий поток завершит операцию и завершит работу.synchronizedможно вводить только после блока операторов.
Этот блок кода, к которому одновременно может обращаться только один поток, называетсяКритический раздел,иsynchronizedТакой механизм, защищающий критическую секцию от входа только одним потоком за раз, называетсяМьютекс. Когда поток застрял в ожидании, потому что другой поток получил блокировку, мы можем сказать, что поток заблокирован блокировкой.блокировать.
В Яве,synchronizedЗа ним стоит блокировка объекта, каждый отдельный объект будет соответствовать разным замкам, а один и тот же объект соответствует одному и тому же замку. Взаимоисключающий доступ может быть достигнут только путем получения одной и той же блокировки.Если два потока получают разные блокировки, они не будут влиять друг на друга. Итак, используяsynchronizedОчень важно различать, какой объектный замок стоит за ним.synchronizedКлючевые слова могут использоваться как в определениях методов, так и в блочных структурах.Конкретные блокировки следующие:
- Использование в блочной структуре
synchronizedключевое слово, полученноеsynchronizedБлокировка, соответствующая объекту в скобках после ключевого слова; -
synchronizedПомечается методом экземпляра, это получает ссылки на объект, соответствующий блокировке; -
synchronizedКогда он отмечен на методе класса (статический метод), он получает блокировку, соответствующую «объекту класса» класса, в котором находится метод. Объект класса здесь можно понимать как один для каждого класса для хранения статических полей и статические методы объекта.
так какsynchronizedДолжен быть соответствующий объект, поэтому мы, естественно, не можем передавать переменные базовых типов вsynchronizedв скобках после него.
ReentrantLock
В Java 5 представлен JDKjava.util.concurrentПакет, возможно, все слышали об этом пакете более или менее, обеспечивает большое количество классов инструментов параллелизма в этом пакете, таких как пулы потока, блокировки, атомные классы данных и т. Д., Для простоты использования одновременного программирования в Язык Java и фактическая эффективность допускают прыжки и границы. иReentrantLockЭто этот пакет a.
ReentrantLockроль иsynchronizedТе же, используются в качестве мьютекса. Далее следует изменить предыдущий код накопления для использованияReentrantLockЗаблокированная версия:
final ReentrantLock lock = new ReentrantLock();
Runnable task = new Runnable() {
public void run() {
for (int i = 0; i < 1000000; ++i) {
lock.lock();
try {
count += 1;
} finally {
lock.unlock();
}
}
}
};
Результат после запуска по-прежнему составляет два миллиона, что указывает на то, чтоReentrantLockОн действительно может играть роль гарантии взаимоисключающего доступа к критической секции. Но с тех порReentrantLockиsynchronizedЭффект одинаково, а из кодовой точки зрения использованияsynchronizedТак даже удобнее, зачем определять спец?ReentrantLockКак быть с таким классом?
В приведенном выше коде, хотя и используетсяReentrantLockТакже напишите спец.try..finallyБлоки, обеспечивающие снятие блокировки, более проблематичны, но из этого можно увидеть одно преимущество: мы можем решить, где заблокировать, а где снять блокировку. Мы могли бы даже заблокировать один метод и разблокировать другим, хотя это было бы рискованно. по сравнению с традиционнымsynchronized,ReentrantLockЕсть некоторые из следующих преимуществ:
-
ReentrantLockОжидание блокировки с тайм-аутом можно реализовать, можно пройтиtryLockметод для блокировки и передачи параметра тайм-аута. Если блокировка не получена после периода ожидания, тоtryLockметод вернет false; -
ReentrantLockМеханизм равнодоступности можно использовать, чтобы позволить потоку, который сначала подает заявку на блокировку, получить блокировку первым, предотвращая ожидание блокировки потоком, но неспособность ее получить; -
ReentrantLockМогут быть реализованы более богатые типы, такие как блокировки чтения-записи.
Более простой способ - AtomicInteger
существуетjava.util.concurrentpackage, мы можем найти очень интересный подпакетatomic, в этом пакете мы видим, что их многоAtomicВ начале "типа обертки" для чего эти классы? Давайте посмотрим на предыдущее использование программы-аккумулятора.AtomicIntegerКак это сделать.
public class AtomicIntegerDemo {
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) throws Exception {
Runnable task = new Runnable() {
public void run() {
for (int i = 0; i < 1000000; ++i) {
count.incrementAndGet();
}
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
Запустив эту программу, мы также можем получить правильный результат в два миллиона. В этой версии кода мы в основном изменили два места, одно из них — поставитьcountТип переменной изменен наAtomicIntegerТип, затем положитьRunnableМетод накопления в объекте изменен наcount.incrementAndGet().
AtomicIntegerОбеспечивает атомарный способ изменения значений переменных. Атомарность гарантирует, что вся операция накопления может рассматриваться как одна операция, и не будет ситуации, когда более мелкие операции перемежаются друг с другом, что приводит к ошибочным результатам. на первом этажеAtomicIntegerОн реализован на основе аппаратного примитива CAS.CAS - это аббревиатура от "Compare and Swap", что означает, что при изменении переменной новое значение и старое значение будут указываться одновременно.Только когда старое значение равно текущему значению переменной, будет ли оно Изменить значение переменной на новое значение. Эта операция CAS гарантированно будет атомарной на аппаратном уровне.
Мы можем либо использоватьAtomicкласс для реализации некоторых простых параллельных функций модификации, а также его можно использовать для управления некоторыми ключевыми управляющими переменными с целью управления параллельным процессом. класс пула потоковThreadPoolExecutorУправляющие переменные, используемые для управления состоянием пула потоков и количеством потоков вctlтолько одинAtomicIntegerполе типа.
проблемы с видимостью памяти
Прочитав, как решить проблему гонки данных, давайте рассмотрим несколько магический пример.
public class MemoryVisibilityDemo {
private static boolean flag;
public static void main(String[] args) throws Exception {
for (int i = 0; i < 10000; ++i) {
flag = false;
final int no = i;
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
flag = true;
System.out.println(String.format("No.%d loop, t1 is done.", no));
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
while (!flag) ;
System.out.println(String.format("No.%d loop, t2 is done.", no));
}
});
t2.start();
t1.start();
t1.join();
t2.join();
}
}
}
Вывод этой программы на моем компьютере таков:
No.0 loop, t2 is done.
No.0 loop, t1 is done.
No.1 loop, t1 is done.
No.1 loop, t2 is done.
No.2 loop, t2 is done.
No.2 loop, t1 is done.
No.3 loop, t2 is done.
No.3 loop, t1 is done.
No.4 loop, t1 is done.
В приведенном выше выводе программы мы видим, что цикл в коде повторяется 10000 раз, но он заканчивается в пятый раз в выводе программы. И в пятом прогоне был выполнен только t1, а оператор конца t2 не был выведен. значит программа завислаwhile (!flag) ;, но t1 явно закончил работу, указывая на то, что в это времяflag = trueОн был выполнен, почему t2 все еще зависает?
Это потому чтовидимость памятиВ прессе, на компьютере наше хранение будет разделено на множество различных уровней. Каждый более распространен, является память и существование, а существование такое как постоянное хранение, такое как диск, SSD. На самом деле, существует множество уровней выше памяти, и более полные системы хранения компьютеров являются последовательными снизу к началу, память, «L3, L2, L1 трехслойного кеша» и регистры. В этой системе хранения, снизу до быстрой структуры, тем быстрее скорость, тем быстрее скорость, поэтому, когда CPU управляет данными памяти, данные будут прочитаны в кеше над памятью.,
Таким образом, если программа, которую вы хотите изменить значение переменной, то система будет сначала написать новое значение кэша L1, только после памяти кэш-памяти записываемых данных на них в нужное время. Хотя такая настройка заставляет общую эффективность системы была улучшена, но также представляет собой проблему, то есть L1, кэш L2 - это два ядерных кэша, что означает, что каждый ядерный многоядерный процессор имеет свой независимый кэш L1, L2. Поэтому, если мы изменим нить, работающую в основном значении переменной, не записанной обратно в память, то другой поток работает на сердечке, чтобы увидеть последнее значение этой переменной, меньше, чем то.
В сочетании с нашим предыдущим примером программы, потому что изменение и чтение статических переменныхflagКод находится в двух разных потоках, поэтому при запуске этой программы на многоядерном процессоре можно запустить два фрагмента кода на двух разных процессорных ядрах. В конце концов, поток t1 заставит поток t1flagЗначение переменной изменяется на true, но, поскольку это значение не было записано обратно в память, значение переменной флага, наблюдаемое потоком t2, по-прежнему равно false, что является причиной зависания предыдущего кода.
Так как же решить эту проблему?
изменчивая переменная
Самый простой способ — использовать переменную volatile, т. е.flagпеременная, помеченная какvolatile,Следующее:
private static volatile boolean flag;
Эта программа может работать стабильно, тогдаvolatileЧто было сделано для решения проблемы видимости памяти? Определяется в соответствии со спецификацией языка Java № JSR-133.Модель памяти Java (JMM),volatileПеременная гарантирует, что существует связь синхронизации между операцией записи переменной и последующей операцией чтения.Эта связь синхронизации гарантирует, что чтение изменчивой переменной может определенно получить самое последнее значение переменной. Под капотом запись в изменчивую переменную запускает кеш для принудительной записи обратно в память, что делает недействительным тот же блок данных в других ядрах процессора и должен быть повторно считан из памяти.Модель памяти JavaКонкретное содержание будет кратко представлено в следующем разделе.
Из приведенной выше проблемы с видимостью памяти мы можем обнаружить, что некоторые проблемы, возникающие в многопоточных программах, связаны с очень низким уровнем знаний, и людям, которые этого не понимают, трудно предотвратить это заранее и проверить потом. Так что для друзей, которые хотят по-настоящему овладеть многопоточным программированием, это будет очень замечательное и долгое путешествие, и я надеюсь, что все смогут придерживаться его до конца.
Модель памяти Java
JSR-133 в спецификации языка Java определяет ряд логических порядков, которые определяют инструкции между различными потоками, тем самым гарантируя отсутствие проблем параллелизма, вызванных видимостью памяти и переупорядочением инструкций, что является правильным для полного освоения многопоточных программ.
В программе мы обычно определяем, что оператор программы выполняется в порядке в коде, например следующий код:
a = 0;
a = 1;
b = 2;
c = 3;
Мы, конечно, думаем, что порядок выполнения программыa = 0; -> a = 1; -> b = 2; -> c = 3;, но на самом деле есть две ситуации, которые могут нарушить порядок выполнения операторов: во-первых, компилятор переупорядочивает инструкции, что может привести к изменению порядка операторов, а во-вторых, это вышеупомянутая видимость памяти.
Для переупорядочивания инструкций компилятора, хотя компилятор гарантирует, что эффект выполнения операторов в одном потоке такой же, как и при последовательном выполнении, нет никакой зависимости между тремя операторами в приведенном выше коде и эффектом выполнения любого порядка. одинаковы, поэтому компилятор может изменить порядок операторов в нем. В однопоточной программе это, конечно, не проблема, выполнение операторов в приведенном выше коде в любом порядке одинаково, но в многопоточном случае проблема сложнее. Если другой поток напечатает значение переменной a после того, как значение переменной b станет равным 2, то, как мы и ожидали, эта программа должна распечатать1. но еслиb = 2;Заявления были переупорядочены вa = 1;до иa = 0;После этого значение, которое мы напечатали, равно0.
Для видимости памяти, еслиb = 2;Результат модификации переменной b предшествуетa = 1;записано обратно в память. Затем в другом потоке, когда значение переменной b становится равным 2, он не может увидеть новое значение 1 переменной a, что также заставит программу распечатать значение, не соответствующее нашим ожиданиям.
Из приведенного выше введения мы видим, что наиболее важным в этой проблеме является порядок выполнения операторов.По умолчанию мы можем гарантировать, что результаты, полученные порядком выполнения в одном потоке, должны соответствовать нашим ожиданиям, но как только ввод многопоточной ситуации, мы не можем дать такую гарантию. ** Так как же мы можем гарантировать порядок выполнения операторов между несколькими потоками? ** Речь идет о модели памяти Java, о которой мы говорили ранее.
Модель памяти Java определяет отношение порядка операторов в разных потоках, которое называется отношением Happens-Before, далее именуемым HB. Это отношение означает, что если «операция A» HB находится в «операции B», тоЕсли «Действие А» произошло до «Действия Б»,, то «операция B» должна быть такой же, как после «операции A»: см. все результаты после «операции A», например, изменение значений переменных. Если "операция А" изменяет значение переменной а на 2, то все "операции Б" должны видеть значение переменной а как 2, будь то переупорядочивание команд компилятором или память между разными процессорными ядрами. Ни одна видимость не может разрушить этот результат.
Именно потому, что ядром этой связи последовательности выполнения инструкций является просмотр ранее выполненных инструкций.Результаты, отраженные в памяти, поэтому эта спецификация называетсяМодель памяти Java.
Общие правила отношений «случается-прежде»:
- В том же потоке «оператор, выполняемый первым» HB находится во «всех операторах, выполняемых после»;
- "запись в изменчивую переменную" HB в "чтение той же переменной";
- "операция разблокировки на замке" HB в "операция блокировки на том же замке";
- «Начать операцию над объектом потока» HB в «Первая строка оператора в задаче потока»;
- «Последняя строка оператора в задаче потока» HB в «операции соединения объекта Thread, соответствующего потоку»;
- Переходное правило: если «операция B» HB находится в «операции A», а «операция C» HB находится в «операции B», то «операция C» также является HB в «операции A».
С помощью первого правила мы определяем порядок выполнения операторов в одном потоке, а с помощью правил со 2 по 4 мы можем определить конкретный порядок выполнения операторов между потоками. Последнее транзитивное правило правила 6 является дополнением ко всей системе правил.Используя это правило, мы можем комбинировать порядок внутри потока в правиле 1 с правилами между потоками в правилах со 2 по 4, чтобы получить окончательную полную систему порядка.
На рисунке ниже левый и правый столбцы — это операторы, выполняемые в двух разных потоках, и их порядок. Если переменная c является изменчивой, то по правилу 2 мы можем знать операциюc = 3ХБ в работеprint c, связь отмечена красной линией на рисунке ниже. Итак, согласно определению JMM,print cВидно, что значение переменной c было изменено на 3, и результат печати будет равен 3, если вprint cПродолжайте выполнять печать переменных a и b под оператором, тогда результаты должны быть 1 и 2 соответственно.
Но мы не можем гарантировать первый справа.print bЗаявление всегда будет распечатать значение 2, даже если это происходит во времени вb = 2после. Из-за переупорядочения команд или проблем с видимостью памяти он может видеть только переменную b вb = 2предыдущее исходное значение. Другими словами, отношение HB не может указывать отношение порядка между операторами перед отношением HB в двух потоках.В приведенном ниже примере этоprint bНет гарантии, что будет напечатано значение 2, а также может быть напечатано исходное значение переменной b.
Суммировать
В этой статье мы в основном рассказываем, как обеспечить корректность многопоточных программ, чтобы выполняемый процесс и результаты соответствовали нашим ожиданиям. Исследуя проблему корректности многопоточных программ, мы вводимТри распространенных метода синхронизации потоковЭто блокировки, переменные CAS и Volatile соответственно. Среди них блокировка имеет синхронизированные ключевые слова иReentrantLockДве реализации.
В процессе мы углубились в нижний уровень компьютерной системы и узнали об архитектуре хранения данных компьютера иvolatileВлияние на кеш и память. Многопоточное программирование — отличная отправная точка, позволяющая нам сочетать теорию компьютеров, которую мы изучали ранее, с практикой программирования, что имеет решающее значение для многих передовых областей знаний.
Поскольку неправильные программы ничего не стоят, самое главное для программы, конечно же, правильность. Но для достижения корректности мы также должны найти способы улучшить производительность программы. Поскольку цель многопоточности — повысить производительность программы за счет взаимодействия нескольких потоков, если эта цель не будет достигнута, многопоточный код, над созданием которого мы так усердно работали, не имеет смысла. В следующей статье мы специально протестируем производительность многопоточных программ, обнаружив подводные камни производительности в многопоточных программах, из-за которых многопоточные программы работают медленнее, чем однопоточные программы, и в конечном итоге мы найдем методы оптимизации производительности для решения этих проблем. подводные камни. Следующая статья будет опубликована на следующей неделе, и заинтересованные читатели могут обратить внимание.