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("змінено=$ok, стан=${state.get()}") // змінено=true, стан=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 = "Тайм-аут"))
    println(ref.get()) // ProcessorState(processed=10, lastError=Тайм-аут)
}

Плюс такого підходу в тому, що ви можете атомарно замінити увесь стан одразу: одним 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, "Диск заповнений")
    println(ref.get()) // ProcessorState(processed=0, lastError=Диск заповнений)
}

Оновлення лічильника всередині стану

Аналогічно можна «інкрементувати» поле 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 і лише потім думайте, чи можна спростити до атоміків.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ