Go에서 고루틴 풀을 구현하는 방법
James Reed
Infrastructure Engineer · Leapcell

0. 소개
이전에 Go의 기본 HTTP 서버가 클라이언트 연결을 처리할 때 각 연결에 대해 고루틴을 생성하는 것은 다소 무식한 접근 방식이라고 언급했습니다. 더 깊이 이해하기 위해 Go 소스 코드를 살펴 보겠습니다. 먼저 가장 간단한 HTTP 서버를 다음과 같이 정의합니다.
func myHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Hello there!\n") } func main() { http.HandleFunc("/", myHandler) // 액세스 경로 설정 log.Fatal(http.ListenAndServe(":8080", nil)) }
진입점인 http.ListenAndServe
함수를 따라갑니다.
// file: net/http/server.go func ListenAndServe(addr string, handler Handler) error { server := &Server{Addr: addr, Handler: handler} return server.ListenAndServe() } func (srv *Server) ListenAndServe() error { addr := srv.Addr if addr == "" { addr = ":http" } ln, err := net.Listen("tcp", addr) if err!= nil { return err } return srv.Serve(tcpKeepAliveListener{ln.(*net.TCPListener)}) } func (srv *Server) Serve(l net.Listener) error { defer l.Close() ... for { rw, e := l.Accept() if e!= nil { // error handle 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 연결에 대해 고루틴을 생성하여 처리합니다. 또한 fasthttp 네트워크 프레임워크가 기본 net/http
프레임워크보다 성능이 더 우수하다고 언급했는데, 그 이유 중 하나는 고루틴 풀을 사용하기 때문입니다. 그렇다면 우리가 직접 고루틴 풀을 구현한다면 어떻게 해야 할까요? 가장 간단한 구현부터 시작하겠습니다.
1. 약한 버전
Go에서는 go
키워드를 사용하여 고루틴을 시작합니다. 고루틴 리소스는 임시 객체 풀과 다릅니다. 다시 넣고 다시 검색할 수 없습니다. 따라서 고루틴은 계속 실행되어야 합니다. 필요할 때 실행되고 필요하지 않을 때 차단되어 다른 고루틴의 스케줄링에 거의 영향을 미치지 않습니다. 그리고 고루틴의 작업은 채널을 통해 전달할 수 있습니다. 다음은 간단한 약한 버전입니다.
func Gopool() { start := time.Now() 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 { fmt.Println("goroutine:", n, i) } }(i) } for i := 0; i < 10000; i++ { data <- i } close(data) wg.Wait() end := time.Now() fmt.Println(end.Sub(start)) }
위의 코드는 프로그램의 실행 시간도 계산합니다. 비교를 위해 풀을 사용하지 않는 버전은 다음과 같습니다.
func Nopool() { start := time.Now() wg := new(sync.WaitGroup) for i := 0; i < 10000; i++ { wg.Add(1) go func(n int) { defer wg.Done() //fmt.Println("goroutine", n) }(i) } wg.Wait() end := time.Now() fmt.Println(end.Sub(start)) }
마지막으로 실행 시간을 비교하면 고루틴 풀을 사용하는 코드가 풀을 사용하지 않는 코드보다 약 2/3의 시간으로 실행됩니다. 물론 이 테스트는 여전히 약간 rough합니다. 다음으로 reflect 문서에서 소개된 Go 벤치마크 테스트 방법을 사용하여 테스트합니다. 테스트 코드는 다음과 같습니다(관련 없는 코드는 많이 제거됨).
package pool import ( "sync" "testing" ) 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() } 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() } func BenchmarkGopool(b *testing.B) { for i := 0; i < b.N; i++ { Gopool() } } func BenchmarkNopool(b *testing.B) { for i := 0; i < b.N; i++ { Nopool() } }
최종 테스트 결과는 다음과 같습니다. 고루틴 풀을 사용하는 코드가 실제로 실행 시간이 더 짧습니다.
$ go test -bench='.' gopool_test.go
BenchmarkGopool-8 500 2696750 ns/op
BenchmarkNopool-8 500 3204035 ns/op
PASS
2. 업그레이드된 버전
좋은 스레드 풀에는 종종 더 많은 요구 사항이 있습니다. 가장 시급한 요구 사항 중 하나는 고루틴이 실행하는 함수를 사용자 정의할 수 있어야 한다는 것입니다. 함수는 함수 주소와 함수 매개변수에 지나지 않습니다. 전달할 함수가 형식이 다른 경우(매개변수 또는 반환 값이 다른 경우) 어떻게 해야 할까요? 비교적 간단한 방법은 리플렉션을 도입하는 것입니다.
type worker struct { Func interface{} Args []reflect.Value } func main() { var wg sync.WaitGroup channels := make(chan worker, 10) for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() for ch := range channels { reflect.ValueOf(ch.Func).Call(ch.Args) } }() } for i := 0; i < 100; i++ { wk := worker{ Func: func(x, y int) { fmt.Println(x + y) }, Args: []reflect.Value{reflect.ValueOf(i), reflect.ValueOf(i)}, } channels <- wk } close(channels) wg.Wait() }
그러나 리플렉션을 도입하면 성능 문제가 발생합니다. 고루틴 풀은 원래 성능 문제를 해결하기 위해 설계되었지만 이제 새로운 성능 문제가 발생했습니다. 그럼 어떻게 해야 할까요? 클로저.
type worker struct { Func func() } func main() { var wg sync.WaitGroup channels := make(chan worker, 10) for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() for ch := range channels { //reflect.ValueOf(ch.Func).Call(ch.Args) ch.Func() } }() } for i := 0; i < 100; i++ { j := i wk := worker{ Func: func() { fmt.Println(j + j) }, } channels <- wk } close(channels) wg.Wait() }
Go에서는 클로저를 제대로 사용하지 않으면 문제가 발생하기 쉽다는 점에 유의해야 합니다. 클로저를 이해하는 핵심은 복사본이 아닌 객체에 대한 참조입니다. 이것은 고루틴 풀 구현의 단순화된 버전일 뿐입니다. 실제로 구현할 때는 풀을 중지하기 위해 중지 채널을 설정하는 것과 같이 많은 세부 사항을 고려해야 합니다. 그러나 고루틴 풀의 핵심은 여기에 있습니다.
3. 고루틴 풀과 CPU 코어 간의 관계
그렇다면 고루틴 풀의 고루틴 수와 CPU 코어 수 사이에 관계가 있을까요? 이것은 실제로 다른 경우에 논의해야 합니다.
1. 고루틴 풀이 완전히 활용되지 않음
즉, channel data
에 데이터가 있으면 즉시 고루틴에 의해 제거됩니다. 이 경우 CPU가 스케줄링할 수 있는 한, 즉 풀의 고루틴 수와 CPU 코어 수가 최적인 한 좋습니다. 테스트에서 이것이 확인되었습니다.
2. channel data
의 데이터가 차단됨
즉, 고루틴이 충분하지 않습니다. 고루틴의 실행 작업이 CPU 집약적이지 않고(대부분의 경우는 그렇지 않음) I/O에 의해서만 차단되는 경우 일반적으로 특정 범위 내에서 고루틴이 많을수록 좋습니다. 물론 특정 범위는 특정 상황에 따라 분석해야 합니다.
Leapcell: Golang 앱 호스팅을 위한 차세대 서버리스 플랫폼
마지막으로 Golang 서비스를 배포하는 데 가장 적합한 플랫폼인 **Leapell**을 추천합니다.
1. 다국어 지원
- JavaScript, Python, Go 또는 Rust로 개발하세요.
2. 무료로 무제한 프로젝트 배포
- 사용량에 대해서만 지불하세요. 요청도 없고 요금도 없습니다.
3. 최고의 비용 효율성
- 유휴 요금 없이 사용량에 따라 지불하세요.
- 예: $25는 평균 응답 시간 60ms에서 694만 건의 요청을 지원합니다.
4. 간소화된 개발자 경험
- 간편한 설정을 위한 직관적인 UI.
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합.
- 실행 가능한 통찰력을 위한 실시간 메트릭 및 로깅.
5. 간편한 확장성 및 고성능
- 고도의 동시성을 쉽게 처리할 수 있도록 자동 확장.
- 운영 오버헤드가 없으므로 구축에만 집중할 수 있습니다.
Leapcell 트위터: https://x.com/LeapcellHQ