[Go] 복구 불가능한 패닉과 복구 가능한 패닉
Go에서는 recover를 통한 패닉 복구 기능을 제공하지만, 복구가 불가능한 패닉도 존재한다.
제어가 어려운 OOM 같은 것만 그런게 아니라 복구 불가능 여부에는 일관성도 전혀 없어서, 잘 알아두는게 좋다.
복구 가능한 패닉
1. 수동 패닉
사용자가 직접 발생시킨 패닉은 당연히 복구가 가능하다.

package main
import "fmt"
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("복구 성공: ", r)
}
}()
panic("패닉 발생")
}2. nil 역참조 패닉
nil 포인터 값을 역참조하면 패닉이 발생한다. 이것도 복구는 가능하다.

package main
import "fmt"
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("복구 성공: ", r)
}
}()
var a *int
*a = 1
}
nil 함수 값을 호출하는 것도 복구는 가능하다.
3. out of range 패닉
배열에 존재하지 않는 인덱스 범위로 인덱싱을 할때 나는 패닉이다.

package main
import "fmt"
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("복구 성공: ", r)
}
}()
nums := []int{1, 2, 3}
fmt.Println(nums[10])
}4. 다운캐스팅 패닉
any 타입에서 다운캐스팅을 할때 체크를 할 수도 있고 체크를 하지 않을 수도 있는데, 체크를 하지 않은 상태에서 캐스팅에 실패하면 패닉이 발생한다.

package main
import "fmt"
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("복구 성공: ", r)
}
}()
nums := []int{1, 2, 3}
boxed := any(nums)
fmt.Println(boxed.(int))
}5. 0으로 나눗셈하기
숫자를 0으로 나누면 패닉이 발생한다.

6. 닫힌 채널에 메세지 보내기
이미 닫힌 채널에 메세지를 보내면 패닉이 발생한다.

package main
import "fmt"
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("복구 성공: ", r)
}
}()
ch := make(chan int)
close(ch)
ch <- 1
}복구 불가능한 패닉
복구가 불가능한 패닉이 더 많다.
1. Out Of Memory 패닉
프로세스에 가용된 메모리를 초과하면 패닉이 발생한다.
이건 당연히 복구가 불가능한게 맞다.
정확히는 Go에서 패닉을 날리는게 아니라, 보통 OS 수준에서 죽여버린다.
2. Stack Overflow 패닉
스택이 넘쳐도 패닉이 난다. 이것도 제어가 불가능한게 당연하다.

package main
import "fmt"
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("복구 성공: ", r)
}
}()
var f func(a [1000]int64)
f = func(a [1000]int64) {
f(a)
}
f([1000]int64{})
}3. concurrent map write
이건 납득이 어려운 부분 중 하나다. 좀 토나오는 설계 요소다.
그냥 map에다가 동시에 쓰기를 시도하면 패닉을 던지는데, 복구도 불가능하다.

package main
import "fmt"
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("복구 성공: ", r)
}
}()
m := map[string]int{}
go func() {
for {
m["x"] = 1
}
}()
for {
_ = m["x"]
}
}
내가 겪은 unrecoverable 패닉케이스 중 상당수가 이거였다.
서드파티 라이브러리를 갖다쓰거나, 이런저런 레이어를 쌓다보면 객체에 맵이 들어있을 때가 많은데, 이런걸 사용하다보면 터질 때가 잦다.
항상 발생하는 것도 아니고 몰릴때 확률적으로 발생하다보니 추적이 더 곤란한 부분이 있다.
애초에 이딴걸 왜 복구 불가능하게 만든거지? 제정신인가?
4. nil 함수를 고루틴 실행
이것도 일관성이 없는 부분 중 하나다.
nil 역참조도 복구 가능하고, nil 함수 호출도 복구 가능한데, nil 함수를 고루틴으로 호출하는건 복구 불가능하다.

package main
import "fmt"
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("복구 성공: ", r)
}
}()
var f func()
go f()
}
생각없이 설계해둔 것 같다.
5. 데드락
데드락도 패닉 유발 요소가 되는데, 이것도 복구 불가능한 패닉에 들어간다. 왜지...?

package main
import "fmt"
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("복구 성공: ", r)
}
}()
select {}
}
아무튼 패닉 조건은 모든 고루틴이 블락걸릴 경우다.
흔한 경우는 아닐 것이다.
6. 네이티브 스레드 리소스 고갈
고루틴이 경량 스레드라서 제한없이 늘려나갈 수 있을 것이라고 환상을 다들 갖고 있지만, 실제로는 그렇지 않다. 헛소리다.
스레드 제한이 부딪히면 다음과 같은 복구 불가능한 패닉이 발생한다.
runtime: program exceeds 10000-thread limit
고루틴은 기본적으로 스택 기반 그린 스레드로 동작하고, I/O에 블락이 걸리면 네이티브 스레드를 호출한다.
근데 만약 I/O 블락이 너무 많이 걸려서 네이티브 스레드를 끊없이 호출하다보면, 네이티브 스레드 개수 제한이 걸릴 수 있다. 그러면 이것도 패닉케이스가 된다.
7. 자식 고루틴에서 발생한 패닉
자식 고루틴에서 발생한 패닉은 상위 고루틴에서 복구할 수 없다. 그래서 고루틴을 사용할 때는 항상 신중하게 살펴보거나, 자식 고루틴 안에도 recover를 달아줘야 한다.

package main
import "fmt"
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("복구 성공: ", r)
}
}()
go func() {
panic("패닉 발생!")
}()
for {
}
}
이것도 실수하기 매우 좋은 부분 중 하나다.
서브프로세스 하나에서 패닉케이스가 터져도 전체 프로그램이 다운되는 것이다.
이것도 Go의 저가용성에 한몫 보태는 그런 지점 중 하나다.
참조
https://stackoverflow.com/questions/57486620/are-all-runtime-errors-recoverable-in-go
https://stackoverflow.com/questions/32042048/go-runtime-program-exceeds-10000-thread-limit