Semaphore Principle

Semaphore

A Semaphore controls access to a shared resource by maintaining a set of permits. Threads acquire permits before accessing the resource and release them when done. Built on AQS shared mode.

Document Structure:

  • [Basic Usage](#Basic Usage) / [AQS State](#AQS State) --- Overview
  • [acquire() Deep Dive](#acquire() Deep Dive) --- Call flow, tryAcquireShared, fair vs unfair, enqueue, park, timeout, interrupt
  • [release() Deep Dive](#release() Deep Dive) --- Call flow, tryReleaseShared, wake-up engine, PROPAGATE cascade
  • [Shared AQS Internals](#Shared AQS Internals) --- The PROPAGATE(-3) race, propagate==0 problem
  • [Behavior & Patterns](#Behavior & Patterns) --- No ownership, common patterns, comparison
  • FAQ --- Common questions answered

Basic Usage

java 复制代码
Semaphore semaphore = new Semaphore(3);  // 3 permits

semaphore.acquire();     // blocks if no permits available
try {
    accessResource();
} finally {
    semaphore.release();  // return permit
}

// Non-blocking (no wait, no enqueue)
if (semaphore.tryAcquire()) {
    try { accessResource(); }
    finally { semaphore.release(); }
} else {
    handleNoPermit();
}

// Timed
if (semaphore.tryAcquire(5, TimeUnit.SECONDS)) {
    try { accessResource(); }
    finally { semaphore.release(); }
}

// Acquire multiple permits at once
semaphore.acquire(2);    // need 2 permits
try { accessHeavyResource(); }
finally { semaphore.release(2); }

AQS State = Available Permits

复制代码
Semaphore(3):
  state = 3  -> 3 permits available

  acquire():  CAS(state, 3, 2) -> success, 2 permits left
  acquire():  CAS(state, 2, 1) -> success, 1 permit left
  acquire():  CAS(state, 1, 0) -> success, 0 permits left
  acquire():  state == 0 -> enqueue -> park (wait for release)

  release():  CAS(state, 0, 1) -> 1 permit -> unpark waiting thread

Key difference from CountDownLatch: Semaphore's state goes both directions (acquire decrements, release increments). CountDownLatch only decrements and never resets.


acquire() Deep Dive

Everything related to acquire(): the call flow, fair vs unfair tryAcquireShared, enqueue, parking, timeout, and interrupt handling.

acquire() Call Flow

复制代码
acquire()
  -> sync.acquireSharedInterruptibly(1)
    -> tryAcquireShared(1)              <- TRY TO GRAB PERMIT (non-blocking CAS)
       remaining >= 0 -> success (got permit)
       remaining < 0  -> fail (not enough permits)
    -> doAcquireSharedInterruptibly(1)  <- only entered if no permits
       -> addWaiter(SHARED)            <- enqueue node into CLH queue
       -> spin loop {
           -> tryAcquireShared(1)      <- RE-CHECK permits after each wake-up
           -> shouldParkAfterFailedAcquire()
           -> parkAndCheckInterrupt()  <- ACTUAL WAIT
         }
       -> setHeadAndPropagate()        <- on success: promote to head, maybe wake next
    -> acquire() returns
  -> accessResource()

Same AQS machinery as CountDownLatch's await(). The difference is in tryAcquireShared --- Semaphore actually consumes a permit (CAS decrement), while CountDownLatch just checks if state == 0.

acquire() Source Code

java 复制代码
// Semaphore.acquire()
public void acquire() throws InterruptedException {
    sync.acquireSharedInterruptibly(1);
}

// AQS.acquireSharedInterruptibly() --- same as CountDownLatch
public final void acquireSharedInterruptibly(int arg) {
    if (Thread.interrupted()) throw new InterruptedException();
    if (tryAcquireShared(arg) < 0)
        doAcquireSharedInterruptibly(arg);
}

tryAcquireShared --- Fair vs Unfair

Semaphore has two implementations. The default is unfair.

Unfair (default):

java 复制代码
int nonfairTryAcquireShared(int acquires) {
    for (;;) {
        int available = getState();
        int remaining = available - acquires;
        if (remaining < 0)
            return remaining;                    // not enough permits -> fail
        if (CAS(state, available, remaining))
            return remaining;                    // success -> remaining permits
    }
}

Fair:

java 复制代码
int tryAcquireShared(int acquires) {
    for (;;) {
        if (hasQueuedPredecessors())
            return -1;                           // someone waiting -> don't barge
        int available = getState();
        int remaining = available - acquires;
        if (remaining < 0)
            return remaining;
        if (CAS(state, available, remaining))
            return remaining;
    }
}

The only difference: fair version calls hasQueuedPredecessors() first. If there are threads already waiting in the queue, the incoming thread yields and enqueues behind them instead of grabbing the permit.

Return value semantics --- critical difference from CountDownLatch:

Return value Meaning What happens next
Positive (e.g., 2) Acquired, permits remain for others setHeadAndPropagate -> propagate (wake next)
Zero (0) Acquired, but NO permits left setHeadAndPropagate -> propagate ONLY if PROPAGATE breadcrumb exists
Negative (e.g., -1) Not enough permits Enqueue and park

The return 0 case is where PROPAGATE matters. CountDownLatch always returns 1 or -1, never 0. Semaphore can return 0 when the last permit is taken.

hasQueuedPredecessors() --- Fair Mode Gate

java 复制代码
public final boolean hasQueuedPredecessors() {
    Node t = tail;
    Node h = head;
    Node s;
    return h != t &&
        ((s = h.next) == null || s.thread != Thread.currentThread());
}

Returns true if there are threads queued before us. The check:

  • h != t --- queue is not empty
  • s == null --- a node is mid-enqueue (next link not set yet) -> assume someone is waiting
  • s.thread != currentThread --- head's successor is a different thread -> someone is ahead of us

If s.thread == currentThread, we ARE the head's successor (reentrant acquire or woke up and re-trying) -> don't block ourselves.

Timed acquire --- tryAcquire(timeout, unit)

java 复制代码
// Semaphore.tryAcquire(timeout, unit)
public boolean tryAcquire(long timeout, TimeUnit unit) throws InterruptedException {
    return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}

// AQS.tryAcquireSharedNanos
public final boolean tryAcquireSharedNanos(int arg, long nanosTimeout) {
    if (Thread.interrupted()) throw new InterruptedException();
    return tryAcquireShared(arg) >= 0 ||           // fast path: permit available
           doAcquireSharedNanos(arg, nanosTimeout); // slow path: wait with timeout
}

doAcquireSharedNanos is the timed version of doAcquireSharedInterruptibly:

java 复制代码
private boolean doAcquireSharedNanos(int arg, long nanosTimeout) {
    if (nanosTimeout <= 0L) return false;
    final long deadline = System.nanoTime() + nanosTimeout;
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        for (;;) {
            final Node p = node.predecessor();
            if (p == head) {
                int r = tryAcquireShared(arg);
                if (r >= 0) {
                    setHeadAndPropagate(node, r);
                    p.next = null;
                    failed = false;
                    return true;                          // <- SUCCESS
                }
            }
            nanosTimeout = deadline - System.nanoTime();
            if (nanosTimeout <= 0L)
                return false;                              // <- TIMEOUT: return false
            if (shouldParkAfterFailedAcquire(p, node) &&
                nanosTimeout > spinForTimeoutThreshold)
                LockSupport.parkNanos(this, nanosTimeout); // <- timed park
            if (Thread.interrupted())
                throw new InterruptedException();          // <- INTERRUPT: throw
        }
    } finally {
        if (failed) cancelAcquire(node);                   // <- cleanup on timeout or interrupt
    }
}

Three exit paths:

Exit How Return / Throw cancelAcquire called?
Success tryAcquireShared >= 0 return true No (failed = false)
Timeout nanosTimeout <= 0 return false Yes
Interrupt Thread.interrupted() throw InterruptedException Yes

spinForTimeoutThreshold: If remaining time is very short (< 1000ns), the thread spin-waits instead of parking. Parking has OS overhead (~1-10us), so for sub-microsecond waits, spinning is cheaper.

Interrupt Handling

Same as CountDownLatch --- acquire() uses acquireSharedInterruptibly, which responds to interrupts:

java 复制代码
semaphore.acquire();  // throws InterruptedException if interrupted while waiting

If interrupted while parked:

  1. parkAndCheckInterrupt() returns true
  2. doAcquireSharedInterruptibly throws InterruptedException
  3. cancelAcquire(node) removes the node from the queue
  4. The permit count is unaffected --- the thread never acquired a permit

release() Deep Dive

Everything related to release(): the CAS increment, wake-up engine, and propagation cascade.

release() Call Flow

复制代码
release()
  -> sync.releaseShared(1)
    -> tryReleaseShared(1)              <- CAS INCREMENT (non-blocking)
       CAS(state, current, current+1)
       -> always returns true
    -> doReleaseShared()                <- WAKE first waiter (always called)
       -> unparkSuccessor(head)         <- unpark head's successor
    -> release() returns

Key difference from CountDownLatch's countDown(): tryReleaseShared always returns true for Semaphore (every release adds a permit). CountDownLatch's tryReleaseShared only returns true on the 1->0 transition.

tryReleaseShared --- The CAS Increment

java 复制代码
boolean tryReleaseShared(int releases) {
    for (;;) {
        int current = getState();
        int next = current + releases;
        if (next < current) throw new Error("Maximum permit count exceeded");  // overflow
        if (CAS(state, current, next))
            return true;  // always true -> AQS always calls doReleaseShared()
    }
}
  • Always returns true --- every release() triggers doReleaseShared()
  • The overflow check (next < current) catches integer overflow when permits exceed Integer.MAX_VALUE
  • CAS loop handles concurrent releases safely

doReleaseShared() --- The Wake-Up Engine

Same code as CountDownLatch (shared AQS machinery). See count-down-latch.md for the full deep dive on doReleaseShared, h == head exit condition, and concurrent callers.

java 复制代码
private void doReleaseShared() {
    for (;;) {
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            if (ws == Node.SIGNAL) {
                if (!CAS(h.waitStatus, SIGNAL, 0))
                    continue;
                unparkSuccessor(h);              // wake next thread
            }
            else if (ws == 0 &&
                     !CAS(h.waitStatus, 0, PROPAGATE))
                continue;                        // set PROPAGATE breadcrumb
        }
        if (h == head) break;
    }
}

Important : doReleaseShared() only unparks one thread per invocation. The "wake multiple" effect is a cascade: each woken thread calls setHeadAndPropagate -> doReleaseShared(), which unparks the next one. See the cascade walkthrough below.

The Propagation Cascade

When permits are released, the cascade wakes threads one by one. The key: propagation continues as long as propagate > 0 (permits remain) or the PROPAGATE breadcrumb is set.

复制代码
Semaphore(0), 3 threads waiting, then release(3):

Initial:
  state = 0
  Queue: head -> [T-1 SIGNAL] -> [T-2 SIGNAL] -> [T-3, 0] <- tail

release(3): CAS(state, 0, 3) -> state = 3
  -> doReleaseShared() -> unpark T-1

T-1 wakes:
  tryAcquireShared(1) -> remaining = 2 (positive!)
  -> setHeadAndPropagate(node, propagate=2)
    -> propagate > 0 -> doReleaseShared() -> unpark T-2

T-2 wakes:
  tryAcquireShared(1) -> remaining = 1 (positive!)
  -> setHeadAndPropagate(node, propagate=1)
    -> propagate > 0 -> doReleaseShared() -> unpark T-3

T-3 wakes:
  tryAcquireShared(1) -> remaining = 0 (zero!)
  -> setHeadAndPropagate(node, propagate=0)
    -> propagate == 0 -> condition (a) fails
    -> check h.waitStatus < 0 -> if PROPAGATE breadcrumb exists, still propagate
    -> no more waiters -> doReleaseShared() finds head == tail -> done

All 3 threads acquired permits
Queue: head(nodeT3) <- tail (empty)
state = 0

The propagate == 0 case is where Semaphore differs from CountDownLatch. CountDownLatch always returns 1 (propagate > 0), so condition (a) always fires. Semaphore can return 0, requiring the PROPAGATE fallback.

setHeadAndPropagate --- Three Paths in Semaphore

Same code as CountDownLatch, but Semaphore exercises all three paths:

java 复制代码
private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head;           // save old head
    setHead(node);           // promote us to head
    if (propagate > 0        // (a) permits remain
        || h == null         // (b) defensive
        || h.waitStatus < 0  // (c) old head has PROPAGATE or SIGNAL
        || (h = head) == null // (d) defensive re-read
        || h.waitStatus < 0) // (e) new head has PROPAGATE or SIGNAL
    {
        Node s = node.next;
        if (s == null || s.isShared())
            doReleaseShared();
    }
}

Path 1: propagate > 0 --- permits remain (e.g., release(3), first two acquirers)

复制代码
tryAcquireShared(1) -> remaining = 2 -> propagate = 2
  propagate > 0 -> condition (a) fires -> doReleaseShared() -> unpark next

Straightforward. Same as CountDownLatch (which always takes this path).

Path 2: propagate == 0, PROPAGATE breadcrumb exists --- last permit taken, concurrent release happened

复制代码
tryAcquireShared(1) -> remaining = 0 -> propagate = 0
  propagate > 0? NO
  h.waitStatus < 0? -> old head has PROPAGATE(-3) -> YES -> doReleaseShared() -> unpark next

This is the PROPAGATE race scenario. A concurrent release() set PROPAGATE on the old head. Without this check, the release signal would be lost.

Path 3: propagate == 0, no breadcrumb --- last permit taken, no concurrent release

复制代码
tryAcquireShared(1) -> remaining = 0 -> propagate = 0
  propagate > 0? NO
  h.waitStatus < 0? -> old head ws == 0 -> NO
  h.waitStatus < 0? -> new head ws == 0 -> NO
  -> ALL conditions fail -> skip doReleaseShared() -> no propagation

Correct --- no permits left, no concurrent release, don't wake anyone.

Decision table:

propagate Old head ws New head ws Propagate? Why
> 0 any any Yes Permits remain
0 PROPAGATE(-3) any Yes Concurrent release detected via old head
0 0 PROPAGATE(-3) Yes Concurrent release detected via new head
0 >= 0 >= 0 No No permits, no concurrent release

PROPAGATE Behavior: CountDownLatch vs Semaphore

The code is identical (shared AQS). The behavior differs because of tryAcquireShared return values:

CountDownLatch Semaphore
PROPAGATE set by doReleaseShared? Yes (same code) Yes (same code)
propagate value Always 1 Can be 0
Condition (a) fires? Always Not when propagate == 0
PROPAGATE breadcrumb consumed? Never (condition a short-circuits) Yes (conditions c/e catch it)
Lost wake-up possible without PROPAGATE? No Yes

Shared AQS Internals

The PROPAGATE (-3) Race --- Full Scenario

This is the race we deferred from count-down-latch.md. It only manifests when tryAcquireShared returns 0.

Setup: Semaphore(1), T-1 and T-2 waiting, two releasers T-A and T-B.

复制代码
Timeline:

1. T-A: release() -> state 0->1 -> doReleaseShared()
   head.ws == SIGNAL -> CAS(SIGNAL, 0) -> unparkSuccessor -> unpark T-1

2. T-1 wakes: tryAcquireShared(1) -> state 1->0, return 0 (propagate=0)
   (T-1 took the LAST permit --- propagate is 0, meaning "no more permits")

3. T-B: release() -> state 0->1 -> doReleaseShared()
   head.ws == 0 (T-A already cleared it in step 1)
   -> CAS(0, PROPAGATE) <- leaves breadcrumb
   -> h == head -> break -> return
   (T-B does NOT unpark anyone --- ws was 0, not SIGNAL)

4. T-1 (still in setHeadAndPropagate):
   h = head (old head, which now has ws = PROPAGATE = -3)
   setHead(nodeT1)
   propagate == 0 -> condition (a) FAILS
   h.waitStatus == -3 < 0 -> condition (c) FIRES!
   -> doReleaseShared() -> head.ws == SIGNAL (set by T-2 during enqueue)
   -> CAS(SIGNAL, 0) -> unparkSuccessor -> unpark T-2

5. T-2 wakes: tryAcquireShared(1) -> state 1->0, return 0 -> success!

Without PROPAGATE : Step 3 would do nothing (ws0, no breadcrumb). Step 4: T-1 sees propagate0, all conditions fail, stops. T-2 stays parked forever even though T-B released a permit. Lost wake-up bug.

The timing window:

复制代码
T-A's doReleaseShared()          T-1 (waking up)              T-B's doReleaseShared()
-----                            -----                         -----
CAS(SIGNAL -> 0)
unparkSuccessor(T-1)
h == head -> break
                                 tryAcquireShared -> 0
                                 (about to setHeadAndPropagate)
                                                               head.ws == 0
                                                               CAS(0 -> PROPAGATE)
                                                               h == head -> break
                                 setHeadAndPropagate:
                                   h.ws == PROPAGATE < 0
                                   -> condition (c) fires!
                                   -> doReleaseShared()
                                   -> unpark T-2

PROPAGATE Lifecycle Summary

Step Who What Why
Set doReleaseShared() CAS(head.ws, 0, PROPAGATE) "A release happened but successor was already unparked"
Read setHeadAndPropagate() h.waitStatus < 0 check "Did a release happen while I was acquiring?"
Consumed setHeadAndPropagate() Calls doReleaseShared() "Yes --- keep propagating to avoid lost wake-up"

Behavior & Patterns

No Ownership

Unlike ReentrantLock, Semaphore has no concept of ownership. Any thread can release a permit, even if it didn't acquire one:

java 复制代码
Semaphore sem = new Semaphore(0);
// Thread-A:
sem.release();  // permits = 1 (Thread-A never acquired!)
// Thread-B:
sem.acquire();  // succeeds --- gets the permit Thread-A created

This is by design --- Semaphore is a counter, not a lock. But it means permits can leak if you forget to release, or over-release:

java 复制代码
// BUG: release without acquire -> permits grow unbounded
for (int i = 0; i < 1000; i++) {
    sem.release();  // oops, should have been acquire + release
}

Fair vs Unfair --- When to Use

java 复制代码
Semaphore unfair = new Semaphore(3);         // default: unfair
Semaphore fair   = new Semaphore(3, true);   // fair: FIFO ordering
Unfair (default) Fair
Behavior Incoming thread can grab permit even if others are queued Strictly FIFO --- new arrivals queue behind waiting threads
Throughput Higher (less context switching) Lower (always enqueues)
Starvation Possible (unlucky threads keep losing to newcomers) Impossible (guaranteed ordering)
Use when Performance matters, starvation is acceptable Fairness required, latency SLA per request

Common Patterns

Connection Pool / Rate Limiting
java 复制代码
class ConnectionPool {
    private final Semaphore semaphore;
    private final Queue<Connection> pool;

    ConnectionPool(int maxConnections) {
        semaphore = new Semaphore(maxConnections);
        pool = new ConcurrentLinkedQueue<>();
        for (int i = 0; i < maxConnections; i++)
            pool.add(createConnection());
    }

    Connection acquire() throws InterruptedException {
        semaphore.acquire();           // block if all connections in use
        return pool.poll();
    }

    void release(Connection conn) {
        pool.offer(conn);
        semaphore.release();           // allow another thread in
    }
}
Bounded Resource Access
java 复制代码
// Allow max 5 concurrent API calls
Semaphore apiThrottle = new Semaphore(5);

void callApi() throws InterruptedException {
    apiThrottle.acquire();
    try {
        httpClient.call(endpoint);
    } finally {
        apiThrottle.release();
    }
}
Binary Semaphore (Mutex)
java 复制代码
Semaphore mutex = new Semaphore(1);  // acts like a lock (but no ownership/reentrancy)
mutex.acquire();
try { criticalSection(); }
finally { mutex.release(); }

Semaphore vs CountDownLatch vs ReentrantLock

Feature Semaphore CountDownLatch ReentrantLock
Permits/count N (configurable) N (one-shot) 1 (exclusive)
Direction Both (acquire/release) Down only (countDown) Lock/unlock
Reusable Yes No Yes
Ownership No No Yes (thread-bound)
Reentrant No N/A Yes
AQS mode Shared Shared Exclusive
tryAcquireShared returns 0? Yes (last permit) Never (always 1 or -1) N/A (exclusive)
PROPAGATE needed? Yes (critical) No (propagate always > 0) N/A
Use case Rate limiting, pools Wait for N events Mutual exclusion

FAQ

Q: What happens if I release more than I acquire?

Permits grow beyond the initial count. Semaphore doesn't track ownership, so it can't prevent this:

java 复制代码
Semaphore sem = new Semaphore(3);
sem.release();  // permits = 4 (no acquire first!)
sem.release();  // permits = 5

This is a bug in your code, not a Semaphore feature. Always pair acquire/release in try/finally.

Q: Can acquire(2) succeed if only 1 permit is available?

No. tryAcquireShared(2) computes remaining = available - 2. If available == 1, remaining == -1 < 0, so it returns -1 (fail). The thread enqueues and waits until 2 permits are available.

Q: Does release() always wake a thread?

release() always calls doReleaseShared(), but it only unparks a thread if head.waitStatus == SIGNAL. If no threads are waiting (head == tail), the permit is just added to the count for future acquirers.

Q: Why does unfair Semaphore have better throughput?

With unfair, an incoming thread can grab a permit without entering the queue. This avoids the overhead of: enqueue -> park -> context switch -> unpark -> dequeue. The thread that just released might immediately re-acquire on the same CPU core (cache-hot), which is much faster than waking a parked thread on a different core.

Q: How does timed tryAcquire differ from untimed acquire?

acquire() tryAcquire(timeout, unit)
AQS method doAcquireSharedInterruptibly doAcquireSharedNanos
Park call LockSupport.park(this) LockSupport.parkNanos(this, nanosTimeout)
On timeout N/A (waits forever) return false -> cancelAcquire
On interrupt throw InterruptedException throw InterruptedException
On success Returns (void) return true
Spin optimization No Yes (spin if < 1000ns remaining)

相关推荐
ZPC82102 小时前
自定义机械臂驱动(Action Server + /joint_states 发布)
算法
啊我不会诶2 小时前
牛客练习赛151
算法·深度优先·图论
我登哥MVP2 小时前
【SpringMVC笔记】 - 8 - 文件上传与下载
java·spring boot·spring·servlet·tomcat·maven
额呃呃2 小时前
Andriod项目番茄钟
java·开发语言
Ricardo-Yang2 小时前
# BPE Tokenizer:从训练规则到推理切分的完整理解
人工智能·深度学习·算法·机器学习·计算机视觉
梅孔立2 小时前
Java 基于 POI 模板 Excel 导出工具类 双数据源 + 自动合并单元格 + 自适应行高 完整实战
java·开发语言·excel
qyzm2 小时前
牛客周赛 Round 140
数据结构·python·算法
Severus_black2 小时前
顺序表、单链表经典算法题分享(未完待续...)
c语言·数据结构·算法·链表
我不是懒洋洋2 小时前
【经典题目】栈和队列面试题(括号匹配问题、用队列实现栈、设计循环队列、用栈实现队列)
c语言·开发语言·数据结构·算法·leetcode·链表·ecmascript