Mutex (взаємне виключення) — це примітив синхронізації, який гарантує, що лише один потік може виконувати критичну секцію коду в кожен момент часу. Згідно з Microsoft Docs (Synchronization Objects, 2024), основний принцип Mutex — володіння: потік, який захопив Mutex, стає його власником і звільняє його лише при виході з критичної секції. Mutex — фундаментальний інструмент для запобігання стану гонки (Race Condition) та забезпечення цілісності даних у багатопотокових додатках.
Головне
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 знаходиться в одному з двох станів: заблокований (locked) — захоплений потоком; вільний (unlocked) — не захоплений. Дві базові операції — lock() (захоплення) та unlock() (звільнення). Якщо Mutex уже заблокований, потік, що викликає lock(), блокується до моменту звільнення. У JVM заблокований потік переходить у стан BLOCKED і не споживає CPU.
Коли Mutex звільняється, система вибирає, який із потоків, що очікують, отримає блокування. При несправедливому (non-fair) плануванні вибір може впасти на потік, який щойно звільнив м'ютекс — це підвищує пропускну здатність, але може призвести до Starvation (голодування). Справедливий (fair) планувальник використовує чергу FIFO: перший потік, що очікує, отримує блокування першим. ReentrantLock(true) реалізує саме такий механізм.
Більшість реалізацій Mutex у Java/Kotlin підтримують рекурсивне (reentrant) захоплення. Якщо потік уже володіє Mutex і знову викликає lock(), операція успішна — Mutex не блокує сам себе. При цьому лічильник рекурсії збільшується, і потік повинен викликати unlock() стільки ж разів, скільки lock(). Це важливо для рекурсивних викликів і вкладених критичних секцій.
Розглянемо типове завдання — захист спільного лічильника від стану гонки за допомогою ReentrantLock (класичний Mutex у Java/Kotlin). Без Mutex код давав би неправильний результат; із Mutex усі 1000 потоків гарантовано збільшують значення лічильника.
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.
fun increment() {
mutex.withLock { // lock + try/finally автоматично
count++
}
}
fun getCount(): Int = mutex.withLock { count }
Ці три механізми синхронізації часто плутають, хоча вони мають різні властивості та сфери застосування. Mutex — бінарний, із володінням. Семафор — лічильник дозволів, без володіння. Монітор — високорівневий механізм, що об'єднує Mutex з умовними змінними (condition variables). Розуміння відмінностей критично важливе для вибору правильного інструменту під конкретне завдання.
| Параметр | Mutex | Семафор | Монітор |
|---|---|---|---|
| Тип | Бінарний (0/1) | Лічильний (0..N) | Бінарний + умови |
| Володіння | Лише власник може unlock | Будь-який потік може signal | Лише власник |
| Рекурсивність | Зазвичай так (reentrant) | Ні | Так |
| Умовне очікування | Ні (потрібен Condition) | Ні | Вбудовано (wait/notify) |
| Приклад у Java/Kotlin | ReentrantLock | Semaphore(permits) | synchronized |
Коли вибирати Mutex: потрібно захистити один ресурс від одночасного доступу — наприклад, спільну колекцію, файл або лічильник. Коли вибирати Семафор — потрібно обмежити кількість одночасних доступів до пулу ресурсів, наприклад пул з'єднань із БД на 5 підключень. Коли вибирати Монітор — потрібна синхронізація з умовним очікуванням, наприклад producer-consumer черга через wait/notify. У сучасній Android-розробці synchronized часто замінюють на ReentrantLock або kotlinx.coroutines Mutex.
Найпоширеніша помилка — відсутність finally-блоку для виклику unlock(). Якщо в критичній секції виникає виняток, Mutex залишається заблокованим, і інші потоки чекають вічно. Навіть якщо ви впевнені, що винятки неможливі — завжди використовуйте try/finally або withLock. Це принцип defensive programming, особливо важливий у мобільній розробці, де винятки можуть виникати через нестачу пам'яті або Configuration Changes.
Коли в додатку використовується кілька Mutex, критично важливо встановлювати єдиний порядок їх захоплення. Якщо Thread A захоплює M1 → M2, а Thread B захоплює M2 → M1, виникає Deadlock. У великих проєктах (понад 50 тисяч рядків коду) порядок блокувань документується в архітектурному рішенні та перевіряється лінтерами. Інструмент Lock Checker у IntelliJ IDEA автоматично виявляє непослідовний порядок захоплення блокувань.
Утримання Mutex довше 1-2 мілісекунд — ознака неправильного дизайну. Критична секція повинна містити лише мінімально необхідні операції. Мережеві запити, файловий ввід-вивід і складні обчислення мають виконуватися за межами заблокованого блоку. В Android тривале утримання блокування в UI-потоці призводить до пропуску кадрів (jank) та ANR. Використовуйте ReadWriteLock, якщо критична секція в основному складається з операцій читання.
Бібліотека kotlinx.coroutines надає власну реалізацію Mutex, яка фундаментально відрізняється від класичного ReentrantLock. Головна відмінність — suspending Mutex не блокує потік ОС, а призупиняє корутину до моменту звільнення блокування. Це означає, що потік може виконувати інші корутини, поки поточна очікує Mutex.
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+ корутинах.
Часті запитання
Володіння (ownership) — принципова відмінність. Mutex запам'ятовує, який потік його захопив, і лише цей потік може його звільнити. Бінарний семафор (Semaphore(1)) не має власника — будь-який потік може виконати release(). Тому Mutex безпечніший: інший потік не може випадково звільнити чуже блокування, а семафор може.
synchronized простіший і коротший — використовуйте його для простих критичних секцій без таймаутів і без контролю справедливості. ReentrantLock використовуйте, коли потрібен TryLock з таймаутом, fair-планування, Condition Variables або переривання потоку, що очікує (lockInterruptibly). Для корутин завжди використовуйте kotlinx.coroutines.sync.Mutex.
Spinlock — це блокування, при якому потік не засинає, а в циклі (spin) перевіряє стан блокування. Spinlock споживає CPU, але не перемикає контекст, що робить його вигідним для коротких критичних секцій (до 10 інструкцій). Mutex переводить потік у стан BLOCKED, що дорожче на 10-50 мікросекунд через перемикання контексту, але не витрачає CPU.
На рівні ядра Linux Mutex реалізований через futex (fast userspace mutex). Потік спочатку намагається захопити блокування в userspace через атомарну інструкцію CAS (Compare-And-Swap). Якщо Mutex вільний — захоплення відбувається без syscall. Якщо зайнятий — потік робить syscall futex(FUTEX_WAIT) і засинає. При звільненні syscall futex(FUTEX_WAKE) пробуджує один потік, що очікує.
Так, існують міжпроцесні Mutex (inter-process mutex). У Windows це Named Mutex, у Linux — pthread_mutexattr_setpshared з атрибутом PTHREAD_PROCESS_SHARED. В Android Bionic libc також підтримує міжпроцесні Mutex через файлові дескриптори. Міжпроцесні Mutex використовуються для синхронізації між різними додатками або між процесом і його дочірніми процесами.
Підсумки
Ми розробимо мобільний застосунок під ключ
IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.
Читайте також