Interview/Golang

Golang의 Goroutine

김 정출 2024. 10. 8. 22:30

Goroutine

  • Golang에서 Goroutine은 경량 스레드의 일종으로, 함수나 메서드를 동시에 실행하기 위해 사용됩니다.
  • 일반적인 스레드와는 다르게, 매우 가볍고 고효율적으로 동작하기 때문에 많은 수의 Goroutine을 동시에 실행할 수 있습니다. Goroutine은 Go 런타임이 내부적으로 관리하는 작은 작업 단위입니다.

Goroutine 사용 방법

다음과 같이 go 키워드를 사용하여 함수를 goroutine으로 실행할 수 있습니다:

go someFunction()

이 코드를 실행하면 someFunction()은 별도의 goroutine으로 실행되며, 메인 프로그램과 병렬로 동작하게 됩니다. 중요한 점은, go 키워드로 실행된 함수는 비동기적으로 동작하기 때문에 해당 함수가 언제 완료되는지는 보장되지 않습니다.


Goroutine이 왜 스레드보다 훨씬 가볍다고 하는가?

  • Go의 Goroutine이 스레드보다 훨씬 가볍다고 하는 이유는 Goroutine의 구조와 관리 방식이 운영체제(OS)에서 제공하는 스레드와 다르기 때문입니다. `Goroutine`은 Go 런타임이 관리하는 User **레벨의 경량 스레드로, 여러 가지 기술적 이유로 인해 스레드보다 훨씬 적은 리소스를 사용하고, 더 효율적으로 동작합니다. 그 근거는 다음과 같습니다.

1. 스택 크기의 유연성 (Dynamic Stack Sizing)

  • OS 스레드는 초기화될 때 고정된 크기의 스택을 할당받습니다. 일반적으로 1MB 이상의 메모리를 사용하는데, 이 크기는 변경할 수 없습니다. 만약 스레드가 더 많은 스택을 필요로 하면, 새로운 스택을 재할당하거나 크래시가 발생할 수 있습니다.
  • 반면에, Goroutine은 시작 시 매우 작은 스택(2KB)** 만을 할당받습니다. 필요에 따라 Go 런타임이 스택의 크기를 동적으로 조정하며, 이 과정은 개발자가 직접 관리할 필요가 없습니다. 작은 스택으로 시작해 작업이 커지면 런타임이 스택 크기를 확장하고, 다시 축소하기도 합니다.
  • 이 유연한 스택 관리 방식 덕분에 Goroutine은 메모리 사용량을 크게 절감할 수 있습니다. 작은 메모리로 수많은 Goroutine을 실행하는 것이 가능하며, 특히 많은 수의 동시 작업이 필요한 경우 Goroutine의 효율성이 극대화됩니다.

2. Go 런타임에서의 M스케줄링

  • OS 스레드는 운영체제가 직접 관리하는 1:1 스케줄링 모델을 따릅니다. 즉, 각 스레드는 하나의 하드웨어 스레드(또는 CPU 코어)에 대응되며, 운영체제가 스레드 간의 스케줄링을 관리합니다. 이 방식은 스레드가 많아질수록 스케줄링 오버헤드가 커지고, 문맥 전환(context switching)이 빈번하게 발생해 성능에 영향을 미칩니다.
  • 반면, Go는 자체 런타임에서 M스케줄링을 구현하여 여러 Goroutine(M)을 소수의 OS 스레드(N)에서 실행합니다. 즉, 많은 수의 Goroutine이 소수의 OS 스레드에서 효율적으로 병렬 처리됩니다.
    • Go의 스케줄러는 Goroutine을 효율적으로 관리하며, 필요할 때 적절히 OS 스레드에 매핑합니다. 이로 인해, Goroutine을 많이 생성해도 OS 스레드의 수를 급격히 늘릴 필요가 없으므로 스레드 오버헤드를 크게 줄일 수 있습니다.
    • OS 스레드 간의 문맥 전환에 비해 Goroutine 간의 문맥 전환은 비용이 훨씬 적습니다**. Go 런타임 내에서 이루어지는 문맥 전환은 운영체제에서 이루어지는 문맥 전환보다 경량이며, 더 적은 자원을 소모합니다.

3. 빠른 생성 및 종료

  • OS 스레드의 생성과 소멸은 비교적 무겁습니다. 운영체제가 스레드를 만들 때는 다양한 초기화 작업을 처리하고, 스택을 할당하며, 컨텍스트 정보를 준비합니다. 이런 작업은 운영체제 차원에서 처리되므로 시간이 오래 걸리고, 자원도 더 많이 소모됩니다.
  • 반면, Goroutine은 매우 빠르게 생성되고 **비용이 적습니다. Go 런타임이 유저 레벨에서 Goroutine을 관리하므로, 운영체제가 개입하지 않아도 되며, 런타임 내부에서 바로 가벼운 작업으로 처리할 수 있습니다. 이는 많은 수의 Goroutine을 생성하고 관리할 때 큰 이점이 됩니다.

4. 비동기 I/O와 블로킹 관리

  • OS 스레드는 기본적으로 블로킹 I/O를 처리할 때, 해당 스레드는 작업이 완료될 때까지 대기 상태에 놓입니다. 이를 효율적으로 처리하기 위해서는 비동기 I/O를 사용하는 별도의 처리 메커니즘이 필요합니다.
  • Go의 Goroutine은 블로킹 I/O 호출을 하더라도 Go 런타임이 다른 Goroutine으로 작업을 스위치합니다. Go 런타임은 자동으로 **비동기 I/O를 관리하며, Goroutine이 블로킹 상태에 놓여도 전체 시스템의 성능에 미치는 영향을 최소화합니다. 이를 통해 OS 스레드에 대한 추가적인 복잡성을 줄일 수 있습니다.

5. 간단한 동시성 모델 (Channels)

  • Goroutine은 OS 스레드와 달리 Go의 채널(Channel)을 통해 안전하고 간편한 통신을 할 수 있습니다. 채널을 통해 Goroutine 간 데이터를 주고받을 수 있으며, 이를 통해 데이터를 안전하게 공유하거나 동기화할 수 있습니다.
  • OS 스레드는 공유 메모리를 사용하며, 이를 동기화하기 위해 락(Mutex) 같은 복잡한 동기화 메커니즘이 필요합니다. 락을 잘못 사용하면 교착 상태(deadlock)나 경합 상태(race condition)가 발생할 수 있으며, 관리도 어렵습니다. Go는 이러한 문제를 Goroutine과 채널을 통해 더 간단하고 안전하게 해결합니다.

Goroutine의 내부 관리

  • Goroutine은 Go 런타임이 관리하는 GMP스케줄링 모델을 사용합니다.
  • 이 모델은 여러 개의 goroutine을 몇 개의 운영체제(OS) 스레드에 매핑하는 방식으로 동작합니다.

1. Goroutine, OS 스레드, 그리고 프로세서(P):

Go 런타임에는 3개의 중요한 개념이 있습니다:

  • G (Goroutine): Goroutine은 함수의 호출 스택, 스레드 지역 데이터, 그리고 실행 컨텍스트를 포함하는 경량의 작업 단위입니다.
  • M (Machine): M은 OS 스레드입니다. Go 런타임은 여러 개의 OS 스레드를 생성하고, 필요에 따라 goroutine을 이 스레드 위에 실행시킵니다.
  • P (Processor): P는 Go 스케줄러가 goroutine을 M에 할당할 때 사용하는 논리적 프로세서입니다. 기본적으로 GOMAXPROCS 환경 변수를 통해 프로세서 수를 설정할 수 있습니다. 기본 값은 현재 CPU 코어의 수와 동일합니다.

2. 스케줄링:

Go는 프리엠티브 스케줄링협력적 스케줄링을 결합하여 Goroutine을 관리합니다.

  • 프리엠티브 스케줄링: Go 런타임은 특정 지점에서 goroutine을 중단하고 다른 goroutine을 실행할 수 있습니다. 예를 들어, 시스템 호출이나 채널 통신, I/O 작업이 발생하면 런타임이 개입하여 스레드가 다른 작업을 처리하도록 할 수 있습니다.
  • 협력적 스케줄링: 일반적인 코드 실행 중에는 Goroutine이 주기적으로 런타임의 개입 없이 계속 실행됩니다. 이 과정에서 런타임은 함수 호출 또는 메모리 할당과 같은 특정 지점에서 중단점을 설정하고 필요시 스케줄러가 개입할 수 있게 합니다.

3. Goroutine의 스택:

Goroutine의 스택은 처음에 매우 작게(약 2KB) 시작하며, 필요한 경우 동적으로 증가합니다. 이는 스레드 기반의 프로그램에서 흔히 사용되는 고정 크기의 스택보다 메모리를 효율적으로 사용할 수 있게 해 줍니다. 스택이 커지면 Go 런타임은 자동으로 스택 크기를 조정하며, 필요하지 않을 때 다시 축소할 수 있습니다.

4. Work Stealing:

Go의 스케줄러는 Work Stealing 알고리즘을 사용합니다. 이는 각 P에 큐(queue)를 유지하며, P에 할당된 goroutine을 M에 실행시킵니다. 만약 하나의 P가 모든 goroutine을 처리하고 남는다면, 다른 P로부터 작업을 훔쳐와 스케줄링하는 방식으로 성능을 극대화합니다.

5. GOMAXPROCS:

Go 런타임에서 사용할 수 있는 논리적 CPU의 수는 GOMAXPROCS로 결정됩니다. 기본적으로 이 값은 시스템의 CPU 코어 수와 동일하게 설정되지만, 명시적으로 설정할 수도 있습니다. 이는 여러 코어에서 goroutine이 병렬로 실행되는 정도를 제어합니다.

runtime.GOMAXPROCS(4) // 최대 4개의 프로세서를 사용

Goroutin의 M

Go 런타임에서 M(Machine)은 실제로 OS 스레드를 나타내며, OS 스레드를 생성하고 관리하는 역할을 합니다. Goroutine(G)은 M에 의해 실행되며, 여러 M을 통해 실제 CPU 코어에서 병렬로 동작합니다. OS 스레드 생성은 런타임이 자동으로 처리하는데, 이 과정은 동시성과 성능을 최적화하기 위해 매우 중요한 역할을 합니다. 이제 M에서 OS 스레드를 생성하는 과정을 자세히 설명하겠습니다.

1. M의 역할과 OS 스레드 생성

Go 프로그램이 실행될 때, Go 런타임은 M을 이용해 OS 스레드를 생성합니다. 이 M은 goroutine을 실제 CPU 위에서 실행하는 주체입니다. M은 운영체제의 커널과 상호작용하여 OS 스레드를 생성하고, 각 OS 스레드에서 하나 이상의 goroutine을 실행할 수 있습니다.

OS 스레드 생성 과정:

  • Go 프로그램이 시작되면 최소 1개의 M이 OS 스레드를 생성합니다. 이 스레드는 메인 goroutine(즉, main 함수)을 실행합니다.
  • goroutine이 많아지고, 추가적인 M이 필요하면 런타임은 새로운 M을 생성하고, 그 M이 또 다른 OS 스레드를 생성해 CPU 자원을 활용할 수 있게 만듭니다.

일반적으로 OS 스레드는 운영체제의 커널이 관리하며, Go 런타임은 직접적으로 OS 스레드를 생성하고 해제하는 API를 호출합니다. 이는 고정된 스레드 수에 의존하지 않고 동적으로 OS 스레드를 관리할 수 있게 해줍니다.

2. 스레드 생성의 동기:

새로운 OS 스레드를 생성하는 주요 이유는 다음과 같습니다:

(1) 블로킹 작업 처리:

  • Go의 기본적인 철학은 Non-blocking I/O이지만, 때로는 블로킹 시스템 호출이나 블로킹 작업이 발생할 수 있습니다. 예를 들어, 네트워크 호출이나 파일 시스템 I/O 같은 경우입니다.
  • Go 런타임은 goroutine에서 블로킹 작업이 발생하면, 그 작업이 실행 중인 M(OS 스레드)을 다른 작업으로부터 차단하지 않고 새로운 M을 만들어서 다른 goroutine이 병목 없이 계속 실행될 수 있게 합니다.
    • 예: 한 goroutine이 파일을 읽기 위해 블로킹 시스템 호출을 수행하면, Go 런타임은 현재 OS 스레드를 다른 작업에 사용할 수 있게 새로운 OS 스레드를 생성하고, 블로킹된 작업을 다른 스레드로 넘깁니다.

(2) Goroutine이 프로세서를 기다릴 때:

  • Go 런타임은 GOMAXPROCS로 설정된 CPU 코어 수 만큼 P (Processor)를 관리합니다. 각 P는 하나의 M(OS 스레드)을 할당받아 goroutine을 실행합니다. P에 할당된 M이 없거나 다른 M이 바쁜 경우, 새로운 M을 생성해 실행할 수 있습니다.

3. M과 P (Processor)의 관계

  • M과 P는 1:1로 연결: MP와 연결된 상태에서만 Goroutine을 실행할 수 있습니다. 이는 Go 스케줄러가 goroutine을 OS 스레드에서 실행하기 위한 구조입니다.
  • M과 P의 분리: P는 실제 CPU 코어와 연결된 프로세서 단위입니다. Go 스케줄러는 P가 작업을 관리하는 동안, M(OS 스레드)은 그 위에서 실행됩니다.
  • 즉, M은 P 위에서 goroutine을 스케줄링하여 실행하는 주체이기 때문에, M이 P 없이 독립적으로 작업을 수행할 수는 없습니다. P가 없으면 M은 놀고 있는 상태가 됩니다. 이 경우, 추가적인 P가 필요할 때 새로운 M을 생성해 OS 스레드를 할당할 수 있습니다.

4. OS 스레드 해제

Go는 필요에 따라 OS 스레드를 해제하거나 다시 사용할 수 있습니다. 예를 들어, 오랜 시간 동안 사용되지 않거나 블로킹된 OS 스레드는 런타임이 해제할 수 있습니다. 이는 시스템 자원을 효율적으로 관리하고 메모리와 CPU 사용을 최적화하는 데 도움이 됩니다.

  • Idle M: 사용하지 않는 Midle 상태가 됩니다. 이 M은 여전히 OS 스레드를 유지하지만, 다른 작업을 기다립니다. 일정 시간이 지나면 이러한 idle M은 해제되어 시스템 리소스를 줄입니다.
  • OS 스레드 재사용: Go 런타임은 OS 스레드를 재사용할 수 있습니다. 만약 새로운 M이 필요하다면, 이미 사용했던 OS 스레드를 재활용할 수 있어 성능에 도움이 됩니다.

5. 실제 OS 스레드의 효율적 관리

Go의 런타임은 매우 많은 goroutine을 처리하는 데 있어 OS 스레드의 수를 최소화하려고 노력합니다. 그 이유는 OS 스레드를 많이 생성하면 문맥 전환 비용(context switch)이 증가하고, 운영체제 자원이 낭비될 수 있기 때문입니다.


Goroutine 관리의 장점

  • 경량성: Goroutine은 매우 가벼워서 수십만 개의 goroutine을 생성하더라도 메모리 및 성능에 큰 부담을 주지 않습니다.
  • 자동 관리: OS 스레드에 대한 복잡한 관리를 개발자가 직접 할 필요 없이, Go 런타임이 자동으로 goroutine을 OS 스레드에 매핑하고 스케줄링합니다.
  • 효율적 스케줄링: Go의 M모델은 goroutine을 스케줄링하는 데 있어 CPU와 메모리 자원을 효율적으로 사용하여, 성능을 극대화할 수 있습니다.

결론

Goroutine은 Go에서 동시성을 효율적으로 관리하는 중요한 기능으로, Go 런타임이 스레드와 스케줄링 작업을 관리합니다. 개발자는 복잡한 스레드 관리 없이 goroutine을 사용하여 고성능, 병렬 프로그램을 쉽게 작성할 수 있습니다.