DEV Community

Chig Beef
Chig Beef

Posted on • Edited on

Golang's Attack On Memory (Go1.22 Update Issue)

Preface

A new version of Go was recently released, Go 1.22. In this update, an experimental feature was added.

Previously, the variables declared by a "for" loop were created once and updated by each iteration. In Go 1.22, each iteration of the loop creates new variables, to avoid accidental sharing bugs. The transition support tooling described in the proposal continues to work in the same way it did in Go 1.21.

What That Means

If you understood that, you can skip this section. Basically, when you create a for loop, you use a variable (eg. i) to tell you which iteration of the loop you are on. Go used to use one variable as i, and update it. This means there is only one variable. In the new version of Go this is no longer the case. Every iteration of the loop creates a new copy of i to use. This functionality is also present when using range and when using a for each loop.

Why Is This A Good Idea?

This is actually a great idea. Let's imagine we're looping over a slice of objects. Now we have another slice, and this slice will contain pointers to each item in the first slice. Well, in the first version, we will get a bit of a surprise, because every item in the second slice will be the same! Now, I know that sounds dumb, no one would every do something like that, but it happens, and it can be way more implicit. This is especially the case because you're not really thinking about this, when you're creating a loop, you're usually thinking about a specific algorithm, not whether a variable is a copy.

Initial Thoughts

I, along with pretty much everybody else, thought that this was a great idea. This would surely prevent a lot of bugs, especially with newbies who aren't really familiar with how Go handles loops. It's all around a great win.

The Ugly

Then I did some poking around. Here is the first function I created.

func test_regular_num_loop(iter int) {
    fmt.Println("test_regular_num_loop")
    var iPointer *int

    for i := 0; i < iter; i++ {
        if i == 0 {
            iPointer = &i
        }
        fmt.Println(i)
    }

    fmt.Println("pointer", *iPointer)
}
Enter fullscreen mode Exit fullscreen mode

What are we doing here? This is pretty simple, all we are doing is looping however many times we want, and in the first iteration only, we are creating a pointer to i. Once the loops is finished, we will check what iPointer points to. Using Go 1.21, we get this as output (when iter is set to 10).

0
1
2
3
4
5
6
7
8
9
pointer 10
Enter fullscreen mode Exit fullscreen mode

Why is the pointer not 9? This isn't related to the new feature, but just in case you forgot (because I did too), i is incremented every loop, and the loop only ends when i is greater or equal to 10. Since 9 is lower than 10 we need to do one more iteration, but i ends on the value of 10. Now let's try with Go 1.22.

0
1
2
3
4
5
6
7
8
9
pointer 0
Enter fullscreen mode Exit fullscreen mode

I hope that was the result you expected, remember, with this new feature each iteration creates a new memory address under the hood for us to point to, so the 0 we start off with isn't updated, therefore, the pointer points to 0.

My Expectation

I thought this feature was only going to effect the range keyword, for example.

func test_range_num_loop(iter int) {
    fmt.Println("test_range_num_loop")

    var iPointer *int

    for i := range iter {
        if i == 0 {
            iPointer = &i
        }
        fmt.Println(i)
    }

    fmt.Println("pointer", *iPointer)
}
Enter fullscreen mode Exit fullscreen mode

This has the same results as the results received from Go 1.22. Which makes sense, it's kind of syntax sugar, so it should give the same result. I don't know why I expected it only to effect the range keyword, but I was mistaken, and that's my bad (skill issue).

How To Simulate Old Functionality

If you want to keep the old functionality, you can just use a while loop (but remember, Go only has the for keyword).

func test_while_num_loop(iter int) {
    fmt.Println("test_while_num_loop")

    var iPointer *int

    i := 0
    for i < iter {
        if i == 0 {
            iPointer = &i
        }
        fmt.Println(i)

        i++
    }

    fmt.Println("pointer", *iPointer)
}
Enter fullscreen mode Exit fullscreen mode

This solution also leaves the pointer with a 10.

func test_declare_num_loop(iter int) {
    fmt.Println("test_declare_num_loop")
    var iPointer *int

    var i int
    for i = 0; i < iter; i++ {
        if i == 0 {
            iPointer = &i
        }
        fmt.Println(i)
    }

    fmt.Println("pointer", *iPointer)
}
Enter fullscreen mode Exit fullscreen mode

Why Is This An Issue?

After a few seconds of thinking, my mind went to memory. Remember, everything has to be stored, so every extra variable is an extra entry.

Testing My Theory

Let's create a simple benchmark for both the old way, and the new way, to see if my worries are true. First, we're going to need some objects.

type Object struct {
    name  string
    id    int
    x     float64
    y     float64
    alive bool
}
Enter fullscreen mode Exit fullscreen mode

That's a nice big object, and now we need a way to create a whole bunch of these.

func createObjects(amount int) {
    var obj Object
    objs = make([]Object, amount)
    for i := 0; i < amount; i++ {
        obj = Object{
            randomString(),
            rand.Int(),
            rand.Float64(),
            rand.Float64(),
            rand.Intn(2) == 1,
        }
        objs[i] = obj
    }
}
Enter fullscreen mode Exit fullscreen mode

That also looks great, and now for the function we are going to benchmark.

func loop(iter int) Object {
    i := 0

    var obj Object
    for i < iter {
        obj = objs[i]
        obj.name += "."
        obj.id = 0
        obj.x *= 2
        obj.y /= 2
        obj.alive = false

        pObjs[i] = &obj

        i++
    }

    return obj
}
Enter fullscreen mode Exit fullscreen mode

In this, all we are doing is looping over every object that we have, and manipulating them in some way. We then create (and save) a pointer to that object. Now we need an equivalent for Go 1.22's new feature.

func loop_new(iter int) Object {
    for i, obj := range objs {
        obj.name += "."
        obj.id = 0
        obj.x *= 2
        obj.y /= 2
        obj.alive = false

        pObjs[i] = &obj
    }

    return objs[0]
}
Enter fullscreen mode Exit fullscreen mode

Looks exactly the same, except we use the range keyword to make things simpler.
Lastly, we create our benchmark.

func main() {
    fmt.Println("Started")

    iter := 1 << 24

    createObjects(iter)
    pObjs = make([]*Object, iter)

    fmt.Println("Finished Creating Objects")

    for {
        //loop(iter)
        loop_new(iter)
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we are going to build and run this program twice, switching out loop and loop_new each time. This program runs forever, but it will not keep making memory, it should hit a limit and stick near that area.

Why Not Use Go Benchmark?

I tried, but then I realized that it was based on allocations and bytes allocated, which is largely the same, because they are both technically doing the same allocation. What we care about is whether those allocations are happening on the same place, or in a different place.

Results

I'm going to be using task manager (Windows mentioned) to see how much memory is being used. I know this test is a bit sketchy, but any large difference in memory shows the effect regardless.
Maybe make a prediction before I show the results, do you think it will create a bunch more memory? Or did the Go developers do some black magic to try to mitigate this effect.

time given per test: 60s
function: loop       max_memory: 2,372.2mb
function: loop_new   max_memory: 4,393.9mb
Enter fullscreen mode Exit fullscreen mode

That's using 85% more memory. All that memory is garbage, that the garbage collector has to spend time dealing with later on.

Does This Matter

I think it's unfair to say that this matters in every circumstance. In most cases, you won't really care much about memory/won't be using much. However, this is definitely something to keep an eye on.

How To Avoid This

We don't want our programs using 85% more memory, or whatever the number is. It's pretty simple to avoid this.

Firstly, if you don't need to be making pointers to each individual item, don't. If you just want the last one, create a pointer to the last one, outside of the loop.

You can also use while loops to get around this if you're really concerned. In most cases this doesn't matter but just so you're aware this is an option. You could also do some functional type programming and use recursion like a Haskeller, but that's only if you're truly hardcore.

Look over all of your loops. If you're writing a library, or maintaining a codebase, just take a quick look over your loops. I know that might be hard if you have an extremely large codebase, but just do what you can (especially if memory is dire).

Keep an eye out for loops that live a long time, such as loops that last the entire duration of a program (especially when the duration of the program is variable, such as in a game). The memory was held on to for a long time because the loop we made was so long, and therefore wasn't really getting garbage collected.

Don't code embedded systems.

Thoughts

This is one of those issues that you don't need to get too scared about, but you should be keeping an eye out for. It seems so easy to avoid, but will bite you if you don't watch out for it. It's almost the same type of bug as the issue it was trying to fix (as discussed above), but not as bad.
Also, take all the code I've written with a grain of salt. I'm not going to act like I'm the best at Go, but I like to think I'm used to it, so if you spot any mistakes please let me know.
What I didn't benchmark was the speed of the programs. Allocations and garbage collection takes time, so in theory, asking for more memory every iteration of a loop should impact performance in a speed vision.

Top comments (0)