Semaphore — 是一种同步原语,通过计数器和等待线程队列来管理对共享资源的访问。根据Wikipedia, 2024,信号量由 Edsger Dijkstra 于 1965 年提出,用于解决多线程交互问题。该工具可以限制同时操作临界区的线程数量。
要点
Semaphore — 是一种使用计数器管理共享资源访问的同步原语。该概念由 Edsger Dijkstra 于 1965 年提出,成为操作系统中所有现代同步机制的基础。
信号量是一个带有两个原子操作的整型变量:wait (acquire) 和 signal (release)。wait 操作减少计数器,signal 操作增加计数器。当计数器达到零时,调用 wait 的线程将被阻塞,直到另一个线程执行 signal。
信号量的主要目的是保护临界区免受多个线程的同时访问。与互斥锁不同,信号量不需要绑定到所有者线程,这使其适用于更广泛的协调任务。
信号量的概念源于在埃因霍温理工大学开发的 THE 操作系统。Dijkstra 将信号量形式化为数学抽象,证明了它足以实现任何同步原语。
信号量机制基于两个原子操作和一个内部等待队列。在调用 acquire 时,线程检查计数器的值,然后继续执行或阻塞直到资源释放。
创建信号量时,设置权限计数器的初始值。每次 acquire 调用将计数器减 1。如果之后计数器变为负数,线程将被阻塞。release 操作增加计数器并唤醒一个等待中的线程。
import java.util.concurrent.Semaphore
val semaphore = Semaphore(3)
fun accessResource() {
semaphore.acquire()
try {
println("${Thread.currentThread().name} 正在工作")
} finally {
semaphore.release()
}
}
当线程在计数器为零时调用 acquire,操作系统将其放入信号量的 FIFO 队列。线程进入 BLOCKED 状态,不消耗处理器时间。release 调用后,队列中的第一个线程进入 RUNNABLE 状态并获得对资源的访问权。
在同步理论中,信号量分为两种主要类型:二进制(binary)和计数(counting)。类型的选择取决于对资源访问管理的具体任务。
二进制信号量只取 0 和 1 两个值。行为上类似于互斥锁,但没有所有权要求 — 任何线程都可以执行 release。这类信号量适用于实现就绪标志位和线程间事件。
val ready = Semaphore(0)
fun producer() {
Thread.sleep(1000)
ready.release()
}
fun consumer() {
ready.acquire()
println("数据已准备就绪")
}
计数信号量可以取任何非负整数值。用于管理多个实例可用的同类型资源池。例如,5 个网络连接池:每次 acquire 占用一个连接,release 将其返回池中。
计数信号量在限制外部服务访问速度和实现线程池时不可或缺。它们无需手动管理线程即可精确控制并行程度。
| 参数 | 二进制信号量 | 计数信号量 |
|---|---|---|
| 范围 | 0 或 1 | 从 0 到 N |
| 同时线程数 | 1 | 最多 N |
| 应用 | 信号通知、标志位 | 资源池、速率限制 |
开发人员经常混淆信号量和互斥锁,尽管它们之间存在根本性差异。理解这些差异对于在项目中选择正确的同步机制至关重要。
关键区别 — 所有权概念。互斥锁始终知道哪个线程捕获了它,并且只有该线程可以释放它。信号量没有所有者:任何线程都可以调用 release,即使没有调用过 acquire。这使得互斥锁在数据保护方面更安全,而信号量在协调方面更灵活。
在实践中,互斥锁由于针对典型场景的优化,在简单的互斥锁定方面更快。信号量需要维护计数器的额外开销。然而,在限制并行性或实现“生产者-消费者”模式时,信号量是不可替代的。
| 特性 | Semaphore | Mutex |
|---|---|---|
| 所有权 | 无所有者 | 有所有者 |
| 释放 | 任何线程 | 仅所有者线程 |
| 计数器 | 从 0 到 N | 二进制 |
| 用例 | 限制并行性和信号通知 | 保护临界区 |
| 递归 | 否 | 是(可重入) |
在移动应用程序开发中,Semaphore 用于管理对有限资源的访问:网络连接、文件、数据库和硬件组件。现代平台提供了方便的内置实现。
典型场景之一是 HTTP 连接池。应用程序最多可以同时向服务器发送 4 个请求,因为提供商的 API 限制了并行性。初始值为 4 的信号量保证在任何负载下,同时请求的数量不会超过限制,其他线程将在队列中等待。
如果没有信号量,在用户活动突然增加时,服务器基础设施可能会突然过载,导致超时和 429 Too Many Requests 错误。信号量像保险丝一样工作,无论活动线程数量如何,严格传递指定数量的并发调用。
Android 提供了 java.util.concurrent 包中的 Semaphore 类。让我们看一个将并发网络请求限制为两个线程以防止服务器过载的示例。
class ApiClient {
private val throttle = Semaphore(2)
suspend fun fetch(url: String): Result {
throttle.acquire()
return try {
httpGet(url)
} finally {
throttle.release()
}
}
}
在 iOS 中,来自 GCD 的 DispatchSemaphore 解决了同样的任务。开发人员使用它来同步异步代码中对资源的访问,而不阻塞主线程。
let semaphore = DispatchSemaphore(value: 3)
func processBatch(_ items: [UIImage]) {
for img in items {
semaphore.wait()
DispatchQueue.global().async {
applyFilter(to: img)
semaphore.signal()
}
}
}
最常见的错误 — 异常时忘记 release。如果线程在调用 release 之前因错误终止,信号量将永远对其他线程保持阻塞。使用 try/finally 或 defer 确保释放。第二个问题 — 不同线程以不同顺序获取多个信号量时导致死锁。
信号量不仅用于数据保护,还用于复杂多线程场景中的线程协调。了解常见模式可以加速开发并降低同步错误的可能性。
在实际项目中有几种经过验证的信号量使用模式。了解它们有助于避免典型错误并构建可靠的多线程系统。
初始值为 N 的信号量加上通过定时器的定期 release 实现了对 API 请求的速率限制。例如,服务允许每秒 10 个请求:信号量从 10 开始,每次请求减少计数器,单独的 TimerTask 每秒将计数器重置为初始值。这既保护了应用程序也保护了服务器免受超载。
在经典的生产者-消费者任务中,两个信号量管理缓冲区:empty(写入权限)和 full(读取权限)。生产者调用 empty 的 acquire 和 full 的 release,消费者则相反。这种方案保证消费者永远不会读取空缓冲区,生产者也不会过度填充它。
相同的方案是操作系统中有限缓冲区的基础 — 固定大小的环形缓冲区。在移动应用中,此模式用于处理图像、视频文件和分析事件的队列。
信号量成功地用于后台服务中的网络调用节流。例如,分析应用程序向服务器发送事件包。在没有限制并发线程的情况下,在峰值负载(应用程序启动、离线后同步)时,并发请求的数量可能超过服务器限制。初始值为 3 的信号量保证平稳发送并防止服务器端阻塞。
常见问题
信号量不仅仅是计数器,而是具有原子操作和等待队列的同步原语。普通计数器不会阻塞线程,也不会在多个线程并发访问时保证递增的原子性。
是的,死锁可能发生在不同线程以不同顺序获取多个信号量时。例如,线程 A 获取 S1,然后 S2,而线程 B 获取 S2,然后 S1。为项目中的所有信号量建立统一的获取顺序。
线程被阻塞并进入等待状态。它不消耗处理器时间,直到另一个线程调用 release。在 Java 中这是 BLOCKED 状态,在 Swift 中线程被 GCD 挂起。
主要区别 — 所有权。Mutex 只能由所有者线程释放。Binary Semaphore 可以由任何线程释放,这对于线程间信号通知很方便,但对于保护数据完整性则不太安全。
初始值取决于场景。保护一个资源 — 1。对于 N 个连接的池 — N。对于线程间信号通知,使用 0,以便消费者线程等待来自生产者的信号。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。