range loop 변수를 goroutine에서 직접 참조하면 안 되는 이유

computerphilosopher·2022년 1월 26일
1
post-custom-banner

주의: go 1.22 부터 range loop 변수의 스코프가 변경되어 이 글의 내용은 사실이 아니게 되었습니다. 다음 링크를 참조하세요.
https://go.dev/blog/loopvar-preview

range loop란?

slice, map 등의 컬렉션을 편리한 문법으로 순회할 수 있도록 하는 기능이다.

//iterating slice
a := []string{"Foo", "Bar"}
//0 Foo
//1 Bar
for i, s := range a {
    fmt.Println(i, s)
}


//iterating map
m := map[string]int{
"Foo": 1,
"Bar": 2,
"Baz": 3,
}
//Foo 1
//Bar 2
//Baz 3
for key, val := range m {
    fmt.Printf("key: %s, val: %d\n", key, val)
}

range loop의 변수를 goroutine에서 사용하면 일어나는 일

다음의 goroutine을 이용한 코드를 보자.


arr := []string{"Foo", "Bar", "Baz"}
wg := sync.WaitGroup{}

for i, elem := range arr {
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println(i, elem)
	}()
}
wg.Wait()

https://go.dev/play/p/-OYbE-fzHHr

코드가 의도한 바는 명확하다. 루프를 돌면서 slice 안의 원소를 하나씩 출력하는 것이다.

2 Baz
2 Baz
2 Baz

그러나 실제로 실행해보면 놀라운 일이 일어난다. 세 번의 출력 함수가 모두 배열의 마지막 값을 출력한다.

원인

range 루프의 변수는 새로 생성되지 않고 재사용 된다

앞서 소개한 예제는 다음의 코드와 똑같이 동작한다. 매번 새로운 변수를 생성하는 것이 아니라, 기존에 선언한 변수의 값을 바꾼다.

	arr := []string{"Foo", "Bar", "Baz"}
	wg := sync.WaitGroup{}

	i := 0
	elem := arr[0]
	for {
		wg.Add(1)
		go func() {
			defer wg.Done()
			fmt.Println(i, elem)
		}()
		i += 1
		if i == 3 {
			break
		}
		elem = arr[i]
	}
	wg.Wait()

https://go.dev/play/p/Cztw7wIUCzd

문법만 보면 마치 매번 새로운 변수를 선언하는 것처럼 보이기 때문에 매우 헷갈리는 특성이다. 기존 작성된 코드와 하위 호환을 유지하기 위해 go1.x 에서는 바뀔 예정이 없다고 한다.

closure는 함수 밖의 변수를 레퍼런스로 참조한다.

closure에서 함수 바깥의 변수를 참조 할 때는 값을 복사하지 않고 레퍼런스를 참조한다. 다음의 코드를 실행하면 5가 출력된다.

i := 0
func() {
	i = 5
}()

fmt.Println(i)

https://go.dev/play/p/8LyyfxM3uZu

두 가지 원인을 알고 나면 마지막 값을 출력하게 된 이유를 짐작할 수 있다.

  • goroutine을 실행한다.
  • 종료를 기다리지 않고 바로 다음 루프로 넘어간다.
  • goroutine이 fmt.Println을 실행하기도 전에 마지막 루프까지 모두 실행해버렸다
  • 따라서 가장 마지막 값만을 참조하게 되었다.

배열의 크기를 10000으로 늘려서 실행해보면 무작위로 아무 값이나 들어가는 것을 관찰할 수 있다. fmt.Println()이 실행될 당시의 변수 값을 참조하는 것인데 결과가 매번 바뀌며 예측이 거의 불가능하다.

package main

import (
	"fmt"
	"sync"
)

func main() {

	arr := []string{}

	for i := 0; i < 10000; i++ {
		if i%2 == 0 {
			arr = append(arr, "Foo")
			continue
		}
		arr = append(arr, "Bar")
	}
	wg := sync.WaitGroup{}

	for i, elem := range arr {
		wg.Add(1)
		go func() {
			defer wg.Done()
			fmt.Println(i, elem)
		}()

	}
	wg.Wait()
}

https://go.dev/play/p/IQ_sCS46UrN

11 Bar
27 Bar
90 Foo
11 Bar
11 Bar
11 Bar
11 Bar
11 Bar
11 Bar
299 Bar
11 Bar
27 Bar
43 Bar
539 Bar
43 Bar
683 Bar
...

해결 방법

loop 안에서 range variable의 값을 복사한다.

loop 안에서 매번 새로운 변수를 생성하여 참조하는 방법이다.

package main

import (
	"fmt"
	"sync"
)

func main() {

	arr := []string{"Foo", "Bar", "Baz"}
	wg := sync.WaitGroup{}

	for i, elem := range arr {
		wg.Add(1)
		j, e := i, elem
		go func() {
			defer wg.Done()
			fmt.Println(j, e)
		}()

	}
	wg.Wait()
}

https://go.dev/play/p/o6oBTCmIkvJ

closure를 사용하지 않고 함수의 인자로 넘긴다

closure에서 참조하는 대신, 익명 함수의 인자로 넘기면 값이 복사되어 쓰인다.

package main

import (
	"fmt"
	"sync"
)

func main() {

	arr := []string{"Foo", "Bar", "Baz"}
	wg := sync.WaitGroup{}

	for i, elem := range arr {
		wg.Add(1)
		go func(i int, elem string) {
			defer wg.Done()
			fmt.Println(i, elem)
		}(i, elem)
	}
	wg.Wait()
}

참고 문헌

post-custom-banner

0개의 댓글