Skip to content

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:

  • map In Go, it is indeed not concurrency-safe.

  • Multiple requests will execute this function simultaneously.

  • Every request will create updates this map

  • Seems... 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 updates In 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? ​

CharacteristicsDescription
Stack isolationEach goroutine has its own independent stack space.
Call isolationEach function call creates a new stack frame.
LifecycleThe stack frame is automatically reclaimed when the function returns.
No sharingLocal 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.

Last updated on11/07/2026, 02:16 PM
@fishfinal | @fishfinals | [email protected]