English
A concurrency safety issue made me re-understand Go's function stack frames.
Starting from a piece of code that looks like it has concurrency issues.
I. Introduction to the Problem
That day I was writing an API for batch updating member roles:
go
func (api *member) BatchUpdateMemberRoles(ctx *gin.Context) {
// ... parameter binding ...
updates := make(map[uuid.UUID]types.AccountMemberRole)
for _, update := range params.Updates {
memberId, _ := uuidutil.FromCompact(update.MemberId)
updates[memberId] = update.NewRole
}
err := api.service.Member().BatchUpdateMemberRoles(ctx, accountId, info.Id, updates)
// ...
}The code is written, but a thought suddenly popped into my mind:
map It is not concurrency-safe; if multiple requests operate on this interface at the same time, will there be problems?
This question made me pause for a moment. I decided to stop and figure it out thoroughly.
II. Intuitive judgment (which now appears to be wrong)
At the time, my thought was:
mapIn Go, it is indeed not concurrency-safe.Multiple requests will execute this function simultaneously.
Every request will create
updatesthis mapSeems... a bit dangerous?
But on closer thought: if the map for each request is independent, then they shouldn't affect each other, right?
My knowledge has a gap here.
3. Draw a diagram, and the truth will be revealed.
What is a function stack frame?
Every time a function is called, Go allocates a block of memory on the stack, called stack frame Local variables are stored here.
┌─────────────────────────────────────────────────────────────┐
│ Goroutine 1 (Request 1) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Stack frame: BatchUpdateMemberRoles │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ updates (local variable) → points to map A on the heap │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Goroutine 2 (Request 2) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Stack frame: BatchUpdateMemberRoles │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ updates (local variable) → points to map B on the heap │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘Key points:
Each request runs in an independent Goroutine
Each Goroutine has its own independent stack space.
Each function call creates an independent stack frame.
local variable
updatesIn this stack frame
Conclusion: Request 1's updates and request 2's updates, are two completely independent variables, and they point to the heap two different map objects.
IV. The Truth Revealed
go
// ✅ This code is concurrency-safe!
func (api *member) BatchUpdateMemberRoles(ctx *gin.Context) {
// Each request creates its own independent updates
updates := make(map[uuid.UUID]types.AccountMemberRole)
// ...
}Reason: The essence of concurrency safety is Shared data the problem. Here is local variable, not shared, so safe.
V. Under what circumstances is it really unsafe?
Scenario 1: Global variables
go
var globalCache = make(map[string]interface{}) // Global, shared by all requests
func GetCache(key string) interface{} {
return globalCache[key] // ❌ Unsafe! Locking is required
}Scenario 2: Closure capture
go
func Counter() func() int {
count := 0 // shared by multiple calls
return func() int {
count++ // ❌ Unsafe! When called by multiple goroutines
return count
}
}Scenario 3: Sharing after pointer escape
go
var shared *map[string]string
func CreateMap() {
m := make(map[string]string)
shared = &m // ❌ Escaped to the heap and became shared state
}VI. Extended Thinking: Why Are Local Variables Naturally Concurrency-Safe?
| Characteristics | Description |
|---|---|
| Stack isolation | Each goroutine has its own independent stack space. |
| Call isolation | Each function call creates a new stack frame. |
| Lifecycle | The stack frame is automatically reclaimed when the function returns. |
| No sharing | Local variables do not cross call boundaries (unless pointers are explicitly passed). |
That's why we can safely write in the handler:
go
func HandleRequest(ctx *gin.Context) {
var localVar int // Safety
localSlice := []int{} // Safety
localMap := make(map...) // Safety
// These are all "private".
}VII. Gains and Reflections
Core knowledge points
function stack frame: Each function call allocates an independent memory area on the stack.
local variable: Stored in the stack frame, naturally isolated
Concurrency safety ≠ variable type safety: The key is whether the data Sharing
map is unsafe: refers to multiple goroutines reading and writing at the same time the same one map object
A simple criterion
Will this variable be accessed concurrently by multiple goroutines?
Local variables → usually not → safe
Global variable → will → needs synchronization
Pointer escape followed by sharing → will → require synchronization
VIII. Closing Remarks
This review made me realize:
Basic knowledge is the foundation.: Stacks, heaps, frames, and escape analysis—these seemingly low-level concepts are actually the key to understanding concurrency problems.
Don't judge problems based on feelings.: When in doubt, draw the memory layout, and the truth will naturally emerge.
Every question is an opportunity to learn.: Stopping to ask 'Is it really safe?' at that moment was the starting point of this takeaway.
Filling a knowledge gap is a cognitive upgrade.
I hope this blog post can help you, who are as confused as I once was.