Goroutine Case Study#

헷갈리기 쉬운 Go 동시성의 경계들을 탐색해보려고 한다.

예제 코드: GitHub


고루틴#

고루틴은 Go 런타임이 관리하는 독립적인 실행 흐름이다.
OS 스레드 위에서 동작하지만, 생성과 스케줄링, 중단과 재개는 Go 런타임이 담당한다.

여기서 중요한 점은 고루틴이 실행의 단위일 뿐,
취소나 오류 전파, 자원 해제에 대한 책임까지는 함께 가지지는 않는다는 점이다.
이러한 책임은 고루틴을 생성한 쪽에 명시적으로 남겨진다.


채널#

채널은 고루틴 간 값을 전달하기 위한 통신 수단이다.
흔히 동기화가 보장되는 안전한 큐 로 설명되지만,
큐의 직관과 다르게 동작하는 지점들이 존재한다.

채널은 값을 전달하는 순간에만 동기화를 제공할 뿐,
값 자체의 생명주기나 이후 접근까지를 보호해주지는 않는다.
즉, 채널을 사용한다고 해서 메모리 공유 문제가 자동으로 사라지지는 않는다.

또한 채널은 고루틴의 종료를 보장하거나,
작업의 완료 여부를 추적하는 추상화도 아니다.

읽히지 않거나 닫히지 않는 채널은 고루틴을 영구적으로 대기 상태에 빠뜨릴 수 있다.

이러한 특성 때문에 채널은 강력한 도구인 동시에,
고루틴 누수와 경쟁 상태, 데드락의 출발점이 되기도 한다.


Case 1. 고루틴 안에서 발생한 panic은 어디까지 전파되는가?#

고루틴은 독립적인 실행 흐름이지만,
panic과 recover의 관점에서는 독립적인 에러 경계가 아니다.

panic은 고루틴 경계를 넘지 않으며,
recover 또한 같은 고루틴 안에서만 의미를 가진다.

이 특성은 고루틴을 사용할 때
에러 처리와 생명 주기를 어떻게 설계해야 하는지에 직접적인 영향을 준다.


실험 A. 고루틴 내의 에러는 전파되는가?#

아래의 코드는 해당 케이스에 대한 실험 코드와 그 실행 결과이다.

package main

import (
	"fmt"
	"time"
)

func main() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("[main] recovered:", r)
		}
	}()

	go func() {
		defer func() {
			if r := recover(); r != nil {
				fmt.Println("[goroutine] recovered:", r)
			}
		}()
	
		fmt.Println("[goroutine] start panic")
		panic("panic in goroutine")
	}()

	time.Sleep(1 * time.Second)
	fmt.Println("[main] finished")
}

// [goroutine] start panic
// [goroutine] recovered: panic in goroutine
// [main] finished

관찰 1. 고루틴 내부에서 패닉 발생#

[gorouine] start panic
panic은 해당 고루틴의 call stack만 unwinding하고, 외부에 전파되지 않는다.

관찰 2. recover는 같은 고루틴에서만 동작#

[goroutine] recovered: panic in goroutine
고루틴 내에 있던 defer function 은 정상적으로 동작했고,
recover 인해 goroutine이 종료되지 않고 흐름으로 복귀했다.

관찰 3. main의 recover는 panic을 잡지 못함#

main 함수에 있던 defer function 은 동작하지 않았다.
이를 통해, recover가 고루틴 + 콜스택 단위임을 확인할 수 있었다.


실험 B. panic은 어디까지 전파되는가?#

아래와 같이 코드를 수정해보고, 고루틴 내의 panic이 main에서 defer function 을 호출하는지 확인해보았다.

package main

import (
	"fmt"
	"time"
)

func main() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("[main] recovered:", r)
		}
	}()

	go func() {	
		fmt.Println("[goroutine] start panic")
		panic("panic in goroutine")
	}()

	time.Sleep(1 * time.Second)
	fmt.Println("[main] finished")
}

// [goroutine] start panic
// panic: panic in goroutine

// goroutine 34 [running]:
// main.main.func2()
//         /main.go:23 +0x64
// created by main.main in goroutine 1
//         /main.go:15 +0x40
// exit status 2

관찰 4. panic은 고루틴 경계를 넘지 않지만, 프로세스 경계는 넘는다#

결과적으로 고루틴 -> panic -> 프로세스 종료 흐름을 보여주었고,
이는 고루틴 내에서 프로세스 종료로 바로 이어졌음을 확인할 수 있었다.


Case 1 정리#

  • panic은 고루틴 경계를 넘지 않는다.
    panic은 발생한 고루틴의 콜스택만 unwinding 하며,
    다른 고루틴으로 전파되거나 호출자에게 전달되지 않는다.

  • recover는 같은 고루틴 안에서만 유효하다.
    다른 고루틴에 존재하는 defer + recover
    해당 panic을 관찰하거나 제어할 수 없다.

  • recover가 없는 고루틴의 panic은 프로세스를 종료시킨다.
    panic이 고루틴 경계를 넘지는 않지만,
    처리되지 않은 panic은 런타임에 의해 곧바로 프로세스 종료로 이어진다.

  • 고루틴은 실행 단위지, 에러 격리가 아니다.
    고루틴을 분리했다고 해서 에러가 자동으로 격리되거나 안전해지지 않는다.

  • 결국, 고루틴 내부의 panic은 반드시 정책적으로 다뤄야 한다.
    defer + recover 패턴으로 고루틴 내부에서 흡수할 것인지,
    panicerrorresult channel로 변환해 외부에 전달할 것인지,
    혹은 해당 panic을 치명적 오류로 간주할 것인지,

    와 같이, 이 선택은 고루틴을 생성한 쪽의 책임이다.


Case 2. 고루틴은 왜 context를 가지지 않는가?#

고루틴은 독립적인 실행 흐름이지만,
스스로 취소되거나 중단되는 매커니즘을 내장하고 있지는 않다.

즉, 고루틴은 시작될 수는 있지만,
언제 멈춰야 하는지는 알지 못한다.

이 책임은 항상 고루틴을 생성한 쪽에 넘겨진다.

그래서 일반적인 커뮤니티 컨벤션은
고루틴에 context.Context를 포함하는 방향으로 만들어졌다.


실험 A. 고루틴은 종료 조건을 알고 있는가?#

아래의 코드는 context 없이 구현된 실험 코드와 그 실행 결과이다.

package main

import (
	"fmt"
	"time"
)

func main() {
	go func() {
		for {
			fmt.Println("[goroutine1] working...")
			time.Sleep(1 * time.Second)
		}
	}()

	go func() {
		for i := 0; i < 5; i++ {
			fmt.Println("[goroutine2] working...")
		}
	}()

	time.Sleep(5 * time.Second)
	fmt.Println("[main] exit")
}

// [goroutine1] working...
// [goroutine2] working...
// [goroutine2] working...
// [goroutine2] working...
// [goroutine2] working...
// [goroutine2] working...
// [goroutine1] working...
// [goroutine1] working...
// [goroutine1] working...
// [goroutine1] working...
// [main] exit
//
// Process finished with the exit code 0

관찰 1. 첫 번째 고루틴은 종료 조건을 알지 못한다.#

첫 번째 고루틴은 무한 루프로 이루어진 고루틴이며,
종료 조건, 외부 신호, 컨텍스트가 없기 때문에 해당 고루틴은 종료 조건을 모른다.

관찰 2. 두 번째 고루틴은 종료 조건을 알고 있는 것 처럼 보인다.#

두 번째 고루틴은 5번의 반복 후에 자연스럽게 종료되는 고루틴이며,
겉보기에는 종료 조건을 알고 있는 것 처럼 보인다.

하지만, 이 고루틴이 아는 것은 “이 함수의 코드가 여기까지다.” 라는 정보 밖에 없다.

외부 상태, main이 살아있는지, 프로그램이 종료되는지, 작업이 취소되었는지 등
도메인 관점의 종료 조건을 아는 것이 아니라, 문법적 코드 경계에 의해 종료되었을 뿐이다.

즉, 고루틴은 ‘자신의 코드 범위 밖에서 발생하는 종료 조건’을 기본적으로 인식하지 못한다.

  • 지금 이 작업이 더 이상 의미 있는가?
  • 상위 요청은 취소되었는가?
  • 결과를 전달할 대상이 아직 존재하는가?
  • 시스템에 shutdown 중인가?

와 같은 질문에 고루틴은 스스로 대답하지 못하고, 이를 고루틴 바깥의 책임으로 돌린다.


실험 B. context를 전달하면 무엇이 달라지는가?#

아래의 코드는 context를 활용하는 실험 코드와 그 실행 결과이다.

package main

import (
	"context"
	"fmt"
	"time"
)

func main() {
	ctx, cancel := context.WithCancel(context.Background())

	go func(ctx context.Context) {
		for {
			case <- ctx.Done():
				fmt.Println("[goroutine1] cancelled:", ctx.Err())
				return
			default:
				fmt.Println("[goroutine1] working...")
				time.Sleep(1 * time.Second)
		}
	}(ctx)
	
	time.Sleep(3 * time.Second)
	fmt.Println("[main] cancel context")
	cancel()

	time.Sleep(1 * time.Second)
	fmt.Println("[main] exi"t)
}

// [goroutine1] working...
// [goroutine1] working...
// [goroutine1] working...
// [main] cancel context
// [goroutine1] canceled: context canceled
// [main] exit
//
// Process finished with the exit code 0

관찰 1. 고루틴은 context를 소유하지 않는다.#

ctx는 고루틴 내부에서 생성된 것이 아니라,
고루틴을 생성한 쪽에서 주입되었다.

고루틴은 ctx를 받아 ctx.Done()을 감시할 뿐, 취소의 결정권은 호출자에게 있다.

관찰 2. context는 종료 의사를 명시적으로 전달한다.#

실험 A에서는 고루틴이 종료 조건을 알지 못한 채 영원히 실행되었다.

반면 이 실험에서는 main에서 ctxcancel을 통해 생명 주기를 관리하고,
cancel()을 통해 명시적으로 종료 요청을 보내면
고루틴은 이를 감지해 자발적으로 종료한다.

관찰 3. main의 종료와 고루틴 종료는 분리된다.#

main이 끝나 프로세스가 종료되었기 때문에 고루틴이 멈춘 것이 아니라,
context취소라는 명시적 신호에 의해 종료되었다.

관찰 4. context를 갖는 고루틴 생성 시, 호출자 스코프에 cancel()이 없으면 코드 스멜이다.#

context.WithCancel, WithTimeout, WithDeadline으로 생성된 context
취소 지점이 호출자 코드에 명시되어야만 의미를 가진다.

호출자 스코프에서 cancel()이 호출되지 않는다면
고루틴은 종료 조건을 영원히 기다리게 되고,
취소 가능한 것처럼 보이는 API는 실제로 취소되지 않으며,
고루틴 생명주기에 대한 책임이 흐려진다.

이는 고루틴이 아니라 호출자의 책임을 숨기는 코드가 된다.


Case 2 정리#

  • 고루틴은 실행 흐름일 뿐, 언제 멈춰야하는지에 대한 정보는 가지지 않는다.
    외부 조건에 의해 지속되는 고루틴을 만들기 위해선,
    종료 신호를 명시적으로 설계해야 한다.

  • context는 고루틴의 생명주기를 호출자가 통제하기 위한 계약이다. 이를 통해 이벤트 루프, 대기 상태, 서버, 워커, 리스너 같은
    장기 실행 작업의 종료 시점을 구조적으로 관리할 수 있다.


정리. 고루틴은 왜 context를 가지지 않도록 설계되었는가?#

Case 2에서 살펴본 것처럼,
고루틴은 스스로 종료되지 않으며, 종료 의사는 항상 외부에서 전달되어야 한다.

그렇다면 왜 고루틴은 처음부터 context를 내장하지 않았을까?

Go에서 context는 매우 중요한 추상화임에도,
고루틴은 이를 기본적으로 가지지 않는다.
이는 단순한 편의성의 문제가 아니라, 의도된 설계 선택이다.


고루틴은 작업이 아니라 실행 흐름이다#

고루틴은 흔히 경량 스레드로 설명되지만,
개념적으로는 함수를 독립적인 실행 흐름으로 분리하기 위한 추상화다.

그래서 고루틴은
목적을 알지 못하고,
작업의 의미를 해석하지 않으며,
언제 시작되고 언제 끝나야 하는지를 판단하지 않는다.

즉, 고루틴은 의미를 가지지 않는 실행 단위다.

만약 고루틴이 context를 소유한다면,
고루틴은 단순한 실행 흐름을 넘어
자신의 생명주기를 판단하는 주체가 된다.

그리고, Go는 이를 허용하지 않는다.


context는 실행 단위가 아니라 호출 관계에 속한다#

context.Context는 고루틴을 위한 개념은 아니다.

context는 요청과 그 하위 작업을 묶는 호출 관계의 확장으로,
고루틴 뿐만 아니라 gin, gorm 등의 프레임워크에서 작업을 묶는 용도로도 많이 사용한다.

이를 통해 하나의 context
여러 함수 호출을 관통할 수 있고,
여러 고루틴으로 전파될 수 있으며,
고루틴 경계와 반드시 일치하지 않는다.

따라서 context를 고루틴에 내장하는 것은
호출 관계와 실행 단위를 혼동하게 만든다.

Go는 이 둘을 명확히 분리했다.


책임의 방향을 고정하기 위한 Go의 설계 철학이다#

고루틴이 자신의 context를 가진다면,
취소의 책임은 고루틴 내부로 들어오게 된다.

그러나 Go는
“종료를 결정하는 쪽과, 종료되는 쪽은 반드시 분리되어야 한다.” 라는 철학을 가지고 있다.

그래서 context와 취소 시점은 호출자가 생성하고 결정하며,
고루틴은 이를 감지하고 따를 뿐이다.

이 구조를 통해
생명주기의 책임은 항상 위쪽으로 흐른다.


부록. Go의 GMP 스케줄러#

GMP 스케줄러는 Go 런타임이 고루틴을 효율적으로 실행하기 위해 사용하는 스케줄링 모델이다.

GMP는 각각 Goroutine(G), Processor(P), Machine(M)을 의미한다.

이 모델의 핵심은,
OS 스레드를 직접 스케줄링하지 않고, Go 런타임이 고루틴을 스케줄링한다는 점에 있다.


G (Goroutine)#

G는 실행 가능한 함수와 그 실행 상태를 담고 있는 구조체다.

여기에 실행할 함수, 스택, 레지스터 상태, 실행 가능 여부 등의 정보를 포함하며,
Go 스케줄러가 관리하는 가장 작은 실행 단위다.

중요한 점은, G는 스스로 실행되지 않으며,
언제 실행될지, 언제 중단될지는 전적으로 스케줄러에 의해 결정된다.


P (Processor)#

P는 고루틴을 실행하기 위한 실행 권한과 자원의 묶음이다. Processor는 고루틴 실행 큐, 메모리 할당 캐시, 스케줄링 관련 상태를 소유한다.

P의 개수는 GOMAXPROCS 환경 변수에 의해 결정되며,
이는 동시에 실행될 수 있는 고루틴의 최대 개수를 의미한다.

즉, P는 실행 주체는 아니고, 실행을 가능하게 하는 논리적 자원이다.


M (Machine)#

M은 실제로 고루틴을 실행하는 OS 스레드다.

M은 P를 획득해야만 고루틴을 실행할 수 있으며,
P가 없는 M은 실행할 수 있는 작업이 없다.

Go 런타임은 필요에 따라 OS 스레드 생성을 요청하고,
생성된 M을 실행·대기 상태로 조율하며 사용한다.


고루틴은 어떻게 실행되는가#

고루틴 실행 흐름을 단순화하면 다음과 같다.

  1. 고루틴(G)이 생성된다.
  2. G는 특정 P의 run-queue에 들어간다.
  3. M이 P를 획득한다.
  4. M이 P의 run-queue에서 G를 가져와 실행한다.

여기서 볼 수 있는 것은, 스케줄링의 기준이 M이 아니라 G라는 것이다.

Go의 런타임은
“어떤 스레드를 실행할까?“가 아니라,
**“어떤 고루틴을 다음에 실행할까?”**를 중심으로 동작한다.


일반 함수와 스케줄러의 관계#

Go 프로그램이 시작되면,
런타임은 내부적으로 초기 고루틴(main goroutine) 을 생성하고,
그 고루틴 안에서 main.main()함수를 실행한다.

그래서 일반 함수 호출은 현재 실행 중인 고루틴의 스택 위에서 동작하고,
go func() 키워드를 사용했을 때만 새로운 G가 생성된다.

순차적인 코드 역시 GMP 스케줄러의 관리 하에 있으며,
다만 고루틴이 하나뿐이라 스케줄링이 드러나지 않을 뿐이다.


context와의 연결#

GMP 모델에서 G는 의미를 해석하지 않는 실행 단위로 남아 있어야 하고,
취소, 타임아웃, 오류 전파와 같은 정책은 반드시 G 바깥에서 주입되어야 한다.

고루틴이 context를 내장하지 않는 이유도 이 구조와 연결된다.
이 분리가 Go 동시성 모델의 단순함과 예측 가능성을 만든다.


병렬성과의 관계#

GOMAXPROCS를 의도적으로 조절할 수 있고,
이를 통해 제한된 P 에서 어떤 변화가 발생하는지 알아보려고 한다.

아래는 실험 코드와,
P를 제한했을 때와 하지 않았을 때의 실행 시간이다.

package main  
  
import (  
    "fmt"  
    "runtime"
	"sync"    
	"time"
)  
  
func cpuBound() {  
    var x uint64  
    for i := 0; i < 1e9; i++ {  
       x += uint64(i)  
    }  
}  
  
func worker(id int, wg *sync.WaitGroup) {  
    defer wg.Done()  
  
    fmt.Printf("worker id: %d start ...\n", id)  
    cpuBound()  
    fmt.Printf("worker id: %d end ...\n", id)  
}  
  
func appendix() {  
    now := time.Now()  
    fmt.Printf("CPU COUNTS: %d \n", runtime.NumCPU())  
  
    //runtime.GOMAXPROCS(1)  
  
    fmt.Printf("GOMAXPROCS: %d \n", runtime.GOMAXPROCS(0))  
  
    var wg sync.WaitGroup  
    numWorkers := 5  
  
    for i := 1; i <= numWorkers; i++ {  
       wg.Add(1)  
       go worker(i, &wg)  
    }  
  
    wg.Wait()  
  
    fmt.Printf("Total: %fs", time.Since(now).Seconds())  
}
// CPU COUNTS: 14
// GOMAXPROCS: 14   Total: 0.271068s
// GOMAXPROCS: 1    Total: 1.168539s

PROCS가 병렬성을 결정한다#

runtime.GOMAXPROCS는 런타임에서 동시에 실행될 수 있는 P의 개수를 결정한다.

  • GOMAXPROCS = 1
    모든 고루틴은 하나의 P 위에서 시분할로 실행된다.
    총 실행 시간은 거의 직렬 합과 같다.

  • GOMAXPROCS > 1
    여러 P가 동시에 활성화된다.
    CPU bounded는 실제로 병렬 실행된다.
    이로 인해, 총 실행 시간이 유의미하게 줄어든다.