이것이 점프 투 공작소

고루틴에 대해 알아보자 본문

Go

고루틴에 대해 알아보자

겅겅겅 2024. 6. 23. 11:55

1. 동시성(Concurrency)과 병렬성(Parallelism)

동시성과 병렬성은 cpu의 작업 처리방식입니다.

 

동시성은 싱글코어 프로세서에서 멀티 스레드 환경을 구성하여 프로그램이 '동시에 실행되는 것 처럼'(논리적) 보이게 하는 방법이고,

병렬성은 멀티코어 프로세서에서 여러개의 스레드, 프로세스를 병렬로 돌려 프로그램을 '실제로 동시에 실행'(물리적) 시키는 방법입니다.

 

동시성으로 작업하는걸 멀티 태스킹, 병렬성으로 작업하는걸 멀티 프로세싱이라고도 합니다.

 


위에서 동시성은 싱글코어, 벙렬성은 멀티코어에서 사용되는 것 처럼 이야기 했지만,

실제로 운영체제는 두 방법 중 하나를 선택하여 사용하는것이 아닌 섞어서 사용합니다.


예를들어 6코어 12쓰레드의 프로세서가 있다면 6개의 프로세스를 병렬로 실행함과 동시에
6개의 프로세스에서 생성한 스레드 n개를 동시성으로 CPU에서 작업하게됩니다.

동시성으로 프로그램을 실행시키게 되면 'Context Switching으로 인해 발생하는 스위칭 오버헤드','Race Condition','Dead Lock'과 같은 문제들이 발생 할 수 있습니다.
하지만 그럼에도 동시성은 제한된 하드웨어에서 성능을 향상 시키기 위한 효율적인 방법입니다.

2. CSP - GO에서 동시성을 다루는 방법

일반적으로는 동시성을 다루기 위해 '뮤택스', '세마포어' 와 같은 방법으로 동시성문제를 해결하지만,

GO에서 동시성을 다루기 위한 방법으로는 CSP모델을 사용합니다.


CSP의 핵심 철학은 아래와 같습니다.

'공유메모리의 변수에 접근할때 Lock을 걸지 말고,스레드간 통신을 통해 메모리를 공유하라' 

 

Go에서는 '고루틴'이라는 일종의 경량의 가상 스레드를 만들어 Lock이 아닌 고루틴간의 통신을 통해 메모리를 공유합니다.


고루틴에서 고루틴끼리 데이터를 공유할 떄는 'Channel'이라는 객체를 사용하여 send하면서 각 고루틴끼리 communicate 하게됩니다. (OS에서 스레드끼리 공유자원을 이용하는것을 'communicate'한다고 합니다)

고루틴1이 고루틴2에게 데이터를 Channel을 통해 send하면 고루틴2는 고루틴1에게서 데이터가 도착할때 까지 다른 행위를 하지않고 기다립니다. 고루틴2의 이러한 멈춤 상태를 '안정적인 상태'라고 말합니다.


고루틴이 안정적인 상태에 있게되면 기다리는것 외에 어떤 행위도 하지 않기에 공유자원에 대한 Lock이나, sync를 맞출 필요없이 
Channel을 통해 공유되는 자원에 대한 동기화를 보장 할 수 있습니다.

 

하지만 고루틴 또한 일종의 스레드 기반이기에 동시성의 위험을 완전히 해결한것은 아니며,

여러 고루틴이 하나의 값에 대해 접근하는 경우에는 여전히 동기화(sync)가 필요합니다.

3. 고루틴 이란?

앞서 고루틴은 일종의 경량 스레드라고 말했는데,

고루틴이 왜 경량 스레드라고 불리는지 알기 위하여 프로세스와, 스레드에 대해 먼저 알아보고자 합니다.

3-1 프로세스와 스레드

프로세스는 컴퓨터에서 실행되고 있는 프로그램을 의미합니다.

스레드는 프로세스 내에서 실행되는 작업의 단위이며, 하나의 프로세스는 여러 스레드를 가질 수 있습니다.

 

프로세스의 메모리 구조

 

Text 영역 (Code 영역) : 실행 가능한 명령이 포함된 파일이나, 메모리에 존재하는 프로그램의 함수들의 코드가 CPU가 해석 할 수 있는 기계어 형태로 저장되어 있습니다. 컴파일 시점에 결정되고 Read-Only로 되어있습니다.

 

데이터 영역(Data) : 전역 변수, static 변수 등 프로그램이 사용하는 데이터들이 저장되어 있고. data, rodata, bss의 영역으로 나뉘어져 있습니다. rodata영역은 상수선언된 변수, 문자열이 저장됩니다.

  • 초기화된 데이터 세그먼트 : 프로그래머가 초기화하는 전역 변수와 static 변수가 저장됩니다.
  • 초기화 되지 않은 데이터 세그먼트 (bss) :  블록 정적 저장소라고도 불리며, 초기값 없는 전역 변수와 static 변수가 저장됩니다. 프로그램이 시작되기 전 커널에 의해 초기값으로 초기화 됩니다.

스택 영역(Stack) : 호출된 함수가 종료되고 복귀할 주소와 데이터(변수, 리턴값 등)를 임시로 저장하는 공간입니다. 함수의 호출과 동시에 할당되고, 호출이 완료되면 소멸합니다. 너무 많은 양의 함수의 호출되거나 저장되는 데이터의 크기가 영역의 크기를 초과하면 stack overflow가 발생합니다.

 

힙 영역 (Heap) : 생성자나 인스턴스와 같이 동적으로 할당되는 데이터가 저장되는 영역입니다. 프로세스가 실행되는 동안 크기가 동적으로 변화 하게됩니다. 스택 영역의 함수에서 포인터를 사용하게 되면 힙 영역의 메모리에 엑세스하는데 사용됩니다.

스택과 동일하게 힙 영역도 크기가 동적으로 변화합니다.

 

 

멀티스레드 메모리 구조

 

스레드도 OS에서 함수로 구현되기에 각 스레드에 대한 지역변수, static변수를 가지며 이를 관리하기 위한 프로세스와의 별도의 stack영역을 가집니다. 이 stack 영역을 thread stack이라고 부릅니다.

 

실행되는 프로세스들은 각각 별도의 메모리 공간을 가지고 있기에 서로 정보를 공유하기 어렵습니다.

그래서 OS에서는 IPC와 같은 방법들를 사용하지만 IPC의 경우 캐시메모리가 초기화 되기에 비용이 크다는 단점이 있습니다.

 

반면 스레드는 stack 외 프로세스의 영역 Text, Data, Heap 영역을 공유하기에 각 스레드간 통신이 가능합니다.

그렇기에 프로그램에서 동시성 작업이 필요할 때에는 멀티스레드를 사용하는 경우가 일반적입니다.

3-2 스케줄링과 컨텍스트 스위칭

프로세스 컨텍스트 스위칭

 

물리적으로 하나의 CPU에는 하나의 프로세스만 할당되어 실행 될 수 있습니다.

그렇기에 OS는 '동시성'을 위해 모든 프로세스에 우선순위를 할당하여, CPU에서 실행중인 프로세스의 우선순위가 대기중인 프로세스의 우선순위보다 낮아지게 되면 CPU가 실행시킬 프로세스를 변경 하게됩니다.

 

OS의 '스케줄러'가 해당 작업을 수행하고 이를 '스케줄링'이라고 합니다.

 

CPU에 할당된 '프로세스1'이 스케줄링을 통해 다른 프로세스 '프로세스2'로 변경될 때 실행되던 '프로세스1'의 정보를 '프로세스1'의 PCB에 저장하고 '프로세스2'를 CPU에 할당합니다. 이후 '프로세스2'의 PCB에 저장되어있던 '프로세스2'의 정보를 다시 불러온 다음, CPU가 '프로세스2'을 실행시킵니다.

 

이러한 과정을 Context Switching 이라고 하며, 컨텍스트 스위칭이 일어나면 필연적으로 PCB의 정보가 변경되기에 비용이 발생합니다.

CPU에 할당된 프로세스가 변경되기에 OS는 CPU의 캐시메모리를 무효화(flush)시켜줍니다.

그렇기에 변경된 프로세스는 CPU캐시를 사용하지 못하고 메모리에 직접 접근해야하고 프로세스가 변경되며 참조하는 메모리 주소를 변경하는 등 성능문제도 발생합니다. 

 

위와 같이 컨텍스트 스위칭이 일어나며 발생하는 성능 저하를 Context Switching Overhead라고 합니다.

 

스레드 컨텍스트 스위칭

여러 프로세스가 동시에 실행되기 위해 Context Switching이 일어나듯, 다중 스레드 환경에서도 동일하게 스레드의 컨텍스트 스위칭과 스케줄링이 일어납니다. 

동일한 프로세스의 스레드끼리 컨텍스트 스위칭이 발생하게 되면 프로세스의 컨텍스트 스위칭보다 비용이 훨신 저렴합니다.

(다른 프로세스의 스레드간 컨텍스트 스위칭이 발생하면, 프로세스 컨텍스트 스위칭과 동일한 비용이 발생합니다.)

 

스레드간 컨텍스트 스위칭이 일어나서 현재 상태를 백업할때에도 불러올 때에도 동일한 프로세스 이기에 PCB가 아닌 PCB안의 'TCB'에 해당 스레드의 정보를 저장하고 불러오게 됩니다.

 

TCB는 PCB에 비해 적은 정보만 필요하므로 스레드의 컨텍스트 스위칭은 프로세스의 컨텍스트 스위칭보다 더 빠르게 동작합니다.

(고루틴은 TCB보다 더 작은 정보가 필요하여 TCB보다 더 빠릅니다!)

3-3 PCB와 TCB

 

PCB(프로세스 제어 블록)

 

OS가 프로세스를 관리하기 위해 프로세스의 정보를 저장해 놓은 구조체 입니다.

프로세스의 상태, 프로그램 카운터(PC), 레지스터에 들어있는 정보, 프로세스의 현재 포인터 와 같은 데이터들이 들어있습니다.

 

컨텍스트 스위칭이 일어나게 되면 레지스터에 존재하는 현재 실행중인 프로세스의 정보(프로그램 카운터, 스택 포인터 등)를 어딘가에 저장해 두어야 하는데, 이때 OS는 해당 프로세스의 PCB를 사용합니다. PCB는 프로세스 생성과 동시에 생성되고 프로세스가 완료되면 사라집니다.

 

PCB는 아래와 같은 정보를 가집니다.

  • 프로세스 식별자(Process ID) : 프로세스 고유 번호
  • 프로세스 상태(Process State) : 프로세스의 현재 상태 정보입니다. (생성, 준비, 실행, 대기, 완료)
  • 프로그램 카운터(Program Counter) : 프로세스가 다음에 실행할 명령어의 주소
  • CPU 레지스터 및 일반 레지스터 : 프로세스가 실행 중에 사용하는 레지스터의 값들이 저장
  • CPU 스케줄링 정보 : 우선 순위, 최종 실행시각, CPU 점유시간과 같은 값들이 저장
  • 메모리 관리 정보 : 해당 프로세스의 주소 공간, 메모리 크기 등과 같은 값들이 저장
  • 프로세스 계정 정보 : 페이지 테이블, 스케줄링 큐 포인터, 프로세스 소유자, 부모 프로세스 등과 같은 값들이 저장
  • 입출력 상태 정보 : 프로세스에 할당된 입출력장치 목록, 열린 파일 목록들이 저장
  • 포인터 : 프로세스에서 사용하는 데이터(입출력장치, 파일목록 등)의 메모리 주소정보가 저장

 

TCB (스레드 제어 블록)

 

TCB는 PCB안에 속해있는 구조체 입니다.

PCB처럼 스레드의 정보가 저장되어 있으며 스레드가 생성될 때 생성되고 종료되면 같이 사라집니다.

multi thread, multi process 환경에서는 프로그램 카운터(PC)를 PCB가 아닌 TCB에 저장합니다.

스레드와 관련된 데이터만 저장되기에 PCB보다 적은 데이터를 가집니다. (고루틴은 TCB보다 더 작은 데이터를 가집니다)

 

TCB는 아래와 같은 데이터를 가집니다.

  • 스레드 식별자(Thread ID)  : 스레드가 생성될 때 운영 체제에서 스레드에 할당하는 고유 식별자
  • 스레드 상태: 스레드가 시스템을 통해 진행됨에 따라 변경되는 스레드의 상태 (New, Runnable, Blocked, Terminated)
  • CPU 정보: 스레드가 얼마나 진행되었는지, 어떤 데이터가 사용되고 있는지 등 OS가 알아야 하는 모든 정보가 포함스레드
  • 우선 순위: 스레드 스케줄러가 작업을 시작해야 하는 스레드 결정을 위한 우선순위 값
  • 포인터 : 스레드를 생성한 프로세스의 포인터 주소, 스레드가 생성 한 스레드의 포인터 주소, 이 스레드의 stack의 포인터 주소

3-4 스레드 모델

스레드는 크게 유저 스레드(User Threads)와 커널 스레드(Kernel Thread)로 나뉩니다.

유저 스레드는 사용자 수준의 라이브러리에서 관리하는 스레드이고 커널스레드는 OS에 의해 직접 관리되는 스레드입니다.

유저 스레드 (User Threads)

유저 영역에서 동작하는 스레드입니다.

라이브러리를 통해서 생성, 삭제, 스케줄링이 되기에 커널영역에서 유저 스레드를 인지하지 못하며 커널입장에서는 많은 유저 스레드가 있어도 하나의 커널스레드로서 인식하게됩니다.

 

그렇기에 OS 단계의 컨텍스트 스위칭이 일어나지 않아 커널스레드에 비해 비용이 저렴하지만

시스템 콜과 같은 이유로 하나의 유저 스레드가 블록된다면 모든 유저스레드가 블록되는 상황이 발생하게됩니다.

 

유저 스레드 정리

  • 프로세스 1개에 커널 스레드 1개가 할당되나, 유저 스레드는 여러개 존재 할 수 있습니다.  (커널 입장에서 유저 스레드는 하나의 커널스레드)
  • 유저 영역의 스레드 라이브러리가 스케줄링을 담당한다.
  • 유저스레드의 TCB는 프로세스 내에서 관리됩니다. (커널모드 전환 불필요, 프로그램의 시스템 콜이 일어나면 커널모드 전환)

커널 스레드 (Kernel Threads)

커널 영역에서 동작하는 스레드입니다.

OS에 의해 생성, 삭제, 스케줄링이 일어나게 됩니다.

OS에서 직접 관리하기에 안정적이지만 대부분의 프로그램들은 사용자 영역에서 동작하기에 프로그램에서 커널스레드를 자주 사용하게 되면 '유저 영역 -> 커널영역'간 호출(시스템콜)이 많아지므로 성능저하가 발생 할 수 있습니다.

 

커널 스레드 정리

  • 커널 스레드를 사용하면 프로세스 내의 사용자 스레드 1개 당 커널 스레드 1개가 할당된다.
  • 커널이 전체 TCB와 PCB를 관리합니다.
  • 실행중인 스레드가 시스템 콜로인해 블락되어도 해도 해당 프로세스 내 다른 스레드가 CPU에서 실행 될 수 있습니다. (유저 스레드는 커널 입장에서 하나의 커널 스레드이므로 불가능)
  • OS레벨의 스케쥴링과 시스템콜로 인한 성능저하가 존재합니다.

 

스레드 모델

 

유저 스레드와 커널스레드가 따로 존재하기에 두 스레드를 함께 다루기 위한 방법들이 존재합니다.

크게 3가지 방법이 있는데 간단히 알아보겠습니다.

고루틴은 Many To Many 방식을 사용합니다.

Many To One (M:1)

하나의 커널스레드에 N개의 유저 스레드가 연결되어 있는 형태입니다.

한번에 하나의 유저 스레드만 커널에 접근가능하기에 멀티코어 시스템에서 충분한 활용이 어렵다는 큰 단점이 있습니다.

Many To Many (M:N)

여러 유저 스레드가 여러 커널스레드와 연결되는 형태입니다.

M:N 모델이라고도 하며, 오늘날의 멀티코어 환경에서 활용하기 좋은 방식입니다.

고루틴에서 사용하는 스레드모델 방식입니다.

One To One (1:1)

하나의 유저스레드에 하나의 커널 스레드가 연결되는 방식입니다.

멀티코어에서 활용 가능하며 컨텍스트 스위칭이 커널레벨에서만 동작하기에  Many To Many 모델보다 비용이 높습니다.

고루틴이란?

 

드디어! 고루틴입니다.

앞서 계속해서 언급되었듯 고루틴은 일종의 경량 스레드입니다.

동시성을 만들기 위한 GO의 수단이고, 고루틴 스케줄러에서 M:N모델의 형태로 관리하는 사용자 공간의 스레드입니다.

 

고루틴은 스레드 보다 가볍습니다

고루틴을 생성하게 되면 2KB의 스택 공간만 생성이 됩니다. (스레드에 비해 500배 가볍습니다.)

고루틴은 필요에 따라 고루틴간 통신을 통해 Heap영역을 공유 및 사용합니다.

  •   쓰레드는 사용할 메모리 공간과 각 메모리 간의 경계 역할을 하는 Guard Page라고 불리는 메모리 영역을 함께 필요로하여 약 1Mb의 메모리 공간을 소모하여 생성됩니다.
  •  Thread 생성이 많아질 수록 프로세스의 스택영역을 더 많이 차지하게 되고 힙과 스택영역은 크기를 공유하기에 스레드가 많이 생성될수록 힙 영역이 작아지게되고 이에따라 OOM이 발생 할 수 있습니다. 그렇기에 자바와 같은 언어에서는 스레드풀을 만들어서 스레드의 수를 관리하지만 고루틴은 가볍기에 위와 같은 문제에서 비교적 자유롭습니다. (고루틴은 Go 런타임에 의해 생성 및 소멸되며 매우 적은 비용으로 이루어집니다. Go 언어는 고루틴의 수동 관리를 지원하지 않습니다.) 

고루틴은 Context Switching 비용이 가볍습니다.

스레드가 스케줄링 되며, 컨텍스트 스위칭이 발생 할때 일반적인 스레드는 레지스터의 정보를 저장하고 복구하는 작업을 진행할 때

모든 레지스터를 대상으로 저장과 복구가 진행됩니다.

 

대표적으로

16개의 범용 레지스터, 프로그램 카운터(PC), 스택 포인터(SP), Segment레지스터

16개의 XMN레지스터, FP coprocessor state, 16개의 AVX 레지스터, 모든 MSR 에 저장과 복구가 이루어 지는데

 

고루틴의 컨텍스트 스위칭에서는 3개의 레지스터 프로그램 카운터(PC), 스택 포인터(SP), DX를 대상으로만 저장과 복구 작업이 이루어지기에 PCB와 TCB에 비해 Context Switching 비용이 훨신 더 가볍습니다.  

  • 프로그램 카운터(PC) : 고루틴 인터럽트 후 복원하기 위한 프로그램 카운터(PC)로 고루틴 내부에 저장
  • 스택 포인터(SP) : 고루틴의 스택을 가르키는 포인터 (SP)
  • DX : CPU 레지스터 중 DX

4. Go 스케줄러

CPU 스케줄러가 여러 쓰레드의 실행순서를 결정하고, 실행시키듯이 Go에서는 고루틴의 실행방법과 순서를 담당하는 스케줄러가 있습니다.
Go스케줄러는 m:n 모델 스케줄링 기법을 사용하는데 m은 고루틴의 갯수이고 n은 고루틴과 연결될 커널 스레드 수 입니다.
m개의 고루틴이 구동하면 n개의 커널 스레드에서 스케줄링을 시작합니다.

4-1 Go 스케줄러 구성 항목

Go스케줄러는 5개의 구성 항목을 가집니다.

 

OS스레드(M) : 고루틴을 동작시킬 수 있는 실제 OS의 스레드

- P로 부터 G를 할당받아서 실행시킵니다.

- 실행중인 G,P의 포인터를 가지고 있습니다.

 

고루틴(G) : Go에서 동시성을 위해 사용되는 경량 스레드

- Go런타임이 관리합니다.

- 컨텍스트 스위칭을 위한 SP, 고루틴 상태정보를 가지고 있습니다.

- LRQ에서 대기합니다.

 

논리적 프로세서(P) : OS스레드가 특정 고루틴을 실행 시킬 수 있도록 매핑
- Go프로그램에서 사용 가능한 프로세서의 최대 수는 GO의 변수 'GOMAXPROCS' 값에 따라 결정됩니다.
- GOMAXPROCS의 기본값은 컴퓨터의 논리적 CPU 코어 개수

- 프로세스나 스레드의 실행 상태를 저장하고 복원하는 데 필요한 모든 정보(컨텍스트 정보)를 가지고 있으며, LRQ를 가지고 있어서 큐로부터 전달받은 G M에 할당합니다.

 

로컬큐(LRQ) : 
- P마다 하나씩 존재합니다.

- 내부적으로 FIFO, LIFO 2개의 큐를 혼합하여 사용합니다.

- P가 고루틴을 생성하거나 스케줄링할 때, 먼저 자신의 LRQ를 사용합니다.

- G를 하나씩 POP하여 논리적 프로세서(P)에 전달합니다.

- GO에는 GRQ와 LRQ 2개의 큐가 존재함으로서 Race Condition과 Lock을 예방 할 수 있습니다.

 

글로벌큐(GRQ) : 
- LRQ에 할당되지 못한 큐가 존재합니다. 
- 실행중인 G은 한번에 10ms까지 실행되는데 10ms동안 실행된 G은 대기 상태가 되어 글로벌큐로 이동됩니다.
- 고루틴 생성 시점에 LRQ가 가득 차있으면 글로벌큐에 고루틴이 저장됩니다.

- GRQ는 P의 LRQ가 비어있을 때만 사용됩니다.

 

로컬큐에서 2개의 큐를 사용하는 이유?

LRQ에서 FIFO와 LIFO를 함께 사용하는 이유는 캐시 지역성(cache locality) 때문입니다.

 

실행중인 고루틴 G에서 새로운 고루틴 G-1를 만든다고 가정해봅시다.

G가 G-1의 종료되기를 기다려야하는 작업이라면 G입장에서는 G-1가 다른 고루틴들보다 빨리 종료되어야 하며, 

G, G-1 모두 동일한 메모리의 위치를 참조할 가능성이 높기에 G-1가 종료되자마자 G가 실행되어야 캐시를 사용할 가능성이 매우 높아지는 등 여러 성능상 이점이 있습니다.

 

이러한 상황을 고려하여 고루틴은 FIFO, LIFO 2개의 큐를 사용합니다.

 

- 고루틴 생성 시 (LIFO 큐 사용): 새로운 G가 생성되면 LIFO 큐에 추가됩니다

  1. 고루틴 G, G2, G-1가 순서대로 생성됩니다. (G-1은 G에서 생성한 고루틴)
  2. 생성된 고루틴들은 LIFO 큐에 들어갑니다.
  3. LIFO 큐 상태: [G-1, G2, G] (나중에 생성된 고루틴 G-1이 큐의 앞쪽에 위치)

- 고루틴 실행 (FIFO 큐 사용): FIFO큐에 들어온 G는 스케줄러에 의해 POP되어 실행됩니다.

  • 실행될 G-1는 LIFO큐에서 POP되어 FIFO큐로 들어옵니다. (가장 마지막에 생성된 G-1이 FIFO 큐에 들어옵니다)
  • FIFO 큐 상태: [G-1]
  • G-1가 실행됩니다.
  • 다음으로 G2가 FIFO 큐로 이동하고 실행됩니다.
  • FIFO 큐 상태: [G2]
  • G2가 실행됩니다.
  • 마지막으로 G가 FIFO 큐로 이동하고 실행됩니다.
  • FIFO 큐 상태: [G1]
  • G1이 실행됩니다.

위와 같은 방식을 통해 cache locality를 확보하며 메모리 접근 시간과 프로그램의 실행속도를 줄일 수 있습니다.

 

고루틴이 실행되는 전체적인 순서는 이렇습니다.

  1. P와 M의 초기화:
    • Go 프로그램이 시작되면, 지정된 GOMAXPROCS 값에 따라 여러 개의 P가 생성됩니다.
    • 각 P는 실행 대기 중인 고루틴들을 관리하기 위한 LRQ를 가지고 있습니다.
    • OS는 M을 생성하고, M은 P와 연결되어 G을 실행합니다.
  2. 고루틴 실행:
    • P는 자신의 LRQ에서 G을 꺼내어, 연결된 M에서 실행하도록 합니다.
    • M은 P의 컨텍스트 내에서 고루틴을 실행하며, OS가 할당한 시간 동안 이를 수행합니다.
  3. M의 스피닝(Spinning) 상태 진입
    • M이 유휴 상태로 전환되지 않고 계속해서 반복적으로 P의 LRQ에서 다른 고루틴을 찾으며 작업 가능 여부를 확인하며 대기하는 상태를 의미합니다.
    • 스피닝 상태에서 M은 P의 LRQ에서 다음 고루틴을 가져와 실행합니다.
    • 스피닝을 통해 고루틴이 곧 실행될 수 있는 상황에서는 컨텍스트 스위칭 없이 빠르게 고루틴을 실행할 수 있습니다.
    • 스피닝 상태에서는 CPU 사이클을 소비하며 계속해서 LRQ를 확인하므로, 너무 오랫동안 스피닝을 유지하면 CPU 자원을 낭비할 수 있습니다.
  4. 고루틴 스케줄링 시작:
    • P의 LRQ가 비어 있으면, P는 GRQ에서 고루틴을 가져옵니다, 하지만 GRQ도 비어있다면 다른 P에 존재하는 절반의 고루틴을 가져옵니다.(작업 훔치기 전략)
    • 이러한 과정을 통해 고루틴이 효율적으로 스케줄링되고, CPU 자원이 최대한 활용됩니다.

고루틴 스케줄링 예시

  • Go 프로그램이 시작되면, GOMAXPROCS가 4로 설정되어 있다고 가정합니다.
  • 4개의 P가 생성되고, 각각의 P는 G들을 관리하기 위한 LRQ를 가지고 있습니다.
  • 운영체제는 여러 개의 M을 생성하고, 이들은 각 P와 연결되어 G을 실행합니다.
  1. P1의 LRQ에 고루틴 G1, G2, G3가 있습니다.
  2. M1이 P1과 연결되어 있고, G1을 실행 중입니다.
  3. M1이 할당된 시간 동안 G1의 작업을 완료하면, M1은 스피닝 상태로 전환됩니다.
  4. 스피닝 상태의 M1은 P1의 LRQ에서 G2를 가져와 실행을 시작합니다.
  5. P1의 LRQ가 비어 있다면, P1은 GRQ에서 고루틴을 가져오거나 다른 P의 고루틴을 훔쳐올 수 있습니다.

고루틴의 컨텍스트 스위칭 시점은 언제일까?

 

OS에서 컨텍스트 스위칭은 보통 시스템콜이 일어날때 발생하지만,

고루틴은 GO스케줄러가 관리하기에 아래와 같은 상황에서 컨텍스트 스위칭이 발생합니다.

  • 고루틴간 통신 (unbuffered channel에 접근 시)
  • 시스템 콜
  • 블로킹 작업 (시스템 I/O 등)
  • 고루틴 생성 및 종료
  • time.Sleep()과 같은 Sleep 함수를 통해 일정시간 대기
  • runtime.Gosched() Function 같은 로직이 실행