Why Does Go’s RWMutex Deadlock While Java’s Doesn’t? A Deep Dive
An in‑depth analysis of a deadlock caused by Go’s sync.RWMutex in a microservice, comparing its non‑reentrant read‑write lock behavior with Java’s ReentrantReadWriteLock, illustrating the root cause, code examples, and design rationale behind Go’s lock implementation.
Hello everyone, I’m XiaoLou. I recently encountered a bug where an online Go service deadlocked due to its read‑write lock, which turned out to be a new service with limited impact.
Fault Replay
A simple architecture: a Go server provides an HTTP interface, and a client calls it. After a period of normal operation, the client suddenly timed out on every request while the server showed no errors or request metrics, as if the requests never reached it.
Manual testing on the server confirmed it was stuck. Using pprof (similar to Java’s jstack) would have pinpointed the issue, but pprof was not enabled, so the code was modified to enable it and redeployed.
Two days later the problem reappeared; pprof revealed the goroutine was blocked in a function that determines whether a cluster or service is low‑traffic, based on configuration stored in a central config center.
The low‑traffic configuration defines a sync.RWMutex and a map scopesMap that holds services requiring gray release.
When the configuration changes, reset refreshes scopesMap under a write lock.
To check if a service is gray, a read lock is acquired to see if a rule exists.
Then another lock is taken to verify whether the service matches the rule.
The problem: the read lock was acquired twice, the second acquisition was unnecessary and caused the deadlock.
Why Does It Deadlock?
The first suspicion was the reentrancy of Go’s lock.
In Java, reentrant locks are common; a ReentrantLock allows the same thread to acquire the lock multiple times.
Go’s locks, however, are not reentrant:
Although you can implement a non‑reentrant lock in Java, most Java code uses reentrant locks for convenience.
Why doesn’t Go provide a reentrant lock? The designers consider it a bad design choice.
Regarding sync.RWMutex, its read lock is shared (multiple readers can hold it), while write locks are exclusive.
Read‑vs‑read: not mutually exclusive
Read‑vs‑write / write‑vs‑write: mutually exclusive
Because reads are not exclusive, one might think the read lock is effectively reentrant. A demo confirms that a goroutine can acquire the read lock twice, but if a write lock is pending, the second read acquisition will block, leading to deadlock.
The code shows that when a read lock is held, another goroutine trying to acquire a write lock will block; a subsequent read lock request from the first goroutine then waits for the pending write lock, creating a deadlock.
In Java, ReentrantReadWriteLock allows lock downgrade (write → read) but not upgrade (read → write). A thread holding a read lock can reacquire the read lock, avoiding the deadlock seen in Go.
Java’s lock supports downgrade but not upgrade; a thread with a write lock can obtain a read lock, but a thread with a read lock cannot obtain a write lock.
Java provides fair and non‑fair modes; in fair mode, lock acquisition respects the waiting queue, while in non‑fair mode, write locks can preempt, and read locks may yield if a writer is queued.
Thus, the inconsistency between Go and Java read‑write lock implementations caused the bug.
Is This Reasonable?
Conceptually, if a goroutine holds a read lock, other goroutines needing a write lock must wait. But why should the same goroutine, when reacquiring the read lock, be forced to wait for a pending writer?
Imagine a patient in a doctor's office who locks the door; no one else can enter until the patient leaves. Go’s implementation makes the patient peek outside for waiting people and then wait, causing a deadlock.
Why Did Go Implement It This Way?
An issue on GitHub (https://github.com/golang/go/issues/30657) discusses the same problem. The Go team explains that Go’s lock does not track goroutine identity; it only knows the order of calls, so it cannot distinguish re‑entrancy.
If a goroutine holds a read lock, another goroutine may call Lock for a write lock; then no goroutine can acquire a read lock until the first read lock is released. This prevents recursive read locking and ensures the lock eventually becomes available.
The Go source comment reinforces this design decision.
Conclusion
This deadlock pitfall is easy to encounter, especially for Java developers writing Go code. Understanding Go’s lock design and avoiding double read‑lock acquisition prevents such issues.
Go’s designers consider reentrant locks a bad design and deliberately omitted them, which explains why the behavior may look like a bug but has a rationale.
What are your thoughts on Go’s read‑write lock implementation?
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Xiao Lou's Tech Notes
Backend technology sharing, architecture design, performance optimization, source code reading, troubleshooting, and pitfall practices
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
