JavaRush /Курсы /Kotlin SELF /AtomicInteger и AtomicReference: атомарные операции и гра...

AtomicInteger и AtomicReference: атомарные операции и границы применимости

Kotlin SELF
55 уровень , 2 лекция
Открыта

1. Введение

Если вы только что подружились с Mutex, может возникнуть логичный вопрос: «Зачем мне ещё один инструмент синхронизации? Я уже умею закрывать всё на замок». И правда, Mutex — как большой, красивый, надёжный замок на двери. Но иногда вам нужно не запирать квартиру, а просто… не дать кому-то одновременно нажать одну кнопку. В таких случаях тащить «замок на дверь» может быть избыточно.

Атомарный тип — это контейнер, который даёт операции вида «прочитал → изменил → записал» как одну неделимую операцию (в терминах конкурентного доступа). То есть другие потоки/корутины не увидят «середину» обновления.

Важно аккуратно держать в голове два слова:

  • атомарность — операция либо целиком произошла, либо не произошла (и не «наполовину»);
  • инвариант — правило целостности, которое нельзя нарушать.

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

2. AtomicInteger: потокобезопасный счётчик без Mutex

Начнём с самого приятного: AtomicInteger решает классическую проблему «счётчик в нескольких корутинах» так же просто, как хотелось бы, чтобы работал counter++… но не работает.

Подключается он из Java‑пакета:

import java.util.concurrent.atomic.AtomicInteger

Мини‑пример: 1000 корутин инкрементят счётчик

Этот пример похож на «гонку» из лекции про race condition, только теперь мы заменим var counter = 0 на атомик.

import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val counter = AtomicInteger(0)

    val jobs = List(1_000) {
        launch(Dispatchers.Default) {
            counter.incrementAndGet()
        }
    }

    jobs.forEach { it.join() }
    println(counter.get()) // 1000
}

Здесь incrementAndGet() — атомарная операция. Никто не «потеряет» инкремент.

Обратите внимание на стиль: мы не пишем counter++. У атомиков другой API: get(), set(...), incrementAndGet() и друзья.

Чем incrementAndGet() отличается от get() + set()

Очень важно понять границу: атомарной является конкретная операция атомика, а не ваши три строки рядом с ним.

Сравните:

import java.util.concurrent.atomic.AtomicInteger

fun main() {
    val c = AtomicInteger(0)

    c.incrementAndGet()
    println(c.get()) // 1
}

и вот так (так делать обычно не надо):

import java.util.concurrent.atomic.AtomicInteger

fun main() {
    val c = AtomicInteger(0)

    val current = c.get()
    c.set(current + 1)

    println(c.get()) // 1 (в одном потоке ок, но в конкуренции это уже “составная логика”)
}

В многопоточности/многокорутинности второй вариант снова превращается в «прочитал → посчитал → записал», и между чтением и записью другой участник может успеть сделать своё обновление.

3. Когда атомика недостаточно: атомарное значение и атомарная логика

В этом разделе мы подходим к моменту, который чаще всего ломает мозг новичкам: «Я же использую атомик, почему всё ещё бывает неправильно?»

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

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

Представим, что у нас есть «обработчик событий», и мы хотим:
1) увеличивать счётчик обработанных событий,
2) но не превышать лимит (например, 100).

Наивный check‑then‑act с атомиком: это всё ещё гонка

import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val processed = AtomicInteger(99)
    val limit = 100

    val jobs = List(2) {
        launch(Dispatchers.Default) {
            if (processed.get() < limit) {
                processed.incrementAndGet()
            }
        }
    }

    jobs.forEach { it.join() }
    println(processed.get()) // может стать 101
}

Да, вы не ослышались: может стать 101. Почему? Потому что условие processed.get() < limit и инкремент — это уже «проверил → сделал». Каждый шаг сам по себе «безопасный», но вместе они не склеены в один атомарный блок.

Это классический паттерн check‑then‑act, который мы уже видели, только теперь «участниками гонки» стали не var, а последовательность операций.

CAS‑цикл: делаем «проверил → обновил» атомарно

Чтобы починить такую логику без Mutex, используется CAS‑подход: Compare‑And‑Set. Он выглядит как «поставь новое значение, но только если текущее значение — то, которое я ожидаю».

У AtomicInteger есть метод compareAndSet(expected, new).

Схематично это можно представить так:

flowchart TD
    A[прочитать текущее значение] --> B[посчитать новое]
    B --> C{compareAndSet ожидаемое?}
    C -- да --> D[успех: значение заменено]
    C -- нет --> A[повторить: кто-то успел изменить]

А вот рабочий код: короткий, но очень показательный.

import java.util.concurrent.atomic.AtomicInteger

fun tryIncrementUpToLimit(counter: AtomicInteger, limit: Int): Boolean {
    while (true) {
        val current = counter.get()
        if (current >= limit) return false

        val next = current + 1
        if (counter.compareAndSet(current, next)) return true
        // иначе кто-то обновил значение — повторяем
    }
}

И маленькая проверка:

import java.util.concurrent.atomic.AtomicInteger

fun main() {
    val c = AtomicInteger(0)
    println(tryIncrementUpToLimit(c, 1)) // true
    println(tryIncrementUpToLimit(c, 1)) // false
    println(c.get())                     // 1
}

CAS‑цикл — это «оптимистичная» синхронизация: мы предполагаем, что никто не помешает, пробуем обновить, и если помешали — повторяем.

4. AtomicReference<T>: атомарно меняем ссылку на состояние

После AtomicInteger логично спросить: «Окей, а если мне нужно атомарно хранить не число, а что-то посложнее?»

Тогда появляется AtomicReference<T> — контейнер для ссылки.

Подключение:

import java.util.concurrent.atomic.AtomicReference

Мини‑пример: атомарная смена состояния

Сделаем мини‑«машину состояний»:


import java.util.concurrent.atomic.AtomicReference

fun main() {
    val state = AtomicReference("IDLE")

    val ok = state.compareAndSet("IDLE", "RUNNING")
    println("changed=$ok, state=${state.get()}") // changed=true, state=RUNNING
}

Идея простая: если вы хотите, чтобы переход "IDLE" "RUNNING" произошёл строго один раз, CAS даёт очень понятный инструмент.

Состояние приложения как data class

Теперь пример ближе к реальности. Пусть в нашем учебном приложении есть статистика обработки:

  • processed — сколько событий обработано,
  • lastError — последняя ошибка (если была).

Мы можем хранить это как неизменяемый объект и заменять целиком.

import java.util.concurrent.atomic.AtomicReference

data class ProcessorState(
    val processed: Int,
    val lastError: String?
)

fun main() {
    val ref = AtomicReference(ProcessorState(processed = 0, lastError = null))

    ref.set(ProcessorState(processed = 10, lastError = "Timeout"))
    println(ref.get()) // ProcessorState(processed=10, lastError=Timeout)
}

Плюс такого подхода в том, что вы можете атомарно заменить всё состояние разом: одним set(...) или CAS‑операцией. Это иногда значительно упрощает жизнь по сравнению с «двумя var рядом».

5. CAS на AtomicReference: обновляем состояние без блокировок

Сейчас будет часть, где многие впервые чувствуют «силу» AtomicReference: мы можем делать атомарные обновления сложного состояния, если будем менять его копированием, а не «мутированием внутри».

И вот здесь прямо просится copy() у data class: мы берём старое состояние, делаем новое, заменяем ссылку.

Функция «записать ошибку» через CAS‑цикл

import java.util.concurrent.atomic.AtomicReference

data class ProcessorState(val processed: Int, val lastError: String?)

fun setError(ref: AtomicReference<ProcessorState>, message: String) {
    while (true) {
        val current = ref.get()
        val next = current.copy(lastError = message)
        if (ref.compareAndSet(current, next)) return
    }
}

Проверка:

import java.util.concurrent.atomic.AtomicReference

data class ProcessorState(val processed: Int, val lastError: String?)

fun main() {
    val ref = AtomicReference(ProcessorState(0, null))

    setError(ref, "Disk is full")
    println(ref.get()) // ProcessorState(processed=0, lastError=Disk is full)
}

Обновление счётчика внутри состояния

Аналогично можно «инкрементить» поле processed, не прибегая к AtomicInteger:

import java.util.concurrent.atomic.AtomicReference

data class ProcessorState(val processed: Int, val lastError: String?)

fun incProcessed(ref: AtomicReference<ProcessorState>) {
    while (true) {
        val current = ref.get()
        val next = current.copy(processed = current.processed + 1)
        if (ref.compareAndSet(current, next)) return
    }
}

Это работает корректно, но помните: такая стратегия создаёт новые объекты состояния (а значит, аллокации). Иногда это нормальная цена за простоту и целостность, иногда — нет. Наша цель сейчас не «оптимизировать до последнего байта», а научиться выбирать инструмент по задаче.

6. Важная ловушка: AtomicReference не делает объект потокобезопасным

Сейчас будет момент, который регулярно ломает проекты (и настроение). AtomicReference защищает замену ссылки, но не превращает объект, на который эта ссылка указывает, в магически потокобезопасный.

То есть вот так можно сделать очень больно:

import java.util.concurrent.atomic.AtomicReference

data class MutableBox(var x: Int)

fun main() {
    val ref = AtomicReference(MutableBox(0))

    val box = ref.get()
    box.x++ // эта операция не стала потокобезопасной “из-за атомика”

    println(ref.get().x) // 1
}

В одном потоке всё «вроде нормально». Но если несколько корутин будут делать ref.get().x++, вы снова получите гонку — просто не на ref, а на поле x.

Поэтому практическое правило звучит так: AtomicReference хорошо работает, когда вы храните внутри неизменяемые (immutable) объекты и заменяете их целиком. Для мутируемых объектов атомик — это лишь «безопасный держатель ссылки», не более.

7. Границы применимости атомиков

Чтобы не превратить синхронизацию в религию («всё решаем атомиками!»), полезно зафиксировать простую карту выбора.

Задача Обычно лучше Почему
Счётчик событий, метрика «сколько обработано»
AtomicInteger
Дёшево, просто, атомарный инкремент
Один флаг состояния «запущено/не запущено» AtomicReference или AtomicBoolean CAS позволяет сделать «только один запуск»
Инвариант из нескольких полей («A согласовано с B») Mutex или другой дизайн Нужна атомарность группы операций, не одного значения
Хочется хранить объект и менять его поля «внутри» Осторожно: атомик не спасёт Внутренняя мутация остаётся shared mutable state
«Проверил условие → сделал действие» CAS-цикл или Mutex Это check‑then‑act, нужен единый атомарный блок

Отдельно отмечу: в экосистеме Kotlin есть и другие подходы к атомарности (например, отдельные библиотеки и обсуждаемые common‑API для кроссплатформы). Но в рамках Kotlin/JVM мы держимся практичного пути: java.util.concurrent.atomic.*, потому что он широко используется и хорошо стыкуется с корутинами на JVM.

8. Как использовать атомики в приложении: счётчики и статус

Сделаем небольшой «практический» кусочек, который можно мысленно продолжать из лекций про гонки и Mutex. Представим, что у нас есть простой обработчик задач, и мы хотим вести две вещи:

  • processedCount — сколько задач обработано,
  • status — строка статуса: "IDLE", "RUNNING", "STOPPED".

Версия «до»: было бы var

Мы не будем писать полный «плохой» код (мы уже видели его в гонках), но идея такая: два var и куча корутин вокруг — почти гарантированная гонка.

Версия «после»: AtomicInteger + AtomicReference

import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference

class ProcessorStats {
    val processedCount = AtomicInteger(0)
    val status = AtomicReference("IDLE")
}

Теперь сделаем функции, которые использует наш «движок»:

import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference

fun start(status: AtomicReference<String>): Boolean {
    return status.compareAndSet("IDLE", "RUNNING")
}

fun markProcessed(counter: AtomicInteger) {
    counter.incrementAndGet()
}

И маленький «симулятор» корутинами:

import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val processed = AtomicInteger(0)
    val status = AtomicReference("IDLE")

    println(start(status)) // true
    println(status.get())  // RUNNING

    val jobs = List(1_000) {
        launch(Dispatchers.Default) { markProcessed(processed) }
    }
    jobs.forEach { it.join() }

    println(processed.get()) // 1000
}

Получилось простое правило: «статус меняем CAS’ом, счётчик — атомарным инкрементом». Никаких Mutex, потому что наши инварианты здесь действительно «одно значение = одна атомарная операция».

9. Типичные ошибки при работе с AtomicInteger и AtomicReference

Ошибка №1: считать, что «если я использую атомик, то гонок больше не бывает».
Атомик защищает конкретные операции над конкретным значением, но если вы строите логику из нескольких шагов (особенно check‑then‑act), гонка возвращается уже на уровне алгоритма. В таких местах либо используйте CAS-цикл, либо честно берите Mutex.

Ошибка №2: писать get() → «подумал» → set() и ожидать атомарности.
Этот паттерн выглядит прилично, но в конкурентной среде между get() и set() кто-то другой может успеть обновить значение. Если вам нужно обновление «на основе текущего», используйте incrementAndGet(), addAndGet(), compareAndSet() или CAS-цикл.

Ошибка №3: использовать AtomicReference и мутировать объект внутри.
AtomicReference гарантирует атомарность замены ссылки, но не делает потокобезопасными поля объекта. Если внутри MutableList, var-поля и вы меняете их напрямую, вы снова в зоне shared mutable state. Надёжный стиль с AtomicReference — хранить immutable‑состояние и заменять его целиком через copy() + CAS.

Ошибка №4: пытаться атомиками «склеить» инвариант из нескольких значений.
Если у вас два атомика (AtomicInteger и AtomicReference) и вы обновляете их отдельно, инвариант «они всегда согласованы» не появится сам по себе. Здесь либо нужен один объект состояния (одна AtomicReference<State>), либо Mutex, либо перепроектирование так, чтобы согласованность обеспечивалась структурой программы, а не надеждой.

Ошибка №5: делать CAS‑цикл без выхода или без понимания, что он может повторяться.
CAS-цикл — это не «волшебная замена замку», а честная стратегия «повторять, если не получилось». Если вы забыли предусмотреть условия выхода (например, лимит) или делаете внутри тяжёлую работу, можно получить неприятные эффекты: лишние повторы, нагрузку на CPU и странное поведение. CAS-цикл должен быть коротким, а расчёт next — быстрым и детерминированным.

Ошибка №6: пытаться оптимизировать раньше времени и заменять Mutex атомиками «просто потому что быстрее».
Да, атомики часто легче, чем блокировки. Но цена ошибки в конкурентном коде обычно выше, чем цена микроскопической оптимизации. Если вы не можете чётко сформулировать инвариант как «одно атомарное значение и одна атомарная операция», сначала пишите через Mutex и только потом думайте, можно ли упростить до атомиков.

1
Задача
Kotlin SELF, 55 уровень, 2 лекция
Недоступна
Атомарный тумблер
Атомарный тумблер
1
Задача
Kotlin SELF, 55 уровень, 2 лекция
Недоступна
Тысяча кликов
Тысяча кликов
1
Задача
Kotlin SELF, 55 уровень, 2 лекция
Недоступна
Лимит попыток
Лимит попыток
1
Задача
Kotlin SELF, 55 уровень, 2 лекция
Недоступна
Один старт
Один старт
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ