[Go] Concurrency: 루프 변수 캡쳐 문제

Go에서는 클로저, 그러니까 이름없는 함수는 변수의 캡쳐 기능을 제공한다.
예를 들어, 아래와 같이 클로저에서 외부 변수를 사용하려 시도하면

가져다가 읽고 쓸 수 있는 것이다.
심지어 mutable로 캡쳐를 하기 때문에, 캡쳐본을 수정해도 원본이 바뀐다.


이렇게.

이건 일반적인 사용사례에서는 아주 간단하고 간편한데, 루프+고루틴과 엮이게 되면 문제가 좀 생긴다.
거의 버그에 가까운 동작인데, Go에서 가장 큰 함정 중 하나다.

일단 고루틴을 쓰지 않을 때는 문제가 없다.
다음과 같이 루프를 돌려서 각 루프의 클로저에서 루프 변수를 캡쳐해서 쓴다면

기대한대로 잘 동작할 것이다.

하지만 고루틴이 더해지면 이상하게 뒤틀린다.

어째선지 마지막 변수인 c만 캡쳐되어 출력되었다.




왜 그런가?

저따위로 동작하는 이유는 간단하다.
루프에 할당되는 변수는 하나의 변수를 할당해서 돌려쓰는 것이기 때문이다.

여타 언어들은 for-each 구문에서 매 반복마다 새 변수가 할당되는게 보통인데, Go는 재활용을 한다.
같은 변수의 주소로 캡쳐를 찍으니까, 고루틴이 도는 시점에서 변경된 최종 상태를 읽어와서 썼을 뿐인 것이다.

그래서 루프의 길이가 3개가 아니라 훨씬 길었다면 슬라이스의 마지막 요소가 아니라 중간 어디쯤의 이상한 요소를 읽을 수도 있다.





해결법 1: 매개변수 전달

가장 깔끔한 해결책은, 고루틴에 값을 전달할때 캡쳐보다는 매개변수 전달을 이용하는 것이다.




해결법 2: 변수 재할당

루프 변수 자체의 문제이므로, 로컬에서 변수를 한번 더 할당하면 그것도 그것대로 문제가 해결된다.




Go 1.22부터...

Go 팀에서도 이 문제를 심각하게 받아들였는지, 처음에는 버그가 쏟아지더라도 유지하겠다고 했었는데, 이제는 코드 호환성을 포기해서라도 문제를 해결하겠다는 의지를 보여주고 있다.

그래서 루프 변수를 매 반복마다 새로 할당하는 형태로 동작을 변경했고, 1.21에 프리뷰로 추가된 상태다. 1.22에 정식으로 추가할 예정이라고 한다.

현재 1.21 버전에서는 다음과 같이 환경변수를 할당하면 해당 동작을 활성화할 수 있는데

그럼 기대한대로 동작하게끔 할 수 있다.

호환성은 모듈 버전을 체크해서 1.21 이하에서는 이전 동작으로 빌드하는 식으로 해서 빌드 호환성은 유지할 거라 하는데, 아무튼 코드 호환성은 깨지는게 확실하다.



참조
https://stackoverflow.com/questions/26692844/captured-closure-for-loop-variable-in-go
https://go.dev/blog/loopvar-preview
https://go.dev/doc/faq#closures_and_goroutines