GO언어 면접의 핵심 - 탈출분석은 어떻게 이루어지나요?

컴파일 원리에서는 포인터의 동적 범위를 분석하는 방법을 이탈 분석이라고 합니다. 일반인의 관점에서 보면 객체에 대한 포인터가 여러 메서드나 스레드에서 참조되는 경우 포인터가 이스케이프되었다고 말합니다.

Go 언어의 이스케이프 분석은 컴파일러가 정적 코드 분석을 수행한 후 메모리 관리를 최적화하고 단순화하는 것으로, 변수가 힙에 할당되었는지 스택에 할당되었는지 확인할 수 있습니다.

C/C++를 작성한 학생들은 유명한 malloc과 새로운 함수를 호출하면 힙에 메모리 조각을 할당할 수 있다는 것을 모두 알고 있습니다. 이 메모리의 사용과 파괴에 대한 책임은 프로그래머에게 있습니다. 주의하지 않으면 메모리 누수가 발생합니다.

Go 언어에서는 기본적으로 메모리 누수에 대해 걱정할 필요가 없습니다. 새로운 함수도 있지만 새 함수를 사용하여 얻은 메모리가 반드시 힙에 있는 것은 아닙니다. 프로그래머에게는 힙과 스택의 차이가 "흐리게" 표시됩니다. 물론 이 모든 작업은 배후에서 Go 컴파일러에 의해 수행됩니다.

Go 언어의 이스케이프 분석의 가장 기본적인 원칙은 다음과 같습니다. 함수가 변수에 대한 참조를 반환하면 이스케이프됩니다.

간단히 말하면, 컴파일러는 코드의 특성과 코드 수명주기를 분석하고, Go의 변수는 함수가 반환된 후 다시 참조되지 않는다는 것을 컴파일러가 증명할 수 있는 경우에만 스택에 할당됩니다. , 스택에 할당됩니다.

Go 언어에는 컴파일러가 힙에 변수를 직접 할당할 수 있는 키워드나 함수가 없으며 대신 컴파일러가 코드를 분석하여 변수를 할당할 위치를 결정합니다.

변수의 주소를 취하는 것은 힙에 할당될 수 있습니다. 그러나 컴파일러가 이스케이프 분석을 수행한 후 함수가 반환된 후 이 변수가 참조되지 않는 것으로 확인되면 여전히 스택에 할당됩니다.

컴파일러는 변수가 외부에서 참조되는지 여부에 따라 이스케이프할지 여부를 결정합니다.

  1. 함수 외부에 참조가 없으면 먼저 스택에 배치됩니다.
  2. 함수 외부에 참조가 있으면 힙에 배치해야 합니다.

C/C++ 코드를 작성할 때 효율성을 높이기 위해 값에 의한 전달(값에 의한 전달)은 생성자의 실행을 피하고 포인터를 직접 반환하려는 시도로 참조에 의한 전달로 "업그레이드"되는 경우가 많습니다.

여기에는 큰 구덩이가 숨겨져 있다는 점을 여전히 기억해야 합니다. 지역 변수가 함수 내부에 정의된 다음 이 지역 변수의 주소(포인터)가 반환됩니다. 이러한 지역 변수는 스택에 할당됩니다(정적 메모리 할당). 일단 함수가 실행되면 변수가 차지하는 메모리는 파괴됩니다. 이 반환 값에 대한 모든 작업(예: 역참조)은 프로그램 실행을 방해합니다. 심지어 프로그램이 직접 충돌하게 만들 수도 있습니다. 예를 들어 다음 코드는 다음과 같습니다.

int *foo ( void )   
{
    
       
    int t = 3;
    return &t;
}

일부 학생들은 위의 함정을 인식하고 더 현명한 접근 방식을 사용할 수 있습니다. 즉, 함수 내부에 새 함수를 사용하여 변수를 생성하고(동적 메모리 할당) 이 변수의 주소를 반환합니다. 변수는 힙에 생성되므로 함수가 종료될 때 소멸되지 않습니다. 하지만 이것으로 충분합니까? new로 생성된 객체는 언제, 어디서 삭제해야 합니까? 호출자가 삭제하는 것을 잊어버리거나 반환 값을 다른 함수에 직접 전달하면 더 이상 삭제할 수 없으며 이는 메모리 누수가 발생함을 의미합니다. 이 함정에 관해 "Effective C++"의 21절을 읽어보세요. 매우 좋습니다!

C++는 가장 복잡한 구문을 가진 언어로 알려져 있으며, C++의 구문을 완전히 마스터할 수 있는 사람은 아무도 없다고 합니다. 이 모든 것은 Go 언어에서는 매우 다릅니다. 위의 예시와 같은 C++ 코드를 문제 없이 Go에 넣어보세요.

앞서 언급한 C/C++에서 발생하는 문제는 Go의 언어 기능으로 강력히 홍보됩니다. 정말 C/C++의 비소이자 Go의 꿀입니다!

C/C++에서 동적으로 할당된 메모리는 수동으로 해제해야 하므로 프로그램을 작성할 때 살얼음판 위를 걷게 됩니다. 여기에는 장점이 있습니다. 프로그래머가 메모리를 완벽하게 제어할 수 있습니다. 그러나 단점도 많습니다. 메모리 해제를 잊어버리는 경우가 많아 메모리 누수가 발생하는 경우가 많습니다. 따라서 많은 현대 언어에는 가비지 수집 메커니즘이 추가되었습니다.

Go의 가비지 수집은 힙과 스택을 프로그래머에게 투명하게 만듭니다. 이는 프로그래머의 손을 자유롭게 하여 비즈니스에 집중하고 "효율적으로" 코드 작성을 완료할 수 있게 해줍니다. 복잡한 메모리 관리 메커니즘을 컴파일러에 맡기면 프로그래머는 즐거운 시간을 보낼 수 있습니다.

탈출 분석의 "건방진 작업"은 변수가 가야 할 곳에 합리적으로 할당합니다. new로 메모리를 신청하더라도 함수를 종료한 후 더 이상 유용하지 않다고 판단되면 스택에 버릴 것입니다. 결국 스택의 메모리 할당은 힙보다 훨씬 빠릅니다. 일반 변수인 것처럼 보이더라도 이스케이프 분석 결과 함수 종료 후 다른 곳에서 참조되는 것으로 확인되면 힙에 할당하겠습니다.

힙에 변수가 할당되면 힙도 스택처럼 자동으로 정리될 수 없습니다. 이로 인해 Go는 자주 가비지 수집을 수행하게 되고 가비지 수집은 비교적 큰 시스템 오버헤드(CPU 용량의 25% 차지)를 차지하게 됩니다.

스택에 비해 힙은 예측할 수 없는 크기의 메모리 할당에 적합합니다. 그러나 이에 대한 대가는 느린 할당과 메모리 조각화입니다. 스택 메모리 할당은 매우 빠릅니다. 스택 할당 메모리는 할당 및 해제를 위해 "PUSH"와 "RELEASE"라는 두 개의 CPU 명령만 필요하며, 힙 할당 메모리는 먼저 적절한 크기의 메모리 블록을 찾은 다음 가비지 수집을 통해 해제해야 합니다.

이스케이프 분석을 통해 힙에 할당할 필요가 없는 변수를 스택에 직접 할당하려고 시도할 수 있습니다. 힙에 변수가 적어지면 힙 메모리 할당 비용이 줄어들고, GC에 대한 부담도 줄어들고 성능이 향상됩니다. 프로그램 실행 속도..

확장 1: 변수가 이스케이프되었는지 확인하는 방법은 무엇입니까?
두 가지 방법: go 명령을 사용하여 탈출 분석 결과를 확인하고 소스 코드를 분해합니다.

예를 들어 다음 예를 사용하십시오.

package main

import "fmt"

func foo() *int {
	t := 3
	return &t;
}

func main() {
	x := foo()
	fmt.Println(*x)
}

go 명령을 사용하십시오.

go build -gcflags '-m -l' main.go

-lfoo 함수가 인라인되는 것을 방지하기 위해 추가되었습니다 . 다음 출력을 얻습니다.

# command-line-arguments
src/main.go:7:9: &t escapes to heap
src/main.go:6:7: moved to heap: t
src/main.go:12:14: *x escapes to heap
src/main.go:12:13: main ... argument does not escape

foo 함수의 변수는 t예상한 대로 이스케이프되었습니다. 우리를 당황하게 만드는 것은 왜 main 함수 x도 이스케이프하는가 하는 것입니다. 이는 일부 함수 매개변수가 fmt.Println(a...interface{})와 같은 인터페이스 유형이기 때문입니다. 컴파일 중에 해당 매개변수의 특정 유형을 결정하기 어렵고 이스케이프도 발생합니다.

디스어셈블리 소스 코드:

go tool compile -S main.go

결과의 일부를 가로채서 그림에 표시된 명령은 t힙에 메모리가 할당되고 이스케이프가 발생했음을 나타냅니다.
여기에 이미지 설명을 삽입하세요.

확장 2: 다음 코드의 변수가 이스케이프되었습니까?
예시 1:

package main
type S struct {}

func main() {
  var x S
  _ = identity(x)
}

func identity(x S) S {
  return x
}

분석: Go 언어 함수는 값으로 전달됩니다. 함수 호출 시 매개변수 복사본이 스택에 직접 복사되며 탈출이 없습니다.

예시 2:

package main

type S struct {}

func main() {
  var x S
  y := &x
  _ = *identity(y)
}

func identity(z *S) *S {
  return z
}

분석: 항등함수의 입력을 바로 반환값으로 간주하며, z에 대한 참조가 없으므로 z는 탈출하지 않는다. x에 대한 참조는 기본 함수의 범위를 벗어나지 않았으므로 x도 이스케이프되지 않았습니다.

예시 3:

package main

type S struct {}

func main() {
  var x S
  _ = *ref(x)
}

func ref(z S) *S {
  return &z
}

분석: z는 x의 복사본입니다. z에 대한 참조는 ref 함수에서 사용되므로 z는 스택에 배치될 수 없습니다. 그렇지 않으면 ref 함수 외부에서 참조로 z를 어떻게 찾을 수 있으므로 z는 힙으로 이스케이프해야 합니다. . 비록 main 함수에서는 ref의 결과가 바로 버려지지만, Go 컴파일러는 그렇게 똑똑하지 않아서 이런 상황을 분석할 수 없습니다. x에 대한 참조가 없으므로 x는 이스케이프되지 않습니다.

예제 4: 구조 멤버에 참조를 할당하면 어떻게 됩니까?

package main

type S struct {
  M *int
}

func main() {
  var i int
  refStruct(i)
}

func refStruct(y int) (z S) {
  z.M = &y
  return z
}

분석: refStruct 함수는 y에 대한 참조를 취하므로 y가 이스케이프됩니다.

예시 5:

package main

type S struct {
  M *int
}

func main() {
  var i int
  refStruct(&i)
}

func refStruct(y *int) (z S) {
  z.M = y
  return z
}

분석: i에 대한 참조는 main 함수에서 가져와 refStruct 함수에 전달됩니다. i에 대한 참조는 항상 main 함수의 범위에서 사용되므로 i는 이스케이프되지 않습니다. 이전 예제와 비교하면 약간의 차이가 있지만 결과적으로 프로그램 효과가 다릅니다. 예제 4에서는 i가 먼저 메인 스택 프레임에 할당된 다음 refStruct 스택 프레임에 할당된 다음 힙으로 이스케이프됩니다. 힙에 한 번 할당되어 총 3번 할당됩니다. 이 예에서는 i가 한 번만 할당된 다음 참조로 전달됩니다.

예시 6:

package main

type S struct {
  M *int
}

func main() {
  var x S
  var i int
  ref(&i, &x)
}

func ref(y *int, z *S) {
  z.M = y
}

분석: 이 예에서는 i가 탈출했으며, 이전 예 5의 분석에 따르면 i는 탈출하지 않을 것이다. 두 예의 차이점은 예 5의 S가 반환 값에 있고 입력은 출력으로만 "흐를" 수 있다는 것입니다. 이 예에서 S는 입력 매개 변수에 있으므로 이스케이프 분석이 실패하고 i는 다음을 수행해야 합니다. 힙으로 탈출하세요.

추천

출처blog.csdn.net/zy_dreamer/article/details/132795412