SoFunction
Updated on 2025-03-04

Golang, detailed explanation of simple memory management skills

introduction

Unless you are prototyping the service, you may be concerned about the memory usage of your application. Smaller memory footprint, lower infrastructure costs, and scaling becomes easier/latency.

Although Go is known for not consuming a lot of memory, there are ways to reduce consumption further. Some of them require a lot of refactoring, but many are easy to do.

Pre-allocated slices

An array is a collection of the same type with continuous memory. Array type definition specifies length and element type. The main problem with arrays is that their size is fixed - they cannot be resized because the length of the array is part of their type.

Unlike array types, the slice type does not have a specified length. Slices are declared the same way as arrays, but no element counts.

Slices are wrappers for arrays, they do not own any data - they are references to arrays. They consist of pointers to the array, length of segments, and their capacity (number of elements in the underlying array).

When you append to a slice without a new value capacity - a new array with a larger capacity is created and the values ​​from the current array are copied into the new array. This results in unnecessary allocation and CPU cycles.

To better understand this, let's take a look at the following code snippet:

func main() {
    var ints []int
    for i := 0; i < 5; i++ {
        ints = append(ints, i)
        ("Address: %p, Length: %d, Capacity: %d, Values: %v\n",
            ints, len(ints), cap(ints), ints)
    }
}

The output is as follows:

Address: 0xc0000160c8, Length: 1, Capacity: 1, Values: [0]
Address: 0xc0000160f0, Length: 2, Capacity: 2, Values: [0 1]
Address: 0xc00001e080, Length: 3, Capacity: 4, Values: [0 1 2]
Address: 0xc00001e080, Length: 4, Capacity: 4, Values: [0 1 2 3]
Address: 0xc00001a140, Length: 5, Capacity: 8, Values: [0 1 2 3 4]

Looking at the output, we can conclude that whenever we have to increase capacity (increase by 2 times), we must create a new underlying array (new memory address) and copy the value into the new array.

The interesting fact is that the factor of capacity growth was once twice as high as capacity <1024, and 1.25 times as high as >= 1024. Since Go 1.18, this has becomeMore linear

name               time/op
Append-10          3.81ns ± 0%
PreallocAssign-10  0.41ns ± 0%
name               alloc/op
Append-10           45.0B ± 0%
PreallocAssign-10   8.00B ± 0%
name               allocs/op
Append-10            0.00
PreallocAssign-10    0.00

Looking at the above benchmark, we can conclude that there is a big difference between assigning values ​​to pre-allocated slices and appending values ​​to slices.

Two linters help to pre-allocate slices:

  • prealloc: A static analysis tool for finding slice declarations that may be pre-allocated.
  • makezero: A static analysis tool for finding slice declarations that are not initialized with zero length and are later used with append.

Order fields in a structure

You may not have thought of this before, but the order of fields in the structure is important for memory consumption.

The following structure is an example:

type Post struct {
    IsDraft     bool      // 1 byte
    Title       string    // 16 bytes
    ID          int64     // 8 bytes
    Description string    // 16 bytes
    IsDeleted   bool      // 1 byte
    Author      string    // 16 bytes
    CreatedAt    // 24 bytes
}
func main(){
    p := Post{}
    ((p))
}

The output of the above function is 96 (bytes), while all fields are added to 82 bytes. Where does the extra 14 bytes come from?

Modern 64-bit CPUs get data in 64-bit (8-byte) blocks. If we have an older 32-bit CPU, it will execute a 32-bit (4-byte) block.

The first cycle takes up 8 bytes.IsDraftThe field takes 1 byte and has 7 unused bytes. It cannot occupy "half" of a field.

The second and third cyclesTitleString, the fourth loop is takenID, and so on. Use againIsDeletedfield, it takes 1 byte and has 7 unused bytes.

What really matters is sorting fields from top to bottom by their size. Sorting the above structures, reducing the size to 88 bytes. The last two fieldsIsDraftandIsDeletedPut in the same block, reducing the number of unused bytes from 14 (2x7) to 6 (1 x 6), saving 8 bytes in the process.

type Post struct {
    CreatedAt    // 24 bytes
    Title       string    // 16 bytes
    Description string    // 16 bytes
    Author      string    // 16 bytes
    ID          int64     // 8 bytes
    IsDeleted   bool      // 1 byte
}
func main(){
    p := Post{}
    ((p))
}

Go types that occupy <8 bytes on a 64-bit architecture:

  • bool: 1 byte
  • int8/uint8: 1 byte
  • int16/uint16: 2 bytes
  • int32/uint32/rune: 4 bytes
  • float32: 4 bytes
  • byte: 1 byte

Rather than manually checking the structures and sorting them by size, use linter to find these structures and (used to) report the "correct" sort.

  • maligned: Deprecated linter, used to report unaligned structures and print out correctly sorted fields. It was deprecated a year ago, but you can still install the old version and use it.
  • govet/fieldalignment: As part of gotools and govet linter, fieldalignment prints out the current/ideal size of the unaligned structure and structure.

To install and run fieldalignment:

go install /x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest fieldalignment -fix <package_path>

Use govet/fieldalignment in the above code:

fieldalignment: struct of size 96 could be 88 (govet)

Use map[string]struct{} instead of map[string]bool

Go does not have built-in collections, usually usedmap[string]bool{}to represent a collection. Although it's more readable, it's very important to use it as a collection because it has two states (false/true) and uses extra memory compared to an empty structure.

Empty structure (struct{}) is a structure type without additional fields, occupying zero-byte storage space.

I don't recommend this unless your map/set contains a lot of values ​​and you need to get extra memory or you are developing for a low memory platform.

Extreme examples of using 100 000 000 to write to a map:

func BenchmarkBool(b *) {
    m := make(map[uint]bool)
    for i := uint(0); i < 100_000_000; i++ {
        m[i] = true
    }
}
func BenchmarkEmptyStruct(b *) {
    m := make(map[uint]struct{})
    for i := uint(0); i < 100_000_000; i++ {
        m[i] = struct{}{}
    }
}

The following results are obtained, which are very consistent throughout the entire operation:

name            time/op
Bool          12.4s ± 0%
EmptyStruct   12.0s ± 0%
name            alloc/op
Bool         3.78GB ± 0%
EmptyStruct  3.43GB ± 0%
name            allocs/op
Bool          3.91M ± 0%
EmptyStruct   3.90M ± 0%

Using these numbers, we can conclude that writing speeds using empty structure maps are 3.2% faster and allocated memory is reduced by 10%.

In addition, usemap[type]struct{}is the correct solution to implementing a collection, because each key has a value. usemap[type]bool, each key has two possible values, which is not a collection, and if the goal is to create a collection, it may be misused.

However, readability is most important than (negligible) memory improvements. Compared to empty structures, it is easier to master the search using boolean values:

m := make(map[string]bool{})
if m["key"]{
 // Do something
}
v := make(map[string]struct{}{})
if _, ok := v["key"]; ok{
    // Do something
}

Reference link:Easy memory-saving tricks in Go | Emir Ribic ()

The above is the detailed explanation of Golang's simple memory management skills. For more information about Golang's memory management, please follow my other related articles!