Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Deep Dive into Go Internals: Data Structures, Memory, and Concurrency

Tech May 19 3

Slice Internals and Growth Mechanism

A slice is represented internally as a struct containing three fields: a pointer to an underlying array, the curent length (len), and the total capacity (cap).

When using append, if the existing capacity is sufficient, the element is added, the length is incremented, and the original slice is returned. If the capacity is exceeded, the slice grows:

  • Pre-1.18: If the current capacity is under 1024, it doubles. If 1024 or more, it grows by approximately 25%.
  • 1.18+: If capacity is under 256, it doubles. For larger capacities, the new size is calculated as (oldCap + 3*256) / 4 (roughly 1.25x) plus the old capacity.

Array vs Slice

Before comparing, distinguish between a reference and a pointer:

  • A reference can be a variable or struct. If a struct property is a pointer, copies of that reference still point to the same memory block.
  • A pointer is a variable storing a memory address.

Differences:

  • Arrays are value types (copied entirely on assignment); slices are reference types.
  • Arrays have fixed lengths; slices are dynamic views over arrays.
  • Slices are built on top of arrays.

Understanding the rune Type

Strings in Go are byte sequences (UTF-8 encoded by default). While ASCII characters fit in one byte, Unicode characters (like Chinese characters) may occupy 2 or 3 bytes in memory.

rune is an alias for int32 and is used to represent a single Unicode code point. Converting a string to []rune allows you to iterate over characters rather than bytes, correctly calculating the character length.


Passing Structs to Functions: Value or Pointer?

Passing by value copies the entire struct, while passing by pointer only copies the memory address.

  • Use Pointers when you need to modify the original struct or if the struct is large (to reduce copying overhead). Note that pointers often cause the data to escape to the heap, increasing GC pressure.
  • Pass by Value is preferred for small, read-only structs as it avoids heap allocation and can be faster due to stack locality.

Argument Passing for Slice, Map, and Channel

Go always passes arguments by value (a copy is made). However, the behavior differs:

  • For value types (int, struct), the copy is isolated from the original.
  • For reference types (slice, map, chan), the underlying struct or pointer is copied, but since the copy points to the same underlying data (e.g., the array for a slice or the hmap pointer for a map), modifications to the data are visible to the caller.

Select Statement Mechanics

The select statement monitors multiple channel operations. If multiple channels are ready, one is chosen randomly.

Characteristics:

  1. Cases must be channel operations (send or receive).
  2. The default case runs immediately if no other channel is ready.
  3. Without default, select blocks until one channel is ready.
  4. If multiple cases are ready, selection is random to ensure fairness.
  5. All channel expressions in the cases are evaluated at the moment select is reached.

Defer Internals and Rules

When a defer statement executes, a _defer struct is created and prepended to a linked list attached to the current goroutine's stack frame. Upon function return, the runtime iterates this list in Last-In-First-Out (LIFO) order.

Rules:

  1. Execution order is LIFO (stack behavior).
  2. Deferred functions can modify named return values because return assigns the value first, then defer runs.
  3. Best practice: Use defer immediately after acquiring resources (e.g., f.Close()) to insure cleanup.

Map Structure and Resizing

Maps use a hash table structure (hmap). The hmap points to an array of buckets (bmap). Each bucket holds keys/values and an overflow pointer for collision chaining.

Resizing (Rehashing): If conditions are met, Go allocates a new bucket array (usually double the size, or same size for overflow cleanup). Keys are then moved from old buckets to new ones incrementally (gradual evacuation) to avoid latency spikes. Old buckets remain until GC clears them.

Trigger Conditions:

  1. Load Factor > 6.5: The map grows (doubles capacity).
  2. Too many overflow buckets: The map rebalances to the same size to compact the data.

Maps and Key Comparability

Only comparable types can be map keys. The following cannot be used as keys:

  • Slices
  • Maps
  • Functions

Map Concurrency and Safety

Maps are not concurrency-safe. Concurrent reads/writes without synchronization will cause a panic.

Solutions:

  • Use sync.Mutex or sync.RWMutex to protect the map.
  • Use sync.Map for specific high-read/low-write concurrent scenarios (though it lacks range support and has different semantics).

Map Memory Deallocation

Deleting a key from a map does not immediately release the memory associated with the bucket or the key/value. The memory is eventually reclaimed by the garbage collector. Setting the map variable to nil effectively releases the reference for GC.


Nil Map vs Empty Map

  • A nil map has not been initialized (var m map[string]int). Writing to it causes a panic.
  • An empty map (e.g., m := make(map[string]int) or m := map[string]int{}) is initialized and ready for use.

Context Usage

context.Context is used to signal cancellation or deadlines across goroutines.

func TestContext(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	go worker(ctx, "alice")
	go worker(ctx, "bob")

	time.Sleep(5 * time.Second)
	cancel()
	time.Sleep(1 * time.Second)
	fmt.Println("all workers stopped")
}

func worker(ctx context.Context, name string) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println(name, "stopped")
			return
		default:
			fmt.Println(name, "working...")
			time.Sleep(time.Second)
		}
	}
}

Channel Internal Structure

A channel is represented by the hchan struct:

type hchan struct {
	qcount   uint           // total data in the queue
	dataqsiz uint           // size of the circular buffer
	buf      unsafe.Pointer // points to the buffer
	elemsize uint16         // size of each element
	closed   uint32         // closed flag
	elemtype *_type         // element type
	sendx    uint           // send index
	recvx    uint           // receive index
	recvq    waitq          // list of waiting receivers
	sendq    waitq          // list of waiting senders
	lock     mutex          // mutex protecting all fields
}

The circular buffer allows efficient FIFO operations without shifting elements.


Channel Send and Receive Flow

Sending:

  1. If recvq is not empty, pass the data directly to the first waiting receiver and wake it.
  2. If recvq is empty, try to put data into the buffer (buf).
  3. If the buffer is full, the sender goroutine is added to sendq and sleeps.

Receiving:

  1. If sendq is not empty, take data from the first waiting sender and wake it.
  2. If sendq is empty, try to read from the buffer.
  3. If the buffer is empty, the receiver goroutine is added to recvq and sleeps.

Channel Characteristics

  1. Reading from a closed channel yields the zero value; writing to a closed channel panics.
  2. Reading/Writing to a nil channel blocks forever.
  3. Closing a nil or already closed channel panics.

Buffered vs Unbuffered Channels

  • Unbuffered: Send blocks until a receiver is ready. Receive blocks until a sender is ready.
  • Buffered: Send blocks only if the buffer is full. Receive blocks only if the buffer is empty.

Channel Thread Safety

Channels are thread-safe. The lock field in hchan ensures mutual exclusion for operations modifying the buffer, waiting queues, and state flags.


Protecting Shared Variables

  1. Confinement: Pass variables via channels to a single owner goroutine.
  2. Mutexes: Use sync.Mutex or sync.RWMutex.
  3. Atomic Operations: Use sync/atomic for primitive types.
  4. Semaphores: Use a buffered channel with capacity 1 to simulate a mutex.

Mutex: Pessimistic Locking

sync.Mutex is a pessimistic lock. It assumes contention is common and forces goroutines to acquire a lock before accessing data.

  • Optimistic Locking: Assumes low contention; usually checks a version or state during update rather than locking upfront.

Mutex Modes: Normal vs Starvation

  1. Normal Mode: Waiters are FIFO. However, woken goroutines must compete with new goroutines (who are likely on CPU), often losing. If a waiter waits > 1ms, the Mutex switches to starvation mode.
  2. Starvation Mode: The lock is handed directly to the waiter at the front of the queue. New goroutines do not spin or compete; they go to the back of the queue. This prevents tail latency for waiting goroutines.

Controlling Concurrency Limits

Using a Buffered Channel (Semaphore):

func main() {
	maxWorkers := 10
	totalTasks := 100
	var wg sync.WaitGroup
	sem := make(chan struct{}, maxWorkers)

	for i := 0; i < totalTasks; i++ {
		wg.Add(1)
		sem <- struct{}{} // Block if maxWorkers are active
		go func(id int) {
			defer wg.Done()
			fmt.Println(id)
			<-sem // Release slot
		}(i)
	}
	wg.Wait()
}

Using ants Pool:

func main() {
	pool, _ := ants.NewPool(3)
	defer pool.Release()
	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		id := i
		pool.Submit(func() {
			defer wg.Done()
			fmt.Printf("Task %d done\n", id)
		})
	}
	wg.Wait()
}

Recovering from Panics

func main() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("Recovered:", r)
		}
	}()
	panic("something went wrong")
}

Simple Goroutine Pool Design

type Pool struct {
	min, max int
	tasks    chan func()
	wg       sync.WaitGroup
}

func NewPool(min, max int) *Pool {
	return &Pool{min: min, max: max, tasks: make(chan func(), 100)}
}

func (p *Pool) Start() {
	for i := 0; i < p.min; i++ {
		go p.work()
	}
	go p.scale()
}

func (p *Pool) work() {
	for task := range p.tasks {
		task()
		p.wg.Done()
	}
}

func (p *Pool) Submit(f func()) {
	p.wg.Add(1)
	p.tasks <- f
}

func (p *Pool) scale() {
	for {
		time.Sleep(time.Second)
		if len(p.tasks) > p.min && p.min < p.max {
			p.min++
			go p.work()
		} else if len(p.tasks) == 0 && p.min > 1 {
			p.min--
		}
	}
}

Stack vs Heap Allocation

Channel Allocasion: Channels are allocated on the heap because they are shared between goroutines and have a lifetime beyond a single function scope.

General Rule: The compiler uses escape analysis to decide.

  • Stack: Local variables that do not escape (e.g., x := 1 inside a function that doesn't pass x anywhere).
  • Heap: Variables whose address escapes the function (e.g., returning a pointer to a local variable, or passing a pointer to a goroutine).

Memory Allocation Strategy

Go allocates a large contiguous heap at startup.

Stack Allocation:

  • Small stacks (<32KB) allocate via mcache -> stackpool -> mheap.
  • Large stacks (>=32KB) go directly to mheap.

Heap Allocation:

  • Tiny objects (<=16B): Allocated via a tiny allocator in mcache.
  • Small objects (>16B, <=32KB): Allocated from mcache -> mcentral -> mheap.
  • Large objects (>32KB): Allocated directly from mheap.

The heap is divided into arenas (8KB pages), managed by mspans, tracked by spans metadata, and marked via bitmap.


Escape Analysis

Escape occurs when a variable that could live on the stack is forced to live on the heap.

Common Escape Scenarios:

  1. Returning a pointer to a local variable.
  2. Sending pointers over channels.
  3. Referencing outer variables in closures.
  4. Slices containing pointers.
  5. Interface types (dynamic dispatch).
  6. Stack overflow (rare).

Detection: Use go build -gcflags '-m'.


Memory Leaks and Detection

Common Causes:

  1. Goroutines blocked forever (no mechanism to stop them).
  2. Unreleased mutexes or deadlocks.
  3. time.Ticker not stopped.
  4. Substring/Subslice Leaks: A small slice reference keeps the entire underlying array alive.

Fix for slice leak: s1 := append(make([]int, 0, 5), s0[:5]...) instead of s1 := s0[:5].

Detection: Use pprof to analyze heap profiles and goroutine counts.


GPM Scheduling Model

  • G (Goroutine): The unit of execution.
  • P (Processor): A resource that manages a local queue of Gs (max 256). There are GOMAXPROCS Ps.
  • M (Machine): An OS thread that executes Gs.

Workflow:

  1. New Gs go to P's local queue (or global queue if full).
  2. M executes Gs from its P's local queue.
  3. If P is idle, M tries to steal Gs from other Ps or the global queue.
  4. If G blocks (syscall), M releases P, and P finds a new M (or spawns one) to run other Gs.

Spin State: M is actively looking for work (spinning) to reduce latency.


Goroutine Preemption

Go uses cooperative and signal-based preemption.

  1. Cooperative (Legacy): Goroutines yield periodically (e.g., function calls). If a goroutine runs >10ms, the runtime sets a flag to force a yield.
  2. Signal-based (Modern): The runtime sends a signal (SIGURG) to the thread (M) to interrupt the goroutine and schedule another, even if the goroutine is in a tight loop without function calls.

Process, Thread, and Goroutine

  • Process: OS-level isolation. Heavy context switching. Independent memory space.
  • Thread: Lighter than process. Shares memory with other threads in the same process. Managed by OS.
  • Goroutine: User-space thread managed by Go runtime. Extremely lightweight (few KB stack). M:N scheduling (many goroutines on few threads).

Garbage Collection (GC)

Go uses a Concurrent Tri-Color Mark and Sweep algorithm.

Evolution:

  • v1.3: Stop-The-World (STW) Mark & Sweep.
  • v1.5: Tri-color with STW only for stack re-scanning.
  • v1.8: Hybrid Write Barrier. No STW required. Stacks are marked black immediately; heap writes use a barrier to mark objects grey.

Tri-Color Logic:

  1. Start with all objects white.
  2. Scan roots -> mark as Grey.
  3. Process Grey: mark children Grey, mark self Black.
  4. Repeat until no Grey objects.

Write Barrier: Ensures that if a black object links to a white object during marking, the white object is kept alive (marked grey).

Tags: gogolang

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.