Goroutine Leaks

번역에 앞서

좋은 글을 저에게 공유해주신 동료이자 리더 PJ에게 감사의 말씀을 드립니다.

본 글은 JacobWalker의 칼럼을 번역한 글입니다.

Goroutine Leaks - 부제 : 잊혀진 Sender

동시성(Concurrency) 프로그래밍은 개발제에게 하나 또는 그 이상의 작업을 사용하여 문제를 해결하는데 도움을 주거나 성능을 향상시키는데 종종 사용되곤 합니다.

Concurrency(동시성)는 이러한 다수의 작업들이 parallel(병렬성)하게 수행되는 것을 의미하지는 않습니다.

( Concurrency와 Parallel의 차이를 명확히 이해해야합니다. Parallel의이란 두 사람이 동시에 각각 업무를 수행하고 있다면 이는 물리적으로 별개의 업무를 수행하기 때문에 parallel하다고 봅니다. 반면에 커피를 마시면서 신문을 보는 사람이 있다면, 물리적으로 두 흐름이 동시에 수행되는 것은 아닙니다. 커피를 마시기 위해 신문 보는 것을 짧은 시간에 잠시 중단하고 커피를 한 모금 마신 뒤 다시 신문을 보는 일로 돌아오는 경우는 물리적으로 두 흐름이 있지는 않지만 동시에 두 가지를 하고 있습니다. 이것을 동시성 혹은 병행성(Concurrency)이라고 부릅니다. Concurrency와 Parallel의은 다르지만 Concurrency이 있어야 Parallel의이 생깁니다. 서로 어느 것이 먼저 되어야 하는 의존 관계가 있는 것은 함께 진행될 수 없습니다. 장갑을 끼면서 동시에 구두를 신을 수는 있겠지만, 양말을 신으면서 동시에 구두를 신을 수는 없습니다. 양말을 신고 나야 구두를 신을 수 있으니까요. 이 둘 사이에 동시성이 없으므로 병렬성이 생기지 않는 것 입니다. 즉, 장갑을 먼저 끼거나 구두를 먼저 신는 것에는 순서에 관계 없지만, 양말은 구두를 신기 전에 신어야 합니다. 예시 출처 : 디스커버리 Go - 염재현 저 )

그것은 이러한 작업이 순차적으로 실행되는 것이 아닌 순서대로 실행되고 있음을 의미합니다.

역사적으로 이러한 타입의 프로그래밍은 3rd party 개발자들이나 표준 라이브러리에 의해 제공되어지는 라이브러리들을 사용하는데 용이했습니다.

Go에서, Goroutine이나 Channel과 같은 Concurrency 특징은 언어와 런타임에서 라이브러리의 필요성을 줄이거나 없애기 위해 내장되어졌습니다.

그리고 이것은 Go에서는 동시성 프로그래밍을 작성하는 것이 아주 쉽다는 환상을 만들어왔습니다.

그러나 당신은 Concurrency를 사용하는 것을 결정할 때 만약 정확하지 않게 사용한다면 몇 가지 희귀한 사이드 이펙트나 함정에 빠질 수 있기 때문에 주의해야합니다.

이러한 함정은 만약 당신이 주의하지않으면 복잡하고 추잡한 버그들을 만들어낼 수도 있습니다.

제가 이 글에서 언급하려고 하는 그 함정들은 Goroutine Leaks(고루틴 누수)와 관련이 있습니다.

Leaking Goroutines

20191012151610 729832

너 기름샌다…

(역자 주: Leak - 새다, 흐르다, 누출, 누수)

메모리 관리에 있어서 Go는 당신을 위해 많은 세부 정보를 관리합니다.

Go 컴파일러는 escape analysis를 사용해서 메모리에 값이 어디에 위치해있는지를 결정합니다.

escape analysis란?

런타임은 가비지 콜렉터를 사용해서 힙 할당을 추적하고 관리합니다.

가비지 콜렉터

물론 당신의 애플리케이션에서 Memory Leak을 만드는 것이 불가능하지는 않지만 크게 줄어들 것입니다.

Memory Leak의 일반적인 타입은 고루틴에서 발생합니다.

당신은 Goroutine을 시작하면 최종적으로는 종료될 것이라고 생각하지만 실제로는 결코 끝나지 않습니다. 그리고 그것은 Goroutine Leak으로 이어집니다.

Goroutine은 애플리케이션의 생명주기 동안 살아있으며 Goroutine에 할당된 메모리는 해제할 수 없게 됩니다.

이것은 고루틴을 어떻게 멈출지 모르는 상태에서 절대 고루틴을 시작하지 마라!라는 조언의 근거 중 하나입니다.

위의 조언에 해당하는 글

일반적인 Goroutine Leak을 설명하기 위해, 아래의 코드를 봅시다.

31 // leak은 버그를 일으키는 함수입니다.
32 // 이 함수는 채널로부터 받는 것을 차단하는 고루틴을 시작합니다.
33 // 채널로 그 어떤 것도 보내지지 않을 것이고,
34 // 채널은 영원히 닫혀지지 않고 그래서 고루틴은 영원히 차단됩니다.
35 func leak() {
36     ch := make(chan int)
37
38     go func() {
39         val := <-ch
40         fmt.Println("We received a value:", val)
41     }()
42 }

leak이라는 이름의 함수를 정의했습니다.

그 함수는 Goroutine(이하 고루틴)이 정수 데이터를 전달할 수 있는 channel(이하 채널)을 만듭니다.

그리고 아래에 고루틴 함수가 생성되고 val 변수는 채널로부터 값을 받기 위해 기다립니다.

고루틴이 기다리고 있는 동안, leak 함수는 리턴됩니다.

이 부분에서 프로그램의 다른 부분은 채널을 통해 신호를 보낼 수 없습니다.

고루틴은 39번째 라인에서 무한히 대기하게 됩니다.

당연히 fmt.println은 절대 수행되지 않을 겁니다.

이 예에서 고루틴 leak은 코드리뷰 동안에 빠르게 알아차릴 수 있습니다.

불행히도 프로덕션 코드에서 고루틴 leak은 일반적으로 찾는 것이 더욱 어렵습니다.

저는 여러분들에게 고루틴 leak이 일어나는 모든 가능성을 보여줄 수는 없지만, 이 글에서 발생할 수 있는 고루틴 leak의 한 종류에 대해서는 자세히 설명하도록 하겠습니다.

Leak : 잊혀진 Sender

이 leak 예제에서 당신은 채널에서 값을 보내는 것을 기다리면서 영원히 block된 고루틴을 보게 될 겁니다.

우리가 살펴볼 프로그램은 검색어에 따라 레코드를 찾은 다음 프린트됩니다.

이 프로그램은 search라는 함수로 구현되어졌습니다.

29 // search는 검색어를 기반으로 레코드를 찾는 함수를 시뮬레이션합니다.
30 // 이 작업은 200ms의 시간이 걸립니다.
31 func search(term string) (string, error) {
32     time.Sleep(200 * time.Millisecond)
33     return "some value", nil
34 }

search 함수는 데이터베이스 쿼리나 웹 호출같은 긴 실행 작업을 시뮬레이션하기 위한 가짜 구현체입니다.

이 프로그램은 아래와 같이 search 함수를 호출합니다.

17 // process는 이 프로그램을 위한 작업입니다.
18 // 레코드를 찾을 것이고 그것을 프린트 할 겁니다.
19 func process(term string) error {
20     record, err := search(term)
21     if err != nil {
22         return err
23     }
24
25     fmt.Println("Received:", record)
26     return nil
27 }

process 함수는 검색어로 표현되는 단일 string 매개변수를 받게 끔 정의되어집니다.

term 변수는 search 함수로 전달되어지고 recorderr를 반환합니다.

만약 에러가 발생한다면 에러는 err를 리턴할 것입니다.

에러가 없다면 출력을 합니다.

몇몇 애플리케이션에서 순차적으로 search를 호출할 때 발생하는 지연이 허용되지 않을 수 있습니다.

search 함수의 수행 속도가 더 빨라질 수 없다고 가정하고, search 함수에 의해 발생한 전체 지연 비용을 감소하지않고 process 함수는 변경되어질 수 있습니다.

이것을 하기위해서는 아래와 같이 고루틴을 사용할 수 있습니다.

불행히도 이 첫번째 시도는 잠재적인 고루틴 leak을 발생시키므로 버그가 존재합니다.

38 // result 구조체는 search 함수의 결과를 래핑했습니다.
39 // 이 구조체는 단일 채널을 통해 두 값을 한번에 전달할 수 있습니다.
40 type result struct {
41     record string
42     err    error
43 }
44
45 // process는 이 프로그램의 작업입니다. 레코드를 찾고 출력합니다.
46 // 만약 작업에 100ms 이상 걸리면 실패한다고 가정하겠습니다.
47 func process(term string) error {
48
49     // 100ms 이내에 취소될 context를 생성합니다.
50     ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
51     defer cancel()
52
53     // 고루틴이 결과를 기록하기 위한 채널을 만들었습니다.
54     ch := make(chan result)
55
56     // 레코드를 찾기 위해 고루틴을 실행합니다.
57     // 리턴된 값에서 result 구조체를 만들어서 채널로 보냅니다.
58     go func() {
59         record, err := search(term)
60         ch <- result{record, err}
61     }()
62
63     // 고루틴 채널에서 수신되기를 대기하거나 컨텍스트가 취소되기를 기다리는 블럭입니다.
65     select {
66     case <-ctx.Done():
67         return errors.New("search canceled")
68     case result := <-ch:
69         if result.err != nil {
70             return result.err
71         }
72         fmt.Println("Received:", result.record)
73         return nil
74     }
75 }

100ms 이후 취소될 컨텍스트를 작성하기 위해 process를 재작성했습니다.

Context를 사용하기 위한 더 많은 정보는 아래의 글을 읽어보세요.

golang.org.blog.post

그런 다음 고루틴이 result 타입의 데이터를 전송하도록 채널을 만듭니다.

익명 함수가 정의된 후 고루틴으로 호출되어집니다.

이 고루틴은 search 함수를 호출하고 버퍼되지 않은 채널로 리턴 밸류를 전송하려고 시도합니다.

고루틴이 이 작업을 하는 동안 process 함수는 select block을 실행합니다.

이 블럭은 둘 다 수신 채널 작업인 두 가지 경우를 갖습니다.

66번째줄에서 ctx.Done()채널로부터 수신받는 경우가 있습니다.

이 경우는 컨텍스트가 취소되면 실행되어집니다 (100ms를 넘은 경우입니다.)

만약 이 경우가 실행되어지면 process 함수는 search 함수에 대한 대기를 포기했다는 에러를 반환하게 될 것입니다.

68번째 줄의 경우 ch 채널로부터 전달받고 값을 result 변수에 대입합니다.

이 프로그램은 이전과 같이 69번째,70번재 줄과 같이 에러를 확인하고 처리합니다.

만약 에러가 없다면 프로그램은 레코드를 출력하고 성공을 표시하기 위해 nil을 리턴합니다.

이러한 리팩토링은 process 함수가 search 함수가 완료되는 것을 기다리는 최대 시간을 설정합니다.

하지만 이러한 구현 또한 잠재적인 고루틴 leak을 만들어냅니다.

이 코드에서 고루틴이 무엇을 하는지 생각해봅시다. 60번째 줄에서 채널에 전송합니다.

이 채널로 전송하면 다른 고루틴이 값을 받을 준비가 될 때 까지 차단 됩니다.

제한 시간 초과의 경우 수신자는 고루틴으로부터 수신을 위해 기다리는 것을 중지하고 계속 진행합니다.

이는 고루틴이 절대 발생하지 않는 수신자가 나타나기를 기다리는 것을 영원히 차단시키게 됩니다.

이것은 고루틴 leak입니다.

수정! 공간 확보

이 leak을 해결하기 위한 가장 간단한 방법은 채널을 버퍼되지 않은 채널에서 용량이 1인 버퍼된 채널로 변경하는 것입니다.

53     // 결과를 기록하기 위한 고루틴 채널을 만들었습니다.
54     // 전송이 차단되지 않게 하기 위해 용량을 주었습니다.
55     ch := make(chan result, 1)

이제 제한 시간 초과의 경우에서 수신자가 실행된 후 search 고루틴은 result 값을 채널에 배치하여 전송을 완료하고 리턴할 것입니다.

고루틴에 대한 메모리는 결국 채널의 메모리와 함께 되찾아집니다.

모든 것이 자연스럽게 작동하게 됩니다.

Behavior of Channels에서 William Kennedy는 채널 행동의 몇 가지 훌륭한 예시를 제공하고 사용에 관한 철학을 제공했습니다.

Listing 10의 마지막 예시는 이 타임아웃 예제와 유사한 프로그램을 보여줍니다.

버퍼링된 채널을 사용하는 시기와 적절한 용량에 대해 더 많은 정보가 필요하다면 위해 해당 글을 읽어보세요.

결론

고는 고루틴을 시작하는 것을 간단하게 제공해주지만 그것들을 지혜롭게 사용하는 것은 우리의 책임이기도 합니다.

이 글에서 저는 어떻게 고루틴이 잘못 사용되어질 수 있는지에 대한 예시를 한 가지 보여드렸습니다.

동시성을 사용할 때 당신이 만날 수 있는 다른 함정뿐만 아니라 고루틴 leak을 만드는 더 많은 방법들이 있습니다.

미래의 글에서 저는 더 고루틴 leak의 더 많은 예제와 다른 동시성 함정들을 제공하겠습니다.

그래서 지금 저는 여러분들에게 이러한 조언을 드리고 싶습니다.

여러분들이 고루틴을 시작할때 마다 여러분 스스로에게 질문 해보세요.

  • 이 고루틴이 언제 종료될까?

  • 고루틴이 종료되는 것을 막고 있는 것은 무엇이지!?

동시성은 유용한 도구이지만 조심해서 사용해야합니다.

Reference

https://www.ardanlabs.com/blog/2018/11/goroutine-leaks-the-forgotten-sender.html



© 2022. by minkuk

Powered by minkuk