Mutex(互斥锁)— 是一种同步原语,保证在任何时刻只有一个线程可以执行临界区代码。根据 Microsoft Docs(Synchronization Objects, 2024),Mutex的基本原理是所有权:获取Mutex的线程成为其所有者,并仅在退出临界区时才释放它。Mutex — 是防止竞态条件和确保多线程应用程序数据完整性的基本工具。
要点
Mutex(Mutual Exclusion的缩写 — 互斥)— 是一个同步对象,用于管理多线程环境中对共享资源的访问。当线程进入临界区时,它获取Mutex。如果另一个线程试图获取同一个Mutex,它将被转入等待状态,直到第一个线程释放锁。
Mutex的架构可以追溯到Edsger Dijkstra于1965年开发的THE操作系统。正是Dijkstra引入了信号量的概念,后来Mutex作为一个特例从中分离出来——带有所有权支持的二进制信号量。现代操作系统(Linux、Windows、Android)在内核级别实现Mutex,这确保了即使在不同进程之间的同步也能正确工作。
Mutex的关键属性是所有权(ownership)。只有获取了互斥锁的线程才能释放它。这使Mutex有别于二进制信号量,在二进制信号量中任何线程都可以执行信号(V操作)。所有权防止了另一个线程意外释放锁,这使得Mutex对于移动开发中的典型同步场景更加安全。根据Android开发者文档(进程和线程,2024),在高竞争条件下使用Mutex代替synchronized可以将性能提高30%。
Mutex处于两种状态之一:已锁定(locked) — 被线程获取;空闲(unlocked) — 未被获取。两个基本操作——lock()(获取)和unlock()(释放)。如果Mutex已被获取,调用lock()的线程将被阻塞直到释放。在JVM中,被阻塞的线程进入BLOCKED状态,不消耗CPU。
当Mutex被释放时,系统选择哪个等待线程将获得锁。在不公平(non-fair)调度下,选择可能落在刚刚释放了互斥锁的线程上——这提高了吞吐量,但可能导致饥饿(Starvation)。公平(fair)调度器使用FIFO队列:第一个等待线程最先获得锁。ReentrantLock(true)正是实现了这种机制。
Java/Kotlin中的大多数Mutex实现都支持递归(reentrant)获取。如果线程已经拥有Mutex并再次调用lock(),操作成功——Mutex不会阻塞自身。递归计数器增加,线程必须调用与lock()相同次数的unlock()。这对于递归调用和嵌套临界区很重要。
考虑一个典型任务——使用ReentrantLock(Java/Kotlin中的经典Mutex)保护共享计数器免受竞态条件的影响。没有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将永远保持锁定——这会导致死锁。finally块保证在任何执行结果下释放Mutex。
Kotlin中的替代方法——使用扩展函数withLock,它自动处理带有finally的lock/unlock。
fun increment() {
mutex.withLock { // lock + try/finally 自动
count++
}
}
fun getCount(): Int = mutex.withLock { count }
这三种同步机制经常被混淆,尽管它们具有不同的属性和应用领域。Mutex — 二进制,有所有权。信号量 — 许可计数器,无所有权。监视器 — 高级机制,将Mutex与条件变量(condition variables)结合起来。理解差异对于为特定任务选择正确的工具至关重要。
| 参数 | Mutex | 信号量 | 监视器 |
|---|---|---|---|
| 类型 | 二进制(0/1) | 可计数(0..N) | 二进制 + 条件 |
| 所有权 | 只有所有者可以解锁 | 任何线程都可以发信号 | 只有所有者 |
| 可重入性 | 通常是(可重入) | 否 | 是 |
| 条件等待 | 否(需要Condition) | 否 | 内置(wait/notify) |
| Java/Kotlin中的示例 | ReentrantLock | Semaphore(permits) | synchronized |
何时选择Mutex:需要保护一个资源免受同时访问——例如共享集合、文件或计数器。何时选择信号量——需要限制对资源池的同时访问数量,例如具有5个连接的数据库连接池。何时选择监视器——需要带有条件等待的同步,例如通过wait/notify的生产者-消费者队列。在现代Android开发中,synchronized经常被ReentrantLock或kotlinx.coroutines Mutex取代。
最常见的错误——缺少finally块来调用unlock()。如果在临界区发生异常,Mutex将保持锁定,其他线程将永远等待。即使您确信异常不可能发生——始终使用try/finally或withLock。这是防御性编程的原则,在移动开发中尤为重要,因为异常可能由于内存不足或配置更改而发生。
当应用程序中使用多个Mutex时,建立统一的获取顺序至关重要。如果线程A获取M1 → M2,而线程B获取M2 → M1,就会发生死锁。在大型项目(超过5万行代码)中,锁定顺序记录在架构决策中,并由linter检查。IntelliJ IDEA中的Lock Checker工具可以自动检测不一致的锁获取顺序。
持有Mutex超过1-2毫秒——设计不当的标志。临界区应仅包含最必要的操作。网络请求、文件输入输出和复杂计算应在锁定块之外执行。在Android中,在UI线程中长时间持有锁会导致丢帧(jank)和ANR。如果临界区主要由读取操作组成,请使用ReadWriteLock。
kotlinx.coroutines库提供了自己的Mutex实现,该实现与经典的ReentrantLock有着根本的不同。主要区别——挂起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。此外,kotlinx.coroutines中的Mutex是非阻塞的:它通过suspend使用挂起,这允许不阻塞池线程。
在实践中,挂起Mutex在协程代码中比经典ReentrantLock更受欢迎,原因有两个:可伸缩性 — 一个协程在等待Mutex,而线程服务于其他协程,这提高了系统的吞吐量;没有BlockedThread — 不会浪费资源来存储阻塞线程的栈。根据JetBrains(Kotlin协程指南,2024),使用挂起Mutex在100多个协程下可将吞吐量提高40%。
常见问题
所有权(ownership) — 根本区别。Mutex记住哪个线程获取了它,并且只有该线程才能释放它。二进制信号量(Semaphore(1))没有所有者——任何线程都可以执行release()。因此Mutex更安全:另一个线程不能意外释放他人的锁,而信号量可以。
synchronized更简单更简短——用于没有超时和公平性控制的简单临界区。当需要带有超时的TryLock、公平调度、条件变量或中断等待线程(lockInterruptibly)时使用ReentrantLock。对于协程,始终使用kotlinx.coroutines.sync.Mutex。
自旋锁 — 是一种锁,线程不睡眠,而是在循环(spin)中检查锁的状态。自旋锁消耗CPU,但不切换上下文,这使其有利于短临界区(最多10条指令)。Mutex将线程转为BLOCKED状态,由于上下文切换,这需要多花10-50微秒,但不消耗CPU。
在Linux内核级别,Mutex是通过futex(快速用户空间互斥锁)实现的。线程首先尝试通过原子CAS(Compare-And-Swap)指令在用户空间获取锁。如果Mutex空闲——获取无需系统调用。如果被占用——线程执行系统调用futex(FUTEX_WAIT)并休眠。释放时,系统调用futex(FUTEX_WAKE)唤醒一个等待线程。
是的,存在进程间Mutex(inter-process mutex)。在Windows中是命名Mutex,在Linux中——带有PTHREAD_PROCESS_SHARED属性的pthread_mutexattr_setpshared。在Android中,Bionic libc也通过文件描述符支持进程间Mutex。进程间Mutex用于不同应用程序之间或进程与其子进程之间的同步。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。