1 고루틴 소개
1.1 동시성과 병렬성의 기본 컨셉
동시성과 병렬성은 멀티스레드 프로그래밍에서 흔히 사용되는 두 가지 개념입니다. 이들은 동시에 발생할 수 있는 이벤트나 프로그램 실행을 설명하는 데 사용됩니다.
- 동시성은 여러 작업이 동시에 처리되지만 언제나 한 번에 하나의 작업만 실행되는 것을 의미합니다. 작업들은 빠르게 서로 전환되어 사용자에게 동시적인 실행의 환상을 줍니다. 동시성은 싱글 코어 프로세서에 적합합니다.
- 병렬성은 여러 작업이 실제로 동시에 동시에 실행되는 것을 의미합니다. 이를 위해서는 멀티코어 프로세서의 지원이 필요합니다.
Go 언어는 동시성을 주요 목표 중 하나로 설계되었습니다. 이를 위해 고루틴(Goroutines)과 채널(Channels)을 통해 효율적인 동시 프로그래밍 모델을 실현합니다. Go의 런타임은 고루틴을 관리하고 이러한 고루틴을 여러 시스템 스레드에 스케줄하여 병렬 처리를 달성합니다.
1.2 Go 언어의 고루틴
고루틴은 Go 언어에서 동시성 프로그래밍을 달성하기 위한 핵심 개념입니다. 고루틴은 Go의 런타임에 의해 관리되는 가벼운 스레드입니다. 사용자의 관점에서 볼 때, 고루틴은 스레드와 유사하지만 더 적은 리소스를 사용하고 빠르게 시작합니다.
고루틴의 특징은 다음과 같습니다:
- 가벼움: 고루틴은 전통적인 스레드보다 적은 스택 메모리를 사용하며, 스택 크기는 필요에 따라 동적으로 확장 또는 축소될 수 있습니다.
- 낮은 오버헤드: 고루틴을 생성하고 소멸하는 오버헤드는 전통적인 스레드보다 훨씬 낮습니다.
- 간단한 통신 메커니즘: 채널은 고루틴 간의 간단하고 효과적인 통신 메커니즘을 제공합니다.
- 블로킹되지 않는 설계: 특정 작업에서 고루틴은 다른 고루틴들이 실행되지 못하게 막지 않습니다. 예를 들어, 하나의 고루틴이 I/O 작업을 기다리는 동안에도 다른 고루틴들은 계속해서 실행될 수 있습니다.
2 고루틴 생성 및 관리
2.1 고루틴 생성 방법
Go 언어에서는 go
키워드를 사용하여 간단히 고루틴을 생성할 수 있습니다. 함수 호출 앞에 go
키워드를 붙이면 해당 함수는 새로운 고루틴에서 비동기적으로 실행됩니다.
간단한 예제를 살펴봅시다:
package main
import (
"fmt"
"time"
)
// "Hello"를 출력하는 함수 정의
func sayHello() {
fmt.Println("Hello")
}
func main() {
// "go" 키워드를 사용하여 새로운 고루틴 시작
go sayHello()
// 메인 고루틴은 sayHello가 실행될 수 있도록 기다림
time.Sleep(1 * time.Second)
fmt.Println("Main function")
}
위 코드에서 sayHello()
함수는 새로운 고루틴에서 비동기적으로 실행됩니다. 즉, main()
함수는 sayHello()
가 끝날 때까지 기다리지 않습니다. 따라서, 메인 고루틴은 sayHello
의 출력문이 실행되도록 하기 위해 time.Sleep
를 사용합니다. 이는 단순히 설명을 위한 것이며, 실제 개발에서는 일반적으로 채널 또는 다른 동기화 방법을 사용하여 서로 다른 고루틴의 실행을 조정합니다.
참고: 실제 응용 프로그램에서는 신뢰할 수 있는 동기화 메커니즘이 아닌
time.Sleep()
을 사용하여 고루틴이 완료될 때까지 기다리는 것은 지양해야 합니다.
2.2 고루틴 스케줄링 메커니즘
Go 언어에서는 고루틴의 스케줄링을 Go 런타임의 스케줄러가 담당하며, 이는 사용 가능한 논리 프로세서에 실행 시간을 할당합니다. Go 스케줄러는 멀티코어 프로세서에서 성능을 높이기 위해 M:N
스케줄링 기술(여러 고루틴이 여러 OS 스레드에 매핑)을 사용합니다.
GOMAXPROCS와 논리적 프로세서
GOMAXPROCS
는 런타임 스케줄러에 사용 가능한 최대 CPU 개수를 정의하는 환경 변수로, 기본값은 머신의 CPU 코어 수입니다. Go 런타임은 각 논리적 프로세서에 대해 하나의 OS 스레드를 할당합니다. GOMAXPROCS
를 설정함으로써 런타임이 사용하는 코어의 수를 제한할 수 있습니다.
import "runtime"
func init() {
runtime.GOMAXPROCS(2)
}
위 코드는 프로그램을 다수의 코어를 가진 머신에서 실행하더라도 최대 2개의 코어로 고루틴을 스케줄링하도록 설정합니다.
스케줄러 작동
스케줄러는 M (머신), P (프로세서), 그리고 G (고루틴)이라는 세 가지 중요한 요소를 사용하여 작동합니다. M은 머신이나 스레드를 나타내며, OS 커널 스레드의 추상화로 사용됩니다. P는 고루틴을 실행하는 데 필요한 리소스를 나타내며, 각 P에는 지역 고루틴 대기열이 있습니다. G는 고루틴을 나타내며, 실행 스택, 명령어 집합 및 기타 정보를 포함합니다.
Go 스케줄러의 작동 원리는 다음과 같습니다:
- M은 G를 실행하기 위해 P가 있어야 합니다. P가 없으면 M은 스레드 캐시로 반환됩니다.
- G가 다른 G에 의해 차단되지 않았을 때(예: 시스템 호출 중), 가능한 한 동일한 M에서 실행되어 CPU 캐시를 효율적으로 활용하기 위해 G의 지역 데이터를 '핫'하게 유지합니다.
- G가 차단되면 M과 P가 분리되고, P는 새로운 M을 찾거나 새로운 M을 깨워 다른 G를 실행하도록 합니다.
go func() {
fmt.Println("고루틴에서 안녕하세요")
}()
위 코드는 새로운 고루틴을 시작하는 것을 보여주며, 이로 인해 스케줄러는 이 새로운 G를 실행을 위한 대기열에 추가합니다.
고루틴들의 선점적 스케줄링
초기에 Go는 협력적 스케줄링을 사용하여 고루틴이 자발적으로 제어를 양보하지 않고 오랜 시간 실행될 경우 다른 고루틴을 굶게 할 수 있었습니다. 지금은 Go 스케줄러가 고루틴들이 선점적으로 스케줄링되도록 구현되어 있어서, 오랫동안 실행되는 G를 정지시켜 다른 G가 실행될 기회를 얻을 수 있습니다.
2.3 고루틴 생명 주기 관리
Go 애플리케이션의 견고함과 성능을 보장하기 위해서는 고루틴의 생명 주기를 정확히 이해하고 적절히 관리하는 것이 중요합니다. 고루틴을 시작하는 것은 간단하지만 적절한 관리 없이는 메모리 누수나 경쟁 상태와 같은 문제를 야기할 수 있습니다.
안전하게 고루틴 시작하기
고루틴을 시작하기 전에 해당 작업 부하와 런타임 특성을 이해하고 시작과 종료가 명확해야 합니다. 고루틴은 종료 조건이 명확하게되어 "고루틴 고아"가 생성되지 않도록 해야 합니다.
func worker(done chan bool) {
fmt.Println("작업 중...")
time.Sleep(time.Second) // 비용이 많이 드는 작업 모사
fmt.Println("작업 완료.")
done <- true
}
func main() {
// 여기에서 Go의 채널 메커니즘이 사용됩니다. 채널을 간단히 메시지 큐로 생각하고 "<-" 연산자를 사용하여 큐 데이터를 읽고 쓸 수 있습니다.
done := make(chan bool, 1)
go worker(done)
// 고루틴이 완료될 때까지 기다립니다.
<-done
}
위 코드는 done
채널을 사용하여 고루틴이 완료될 때까지 기다리는 한 가지 방법을 보여줍니다.
참고: 이 예제에서는 Go의 채널 메커니즘을 사용하는데, 이에 대해 나중 장에서 자세히 다루겠습니다.
고루틴 정지하기
일반적으로 전체 프로그램의 종료는 암시적으로 모든 고루틴을 종료시킵니다. 그러나 긴 시간 실행되는 서비스의 경우 고루틴을 적극적으로 중지해야 할 수 있습니다.
- 채널을 사용하여 정지 신호 보내기: 고루틴은 정지 신호를 확인하기 위해 채널을 주기적으로 확인할 수 있습니다.
stop := make(chan struct{})
go func() {
for {
select {
case <-stop:
fmt.Println("정지 신호 받음. 종료 중...")
return
default:
// 정상 동작 실행
}
}
}()
// 정지 신호 보내기
stop <- struct{}{}
-
context
패키지를 사용하여 생명주기 관리:
ctx, cancel := context.WithCancel(context.Background())
go func(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("정지 신호 받음. 종료 중...")
return
default:
// 정상 동작 실행
}
}
}(ctx)
// 고루틴을 중지하고 싶을 때
cancel()
context
패키지를 사용하면 보다 유연하게 고루틴을 제어할 수 있으며, 타임아웃과 취소 기능을 제공합니다. 대규모 애플리케이션이나 마이크로서비스에서는 context
가 고루틴 생명 주기를 제어하는 권장 방법입니다.