
Go (고) 언어에서 고루틴 풀(Goroutine Pool)을 구현하는 방법은?
0. 들어가며
이전에 Go (고)의 네이티브 HTTP (에이치티티피) 서버가 클라이언트 연결을 처리할 때 각 연결마다 고루틴(goroutine)을 생성하는데, 이는 다소 무식한(?) 접근 방식이라고 언급한 적이 있습니다.
더 깊이 이해하기 위해 Go (고) 소스 코드를 살펴보겠습니다.
먼저, 가장 간단한 HTTP (에이치티티피) 서버를 다음과 같이 정의합니다.
// myHandler 함수는 http.ResponseWriter와 http.Request를 매개변수로 받아 "Hello there!"를 응답합니다.
func myHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello there!\n")
}
func main() {
http.HandleFunc("/", myHandler) // 기본 경로 "/"에 대한 핸들러를 설정합니다.
// 8080 포트에서 HTTP 서버를 시작하고, 오류 발생 시 로그를 기록하고 종료합니다.
log.Fatal(http.ListenAndServe(":8080", nil))
}
진입점인 http.ListenAndServe (에이치티티피 점 리슨앤서브) 함수를 따라가 보겠습니다.
// 파일: net/http/server.go
// ListenAndServe 함수는 주어진 주소와 핸들러로 HTTP 서버를 시작합니다.
func ListenAndServe(addr string, handler Handler) error {
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServe()
}
// ListenAndServe 메서드는 서버를 시작하고 들어오는 연결을 수신 대기합니다.
func (srv *Server) ListenAndServe() error {
addr := srv.Addr
if addr == "" {
addr = ":http" // 주소가 비어 있으면 기본 HTTP 포트를 사용합니다.
}
// TCP 네트워크에서 주어진 주소로 수신 대기합니다.
ln, err := net.Listen("tcp", addr)
if err != nil {
return err // 오류 발생 시 반환합니다.
}
// 실제 연결 처리를 위해 srv.Serve를 호출합니다.
return srv.Serve(tcpKeepAliveListener{ln.(*net.TCPListener)})
}
// Serve 메서드는 수신된 연결을 처리합니다.
func (srv *Server) Serve(l net.Listener) error {
defer l.Close() // 함수 종료 시 리스너를 닫습니다.
...
for {
// 새로운 연결을 수락합니다.
rw, e := l.Accept()
if e != nil {
// 오류 처리
return e // 오류 발생 시 반환합니다.
}
tempDelay = 0
// 새로운 연결 객체를 생성합니다.
c, err := srv.newConn(rw)
if err != nil {
continue // 오류 발생 시 다음 연결을 처리합니다.
}
c.setState(c.rwc, StateNew) // Serve가 반환하기 전에 상태를 설정합니다.
// 각 연결 처리를 위해 새로운 고루틴을 시작합니다.
go c.serve()
}
}
먼저, net.Listen (넷 점 리슨)은 네트워크 포트에서 수신 대기하는 역할을 합니다.
그런 다음 rw, e := l.Accept() (알더블유 콤마 이 콜론 이퀄 엘 점 억셉트)는 네트워크 포트에서 TCP (티시피) 연결을 가져오고, go c.server() (고 씨 점 서버)는 각 TCP (티시피) 연결을 처리하기 위해 고루틴(goroutine)을 생성합니다.
또한 fasthttp (패스트에이치티티피) 네트워크 프레임워크가 네이티브 net/http (넷 슬래시 에이치티티피) 프레임워크보다 성능이 더 우수하며, 그 이유 중 하나는 고루틴 풀(goroutine pool)을 사용하기 때문이라고 언급했습니다.
그렇다면 질문은, 우리가 직접 고루틴 풀(goroutine pool)을 구현한다면 어떻게 해야 할까요?
가장 간단한 구현부터 시작해 보겠습니다.
1. 약한 버전 (Weak Version)
Go (고)에서는 go 키워드를 사용하여 고루틴(goroutine)을 시작합니다.
고루틴(goroutine) 리소스는 임시 객체 풀과 달리 다시 넣었다가 꺼낼 수 없습니다.
따라서 고루틴(goroutine)은 계속 실행되어야 합니다.
필요할 때 실행되고 필요하지 않을 때는 차단(block)되는데, 이는 다른 고루틴(goroutine)의 스케줄링에 거의 영향을 미치지 않습니다.
그리고 고루틴(goroutine)의 작업은 채널(channel)을 통해 전달될 수 있습니다.
여기 간단한 약한 버전이 있습니다.
// Gopool 함수는 고루틴 풀을 사용하여 작업을 처리합니다.
func Gopool() {
start := time.Now() // 시작 시간을 기록합니다.
wg := new(sync.WaitGroup) // WaitGroup을 생성하여 모든 고루틴이 완료될 때까지 기다립니다.
data := make(chan int, 100) // 작업을 전달할 버퍼 크기 100의 채널을 생성합니다.
// 10개의 고루틴을 생성합니다.
for i := 0; i < 10; i++ {
wg.Add(1) // WaitGroup 카운터를 증가시킵니다.
go func(n int) {
defer wg.Done() // 고루틴 종료 시 WaitGroup 카운터를 감소시킵니다.
// 채널에서 데이터를 읽어 처리합니다. 채널이 닫힐 때까지 반복합니다.
for _ = range data {
fmt.Println("goroutine:", n, i) // 고루틴 번호와 반복 변수 i (주의: 클로저 문제 발생 가능)를 출력합니다.
}
}(i)
}
// 10000개의 작업을 채널에 보냅니다.
for i := 0; i < 10000; i++ {
data <- i
}
close(data) // 모든 작업 전송 후 채널을 닫습니다.
wg.Wait() // 모든 고루틴이 완료될 때까지 기다립니다.
end := time.Now() // 종료 시간을 기록합니다.
fmt.Println(end.Sub(start)) // 실행 시간을 출력합니다.
}
위 코드는 프로그램 실행 시간도 계산합니다.
비교를 위해 풀을 사용하지 않는 버전은 다음과 같습니다.
// Nopool 함수는 고루틴 풀을 사용하지 않고 작업을 처리합니다.
func Nopool() {
start := time.Now() // 시작 시간을 기록합니다.
wg := new(sync.WaitGroup) // WaitGroup을 생성합니다.
// 10000개의 고루틴을 생성하여 각각 작업을 처리합니다.
for i := 0; i < 10000; i++ {
wg.Add(1) // WaitGroup 카운터를 증가시킵니다.
go func(n int) {
defer wg.Done() // 고루틴 종료 시 WaitGroup 카운터를 감소시킵니다.
//fmt.Println("goroutine", n) // 주석 처리된 출력문
}(i)
}
wg.Wait() // 모든 고루틴이 완료될 때까지 기다립니다.
end := time.Now() // 종료 시간을 기록합니다.
fmt.Println(end.Sub(start)) // 실행 시간을 출력합니다.
}
마지막으로 실행 시간을 비교해보면, 고루틴 풀(goroutine pool)을 사용한 코드가 풀을 사용하지 않은 코드보다 약 2/3의 시간으로 실행됩니다.
물론 이 테스트는 여전히 다소 거친 편입니다.
다음으로, reflect (리플렉트) 글에서 소개한 Go (고) 벤치마크 테스트 방법을 사용하여 테스트합니다.
테스트 코드는 다음과 같습니다 (관련 없는 많은 코드는 제거됨).
package pool
import (
"sync"
"testing"
)
// Gopool 함수 (벤치마크용, 출력문 제거)
func Gopool() {
wg := new(sync.WaitGroup)
data := make(chan int, 100)
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
for _ = range data {
// 작업 내용 (비어 있음)
}
}(i)
}
for i := 0; i < 10000; i++ {
data <- i
}
close(data)
wg.Wait()
}
// Nopool 함수 (벤치마크용, 출력문 제거)
func Nopool() {
wg := new(sync.WaitGroup)
for i := 0; i < 10000; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
// 작업 내용 (비어 있음)
}(i)
}
wg.Wait()
}
// Gopool 함수에 대한 벤치마크 테스트 함수
func BenchmarkGopool(b *testing.B) {
for i := 0; i < b.N; i++ {
Gopool()
}
}
// Nopool 함수에 대한 벤치마크 테스트 함수
func BenchmarkNopool(b *testing.B) {
for i := 0; i < b.N; i++ {
Nopool()
}
}
최종 테스트 결과는 다음과 같습니다.
고루틴 풀(goroutine pool)을 사용한 코드가 실제로 실행 시간이 더 짧습니다.
$ go test -bench='.' gopool_test.go
BenchmarkGopool-8 500 2696750 ns/op
BenchmarkNopool-8 500 3204035 ns/op
PASS
2. 업그레이드 버전 (Upgraded Version)
좋은 스레드 풀(여기서는 고루틴 풀)에 대해서는 종종 더 많은 요구 사항이 있습니다.
가장 시급한 요구 사항 중 하나는 고루틴(goroutine)이 실행하는 함수를 사용자 정의할 수 있어야 한다는 것입니다.
함수는 함수 주소와 함수 매개변수에 지나지 않습니다.
전달하려는 함수가 서로 다른 형태(다른 매개변수 또는 반환 값)를 가지면 어떻게 될까요?
비교적 간단한 방법은 리플렉션(reflection)을 도입하는 것입니다.
// worker 구조체는 실행할 함수와 해당 함수의 인자를 정의합니다.
type worker struct {
Func interface{} // 모든 타입의 함수를 저장하기 위해 interface{} 사용
Args []reflect.Value // 함수의 인자들을 reflect.Value 슬라이스로 저장
}
func main() {
var wg sync.WaitGroup // WaitGroup 생성
channels := make(chan worker, 10) // worker 작업을 전달할 채널 생성 (버퍼 크기 10)
// 5개의 워커 고루틴을 생성합니다.
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// 채널에서 worker 작업을 받아 처리합니다.
for ch := range channels {
// 리플렉션을 사용하여 함수를 호출합니다.
reflect.ValueOf(ch.Func).Call(ch.Args)
}
}()
}
// 100개의 작업을 생성하여 채널에 보냅니다.
for i := 0; i < 100; i++ {
wk := worker{
// 실행할 함수 (두 정수를 더하여 출력)
Func: func(x, y int) {
fmt.Println(x + y)
},
// 함수에 전달할 인자 (i와 i)
Args: []reflect.Value{reflect.ValueOf(i), reflect.ValueOf(i)},
}
channels <- wk
}
close(channels) // 모든 작업 전송 후 채널을 닫습니다.
wg.Wait() // 모든 워커 고루틴이 완료될 때까지 기다립니다.
}
그러나 리플렉션(reflection)을 도입하면 성능 문제도 발생합니다.
고루틴 풀(goroutine pool)은 원래 성능 문제를 해결하기 위해 설계되었는데, 이제 새로운 성능 문제가 발생했습니다.
그럼 어떻게 해야 할까요?
클로저(Closure)를 사용합니다.
// worker 구조체는 실행할 함수(인자 없는 함수)를 정의합니다.
type worker struct {
Func func()
}
func main() {
var wg sync.WaitGroup // WaitGroup 생성
channels := make(chan worker, 10) // worker 작업을 전달할 채널 생성 (버퍼 크기 10)
// 5개의 워커 고루틴을 생성합니다.
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// 채널에서 worker 작업을 받아 처리합니다.
for ch := range channels {
//ch.Func()를 직접 호출합니다. (리플렉션 사용 안 함)
ch.Func()
}
}()
}
// 100개의 작업을 생성하여 채널에 보냅니다.
for i := 0; i < 100; i++ {
j := i // 클로저가 올바른 i 값을 참조하도록 지역 변수 j를 사용합니다.
wk := worker{
// 실행할 함수 (j와 j를 더하여 출력)
Func: func() {
fmt.Println(j + j)
},
}
channels <- wk
}
close(channels) // 모든 작업 전송 후 채널을 닫습니다.
wg.Wait() // 모든 워커 고루틴이 완료될 때까지 기다립니다.
}
Go (고)에서는 클로저(Closure)를 제대로 사용하지 않으면 쉽게 문제가 발생할 수 있다는 점에 유의해야 합니다.
클로저(Closure)를 이해하는 핵심은 복사본이 아닌 객체에 대한 참조라는 것입니다.
이것은 고루틴 풀(goroutine pool) 구현의 단순화된 버전일 뿐입니다.
실제로 구현할 때는 풀을 중지하기 위한 중지 채널 설정과 같은 많은 세부 사항을 고려해야 합니다.
그러나 고루틴 풀(goroutine pool)의 핵심은 여기에 있습니다.
3. 고루틴 풀(Goroutine Pool)과 CPU 코어 수의 관계
그렇다면 고루틴 풀(goroutine pool)의 고루틴(goroutine) 수와 CPU 코어 수 사이에는 관계가 있을까요?
이는 실제로 다른 경우에 따라 논의해야 합니다.
1. 고루틴 풀(goroutine pool)이 완전히 활용되지 않는 경우
이는 채널 데이터에 데이터가 있는 즉시 고루틴(goroutine)에 의해 가져가지는 것을 의미합니다.
이 경우 물론 CPU가 스케줄링할 수 있는 한, 즉 풀의 고루틴(goroutine) 수와 CPU 코어 수가 최적입니다.
테스트 결과 이를 확인했습니다.
2. 채널 데이터의 데이터가 차단(blocked)되는 경우
이는 고루틴(goroutine)이 충분하지 않다는 것을 의미합니다.
고루틴(goroutine)의 실행 작업이 CPU 집약적이지 않고(대부분의 경우가 그렇지 않음) I/O에 의해서만 차단(blocked)된다면, 일반적으로 특정 범위 내에서는 고루틴(goroutine)이 많을수록 좋습니다.
물론 특정 범위는 특정 상황에 따라 분석해야 합니다.
'Go' 카테고리의 다른 글
| Go (고) 언어 panic (패닉) 및 recover (리커버) 심층 해부: 알아야 할 모든 것! (0) | 2025.05.17 |
|---|---|
| ErrGroup (에러그룹): Go (고) 동시성 프로그래밍의 숨겨진 보석 (0) | 2025.05.17 |
| Go 언어 완벽 마스터: 함수형 프로그래밍이 최고의 선택인 이유 (0) | 2025.05.17 |
| Go 언어로 JWT 완벽 마스터! 안전한 로그인과 권한 부여, A부터 Z까지 파헤치기 (JWT 초보 탈출 가이드) (0) | 2025.05.07 |
| 고(Go) 1.24 정식 출시! 더 빠르고, 똑똑해지고, 강력해진 Go 언어의 모든 것, 지금부터 파헤쳐 볼까요? (0) | 2025.05.07 |