Это лучше всего иллюстрируется на примере.
Предположим, у нас есть простая задача, которую мы хотим выполнить несколько раз параллельно, и мы хотим глобально отслеживать количество выполнений задачи, например, подсчет посещений на веб-странице.
Когда каждый поток достигает точки, в которой он увеличивает счетчик, его выполнение будет выглядеть так:
- Считать количество попаданий из памяти в регистр процессора
- Увеличьте это число.
- Запишите этот номер обратно в память
Помните, что каждый поток может приостановить работу в любой точке этого процесса. Таким образом, если поток A выполняет шаг 1, а затем приостанавливается, после того как поток B выполняет все три шага, когда поток A возобновляет работу, его регистры будут иметь неправильное число обращений: его регистры будут восстановлены, он с радостью увеличит старое число хитов, и сохранить это увеличенное число.
Кроме того, любое количество других потоков могло работать в течение времени, когда поток A был приостановлен, так что поток подсчета, записывающий A в конце, может быть значительно ниже правильного числа.
По этой причине необходимо убедиться, что если поток выполняет шаг 1, он должен выполнить шаг 3, прежде чем любому другому потоку будет разрешено выполнить шаг 1, что может быть выполнено всеми потоками, ожидающими получить единственную блокировку, прежде чем они начнут этот процесс. и снятие блокировки только после завершения процесса, так что эта «критическая часть» кода не может быть неправильно перемежена, что приведет к неправильному счету.
Но что, если операция была атомной?
Да, в стране волшебных единорогов и радуг, где операция приращения является атомарной, блокировка не понадобится для приведенного выше примера.
Однако важно понимать, что мы проводим очень мало времени в мире волшебных единорогов и радуг. Практически на каждом языке программирования операция приращения разбита на три вышеуказанных шага. Это потому, что даже если процессор поддерживает операцию атомарного приращения, эта операция значительно дороже: она должна считывать из памяти, изменять число и записывать ее обратно в память ... и обычно операция атомарного приращения - это операция, которая может потерпеть неудачу, означая, что простая последовательность выше должна быть заменена циклом (как мы увидим ниже).
Поскольку даже в многопоточном коде многие переменные хранятся локально для одного потока, программы гораздо эффективнее, если они предполагают, что каждая переменная является локальной для одного потока, и позволяют программистам заботиться о защите общего состояния между потоками. Особенно с учетом того, что атомарных операций обычно недостаточно для решения проблем многопоточности, как мы увидим позже.
Изменчивые переменные
Если мы хотим избежать блокировок для этой конкретной проблемы, мы должны сначала понять, что шаги, изображенные в нашем первом примере, на самом деле не то, что происходит в современном скомпилированном коде. Поскольку компиляторы предполагают, что только один поток изменяет переменную, каждый поток будет хранить свою собственную кэшированную копию переменной, пока регистр процессора не понадобится для чего-то еще. Пока он имеет кэшированную копию, он предполагает, что ему не нужно возвращаться в память и читать ее снова (что будет дорого). Они также не будут записывать переменную обратно в память, пока она хранится в регистре.
Мы можем вернуться к ситуации, которую мы привели в первом примере (со всеми теми же проблемами с потоками, которые мы определили выше), пометив переменную как volatile , что говорит компилятору, что эта переменная модифицируется другими, и поэтому должна быть прочитана из или записывается в память всякий раз, когда к ней обращаются или изменяют.
Таким образом, переменная, помеченная как volatile, не приведет нас к земле операций атомарного приращения, она только приблизит нас к тому, что мы уже думали.
Делая прирост атомным
После того, как мы используем переменную volatile, мы можем сделать нашу операцию приращения атомарной, используя низкоуровневую операцию условного набора, которую поддерживает большинство современных процессоров (часто называемые сравнить и установить или сравнить и поменять местами ). Этот подход используется, например, в классе Java AtomicInteger :
197 /**
198 * Atomically increments by one the current value.
199 *
200 * @return the updated value
201 */
202 public final int incrementAndGet() {
203 for (;;) {
204 int current = get();
205 int next = current + 1;
206 if (compareAndSet(current, next))
207 return next;
208 }
209 }
Вышеупомянутый цикл многократно выполняет следующие шаги, пока шаг 3 не будет выполнен успешно:
- Считайте значение изменчивой переменной непосредственно из памяти.
- Увеличьте это значение.
- Измените значение (в основной памяти) тогда и только тогда, когда его текущее значение в основной памяти совпадает со значением, которое мы первоначально прочитали, используя специальную атомарную операцию.
Если на шаге 3 происходит сбой (поскольку значение было изменено другим потоком после шага 1), он снова считывает переменную непосредственно из основной памяти и пытается снова.
Хотя операция сравнения и замены обходится дорого, в этом случае она немного лучше, чем использование блокировки, поскольку если поток приостанавливается после шага 1, другие потоки, достигшие шага 1, не должны блокировать и ждать первый поток, который может предотвратить дорогостоящее переключение контекста. Когда первый поток возобновляет работу, он потерпит неудачу при первой попытке записать переменную, но сможет продолжить, перечитав переменную, что, опять же, вероятно, будет дешевле, чем переключение контекста, которое было бы необходимо при блокировке.
Таким образом, мы можем добраться до страны атомарных приращений (или других операций над одной переменной) без использования реальных блокировок, посредством сравнения и обмена.
Так, когда блокировка строго необходима?
Если вам нужно изменить более одной переменной в элементарной операции, тогда потребуется блокировка, для этого вы не найдете специальной инструкции для процессора.
Пока вы работаете с одной переменной и готовы к любой работе, которую вы сделали, чтобы потерпеть неудачу и должны прочитать переменную и начать все заново, сравнение и замена будут достаточно хороши.
Давайте рассмотрим пример, в котором каждый поток сначала добавляет 2 к переменной X, а затем умножает X на два.
Если X изначально один, и два потока выполняются, мы ожидаем, что результат будет (((1 + 2) * 2) + 2) * 2 = 16.
Однако, если потоки чередуются, мы могли бы, даже если бы все операции были атомарными, вместо этого сначала имели бы место оба сложения, а затем умножения, что привело бы к (1 + 2 + 2) * 2 * 2 = 20.
Это происходит потому, что умножение и сложение не являются коммутативными операциями.
Итак, самих атомарных операций недостаточно, мы должны сделать комбинацию атомарных операций.
Мы можем сделать это либо используя блокировку для сериализации процесса, либо мы могли бы использовать одну локальную переменную для хранения значения X, когда мы начинали наши вычисления, вторую локальную переменную для промежуточных шагов, а затем использовать сравнения и замены для устанавливайте новое значение только в том случае, если текущее значение X совпадает с исходным значением X. Если мы потерпим неудачу, нам придется начинать заново, читая X и выполняя вычисления снова.
Здесь есть несколько компромиссов: по мере того, как вычисления становятся длиннее, становится намного более вероятным, что работающий поток будет приостановлен, а значение будет изменено другим потоком, прежде чем мы возобновим, что означает, что сбои становятся намного более вероятными, что приводит к потере времени процессорное время. В крайнем случае большого количества потоков с очень длительными вычислениями, мы могли бы иметь 100 потоков, читающих переменную, и участвовать в вычислениях, и в этом случае только первый завершивший сможет записать новое значение, остальные 99 все равно будут завершите свои вычисления, но обнаружите после завершения, что они не могут обновить значение ... в этот момент каждый из них прочитает значение и начнет вычисление заново. Вероятно, оставшиеся 99 потоков повторяют ту же проблему, тратя огромное количество процессорного времени.
Полная сериализация критической секции через блокировки была бы намного лучше в этой ситуации: 99 потоков приостановились бы, если бы они не получили блокировку, и мы запустили каждый поток в порядке прибытия к точке блокировки.
Если сериализация не является критической (как в нашем случае приращения), и вычисления, которые были бы потеряны в случае неудачного обновления числа, минимальны, можно получить значительное преимущество от использования операции сравнения и замены, поскольку эта операция дешевле, чем блокировка.