원문: https://medium.com/hprog99/concurrency-in-go-a-deep-dive-2abbb4838984 (Translated by Google Gemini)


서론#

동시성이란 무엇인가?#

동시성은 독립적인 활동들의 구성으로 느슨하게 정의될 수 있습니다. 반드시 병렬 실행을 필요로 하지는 않지만, 여러 태스크가 겹치는 시간 동안 진행되도록 허용합니다. 컴퓨팅의 맥락에서 동시 프로그래밍은 여러 태스크가 실행에서 인터리브(interleave - 여러 대상을 번갈아 가며 실행하거나 배치) 될 수 있도록 보장하며, 이는 특히 I/O 바운드 태스크나 수많은 이벤트를 동시에 처리해야 하는 애플리케이션(예: 웹 서버에서 여러 클라이언트 연결 처리)에서 중요합니다.

동시성 vs. 병렬성#

비공식적인 대화에서 종종 혼용되지만, 동시성과 병렬성은 다른 개념입니다.

  • 동시성은 여러 태스크가 겹치는 시간 동안 시작, 실행 및 완료될 수 있는 경우입니다.
  • 병렬성은 태스크가 다른 CPU 코어에서 동시에 실행되는 경우입니다.

동시성은 여러 프로세서나 CPU 코어가 사용 가능할 경우 병렬성을 활용할 수 있지만, 동시성은 근본적으로 여러 태스크와 그들 간의 상호 작용을 관리하는 것에 관한 것이며, 실제로 정확히 같은 순간에 실행되는지 여부와는 무관합니다.

Go에서 동시성은 항상 병렬성을 의미하지는 않습니다. 그러나 Go의 런타임 스케줄러GOMAXPROCS 가 적절히 설정되어 있다면 멀티코어 시스템을 활용하여 고루틴을 병렬로 실행할 수 있습니다.

Go가 동시성에 뛰어난 이유#

  • 경량 고루틴: 무거운 운영 체제 스레드 대신 Go는 스택 메모리 사용량 및 생성 오버헤드 측면에서 매우 가벼운 고루틴을 제공합니다.
  • 채널: 고루틴 간 통신을 위한 일급 언어 구성 요소인 채널은 많은 동시성 패턴을 더 간단하고 명시적으로 만듭니다.
  • 스케줄러: Go는 수많은 고루틴을 효율적으로 관리하는 내장 스케줄러를 포함하여 개발자가 스레드 관리보다는 설계에 집중할 수 있도록 합니다.
  • 단순성: 동시성 기본 요소(고루틴 및 채널)는 개념적으로 더 간단하며 구조화된 접근 방식을 장려합니다.

동시성의 기초#

스레드와 루틴의 개념#

대부분의 프로그래밍 언어에서 동시성은 종종 스레드를 사용하여 달성됩니다. 각 스레드는 자체 스택을 가지며 운영 체제에 의해 스케줄링됩니다. 스레드는 생성 및 관리가 비쌀 수 있으며, 스레드 간 전환(컨텍스트 스위칭)에는 상당한 오버헤드가 있습니다.

Go는 고루틴 이라는 개념을 도입했는데, 이는 Go 런타임에 의해 관리되는 사용자 공간 “스레드” 입니다. 고루틴은 전통적인 OS 스레드에 비해 더 가볍습니다. 런타임은 m:n 스케줄링 이라는 기술을 사용하는데, 여기서 m 개의 고루틴이 n 개의 OS 스레드에 다중화됩니다.

운영 체제 스레드 vs. 사용자 수준 루틴#

  • 운영 체제 스레드: OS에 의해 관리됩니다. 스레드 전환에는 커널 호출 또는 컨텍스트 스위치가 포함될 수 있습니다. 일반적으로 더 무겁습니다.
  • 사용자 수준 루틴 (고루틴): 언어 런타임에 의해 생성 및 관리됩니다. 오버헤드가 적기 때문에 고루틴 간 전환이 더 효율적입니다.

Go 스케줄러의 고수준 개요#

Go의 스케줄러는 종종 GMP 모델이라고 불립니다.

  • G: 고루틴 (실행 가능한 태스크)
  • M: 머신 스레드 (또는 OS 스레드)
  • P: 프로세서 (고루틴의 로컬 실행 큐를 보유)

스케줄러는 각 “P"를 하나의 물리적 코어에 바인딩하고 그 위에서 고루틴을 실행하려고 시도합니다. 고루틴이 블로킹될 때(예: 시스템 호출), 스케줄러는 다른 고루틴을 사용 가능한 다른 스레드로 이동시켜 시스템이 바쁘게 유지되도록 합니다.

고루틴 (Goroutine)#

고루틴이란 무엇인가?#

고루틴은 다른 함수나 메서드와 동시에 실행되는 함수 또는 메서드입니다. 동일한 주소 공간(동일한 프로세스)에서 실행되므로 메모리 공유가 더 간단하지만, 경쟁 조건을 피하기 위해 여전히 동기화가 필요합니다.

고루틴 시작 방법#

함수 또는 메서드 호출 앞에 go 키워드를 배치하여 고루틴을 시작할 수 있습니다.

package main

import (
   "fmt"
   "time"
)

func sayHello() {
   fmt.Println("Hello from goroutine!")
}

func main() {
   go sayHello() // Launching sayHello as a goroutine

   time.Sleep(1 * time.Second) // Give the goroutine time to run
   fmt.Println("Main function ends.")
}

위 예시에서:

  • go sayHello()sayHello를 호출하는 별도의 고루틴을 생성합니다.
  • main 함수는 고루틴을 시작한 직후 즉시 계속됩니다.

자원 점유 및 장점#

고루틴은 작은 스택(일반적으로 수 킬로바이트 정도)으로 시작하며, 이 스택은 필요에 따라 커지거나 줄어듭니다. OS 스레드보다 생성 비용이 저렴하여 상당한 오버헤드 없이 수천 또는 수백만 개의 고루틴을 생성할 수 있습니다. 이는 고도로 동시적인 애플리케이션에 대한 Go의 주요 강점 중 하나입니다.

고루틴 수명 주기 및 스케줄링#

  • 고루틴이 생성되면 실행 큐 에 배치됩니다.
  • 스케줄러는 실행 큐를 사용 가능한 OS 스레드에 매핑하여 실행합니다.
  • 고루틴이 블로킹 호출(예: I/O, 시스템 호출)을 수행하면, 스케줄러는 다른 고루틴을 선택하여 실행할 수 있어 CPU 코어가 활성 상태를 유지하도록 보장합니다.
  • 고루틴이 완료되면 실행 큐에서 제거됩니다.

채널#

채널의 기본#

Go의 채널은 고루틴이 서로 통신하고 실행을 동기화하는 메커니즘을 제공합니다. 채널은 두 고루틴을 연결하는 파이프와 같아서 유형이 지정된 데이터를 보내고 받을 수 있습니다.

  • make 함수를 사용하여 채널을 생성합니다.
    ch := make(chan int)
    
  • 채널은 유형이 지정됩니다 (예: chan int, chan string, chan float64 등).

언버퍼드 채널 대 버퍼드 채널#

  • 언버퍼드 채널 (Unbuffered channels): 언버퍼드 채널에 데이터를 보낼 때, 보내는 고루틴은 다른 고루틴이 해당 채널에서 데이터를 받을 때까지 블록되고, 그 반대도 마찬가지입니다.
  • 버퍼드 채널 (Buffered channels): 버퍼드 채널은 용량을 가집니다. 보내기 작업은 버퍼가 가득 찼을 때만 블록됩니다. 받기 작업은 버퍼가 비었을 때만 블록됩니다.

버퍼드 채널 예시:

ch := make(chan int, 3) // 용량 3을 가진 버퍼드 채널

채널에서 보내기 및 받기#

package main

import (
	"fmt"
)

func main() {
	ch := make(chan string)

	// 보내는 고루틴
	go func() {
		ch <- "Hello from sender!"
	}()

	// 받는 고루틴
	msg := <-ch
	fmt.Println(msg)
}
  • ch <- value는 채널 ch에 값을 보냅니다.
  • value := <-ch는 채널 ch에서 값을 받습니다.

채널 닫기#

채널은 내장 close 함수를 사용하여 닫을 수 있습니다:

close(ch)

채널이 닫히면:

  • 더 이상 보내기 작업이 허용되지 않습니다 (닫힌 채널에 보내면 패닉이 발생합니다).
  • 받기 작업은 즉시 반환되며, 버퍼가 비어 있으면 제로 값을 반환합니다.

이는 종종 더 이상 데이터가 전송되지 않을 것이라는 신호를 받는 측에 보내는 데 사용됩니다.

select#

select는 고루틴이 여러 통신 작업에서 대기할 수 있도록 합니다:

select {
case msg1 := <-ch1:
	fmt.Println("Received:", msg1)
case ch2 <- "Hello!":
	fmt.Println("Sent a message on ch2")
default:
	fmt.Println("No communication")
}
  • select 문은 해당 케이스 중 하나가 진행될 준비가 될 때까지 블록됩니다.
  • 여러 채널이 준비되면, 무작위로 하나를 선택하여 공정성을 보장합니다.
  • default 절이 있으면, 어떤 채널도 준비되지 않았을 경우 즉시 실행됩니다.

Go에서의 동시성 설계 패턴#

5.1. 팬-아웃 / 팬-인#

  • 팬-아웃 (Fan-Out): 여러 고루틴에 작업을 분산시킵니다.
  • 팬-인 (Fan-In): 여러 고루틴의 결과를 하나로 모읍니다.

예시: 여러 숫자의 제곱을 병렬로 계산(팬-아웃)한 다음, 이를 합산(팬-인)하려고 합니다.

package main

import (
	"fmt"
	"sync"
)

func square(n int) int {
	return n * n
}

func main() {
	numbers := []int{1, 2, 3, 4, 5}
	results := make(chan int)

	var wg sync.WaitGroup

	// 팬-아웃
	for _, num := range numbers {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			results <- square(n)
		}(num)
	}

	// 팬-인
	go func() {
		wg.Wait()
		close(results)
	}()

	var total int
	for r := range results {
		total += r
	}

	fmt.Println("Sum of squares:", total)
}

워커 풀#

워커 풀은 많은 작업이 있지만, 동시에 실행되는 고루틴의 수를 제한하려는 경우에 유용합니다. 워커(고루틴) 풀을 생성하고, 각 워커는 공유 채널에서 작업을 처리합니다.

package main

import (
	"fmt"
	"sync"
	"time"
)

func worker(id int, tasks <-chan int, wg *sync.WaitGroup) {
	defer wg.Done()
	for t := range tasks {
		fmt.Printf("Worker %d processing task %d\n", id, t)
		time.Sleep(200 * time.Millisecond)
	}
}

func main() {
	const numWorkers = 3
	tasks := make(chan int, 10)
	var wg sync.WaitGroup

	// 워커 고루틴 시작
	for i := 1; i <= numWorkers; i++ {
		wg.Add(1)
		go worker(i, tasks, &wg)
	}

	// 작업 전송
	for j := 1; j <= 9; j++ {
		tasks <- j
	}
	close(tasks)

	wg.Wait()
	fmt.Println("All tasks processed.")
}

파이프라인#

파이프라인은 각 단계가 데이터를 수신하고, 처리하고, 다음 단계로 전송하는 일련의 단계입니다. 각 단계는 일반적으로 자체 고루틴에서 실행되는 함수로 구현됩니다.

제너레이터 함수#

제너레이터 함수는 함수가 채널을 생성하고 고루틴을 시작하여 일련의 값을 생성하는 패턴입니다.

func generateNumbers(count int) <-chan int {
	out := make(chan int)
	go func() {
		for i := 0; i < count; i++ {
			out <- i
		}
		close(out)
	}()
	return out
}

“Done” 채널 패턴#

중지 또는 완료를 나타내는 신호(종종 struct{})만 전달하는 채널입니다. 고루틴을 정상적으로 종료하는 데 유용합니다.

done := make(chan struct{})

go func() {
	// 일부 작업 수행
	close(done) // 완료 신호
}()

<-done // 고루틴 대기
fmt.Println("Goroutine finished!")

Go에서의 동기화#

Go의 sync 패키지는 뮤텍스(Mutex), RWMutex, WaitGroup, 그리고 once 루틴을 포함하여 채널을 넘어선 동기화 프리미티브를 제공합니다. 이들은 공유 메모리나 데이터 구조를 가질 때 매우 중요합니다.

뮤텍스#

뮤텍스 (상호 배제 잠금)는 한 번에 하나의 고루틴만이 공유 리소스에 접근할 수 있도록 보장합니다.

package main

import (
	"fmt"
	"sync"
)

func main() {
	var count int
	var mu sync.Mutex
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			count++
			mu.Unlock()
		}()
	}

	wg.Wait()
	fmt.Println("Final count:", count)
} 

RWMutex (읽기-쓰기 뮤텍스)#

RWMutex 는 여러 리더가 리소스에 동시적으로 접근할 수 있도록 허용하지만, 하나의 라이터만이 독점적으로 접근할 수 있도록 합니다.

var rw sync.RWMutex
var data = make(map[string]string)

// 리더
rw.RLock()
val := data["key"]
rw.RUnlock()

// 라이터
rw.Lock()
data["key"] = "value"
rw.Unlock() 

WaitGroup#

WaitGroup 을 사용하면 고루틴 그룹이 완료될 때까지 기다릴 수 있습니다.

var wg sync.WaitGroup

for i := 0; i < 5; i++ {
	wg.Add(1)
	go func(i int) {
		defer wg.Done()
		// 작업 수행
	}(i)
}

wg.Wait()
fmt.Println("All goroutines have finished.") 

Cond (조건 변수)#

sync.Cond 는 고루틴이 특정 조건을 기다리거나 신호를 보낼 수 있도록 하는 조건 변수입니다. 이는 채널이나 더 간단한 동기화 프리미티브에 비해 고급 기능이며 덜 일반적으로 필요합니다.

원자적 연산#

sync/atomic 패키지는 카운터나 플래그에 유용한 AddInt64, LoadInt64, StoreInt64와 같은 원자적으로 발생하는 연산을 제공합니다.

고급 동시성 패턴 및 기법#

취소 및 데드라인을 위한 Context#

Go 1.7 에서 도입된 context 패키지는 API 경계를 넘어 취소 신호, 데드라인, 그리고 요청 범위 데이터를 관리하도록 설계되었습니다. 서버에서 흔히 사용되며, 요청이 시간 초과되거나 중단될 경우 취소를 허용하기 위해 함수를 통해 context.Context 를 전달합니다.

func doWork(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("work canceled or timed out")
			return
		default:
			// do some work
		}
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	go doWork(ctx)

	// ...
	time.Sleep(3 * time.Second)
} 

다중 채널을 사용한 Select 멀티플렉싱#

select를 사용하여 여러 채널에서 대기할 수 있습니다. 이는 타임아웃, 취소 또는 여러 이벤트 스트림 수신과 같은 경우에 매우 유용합니다.

func main() {
	ch1 := make(chan int)
	ch2 := make(chan int)

	go func() {
		time.Sleep(1 * time.Second)
		ch1 <- 10
	}()

	go func() {
		time.Sleep(2 * time.Second)
		ch2 <- 20
	}()

	for i := 0; i < 2; i++ {
		select {
		case v1 := <-ch1:
			fmt.Println("Received from ch1:", v1)
		case v2 := <-ch2:
			fmt.Println("Received from ch2:", v2)
		}
	}
} 

리소스 풀링 (예: sync.Pool)#

sync.Pool은 재사용 가능한 객체 풀로, 할당을 줄여줍니다. 수명이 짧은 많은 객체를 처리해야 하는 시나리오에 이상적입니다.

var bufPool = sync.Pool{
	New: func() interface{} {
		return new(bytes.Buffer)
	},
}

func main() {
	buf := bufPool.Get().(*bytes.Buffer)
	buf.WriteString("Hello")
	fmt.Println(buf.String())
	buf.Reset()
	bufPool.Put(buf) // reuse
} 

세마포어를 사용한 동시성 관리#

Go 에서는 버퍼드 채널을 사용하여 세마포어를 모방할 수 있습니다. 예를 들어, 용량이 n인 채널은 최대 n개의 동시 접근을 허용할 수 있습니다.

var limit = make(chan struct{}, 5) // semaphore with capacity 5

func doWork(id int) {
	limit <- struct{}{}   // acquire
	defer func() { <-limit }() // release

	fmt.Printf("Worker %d doing work\n", id)
	time.Sleep(1 * time.Second)
} 

흔한 동시성 함정들#

경쟁 조건 (Race Conditions)#

경쟁 조건 은 여러 고루틴이 공유 데이터에 접근하고 적어도 하나가 적절한 동기화 없이 데이터를 수정할 때 발생합니다. Go의 경쟁 감지기(race detector)를 사용하여 경쟁 조건을 감지할 수 있습니다:

go run -race main.go 

항상 채널이나 동기화 프리미티브(뮤텍스 등)를 사용하여 경쟁 조건을 수정하세요.

교착 상태 (Deadlocks)#

교착 상태 는 고루틴들이 서로를 기다리고 있어서 더 이상 진행할 수 없을 때 발생합니다. 흔한 원인은 다음과 같습니다:

  • 잠금 순환 (고루틴 A가 잠금을 보유하고 B에 의해 잠긴 리소스를 기다리는데, B는 다시 A가 보유한 잠금을 기다리는 경우).
  • 채널에서 잘못된 순서로 보내기/받기, 특히 언버퍼드 채널의 경우.
  • 케이스나 기본값 없이 select {}를 사용하는 경우.

고루틴 누수 (Goroutine Leaks)#

고루틴 누수 는 고루틴이 무기한으로 블록되는 경우에 발생하며, 일반적으로 결코 닫히지 않거나 데이터를 수신하지 않는 채널을 기다릴 때 발생합니다. 이는 장기 실행 애플리케이션에서 메모리 누수로 이어질 수 있습니다.

기아 상태 및 공정성 문제#

기아 상태는 고루틴이 CPU 시간을 얻지 못하거나 리소스를 확보할 수 없을 때 발생합니다. Go는 공정하게 스케줄링하려고 하지만, 다른 고루틴에 무한 루프나 과도한 계산이 있어서 양보하거나 동기화 지점 없이 고루틴이 기아 상태에 빠질 수 있습니다.

채널에 대한 과도한 의존#

채널은 강력하지만 항상 최선의 해결책은 아닙니다. 채널을 과도하게 사용하면 코드가 복잡해질 수 있습니다. 때로는 간단한 뮤텍스나 원자 변수가 더 적절할 수 있습니다.

동시성 디버깅 및 트레이싱#

경쟁 감지기 (race detector) 사용하기#

앞서 언급했듯이 다음 명령어로 프로그램을 실행할 수 있습니다:

go run -race main.go  

경쟁 감지기는 데이터 경쟁에 대해 경고합니다. 이것은 동시성 디버깅에 매우 귀중한 도구입니다.

pprof를 이용한 고루틴 프로파일링#

pprof는 Go의 프로파일링 도구입니다. CPU 사용량, 메모리 할당, 고루틴을 프로파일링할 수 있습니다. 웹 기반 앱의 경우:

import _ "net/http/pprof"

// 그런 다음 앱을 실행하고 \`/debug/pprof\`로 이동합니다.

go trace를 이용한 실행 트레이싱#

프로그램 실행 트레이스를 생성하여 고루틴이 어떻게 스케줄링되는지 등을 확인할 수 있습니다:

go test -trace trace.out  
go tool trace trace.out  

시각화는 병목 현상이나 동시성 문제를 찾아내는 데 도움이 될 수 있습니다.

고루틴 로깅 및 모니터링#

적절하게 배치된 로그 문이나 구조화된 로깅을 사용하면 고루틴 진행 상황을 추적하는 데 도움이 될 수 있습니다. 또한, 로그나 모니터링 대시보드에서 활성 고루틴 수(예: runtime.NumGoroutine())를 추적하여 누수를 조기에 감지할 수 있습니다.

실제 동시성 사용 사례#

웹 서버에서의 동시성#

Go의 내장 HTTP 서버는 각 요청을 동시적으로 처리하기 위해 고루틴을 사용하므로, 고부하 서버에 이상적입니다. 예를 들어, 모든 요청은 핸들러 함수를 실행하는 새로운 고루틴을 트리거합니다. 이 동시성 모델은 수동 스레드 관리 없이 쉽게 확장됩니다.

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {  
   fmt.Fprintf(w, "Hello, Gopher!")  
})  
  
log.Fatal(http.ListenAndServe(":8080", nil))  

백그라운드 작업 및 워커#

마이크로서비스 또는 서버 측 시스템에서는 종종 백그라운드 작업(예: 이메일 전송, 로그 업데이트)이 있습니다. Go 동시성은 채널 또는 메시지 큐에서 작업을 수신하는 워커를 쉽게 시작할 수 있도록 합니다.

마이크로서비스 간 통신#

Go로 마이크로서비스를 구축할 때, 동시성은 여러 다운스트림 호출을 병렬로 처리하는 데 자주 사용됩니다. 예를 들어, 단일 요청이 여러 내부 서비스로 팬아웃되고, 응답을 집계(팬인)하여 신속하게 반환할 수 있습니다.

메시징 및 이벤트 처리#

Go의 동시성 패턴은 여러 고루틴이 메시지 큐나 채널에서 대기하고 이벤트가 들어오면 응답하는 이벤트 기반 시스템을 구현하는 데 자연스럽습니다.

동시성 모범 사례 (Best Practices)#

단순하게 유지하기#

복잡한 동시성 로직은 버그의 일반적인 원인입니다. “연결당 하나의 고루틴” 또는 “채널을 사용하는 워커 풀"과 같은 간단한 패턴을 선호하세요. 지나치게 영리한 동시성 솔루션은 종종 유지보수 문제를 일으킵니다.

공유 상태 제한하기#

메모리를 공유하여 통신하는 것이 아니라 통신하여 메모리를 공유 한다는 개념을 받아들이세요. 가능한 한 각 고루틴이 지역화된 데이터를 가지고 채널을 통해 업데이트나 결과를 통신하도록 시스템을 설계하세요.

고루틴 수명에 유의하기#

항상 고루틴이 완료되면 종료될 수 있도록 보장하세요. 절대 닫히지 않는 채널을 기다리거나, 무한정 블록되는 상황을 피하세요. 고루틴 누수를 방지하기 위해 로그나 메트릭을 통해 추적하세요.

구조화된 동시성 접근 방식#

가능한 한 고루틴에 컨텍스트를 전달하여, 상위 요청이 취소되거나 타임아웃을 초과하면 이를 취소할 수 있도록 하세요. 이 접근 방식은 때때로 구조화된 동시성 이라고 불리며, 상위 컨텍스트가 종료될 때 모든 하위 고루틴이 적절하게 종료되도록 보장합니다.

적절한 오류 처리#

때로는 고루틴에서 오류를 전파해야 할 수 있습니다. 일반적인 패턴은 전용 오류 채널로 오류를 보내거나 콜백 메커니즘을 통해 반환하는 것입니다. 또는 공유(뮤텍스로 보호되는) 변수에 오류를 저장하고 WaitGroup을 통해 완료를 알릴 수 있습니다.


Go의 동시성 모델은 단순성과 강력함으로 널리 칭찬받고 있습니다. 그러나 다른 동시성 모델과 마찬가지로 올바르게 구현하기는 여전히 까다로울 수 있습니다. 채널, 고루틴 및 sync 패키지를 통해 Go는 동시 및 병렬 프로그램을 위한 강력한 빌딩 블록을 제공합니다. 함정을 이해하고, 모범 사례를 활용하며, 디버깅 도구를 효과적으로 사용함으로써 Go에서 견고하고 효율적인 동시성 애플리케이션을 구축할 수 있습니다.