고루틴에 대해 알아보자

Hansu Park·2025년 1월 19일
10
post-thumbnail

도입

GoLang에서는 고루틴을 이용하여 뛰어난 성능의 어플리케이션을 구축할 수 있다. 고루틴의 개념, 동작 원리가 어떻게 되는지, 프로파일링 방법에 대해 알아보자.

사전 지식

우선 쓰레드라는 개념이 굉장히 헷갈리기 쉬워, 이에 대해 정리하려고 한다.

쓰레드는 여러가지를 지칭되어서 이를 정리해보았다.
쓰레드 종류

  • HW 쓰레드: 물리적으로 코어 안에 존재하는 요소, 실질적으로 명령어를 실행함.
  • Kernel 쓰레드: 논리적으로 존재하는 요소, OS에서 관리하며, 쓰레드 관리 비용이 있음.
  • User-Level 쓰레드: 논리적인 요소, 프로그램에서 관리하며, 원하는 만큼 생성 가능

대부분 프로그래머가 제어할 수 있는 유저 레벨 쓰레드와, 이에 관계있는 커널 쓰레드를 집중해서 생각하면 좋다.

그렇다면, 쓰레드를 사용하는 이유는 무엇일까?
쓰레드 사용 이유

  • 여러가지 일을 한 번에 처리한다. (병렬성)
  • I/O등의 Pending되는 작업들에 의해 지연되지 않는다. (지연 방지)
  • context 등의 자원을 쓰레드끼리 공유한다. (자원 공유)
    위와 같은 이유로 똑똑하게 일함으로써, 성능 향상을 꾀할 수 있다.

참고: 스레드 관리 - 개념, 구현(N:1, 1:1, N:M)

Goroutine

공식문서에서는 고루틴을 다음과 같이 소개한다.

goroutine is a lightweight thread managed by the Go runtime.

  • 경량화된 쓰레드(lightweight thread)
  • (managed by the Go runtime)

경량화된 쓰레드란?

공식문서에서는 고루틴을 다음과 같이 소개한다.

goroutine is a lightweight thread managed by the Go runtime.

이와 비슷하게 Kotlin의 coroutine, Java의 Virtaul Thread도 경량화된 쓰레드(lightweight thread)라고 불린다. 경량화된 쓰레드는 무엇일까? 어떻게 경량화할 수 있었던걸까?

비교를 위해 Java의 Virtaul Thread에 관한 을 참고하였다. (Java Thread API와 Virtual Thread가 대비하기 좋았다.)

요약해보자면, 일반적인 쓰레드인 Java Thread는 커널 쓰레드와 1:1 매핑을 만들었으나 경량화된 쓰레드인 Virtual Thread는 N:M 매핑을 만들어 사용했고 이를 통해 경량화가 되었다.

(Java Thread, 1:1 매핑된 모습)

(Java Thread, n:m 매핑된 모습)

1:1 매핑된 경우 발생하는 문제점은 다음과 같다.
1:1 매핑 구조의 문제점

  • 쓰레드 생성/삭제에 따라 커널 레벨의 쓰레드도 생성/삭제해야 한다. --> 비용 발생
  • 큰 스택 공간 및 guard page 필요 --> 큰 메모리 소모
  • 커널 레벨의 컨텍스트 스위치 발생 --> 비용 발생

경량화된 쓰레드에서는 n:m 관계로 자체적으로 쓰레드를 관리함으로써 위 문제를 해소했다.

Go Runtime Scheduler

고루틴을 관리하기 위해 스케줄러가 동작한다. 스케줄러는 아래와 같은 구조를 가지고 있다.

(스케줄러 구조)

자세한 설명은 아래 글들을 참고하자.

요약해보자면 아래와 같은 특징을 가지고 있다.
스케줄러의 특징

  • Global, Local 큐를 가지고있고, 큐에서 고루틴을 가져와 처리한다.
  • blocking이 발생한 고루틴은 별도 분리한 후, 완료된 요소를 다시 큐에 넣는다.
  • system call(커널의 기능을 사용하는 것 e.g. File I/O 등)이 발생하는 고루틴은 blocking될 수 있으나, 대부분 GoLang의 SYSCAL API는 다중화를 지원하여 많은 커널 쓰레드를 만들진 않는다.

Goroutine 프로파일링

설명했듯이, goroutine은 Go runtime에 의해 관리된다. 런타임은 자체적으로 goroutine 개수, 메모리 사용량 등의 정보를 유지한다.

이는 다양한 방법을 통해 확인할 수 있다.

  • runtime/debugruntime.NumGoroutine()
  • Prometheus Metricgo_goroutines
  • net/http/pprof

이 중 프로파일링을 위한 세 번째 방법에 대해 설명하고자 한다.

package main  
  
import (  
    "fmt"  
    "math/rand"    "net/http"    _ "net/http/pprof"  
)  
  
func main() {  
    go func() {  
       // 프로파일링 정보를 해당 포트로 내보낸다.  
       http.ListenAndServe("0.0.0.0:6060", nil)  
    }()  
    goroutineSize := 3  
  
    memoryUsageMB := 100  
  
    for i := 0; i < goroutineSize; i++ {  
       makeMemoryUp(memoryUsageMB, i)  
    }  
      
    select {}  
}  
  
func makeMemoryUp(memoryUsageMB int, i int) {  
    go func(id int, memoryUsageMB int) {  
       size := id + 1  
       memory := make([]byte, size*memoryUsageMB*1024*1024)  
       for j := range memory {  
          memory[j] = byte(rand.Intn(256))  
       }  
  
       fmt.Printf("Goroutine %d is using %d MB of memory\n", id, size)  
       select {}  
    }(i, memoryUsageMB)  
}

위와 같은 간단한 코드가 있다. 3개의 고루틴에서 각각 100MB, 200MB, 300MB를 소모한다. 또한, 6060포트가 열려있는데 net/http/pprof 패키지에서 해당 포트로 프로파일링 정보를 넘겨준다. (참고로 Google에서 사용하는 protocol buffer라는 형식으로 내보낸다.)

http://localhost:6060/debug/pprof/을 방문하면, 다양한 프로파일 데이터들을 볼 수 있다.

또한 graphviz 라이브러리를 통해 프로파일 데이터들을 가시화할 수도 있다.

가시화한 결과는 아래와 같다.

(goroutine 숫자가 3개이다.)

(사용한 메모리 양이 의도한 대로 100MB, 200MB, 300MB이다.)

참고: NAVER D2

마치며

고루틴을 제대로 사용하기 위해 사전지식, 경량화, 동장 방식, 프로파일링에 대해 살펴보았다. 살펴보며 생각보다 깊은 내용들이 있었고, 메모리나 GC에 대해서도 같은 깊이의 많은 내용이 있다는 걸 알게되었다. 이들에 대해서도 다음에 살펴보아야겠다.

0개의 댓글

관련 채용 정보