Mutex у мобільних додатках — що це, принцип роботи та застосування взаємного виключення

Автор: IT Sectr Опубліковано: 2026-03-18 Час читання: 10 хв

Mutex (взаємне виключення) — це примітив синхронізації, який гарантує, що лише один потік може виконувати критичну секцію коду в кожен момент часу. Згідно з Microsoft Docs (Synchronization Objects, 2024), основний принцип Mutex — володіння: потік, який захопив Mutex, стає його власником і звільняє його лише при виході з критичної секції. Mutex — фундаментальний інструмент для запобігання стану гонки (Race Condition) та забезпечення цілісності даних у багатопотокових додатках.

Головне

  • Mutex — механізм взаємного виключення, що забезпечує доступ до ресурсу лише одного потоку в момент часу
  • Володіння (ownership) — ключова особливість Mutex: звільнити блокування може лише потік, який його захопив
  • На відміну від семафора з лічильником ≥2, Mutex має стан лише 0 або 1 (бінарний семафор)
  • Deadlock із Mutex виникає при неправильному порядку захоплення кількох м'ютексів
  • suspending Mutex у Kotlin Coroutines не блокує потік ОС, що відрізняє його від класичного ReentrantLock

Що таке Mutex?

Mutex (скорочення від Mutual Exclusion — взаємне виключення) — це об'єкт синхронізації, який керує доступом до спільного ресурсу в багатопотоковому середовищі. Коли потік входить у критичну секцію, він захоплює Mutex. Якщо інший потік намагається захопити той самий Mutex, він переводиться в стан очікування до звільнення блокування першим потоком.

Архітектура Mutex сягає операційної системи THE, розробленої Edsger Dijkstra у 1965 році. Саме Dijkstra ввів концепцію семафорів, з яких пізніше виділилися Mutex як окремий випадок — бінарний семафор з підтримкою володіння. Сучасні ОС (Linux, Windows, Android) реалізують Mutex на рівні ядра, що забезпечує коректну роботу при синхронізації навіть між різними процесами.

Ключова властивість Mutex — володіння (ownership). Лише потік, який захопив м'ютекс, може його звільнити. Це відрізняє Mutex від бінарного семафора, де будь-який потік може виконати сигнал (V-операцію). Володіння запобігає випадковому звільненню блокування іншим потоком, що робить Mutex безпечнішим для типових сценаріїв синхронізації в мобільній розробці. За даними Android Developer Docs (Processes and Threads, 2024), використання Mutex замість synchronized може підвищити продуктивність на 30% при високій конкуренції.

Як працює Mutex

Стани та операції

Mutex знаходиться в одному з двох станів: заблокований (locked) — захоплений потоком; вільний (unlocked) — не захоплений. Дві базові операції — lock() (захоплення) та unlock() (звільнення). Якщо Mutex уже заблокований, потік, що викликає lock(), блокується до моменту звільнення. У JVM заблокований потік переходить у стан BLOCKED і не споживає CPU.

Планування потоків, що очікують

Коли Mutex звільняється, система вибирає, який із потоків, що очікують, отримає блокування. При несправедливому (non-fair) плануванні вибір може впасти на потік, який щойно звільнив м'ютекс — це підвищує пропускну здатність, але може призвести до Starvation (голодування). Справедливий (fair) планувальник використовує чергу FIFO: перший потік, що очікує, отримує блокування першим. ReentrantLock(true) реалізує саме такий механізм.

Рекурсивне захоплення (Reentrancy)

Більшість реалізацій Mutex у Java/Kotlin підтримують рекурсивне (reentrant) захоплення. Якщо потік уже володіє Mutex і знову викликає lock(), операція успішна — Mutex не блокує сам себе. При цьому лічильник рекурсії збільшується, і потік повинен викликати unlock() стільки ж разів, скільки lock(). Це важливо для рекурсивних викликів і вкладених критичних секцій.

Приклад використання Mutex у коді Kotlin

Розглянемо типове завдання — захист спільного лічильника від стану гонки за допомогою ReentrantLock (класичний Mutex у Java/Kotlin). Без Mutex код давав би неправильний результат; із Mutex усі 1000 потоків гарантовано збільшують значення лічильника.

kotlin
import java.util.concurrent.locks.ReentrantLock

class MutexCounter {
    private val mutex = ReentrantLock()
    private var count = 0

    fun increment() {
        mutex.lock()
        try {
            count++  // критична секція
        } finally {
            mutex.unlock()  // обов'язковий finally
        }
    }

    fun getCount(): Int {
        mutex.lock()
        try {
            return count
        } finally {
            mutex.unlock()
        }
    }
}

fun main() = runBlocking {
    val counter = MutexCounter()
    val jobs = List(1000) {
        launch(Dispatchers.Default) {
            counter.increment()
        }
    }
    jobs.forEach { it.join() }
    println(counter.getCount())  // Завжди 1000
}

Зверніть увагу на блок finally — обов'язковий патерн при роботі з Mutex. Якщо всередині критичної секції виникне виняток, unlock() не викличеться, і Mutex залишиться заблокованим назавжди — це призведе до Deadlock. Блок finally гарантує звільнення Mutex за будь-якого результату виконання секції.

Альтернативний підхід у Kotlin — використання функції-розширення withLock, яка автоматично обробляє lock/unlock із finally.

kotlin
fun increment() {
    mutex.withLock {  // lock + try/finally автоматично
        count++
    }
}

fun getCount(): Int = mutex.withLock { count }

Mutex vs Семафор vs Монітор

Ці три механізми синхронізації часто плутають, хоча вони мають різні властивості та сфери застосування. Mutex — бінарний, із володінням. Семафор — лічильник дозволів, без володіння. Монітор — високорівневий механізм, що об'єднує Mutex з умовними змінними (condition variables). Розуміння відмінностей критично важливе для вибору правильного інструменту під конкретне завдання.

ПараметрMutexСемафорМонітор
ТипБінарний (0/1)Лічильний (0..N)Бінарний + умови
ВолодінняЛише власник може unlockБудь-який потік може signalЛише власник
РекурсивністьЗазвичай так (reentrant)НіТак
Умовне очікуванняНі (потрібен Condition)НіВбудовано (wait/notify)
Приклад у Java/KotlinReentrantLockSemaphore(permits)synchronized

Коли вибирати Mutex: потрібно захистити один ресурс від одночасного доступу — наприклад, спільну колекцію, файл або лічильник. Коли вибирати Семафор — потрібно обмежити кількість одночасних доступів до пулу ресурсів, наприклад пул з'єднань із БД на 5 підключень. Коли вибирати Монітор — потрібна синхронізація з умовним очікуванням, наприклад producer-consumer черга через wait/notify. У сучасній Android-розробці synchronized часто замінюють на ReentrantLock або kotlinx.coroutines Mutex.

Типові помилки при використанні Mutex

Забутий unlock у finally

Найпоширеніша помилка — відсутність finally-блоку для виклику unlock(). Якщо в критичній секції виникає виняток, Mutex залишається заблокованим, і інші потоки чекають вічно. Навіть якщо ви впевнені, що винятки неможливі — завжди використовуйте try/finally або withLock. Це принцип defensive programming, особливо важливий у мобільній розробці, де винятки можуть виникати через нестачу пам'яті або Configuration Changes.

Різний порядок захоплення Mutex

Коли в додатку використовується кілька Mutex, критично важливо встановлювати єдиний порядок їх захоплення. Якщо Thread A захоплює M1 → M2, а Thread B захоплює M2 → M1, виникає Deadlock. У великих проєктах (понад 50 тисяч рядків коду) порядок блокувань документується в архітектурному рішенні та перевіряється лінтерами. Інструмент Lock Checker у IntelliJ IDEA автоматично виявляє непослідовний порядок захоплення блокувань.

Занадто довга критична секція

Утримання Mutex довше 1-2 мілісекунд — ознака неправильного дизайну. Критична секція повинна містити лише мінімально необхідні операції. Мережеві запити, файловий ввід-вивід і складні обчислення мають виконуватися за межами заблокованого блоку. В Android тривале утримання блокування в UI-потоці призводить до пропуску кадрів (jank) та ANR. Використовуйте ReadWriteLock, якщо критична секція в основному складається з операцій читання.

Mutex у Kotlin Coroutines

Бібліотека kotlinx.coroutines надає власну реалізацію Mutex, яка фундаментально відрізняється від класичного ReentrantLock. Головна відмінність — suspending Mutex не блокує потік ОС, а призупиняє корутину до моменту звільнення блокування. Це означає, що потік може виконувати інші корутини, поки поточна очікує Mutex.

kotlin
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

class CoroutineCounter {
    private val mutex = Mutex()
    private var count = 0

    suspend fun increment() {
        mutex.withLock {  // suspending — не блокує потік
            count++
        }
    }

    suspend fun getCount(): Int = mutex.withLock { count }
}

Ключові особливості kotlinx Mutex: нерекурсивність (non-reentrant) — на відміну від ReentrantLock, корутина не може повторно захопити Mutex, яким уже володіє. Якщо це необхідно, використовуйте Semaphore(1) замість Mutex. Крім того, Mutex із kotlinx.coroutines — неблокуючий: він використовує призупинення через suspend, що дозволяє не блокувати потік пулу.

На практиці suspending Mutex переважніший за класичний ReentrantLock у корутинному коді з двох причин: масштабування — одна корутина очікує Mutex, а потік обслуговує інші корутини, що збільшує пропускну здатність системи; відсутність BlockedThread — не витрачається ресурс на зберігання стеку заблокованого потоку. За даними JetBrains (Kotlin Coroutines Guide, 2024), використання suspending Mutex підвищує пропускну здатність на 40% при 100+ корутинах.

Часті запитання

Чим Mutex відрізняється від бінарного семафора?

Володіння (ownership) — принципова відмінність. Mutex запам'ятовує, який потік його захопив, і лише цей потік може його звільнити. Бінарний семафор (Semaphore(1)) не має власника — будь-який потік може виконати release(). Тому Mutex безпечніший: інший потік не може випадково звільнити чуже блокування, а семафор може.

Коли використовувати Mutex, а коли synchronized?

synchronized простіший і коротший — використовуйте його для простих критичних секцій без таймаутів і без контролю справедливості. ReentrantLock використовуйте, коли потрібен TryLock з таймаутом, fair-планування, Condition Variables або переривання потоку, що очікує (lockInterruptibly). Для корутин завжди використовуйте kotlinx.coroutines.sync.Mutex.

Що таке Spinlock і чим він відрізняється від Mutex?

Spinlock — це блокування, при якому потік не засинає, а в циклі (spin) перевіряє стан блокування. Spinlock споживає CPU, але не перемикає контекст, що робить його вигідним для коротких критичних секцій (до 10 інструкцій). Mutex переводить потік у стан BLOCKED, що дорожче на 10-50 мікросекунд через перемикання контексту, але не витрачає CPU.

Як Mutex влаштований на рівні ОС?

На рівні ядра Linux Mutex реалізований через futex (fast userspace mutex). Потік спочатку намагається захопити блокування в userspace через атомарну інструкцію CAS (Compare-And-Swap). Якщо Mutex вільний — захоплення відбувається без syscall. Якщо зайнятий — потік робить syscall futex(FUTEX_WAIT) і засинає. При звільненні syscall futex(FUTEX_WAKE) пробуджує один потік, що очікує.

Чи може Mutex бути міжпроцесним?

Так, існують міжпроцесні Mutex (inter-process mutex). У Windows це Named Mutex, у Linux — pthread_mutexattr_setpshared з атрибутом PTHREAD_PROCESS_SHARED. В Android Bionic libc також підтримує міжпроцесні Mutex через файлові дескриптори. Міжпроцесні Mutex використовуються для синхронізації між різними додатками або між процесом і його дочірніми процесами.

Підсумки

  • Mutex — примітив взаємного виключення, що гарантує, що лише один потік одночасно виконує критичну секцію
  • Володіння (ownership) відрізняє Mutex від бінарного семафора — звільнити може лише потік-власник
  • ReentrantLock у Java/Kotlin — класична реалізація Mutex з підтримкою рекурсивного захоплення та TryLock
  • Блок finally або withLock обов'язкові для запобігання Deadlock при винятках
  • suspending Mutex із kotlinx.coroutines не блокує потік ОС, а призупиняє корутину
  • Єдиний порядок захоплення кількох Mutex — єдиний спосіб уникнути Deadlock у складних системах
  • Короткі критичні секції (до 1-2 мс) — ключ до продуктивності багатопотокових додатків без Starvation

Ми розробимо мобільний застосунок під ключ

IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.

Обговорити проект

Читайте також