Locks¶
etcd-recipes ships three mutual-exclusion recipes:
| Recipe | Use it when |
|---|---|
DistributedMutex |
One holder at a time |
DistributedReadWriteLock |
Many readers, or one writer |
DistributedSemaphore |
At most N holders at a time |
All three extend EtcdConnector, so they share the lifecycle, exception, and
connection-state surface described in Core concepts.
The EtcdLock interface¶
DistributedMutex and the two views of DistributedReadWriteLock implement EtcdLock:
interface EtcdLock {
fun lock()
fun tryLock(timeout: Duration): Boolean
fun tryLock(timeout: Long, timeUnit: TimeUnit): Boolean
fun unlock(): Boolean
val isHeldByCurrentThread: Boolean
val isLocked: Boolean
val holdCount: Int
fun addLockLostListener(listener: LockLostListener)
fun removeLockLostListener(listener: LockLostListener)
}
Why not java.util.concurrent.locks.Lock?
EtcdLock deliberately does not implement Lock. Three of Lock's promises
cannot be honoured over a network: newCondition() has no distributed meaning,
a no-arg tryLock() cannot be answered without a round trip, and — most
importantly — Lock has no vocabulary for losing a lock you already hold.
A distributed lock can evaporate underneath you when its lease expires. Pretending
otherwise by implementing Lock would make that failure invisible.
DistributedMutex¶
Built on etcd's native lock service, so ordering is server-side FIFO by revision.
withLock { } is an inline Kotlin extension. It does compile to a static
EtcdLockKt.withLock(lock, Function0) that Java can call, but a Java lambda would have
to return Unit.INSTANCE, so it buys nothing — Java callers take the lock and release it
in a finally. See the Java guide.
Acquiring with a timeout¶
Blocking forever is rarely what a service wants. tryLock bounds the wait:
try (DistributedMutex mutex = new DistributedMutex(client, "/locks/orders")) {
// Java uses the (long, TimeUnit) overload; the Duration one is Kotlin-facing.
if (mutex.tryLock(5, TimeUnit.SECONDS)) {
try {
System.out.println("Acquired within the timeout");
} finally {
mutex.unlock();
}
} else {
System.out.println("Someone else holds the lock; moving on");
}
}
Kotlin takes Duration, Java takes (long, TimeUnit)
Both overloads exist on every lock. The kotlin.time.Duration one reads better
from Kotlin; the (long, TimeUnit) one is the one to use from Java.
Losing the lock¶
This is the part that has no single-JVM analogue, and it is the part worth reading twice.
A mutex hold is backed by a lease. If the process stalls or the network partitions long enough for that lease to expire, etcd has already handed the lock to the next waiter. The recipe does not try to heal that lease or reclaim the lock: doing so would race the new holder and defeat the entire point of the lock.
Instead, loss is cooperative. You are told, and you decide:
DistributedMutex(client, "/locks/orders").use { mutex ->
// The acquisition lease is deliberately never healed. If it expires, etcd has
// already promoted the next waiter, so the hold is gone and cannot be reclaimed.
mutex.addLockLostListener { cause ->
logger.warn { "Lost the lock, abandoning work: $cause" }
}
mutex.withLock {
// unlock() returns false if the hold was lost while inside the section.
logger.info { "Working" }
}
}
try (DistributedMutex mutex = new DistributedMutex(client, "/locks/orders")) {
// The acquisition lease is deliberately never healed. If it expires, etcd has
// already promoted the next waiter, so the hold is gone and cannot be reclaimed.
mutex.addLockLostListener(cause ->
System.out.println("Lost the lock, abandoning work: " + cause));
mutex.lock();
try {
System.out.println("Working");
} finally {
// Returns false if the hold was lost while inside the section.
mutex.unlock();
}
}
Three things happen on loss:
- every registered
LockLostListenerfires, connectionStatemoves toLOST,- the eventual
unlock()returnsfalserather than throwing.
If you would rather the holding thread be interrupted the instant the lock is lost —
so it cannot keep mutating state it no longer owns — opt in with interruptOnLockLoss:
// Opt in to having the holding thread interrupted the moment the lock is lost,
// rather than letting it run on against state it no longer owns.
DistributedMutex(
client = client,
lockPath = "/locks/orders",
leaseTtlSecs = 5L,
interruptOnLockLoss = true,
).use { mutex ->
mutex.withLock {
logger.info { "Interrupted if the lease expires under us" }
}
}
// Opt in to having the holding thread interrupted the moment the lock is lost,
// rather than letting it run on against state it no longer owns. Java has no
// named arguments, so every preceding parameter must be supplied positionally.
try (DistributedMutex mutex =
new DistributedMutex(
client,
"/locks/orders",
5L, // leaseTtlSecs
io.etcd.recipes.common.ResilienceConfig.DEFAULT,
"worker-1", // clientId
true)) { // interruptOnLockLoss
mutex.lock();
try {
System.out.println("Interrupted if the lease expires under us");
} finally {
mutex.unlock();
}
}
It defaults to false because interrupting a thread that is midway through
non-idempotent work is not automatically safer than letting it finish and fail its
own commit. Choose deliberately.
Reentrancy¶
Holds are per-thread and reentrant, tracked by holdCount:
DistributedMutex(client, "/locks/orders").use { mutex ->
mutex.withLock {
mutex.withLock {
// Reentrant within the same thread; holdCount is now 2.
logger.info { "holdCount=${mutex.holdCount}" }
}
}
}
A thread that dies holding the lock does not release it
The hold is released when unlock() is called or the recipe is closed — not when
the acquiring thread dies. A thread that terminates inside the critical section
leaves the lock held until close() or lease expiry. This matches Curator's
behaviour. Always release in a finally (or use withLock).
DistributedReadWriteLock¶
Many concurrent readers, or exactly one writer. Fair: waiters are served FIFO by create revision, so a steady stream of readers cannot starve a waiting writer.
DistributedReadWriteLock(client, "/locks/catalog").use { rwLock ->
// Any number of readers may hold the read lock at once...
rwLock.readLock.withLock {
logger.info { "Reading the catalog" }
}
// ...but a writer excludes every reader and every other writer.
rwLock.writeLock.withLock {
logger.info { "Rewriting the catalog" }
}
}
try (DistributedReadWriteLock rwLock = new DistributedReadWriteLock(client, "/locks/catalog")) {
// Any number of readers may hold the read lock at once...
rwLock.getReadLock().lock();
try {
System.out.println("Reading the catalog");
} finally {
rwLock.getReadLock().unlock();
}
// ...but a writer excludes every reader and every other writer.
rwLock.getWriteLock().lock();
try {
System.out.println("Rewriting the catalog");
} finally {
rwLock.getWriteLock().unlock();
}
}
DistributedReadWriteLock does not itself implement EtcdLock — it exposes two
properties that do, readLock and writeLock. Everything from the mutex section
(timeouts, lock-lost listeners, reentrancy) applies to each view independently.
Downgrade works, upgrade throws¶
Taking the read lock while holding the write lock (downgrade) is safe and supported:
DistributedReadWriteLock(client, "/locks/catalog").use { rwLock ->
rwLock.writeLock.withLock {
logger.info { "Writing" }
// Downgrading write -> read is safe: take the read lock before releasing
// the write lock, and no other writer can slip in between.
rwLock.readLock.withLock {
logger.info { "Still holding the write lock while reading back" }
}
}
}
Taking the write lock while holding the read lock (upgrade) is not:
DistributedReadWriteLock(client, "/locks/catalog").use { rwLock ->
rwLock.readLock.withLock {
// DON'T: upgrading read -> write self-deadlocks, because the write lock
// waits on a predecessor this very thread is holding. The recipe throws
// rather than hanging forever.
// rwLock.writeLock.lock()
logger.info { "Release the read lock first, then take the write lock" }
}
}
Upgrade throws instead of hanging
A read→write upgrade would make the write lock wait on a predecessor that the
calling thread itself holds — a self-deadlock that no timeout can distinguish
from ordinary contention. Rather than hang, the recipe throws
EtcdRecipeRuntimeException. Release the read lock, then take the write lock,
and re-validate whatever you read: another writer may have run in between.
DistributedSemaphore¶
At most N holders across the cluster.
DistributedSemaphore(client, "/semaphores/api-quota", permits = 3).use { semaphore ->
if (semaphore.tryAcquire(2.seconds)) {
try {
logger.info { "Got a permit; ${semaphore.availablePermits()} left (advisory)" }
} finally {
semaphore.release()
}
} else {
logger.info { "All permits taken; shedding load" }
}
}
try (DistributedSemaphore semaphore = new DistributedSemaphore(client, "/semaphores/api-quota", 3)) {
if (semaphore.tryAcquire(2, TimeUnit.SECONDS)) {
try {
System.out.println("Got a permit; " + semaphore.availablePermits() + " left (advisory)");
} finally {
semaphore.release();
}
} else {
System.out.println("All permits taken; shedding load");
}
}
Semaphore holds are not lock holds¶
DistributedSemaphore deliberately does not implement EtcdLock, because its
holds follow java.util.concurrent.Semaphore's rules rather than a lock's:
EtcdLock |
DistributedSemaphore |
|
|---|---|---|
| Ownership | The acquiring thread | The instance |
| Who may release | Only the holder | Any thread |
| Reentrant | Yes | No |
| Release order | — | LIFO |
DistributedSemaphore(client, "/semaphores/api-quota", permits = 3).use { semaphore ->
// Holds are instance-level, not thread-owned: unlike EtcdLock, any thread may
// release a permit taken by another. Releases are LIFO.
semaphore.acquire()
semaphore.acquire()
logger.info { "Holding two permits" }
semaphore.release()
semaphore.release()
}
Permit loss mirrors lock loss — a listener, connectionState → LOST, release()
returning false, and an opt-in interruptOnPermitLoss:
DistributedSemaphore(client, "/semaphores/api-quota", permits = 3).use { semaphore ->
semaphore.addPermitLostListener { cause ->
logger.warn { "Permit lost, stop doing the guarded work: $cause" }
}
semaphore.withPermit { logger.info { "Working" } }
}
The permit count is fixed by the first writer¶
The canonical count is CAS-created at <semaphorePath>/permits by whichever instance
reaches the path first. An instance that later names the same path with a different
count throws rather than silently reconfiguring the semaphore under everyone else:
// The permit count is CAS-created at <path>/permits by whoever gets there first.
// A later instance naming the same path with a different count is a programming
// error, not a silent reconfiguration, so it throws.
try {
DistributedSemaphore(client, "/semaphores/api-quota", permits = 5).use { semaphore ->
semaphore.withPermit { logger.info { "Never reached if 3 was established first" } }
}
} catch (e: SemaphorePermitMismatchException) {
logger.error { "Requested ${e.requestedPermits}, but the path is fixed at ${e.canonicalPermits}" }
}
availablePermits() is advisory
So is isLocked on a lock. Both are true-at-some-recent-revision, and another
client may act between your read and your next line. Use them for logging and
dashboards; never branch on them to decide whether an acquire will succeed. Use
tryAcquire/tryLock for that — they are atomic.
Coroutines¶
Every lock has suspending twins that release the thread while waiting. Note the
asymmetry: withLock is scoped-only for locks (a lock is thread-owned, so the
suspending version pins a confined dispatcher and releases under NonCancellable),
while the semaphore also exposes split awaitAcquire/awaitRelease because its holds
are instance-level. See Coroutines.
Observability¶
With the Micrometer module wired in, locks report
etcd.lock.wait (tagged acquired/timeout) and etcd.lock.hold timers. Lock paths
never become tags — that would blow up cardinality. See Observability.