Computing

[Go] Goroutine이 어떻게 지역 변수local variable를 참조하는가? (Goroutine, closure 개념) 본문

Programming/Go

[Go] Goroutine이 어떻게 지역 변수local variable를 참조하는가? (Goroutine, closure 개념)

jhson989 2023. 3. 20. 21:02

오늘 포스트에서는 goroutine에 대한 간략한 설명과 함께, goroutine들은 자신이 생성된 함수의 local variable을 어떻게 참조할 수 있는 지에 대해서 정리해보고자 한다.
 


Goroutine

Goroutine은 Go runtime에 의해 관리되는 가벼운(lightweight) thread이다[1]. Thread라는 용어가 이미 통용되고 있고, thread와는 뭔가 차이점이 있기에 새로운 용어를 붙였다고 한다[2]. 다만 역할만 보자면 thread와 동일한 기능을 수행한다고 생각하면 될 것 같다. 즉 goroutine간에는 비동기적 실행이 가능하여 여러 작업(함수)를 concurrently 실행시킬 수 있다. 또한 thread와 마찬가지로 모든 goroutine들은 memory space를 공유한다[1].
 
Go 프로그램을 실행시키면 Go runtime에 의해 첫 goroutine이 생성되어 main 함수를 실행시킨다. 첫 번째 goroutine은 main 함수를 실행시키는 과정에서 밑의 코드 예시와 같이 새로운 Goroutine을 생성할 수 있다.

func new_go_routine() {
    // do something
}

func main() {
    go new_go_routine()
}


main 함수에서 go keyword와 함께 new_go_routine 함수를 호출한다. 이 go 키워드가 새로운 goroutine을 생성하고, 이 goroutine은 go 키워드 뒤에 오는 함수를 실행한다. 이때 main 함수를 실행하는 goroutine과 new_go_routine 함수를 실행하는 goroutine은 비동기적 (asynchronous)으로 실행된다. 비동기적으로 실행된다는 말은 두 함수가 어떠한 순서없이 서로 독립적으로 실행된다는 말이다.

이처럼 함수를 비동기적으로 실행하는 goroutine은 lightweight thread라고 불린다[2]. 가장 큰 이유는 goroutine에 부여되는 stack memory space는 기존 OS thread에 부여되는 space보다 작기 때문이라고 한다. 기존 OS thread는 2MB의 stack 메모리 영역을 가지는데 비해 goroutine은 2KB의 stack 메모리 영역을 가진다[3]. 작은 stack 메모리 영역을 가질 경우 장점은 같은 메모리 공간을 공유하는 goroutine들을 동시에 많이 생성할 수 있다는 것이다[3].

 

가상 메모리 공간 자체는 64bit(혹은 48bit)를 사용하기에 매우 큰 공간이다. 하지만 [4]에 따르면 언어-컴파일러 단에서 stack 메모리 영역에 한계를 설정한다고 한다. Stack 메모리 영역에 한계가 없으면 무한 recursive 함수 호출 등의 에러 상황을 빠르게 탐지하기 어려운 등 여러 문제가 발생하기 때문에 한계를 설정하였다고 한다. 따라서 stack 메모리 자체가 한계가 있기에 개별 goroutine들에 할당되는 stack 메모리가 작을수록 더 많은 goroutine을 동시 생성할 수 있다. 


 

문제 상황

Goroutine을 이용하여 아래와 같은 print_shared 함수를 개발하였다. 위 코드와 다르게 print_shared 함수는 이름이 없고 func 키워드만 있는 함수를 새로운 goroutine을 생성하여 실행한다. 이러한 이름 없는 함수를 function literal, anonymous function이라고 한다.
 

func print_shared() {
    
    wg := sync.WaitGroup{}
    key := 1;
    shared := 2;
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            // shared 변수 값 = 2 출력
            fmt.Printf("%v\n", shared)
        }()
    }
    wg.Wait()
}

 
print_shared 함수는 3개의 goroutine들을 생성하여 function literal을 실행한다. 각 goroutine은 [shared]라는 integer 변수를 출력하게 된다. 위 함수를 실행하면 3개의 숫자 2가 출력된다. 정상 작동됨을 알 수 있다. 그런데 문제는 이 function literal에서 어떻게 print_shared 함수안에 정의된 [shared] 변수를 볼 수 있을까?

 

앞서 말했듯 goroutine 하나([A]라고 하자)가 print_shared 함수를 실행한다. shared 변수는 local variable이기에 이 [A]의 stack 메모리 영역에 정의된다. 이후 [A]는 3개의 추가 goroutine들([B], [C], [D])을 실행시킨다. [B], [C], [D]는 function literal을 실행하는데 이때 각각의 goroutine들은 각자만의 stack 메모리 영역을 부여받는다. 따라서 [B], [C], [D]는 [A]의 stack 메모리 영역을 볼 수 없다. (정확히는 어디 있는 지 알 수 없다가 맞을 듯) 따라서 [shared] 변수가 무엇인지 알 수 없어야 한다.

 

 

 

Closure

이와 관련된 개념이 Closure[5]라는 것이다. Go에서는 이러한 function literals은 closures이다[2]. Closure 설명이 조금은 어려운데 간단히 개념만 정리하자면, 함수가 정의될 때 참조하는 모든 변수들이 그 함수 구현체가 실행되는 동안 유지되는 함수를 의미한다. 위 코드에서 보듯 function literal은 정의될 때 print_shared 함수 내에서 정의되기에 [shared] 변수를 참조할 수 있다. 그러나 실제 function literal 구현체는 새로운 goroutine에 의해서 실행됨으로 [shared] 변수를 참조할 수 없어야 한다.

 

하지만 Go에서는 function literal을 closure라고 정의하기에 정의될 때와 실제 실행될 때(=구현체일 때) 동일한 변수를 참조할 수 있도록 해준다. 구체적으로 Go compiler가 escape analysis 단계에서 로컬 변수들의 참조를 분석하여 로컬 변수가 closure에 의해 참조된다면 이 변수를 heap 영역에 할당한다. 모든 goroutine들은 heap 영역을 공유하기에 이 로컬 변수를 볼 수 있게 되는 것이다.

 

이러한 Closure의 특성 덕분에 goroutine에 의해 실행되는 function literal 구현체는 그 함수가 정의될 때 참조된 모든 변수를 안전하게 참조할 수 있다.
 
 

Reference

[1] https://go.dev/tour/concurrency/1
[2] https://go.dev/doc/effective_go#goroutines
[3] https://medium.com/@jessie_kuo/why-is-goroutine-being-called-a-lightweight-thread-46d70d198ad6
[4] https://stackoverflow.com/questions/10482974/why-is-stack-memory-size-so-limited
[5] https://ko.wikipedia.org/wiki/%ED%81%B4%EB%A1%9C%EC%A0%80_(%EC%BB%B4%ED%93%A8%ED%84%B0_%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D)