Post
Available in: Português

Weak Pointers in Go: Preventing Memory Leaks in Caches with weak.Pointer

Hey everyone!

Memory management in in-memory caches has always been a challenge in Go. If you store data in a standard map, such as map[string]*Resource, you are holding strong references to those objects. This means they will never be garbage collected, even if the rest of the application has stopped using them.

The new weak package, introduced in the standard library in Go 1.24 and consolidated in Go 1.25 and 1.26, solves this exact problem. It brings the concept of weak pointers (weak.Pointer), allowing you to reference objects without preventing them from being reclaimed by the Garbage Collector (GC).

Below is a detailed look at how this feature works and a practical example of implementing a safe cache.


The Problem of Strong References

By default, all pointer references in Go are strong. If an object is reachable from any active variable (such as keys or values in a global cache map), the Garbage Collector is forced to keep it alive in memory.

graph TD
    subgraph "Strong Pointer"
        Map1["map[string]*Data"] -->|Strong Reference| ObjA["Object A"]
        GC1["Garbage Collector"] -->|Cannot collect| ObjA
    end
    subgraph "Weak Pointer"
        Map2["map[string]weak.Pointer[Data]"] -.->|Weak Reference| ObjB["Object B"]
        GC2["Garbage Collector"] -->|Collects if no strong references exist| ObjB
    end

If your cache grows indefinitely and you do not implement aggressive eviction policies (like TTL-based expiration or maximum size limits), the application’s memory usage will continuously inflate, potentially resulting in Out Of Memory (OOM) errors.

Traditional TTL-based approaches partially solve this, but they might evict objects that are still actively used in other goroutines, forcing redundant I/O operations.


The Solution: weak.Pointer

A weak pointer is a reference that does not prevent the garbage collector from reclaiming its referenced object. If the only remaining reference to an object is a weak pointer, the GC will deallocate it during the next cycle.

The native package has a very simple and direct API:

  1. weak.Make(ptr): Creates a weak.Pointer[T] from a conventional strong pointer *T.
  2. wptr.Value(): Returns the original strong pointer *T. If the object has already been collected, it returns nil.

Implementing a Cache with Weak Pointers

Below is a basic implementation of an in-memory cache using weak.Pointer. The cache stores resources, but allows the GC to free them if no other part of the program is holding a strong reference to them.

Run Example on Go Playground

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package main

import (
	"runtime"
	"sync"
	"weak"
)

type Resource struct {
	ID   string
	Data []byte
}

type WeakCache struct {
	mu    sync.RWMutex
	items map[string]weak.Pointer[Resource]
}

func NewWeakCache() *WeakCache {
	return &WeakCache{
		items: make(map[string]weak.Pointer[Resource]),
	}
}

// Set adds a weak reference to the object in the cache
func (c *WeakCache) Set(key string, val *Resource) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.items[key] = weak.Make(val)
}

// Get retrieves the object if it is still alive in memory
func (c *WeakCache) Get(key string) (*Resource, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()

	wptr, ok := c.items[key]
	if !ok {
		return nil, false
	}

	val := wptr.Value()
	if val == nil {
		// Object has been reclaimed by the GC
		return nil, false
	}

	return val, true
}

Advanced Tip: Cleaning Up Orphaned Keys with runtime.AddCleanup

The previous example handles values being deallocated, but the map keys (the string itself) still remain in the map indefinitely, resulting in empty orphaned keys.

To resolve this, Go 1.24 introduced runtime.AddCleanup (a much safer and more efficient alternative to the old runtime.SetFinalizer). It accepts a target object, a callback function, and an argument to be passed to it when the target object becomes unreachable.

We can use it to trigger an automatic cleanup of the corresponding map key whenever the Garbage Collector reclaims the value:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// SetWithCleanup adds the value and registers the automatic cleanup callback
func (c *WeakCache) SetWithCleanup(key string, val *Resource) {
	c.mu.Lock()
	defer c.mu.Unlock()

	c.items[key] = weak.Make(val)

	// Register the cleanup to remove the key from the map when val is garbage collected
	runtime.AddCleanup(val, func(k string) {
		c.mu.Lock()
		defer c.mu.Unlock()

		// Ensure we don't delete a key that has been updated with another active value
		if wptr, ok := c.items[k]; ok && wptr.Value() == nil {
			delete(c.items, k)
		}
	}, key)
}

Unlike SetFinalizer, AddCleanup is generic, handles interior pointers and reference cycles cleanly, and does not delay freeing the object’s memory.


Conclusion

Weak pointers are an important evolution for developing high-performance services in Go. Combining weak.Pointer with runtime.AddCleanup enables the creation of smart memory caches that dynamically respond to runtime memory pressure without complex manual eviction logic or arbitrary TTLs.

It is recommended to use weak pointers only when the lifecycle of the object can be safely delegated to the runtime’s default collector, maintaining robust testing with -race enabled.


Technical References