Go 1.14 이전에는 컴파일러가 모든 defer 문에 대해 힙에 _defer 구조체를 할당하고 이를 각 고루틴의 연결 목록에 연결하였습니다. 이로 인해 상당한 GC 압력이 발생하고 깊게 중첩된 defer에 대해 O(n) 오버헤드가 발생하였습니다.
Go 1.14는 스택 할당된 defer를 도입하여, 컴파일러가 도 escape 분석을 통해 해당 defer가 함수를 초과하지 않는 경우 _defer 구조체를 함수의 스택 프레임에 직접 배치할 수 있게 하였습니다. 이후 버전에서는 오픈 코딩된 defer (Go 1.17+)를 추가하여, 컴파일러가 런타임 호출 대신_cleanup_ 코드를 함수 에필로그에 직접 삽입합니다.
패닉 복구 중에 런타임은 스택 프레임을 하나씩 풀어냅니다. aktiv frames에서 발견된 모든 스택 할당 defer를 실행한 후, 연결 목록에서 남아 있는 모든 힙 할당 defer를 실행합니다. 이 하이브리드 방식은 엄격한 LIFO 순서를 유지하면서 일반적인 경우의 할당 비용을 제거합니다.
Go로 작성된 고주파 거래 API 래퍼가 시장 변동성 동안 200밀리초의 GC 중단을 경험하였습니다.
팀은 문제를 과도한 힙 할당으로 추적하였습니다. 각 HTTP 요청 처리기는 tx.Rollback() 및 연결 정리를 위해 여러 defer 문을 사용하였습니다. 부하가 걸리면, 이는 매초 수백만 개의 _defer 구조체를 생성하여 빈번한 가비지 수집 사이클을 유발하였습니다.
해결책 A: 수동 리소스 관리. 팀은 모든 defer 호출을 제거하고 각 반환 지점에서 명시적인 Close() 및 Rollback()을 사용하는 것을 고려하였습니다. 장점: 제로 할당 오버헤드 및 예측 가능한 성능. 단점: 코드는 연약해지고 오류가 발생하기 쉬우며, 수십 개의 종료 경로에 걸쳐 중복된 정리 로직이 생겼습니다.
해결책 B: 객체 풀링. 그들은 데이터베이스 트랜잭션 객체를 풀링하려고 시도하였습니다. 장점: 사용자 코드에서의 할당 감소. 단점: 이는 _defer 구조체 할당 문제를 해결하지 않으며, 이는 런타임 내부에 있으며 사용자 코드에서 풀링할 수 없습니다.
해결책 C: 컴파일러 업그레이드 및 리팩토링. 팀은 Go 1.13에서 1.18로 업그레이드하고 클로저가 힙으로 탈출하는 변수를 캡처하지 않도록 리팩토링하였습니다. 장점: 자동 스택 할당 및 대부분의 경우에 제로 런타임 비용을 가진 오픈 코딩된 defer. 단점: 패닉 복구 동작이 올바르게 유지되는지 검증하기 위해 광범위한 회귀 테스트가 필요하였습니다.
그들은 해결책 C를 선택하였습니다. 배포 후, GC 중단 시간은 서브 밀리초로 감소하였고, 요청 처리량은 비즈니스 로직의 변화 없이 40% 증가하였습니다.
이름이 붙은 반환 매개변수를 수정하는 함수를 지연시키면 최종 반환 값에 어떤 영향을 미치고, 이름이 없는 반환에서는 언제 이 패턴이 실패하는가?
Go 함수가 이름이 붙은 반환 값을 사용하면 (예: func f() (err error)), 지연된 함수는 해당 반환 매개변수의 실제 스택 슬롯을 닫습니다. defer 내부에서 그 이름에 대한 할당은 호출자에게 반환될 값을 수정합니다. 이름이 없는 반환인 경우, 반환 값은 지연된 함수가 실행되기 전에 임시 레지스터나 스택 위치에 복사되므로 defer 내부의 수정이 호출자에게는 보이지 않습니다. 후보자들은 종종 defer가 함수의 실제 종료 순간이 아니라 등록 순간에 이름이 붙은 결과의 최종 값을 본다는 점을 놓칩니다.
과거의 Go 버전에서 O(n²) 성능 특성을 지연된 함수가 보여주는 원인은 무엇이며, 스택 할당이 왜 이 비용을 완전히 제거하지 못하는가?
Go 1.14 이전 버전에서 for 루프 안에 defer를 배치하면 매 반복마다 새로운 힙 객체가 할당되어 연결 목록에 추가되었습니다. 이는 반복마다 선형으로 증가하는 목록이 생성되어 이차 복잡성을 야기하였습니다. Go 1.14+에서는 스택에서 이를 할당하지만, 런타임은 여전히 함수 종료 시 이 defer들을 역순으로 풀고 실행해야 합니다. 함수가 n개의 작업을 지연시키면, 종료 경로는 이를 처리하기 위해 O(n) 시간이 필요합니다. 후보자들은 루프 내에서의 지연이 스택 할당에도 불구하고 여전히 안티 패턴이라는 점을 종종 놓치며; 수동 정리는 함수 범위에서 O(n) 집계 대신 각 반복에 O(1) 오버헤드를 제공합니다.
패닉 복구와 지연된 함수의 상호작용이 어떻게 지연된 호출이 자신이 패닉할 경우 다시 시작되지 않도록 하고, 이것이 순차 실행과 어떻게 다른가?
Go 함수가 패닉되면, 런타임은 스택을 풀면서 지연된 함수를 순차적으로 호출합니다. 지연된 함수가 recover() 없이 패닉하면, 그 새로운 패닉이 원래의 패닉 값을 대체합니다. 중요하게도, 지연된 함수로부터 패닉이 발생하면, 런타임은 특정 프레임에서 나머지 defer들을 실행하는 것을 중단하고 위쪽으로 풀어갑니다. 후보자들은 지연된 함수들이 트랜잭셔널하지 않다는 점을 종종 놓치며; 후속 defer가 패닉할 경우 영향을 롤백하지 않으며, defer 내부의 패닉은 해당 프레임에 대한 나머지 defer 체인의 실행을 중단시켜, 나중의 defer가 중요한 정리를 수행해야 한다면 리소스를 누수할 수 있습니다.