Locks are where language ergonomics diverge the most sharply of any topic in this manual: Java and Kotlin inherit a rich java.util.concurrent.locks toolkit, Go has exactly one deliberately minimal sync.Mutex, Python's Lock exists mostly to protect compound operations the GIL doesn't cover, and JavaScript has no lock primitive at all for the common case because there's usually only one thread to contend with.
Language Verdict: Pros, Cons & Recommendation
- Runtime detects the fully-deadlocked case automatically and crashes with a full stack dump
- sync.RWMutex built into the standard library, simple two-method API
- defer mu.Unlock() makes the release-on-panic idiom effectively foolproof
- sync.Mutex is NOT reentrant -- and Go deliberately provides no reentrant alternative at all
- TryLock has no timeout parameter; bounded waits must be hand-built
- Richest lock toolkit of the five: synchronized, ReentrantLock, ReadWriteLock, StampedLock
- Reentrant by default -- the safer failure mode for nested locking
- tryLock with timeout built in
- No proactive deadlock detection -- you find out via a thread dump after the fact
- unlock() in a finally block is manual, easy to forget compared to synchronized
- Mutex is coroutine-native -- suspends instead of blocking a carrier thread
- Full interop with Java's lock toolkit when you need reader/writer or StampedLock
- Mutex is NOT reentrant, a real gotcha for people used to synchronized/ReentrantLock
- No coroutine-native reader/writer lock -- must fall back to blocking Java locks
- acquire(timeout=...) uniformly covers immediate/bounded/blocking modes
- Explicit Lock vs. RLock choice makes reentrancy an intentional decision
- No reader/writer lock in the standard library at all
- GIL means locks mostly protect compound operations, not raw throughput -- easy to reach for one you don't actually need
- No lock needed at all for the overwhelmingly common single-threaded case -- one less thing to get wrong
- Atomics.wait/notify exist for the rare SharedArrayBuffer/worker case
- Zero built-in coordination primitives once you do have real threads (workers) -- everything is hand-rolled on Atomics
- No deadlock detection or diagnosis tooling comparable to jstack/pprof/py-spy for the async-hang equivalent
Recommendation: Default to the language's built-in lock (synchronized/ReentrantLock, Mutex, sync.Mutex, Lock/RLock) and always release it in a finally/defer/with/try-finally-equivalent; only Go gives you a safety net (deadlock crash-and-dump) if you get lock ordering wrong, so discipline about consistent lock-acquisition order matters most in Java, Kotlin, and Python.
Concurrency Mechanics, Side by Side
var mu sync.Mutex
func withdraw(amount int) {
mu.Lock()
defer mu.Unlock() // idiomatic -- runs even on panic
balance -= amount
}
sync.Mutex is Go's one and only basic lock type, with exactly two methods worth knowing day-to-day: Lock() and Unlock(). The idiom is always defer mu.Unlock() immediately after Lock() so the unlock runs even if the function panics -- functionally identical in intent to Java's try/finally, just expressed with Go's defer keyword instead.
This is a classic interview trap because the 'wrong' answer for a given language is a guaranteed deadlock, not a warning.
var mu sync.Mutex
func outer() {
mu.Lock()
defer mu.Unlock()
inner() // DEADLOCKS -- sync.Mutex is NOT reentrant
}
func inner() {
mu.Lock() // blocks forever, mu is already held by this same goroutine
defer mu.Unlock()
}
sync.Mutex has no concept of 'owning goroutine' at all -- it's a pure counting-free binary lock, so a second Lock() call from the same goroutine blocks exactly as if a different goroutine held it, which means forever. The Go team's stance is explicit: reentrant locks tend to hide design problems (a function that needs to know it already holds a lock is arguably poorly factored), so Go does not provide one in the standard library.
var rw sync.RWMutex
rw.RLock() // many readers can hold this simultaneously
defer rw.RUnlock()
value := cache[key]
rw.Lock() // exclusive
defer rw.Unlock()
cache[key] = value
sync.RWMutex is built into the standard library with the same RLock/Lock split as Java's ReadWriteLock, and is idiomatic Go for read-heavy shared maps/caches guarded by a mutex (as opposed to sync.Map, which is a different, lock-free-ish concurrent map for a narrower set of access patterns).
// TryLock (Go 1.18+) -- immediate only, no timeout parameter:
if mu.TryLock() {
defer mu.Unlock()
} else {
// busy right now
}
// A bounded wait is built by hand with a timer + retry or a channel + select:
select {
case <-acquired:
case <-time.After(500 * time.Millisecond):
// timed out
}
TryLock() was only added in Go 1.18 and is immediate/non-blocking only -- there is no built-in timed variant. A genuinely bounded lock acquisition is usually expressed with a channel-based semaphore pattern and select with time.After instead of trying to force a timeout onto sync.Mutex directly, which is the more idiomatic Go shape for this need anyway.
func main() {
var mu sync.Mutex
mu.Lock()
mu.Lock() // no other goroutine can ever unlock this
}
// Output: fatal error: all goroutines are asleep - deadlock!
// (with a full goroutine dump of every stack, printed automatically)
This is a genuine Go differentiator: the runtime detects the specific case where every goroutine in the program is blocked and none can ever make progress, and crashes immediately with 'fatal error: all goroutines are asleep - deadlock!' plus a dump of every goroutine's stack -- turning a silent hang into an immediate, diagnosable crash. It does not detect deadlocks that involve, e.g., a goroutine blocked on network I/O that will eventually time out; only the true all-blocked case.