移动应用中的Mutex — 什么是互斥锁、工作原理及互斥的应用

作者: IT Sectr 发布日期: 2026-03-18 阅读时间: 10 分钟

Mutex(互斥锁)— 是一种同步原语,保证在任何时刻只有一个线程可以执行临界区代码。根据 Microsoft Docs(Synchronization Objects, 2024),Mutex的基本原理是所有权:获取Mutex的线程成为其所有者,并仅在退出临界区时才释放它。Mutex — 是防止竞态条件和确保多线程应用程序数据完整性的基本工具。

要点

  • Mutex — 互斥机制,确保每次只有一个线程可以访问资源
  • 所有权(ownership) — Mutex的关键特性:只有获取锁的线程才能释放它
  • 与计数≥2的信号量不同,Mutex只有0或1的状态(二进制信号量)
  • Mutex的死锁发生在多个互斥锁获取顺序不正确时
  • Kotlin协程中的挂起Mutex不会阻塞操作系统线程,这使其与经典的ReentrantLock有所区别

什么是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如何工作

状态和操作

Mutex处于两种状态之一:已锁定(locked) — 被线程获取;空闲(unlocked) — 未被获取。两个基本操作——lock()(获取)和unlock()(释放)。如果Mutex已被获取,调用lock()的线程将被阻塞直到释放。在JVM中,被阻塞的线程进入BLOCKED状态,不消耗CPU。

等待线程的调度

当Mutex被释放时,系统选择哪个等待线程将获得锁。在不公平(non-fair)调度下,选择可能落在刚刚释放了互斥锁的线程上——这提高了吞吐量,但可能导致饥饿(Starvation)。公平(fair)调度器使用FIFO队列:第一个等待线程最先获得锁。ReentrantLock(true)正是实现了这种机制。

递归获取(Reentrancy)

Java/Kotlin中的大多数Mutex实现都支持递归(reentrant)获取。如果线程已经拥有Mutex并再次调用lock(),操作成功——Mutex不会阻塞自身。递归计数器增加,线程必须调用与lock()相同次数的unlock()。这对于递归调用和嵌套临界区很重要。

Kotlin中使用Mutex的示例

考虑一个典型任务——使用ReentrantLock(Java/Kotlin中的经典Mutex)保护共享计数器免受竞态条件的影响。没有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将永远保持锁定——这会导致死锁。finally块保证在任何执行结果下释放Mutex。

Kotlin中的替代方法——使用扩展函数withLock,它自动处理带有finally的lock/unlock。

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)二进制 + 条件
所有权只有所有者可以解锁任何线程都可以发信号只有所有者
可重入性通常是(可重入)
条件等待否(需要Condition)内置(wait/notify)
Java/Kotlin中的示例ReentrantLockSemaphore(permits)synchronized

何时选择Mutex:需要保护一个资源免受同时访问——例如共享集合、文件或计数器。何时选择信号量——需要限制对资源池的同时访问数量,例如具有5个连接的数据库连接池。何时选择监视器——需要带有条件等待的同步,例如通过wait/notify的生产者-消费者队列。在现代Android开发中,synchronized经常被ReentrantLock或kotlinx.coroutines Mutex取代。

使用Mutex的常见错误

在finally中忘记解锁

最常见的错误——缺少finally块来调用unlock()。如果在临界区发生异常,Mutex将保持锁定,其他线程将永远等待。即使您确信异常不可能发生——始终使用try/finally或withLock。这是防御性编程的原则,在移动开发中尤为重要,因为异常可能由于内存不足或配置更改而发生。

不同的Mutex获取顺序

当应用程序中使用多个Mutex时,建立统一的获取顺序至关重要。如果线程A获取M1 → M2,而线程B获取M2 → M1,就会发生死锁。在大型项目(超过5万行代码)中,锁定顺序记录在架构决策中,并由linter检查。IntelliJ IDEA中的Lock Checker工具可以自动检测不一致的锁获取顺序。

临界区过长

持有Mutex超过1-2毫秒——设计不当的标志。临界区应仅包含最必要的操作。网络请求、文件输入输出和复杂计算应在锁定块之外执行。在Android中,在UI线程中长时间持有锁会导致丢帧(jank)和ANR。如果临界区主要由读取操作组成,请使用ReadWriteLock。

Kotlin协程中的Mutex

kotlinx.coroutines库提供了自己的Mutex实现,该实现与经典的ReentrantLock有着根本的不同。主要区别——挂起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。此外,kotlinx.coroutines中的Mutex是非阻塞的:它通过suspend使用挂起,这允许不阻塞池线程。

在实践中,挂起Mutex在协程代码中比经典ReentrantLock更受欢迎,原因有两个:可伸缩性 — 一个协程在等待Mutex,而线程服务于其他协程,这提高了系统的吞吐量;没有BlockedThread — 不会浪费资源来存储阻塞线程的栈。根据JetBrains(Kotlin协程指南,2024),使用挂起Mutex在100多个协程下可将吞吐量提高40%。

常见问题

Mutex与二进制信号量有何区别?

所有权(ownership) — 根本区别。Mutex记住哪个线程获取了它,并且只有该线程才能释放它。二进制信号量(Semaphore(1))没有所有者——任何线程都可以执行release()。因此Mutex更安全:另一个线程不能意外释放他人的锁,而信号量可以。

何时使用Mutex,何时使用synchronized?

synchronized更简单更简短——用于没有超时和公平性控制的简单临界区。当需要带有超时的TryLock、公平调度、条件变量或中断等待线程(lockInterruptibly)时使用ReentrantLock。对于协程,始终使用kotlinx.coroutines.sync.Mutex。

什么是自旋锁,它与Mutex有何不同?

自旋锁 — 是一种锁,线程不睡眠,而是在循环(spin)中检查锁的状态。自旋锁消耗CPU,但不切换上下文,这使其有利于短临界区(最多10条指令)。Mutex将线程转为BLOCKED状态,由于上下文切换,这需要多花10-50微秒,但不消耗CPU。

Mutex在操作系统级别是如何构建的?

在Linux内核级别,Mutex是通过futex(快速用户空间互斥锁)实现的。线程首先尝试通过原子CAS(Compare-And-Swap)指令在用户空间获取锁。如果Mutex空闲——获取无需系统调用。如果被占用——线程执行系统调用futex(FUTEX_WAIT)并休眠。释放时,系统调用futex(FUTEX_WAKE)唤醒一个等待线程。

Mutex可以是进程间的吗?

是的,存在进程间Mutex(inter-process mutex)。在Windows中是命名Mutex,在Linux中——带有PTHREAD_PROCESS_SHARED属性的pthread_mutexattr_setpshared。在Android中,Bionic libc也通过文件描述符支持进程间Mutex。进程间Mutex用于不同应用程序之间或进程与其子进程之间的同步。

总结

  • Mutex — 互斥原语,保证只有一个线程同时执行临界区
  • 所有权(ownership)将Mutex与二进制信号量区分开来——只有所有者线程才能释放
  • Java/Kotlin中的ReentrantLock — 经典的Mutex实现,支持递归获取和TryLock
  • Finally块或withLock对于防止异常时的死锁是强制性的
  • kotlinx.coroutines中的挂起Mutex不会阻塞操作系统线程,而是挂起协程
  • 多个Mutex的统一获取顺序 — 在复杂系统中避免死锁的唯一方法
  • 短临界区(最多1-2毫秒)— 无饥饿的多线程应用程序性能的关键

我们将开发一款交钥匙移动应用程序

IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。

讨论项目

另请阅读