1. 고루틴
- 고프로그램을 구성하는 가장 기본적인 단위 중 하나이다. GO 코드의 시작점인 main 함수 또한 고루틴 으로 할당되어 실행된다.
- 고루틴은 다른 코드와 함께 동시에 실행되는 함수라고 이해하면 쉽다.
사용방법은 Go Tour에서 확인 바란다. 아래 간단한 예제를 첨부한다.
func hello(){
fmt.Println("안녕 잘지내?")
}
func main(){
go hello()
//익명함수 방식
func(){
fmt.Println("응 잘지내")
}()
}
고루틴은 OS 스레드 인가? 그린 스레드 인가?라는 주제를 시작으로 이야기를 풀어간다.
OS 스레드 란?
-OS 작업에 의해서 관리되는 스레드를 OS 스레드 라고 한다. 개별적인 스택, 레지스터 상태를 가지고 있으며, 커널이 스케쥴링을 하며 이는 CPU 코어 간의 부한 분산을 가능하게 합니다. 그러나 이로 인해 스레드 간의 교체에 따른 오버헤드를 컨택스트 스위치라고 일컬으며 이 비용이 높다는 단점이 존재한다.
그린 스레드란?
-그린 스레드란 프로그램 또는 런타임 시스템이 관리하는 가벼운 스레드로, 가상 스레드라고도 한다. OS 스레드 보다 생성과 관리비용이 훨씬 적지만 한 번에 하나의 OS 스레드에서만 실행될 수 있습니다.
고 루틴은 이 둘 중에 어디에도 분류할 수 없다. 그린스레드 보다 높은 수준의 추상화인 코루틴이다.
코루틴 이란?
- 코루틴은 단순히 동시에 실행되는 서브루틴(함수, 클로져, 메서드) 로서 , 비선점적이다. 다시 말해 인터럽트 할 수 없다는 뜻이다. 이런 특성에 따라 코루틴은 2가지 특징이 있는데 중단과 재개 의 특징이 존재한다. 자신의 실행을 일시적으로 중단하고, 필요에 따라 다시 재개할 수 있다.
아무리 이런 동시기능을 제공해 주는 기능이 있더라도 누군가는 이 동시 기능이 가능하게 임무분담을 해주어 햐는데 GO에서는 이 메커니즘을 M:N 스케쥴러라고 한다.
가용한 그린스레드 보다, 많은 고 루틴이 존재한다면 스케쥴러는 사용가능한 스레드들로 고루틴을 재분배 하고 , 이고루틴이 대기상태가 되면 다른 고루틴이 실행될 수 있도록 한다.
M개의 그린스레드를 N 개의 OS 스레드에 맵핑한다는 의미이다. 프로세스 단위에서 스레드로 맵핑이 올라간다고 생각하면 생각보다 범위가 너무 넓어진다.그린스레드와 OS 스레드 중간 어딘가의 추상화 단계 에서 그린스레드 의 고 루틴 이 넘치면 이에 스레드가 할당되어 고루틴이 비선점적으로 실행된다 ? 스레드 와 고루틴 간의 통신 수단은 무엇이 될지 어떻게 진행될지 , 이들 간의 스위칭에 어떤 문제가 존재하는지 정말 많은 의문이 꼬리에 꼬리를 물지만 이에 대해 6장에서 보다 자세히 설명한다고 하니 그냥 이런 게 있나 보다 하고 넘어가자.
M 은 OS 스레드에 의해 할당되는 쓰레드의 모습을 표현 하였다. 이에 OS 쓰레드 하나에는 모든 P 가 붙게 되고 이는 하나의 메인 고 루틴 이 존재하고,
P 안에는 로컬큐 고루 틴들이 할당된다.
M 은 단지 OS에 의해 할당받고 실행된다면 고 루틴은 이 M에 의해 실행이 된다고 이해하면된다.(https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part2.html)
Go에서는 Folk-Join 모델을 따른다.
동시성 모델 중 하나로, 프로그램 어느 지점에서든 자식 분기를 만들어 미래 어느 시점에 다시 합쳐진다.
고에서는 단순히 go 키워드를 이용하면 위 표에서 포이는 포크의 한지점이 생성되는 것이다. 고 키워드 로직이 마무리가 된다면 join 지점에 의해 메인 고 루틴에 합류된다.
위에서 코루틴의 범위중 하나에 클로저라는 단어를 언급했다. 클로저 란 함수 의 실행 컨택스트 내에서 함수 실행환경을 캡처하여 함수호출시 동일한 환경을 제공해 준다. 아래 예제를 보자.
var wg sync.WaitGroup
greeting := "안녕 잘지내"
wg.Add(1)
go func(){
defer wg.Done()
greeting = "응 잘지내"
}()
wg.Wait()
fmt.Println(greeting)
이는 "응 잘 지내"를 반환한다. 고 루틴 은 자신이 생성된 곳과 동일한 주소 공간에서 실행되기 때문에, 가능한 일이다.
var wg sync.WaitGroup
for _,a := range []string{"ㄱ","ㄴ","ㄷ","ㄹ"} {
wg.Add(1)
go func(){
defer wg.Done()
fmt.Printnl(a)
}()
}
wg.Wait()
이 코드의 결괏값으로는 ㄹ,ㄹ,ㄹ,ㄹ 이 출력된다. 왜?
for 루프가 돌 때마다 a의 값을 가져가 사용하기 위해 클로저가 캡처된다고 생각해 보자. 그러나 스케쥴링된 고 루틴이 어느 시점에 실행될지 알 수가 없다. 미래의 어느 시점에든지 실행될 수 있기 때문에 어떤 값이 출력될지 정해져 있지 않다.
고 루틴이 실행되기 전에? for 루프가 종료될 확률이 높다는 의미이다.
그렇게 된다면 루프의 범위를 벗어난다면 a는 어디서 참조를 해오는 것인가? 저 a의 변수가 고 루틴에서 참조가 가능하도록 힙 공간으로 옮겨진 것이다.
함수의 인자값으로 던져줘서 복사를 시키면 원하는 결괏값을 반환하게 된다.
이는 다시 말해 고 루틴은 동일한 주소 공간에서 작동하며 단순 함수를 호스팅하는것 이것을 기억하고 넘어가자
(버려진 고루틴은 가비지 컬렉터에 의해 회수되지 않는다. 이에 대하여 4장에서 언급한다고 하니 인지만 하자)
고 루틴이 얼마나 가벼운지 에 대하여 설명하는데 이론상 8기가 램에 수백만개의 고루틴 생성이 가능하다.
여기서 이론상이라는 말이 붙은 이유는 콘텍스트 스위칭 이 라는 개념이 존재하기 때문이다.
전환하기 위해 자신의 상태를 저장하고, 변경되는 프로세스의 상태를 불러오는 것을 말한다.
프로세스 가 너무 많아 프로세스 사이의 컨텍스트 스위칭에 모든 CPU 시간을 소모하느라 작업 수행이 불가능할 수 있으며,
OS 스레드를 사용하면 스위칭으로 인한 비용 발생에 따른 성능저하 가 존재한다.
반면 소프트웨어 안에서의 스위칭의 비용은 상당히 저렴하다.
즉 위 절에서는 고 루틴으로 인한 성능문제 가 발생된다면? 명확하게 고 루틴으로 인한 성능 문제라는 사실이 밝혀졌을 때 그 비용을 논의해야 한다고 주장한다.
2. Sync 패키지
go의 Sync 패키지 에는 저수준 메모리 동기화에 유용한 동시성 기본 요소들이 포함되어 있다.
2-1 WaitGroup
동시에 수행될 연산의 집합을 기다릴 때 유용하다. 아래 예제를 보자.
var wg sync.WaitGroup
wg.Add(1)
go func(){
defer wg.Done()
fmt.Println("1st Go Routine is sleeping")
time.Sleep(1)
}()
wg.Add(1)
go func(){
defer wg.Done()
fmt.Println("2nd Go Routine is sleeping")
time.Sleep(1)
}()
wg.Wait()
fmt.Println("All go routines are done")
2-2 Mutex와 RWMutex
Mutex는 상호배제 의 약자로 , 프로그램 의 임계영역을 보호하는 방법이다.
GO 답게 말한다면, 채널은 메모리를 통해 공유하는 반면, Mutex는 개발자가 메모리에 접근하는 방법을 통제하는 규칙을 만들어 메모리를 공유한다.
func Test_Mutex(t *testing.T) {
var count int
var lock sync.Mutex
increment := func() {
defer lock.Unlock()
lock.Lock()
count++
fmt.Println("Added Count current is : ",count)
}
decrement := func() {
defer lock.Unlock()
lock.Lock()
count--
fmt.Println("Sub Count current is : ",count)
}
var proc sync.WaitGroup
for i:=0;i<5;i++{
proc.Add(1)
go func(){
defer proc.Done()
increment()
}()
}
for i:=0;i<5;i++{
proc.Add(1)
go func(){
defer proc.Done()
decrement()
}()
}
proc.Wait()
fmt.Println("All Tasks done")
}
mutex의 구조체를 이용해서 함수의 호출 전후로 lock과 unlock을 진행한다. (자바의 syncronized 키워드 가 어떻게 동작하는지 어림잡아 추측이 가지 않는가?)
단순하게 unlock을 하지 않는다면? 프로그램은 deadlock에 빠지고 panic 이 떨어져 프로세스는 떨어지게 된다.
이러한 임계영역의 진입 은 다소 비용이 많이 들기에 최소화하기 위한 노력을 많이 한다. 이 결과의 산출물이
RwMutex이다. 임계영역의 범위를 줄이는 방법이다. read, write를 구분하는 것이다 항상 모든 스레드에서 쓰기를 동반한 모든 작업이 필요하지 않는 경우가 존재하지 않겠는가? 에서 출발한 아이디어이다.
읽기 잠금 요청을 할 수 있지만, 다른 프로세스에서 쓰기 권한을 가지지 않은 경우에만 가능한다 던 지, 아무도 쓰기 잠금을 보유하지 않고 있다면, 여러 개의 포르세스에서 읽기 잠금을 보유할 수 있다. 논리적으로 합당하다면 이런 경우에는 RWMutex를 사용하는 것이 합당하다.
2-3 Cond
고 루틴들이 대기하거나 , 어떤 이벤트의 발생을 알리는 집결 지점 (https://pkg.go.dev/sync#Cond).
두 개이상의 고 루틴 사이에서, 어떤 것이 발생했다는 사실에 대한 임의의 신호를 이벤트라고 일컫는다. 고 루틴의 실행 전 이러한 신호들 중 하나를 기다리고 싶을 수도 있는데 cond 타입이 이를 도와준다.
cond 타입 없이 나이브하게 구현을 해본다면
무한루프를 사용하는 것이다.
이렇게 되면 코어의 모든 사이클을 소모하기에 매우 비효율적이다. 이를 개선하기 위해 time.sleep을 이용해 강제로 스위칭을 일으킬 수 있다. 이 방법이 무한루프 보다 조금 더 낫지만 여전히 비효율적이다. 어느 정도의 슬립 이 필요한지 모르며 또한 이 슬립이 길어진다면 성능의 저하로 직결되기 때문이다.
고 루틴이 신호를 받을 때까지 슬립하고 있으며, 자신의 상태를 확인할 수 있는 방법 = cond 타입이 해주는 일이다.
cond의 L locker 를 이용해 L.Lock, L.Unlock 을 활용이 가능하며 cond 의 자체적인 메서드 인 wait을 이용해 고 루틴을 일시중지 시킬 수 있다.
wait 은 단지 고 루틴이 멈추는것이 아닌 다른 고루틴이 os 스레드에서 실행될 수 있도록 한다.
func cond2() {
c := sync.NewCond(&sync.Mutex{})
queue := make([]interface{}, 0, 10)
removeFromQueue := func(delay time.Duration) {
time.Sleep(delay)
c.L.Lock()
queue = queue[1:]
fmt.Println("Removed from queue")
c.L.Unlock()
c.Signal()
}
for i := 0; i < 10; i++ {
c.L.Lock()
for len(queue) == 2 {
c.Wait()
}
fmt.Println("Adding to queue")
queue = append(queue, struct{}{})
go removeFromQueue(1 * time.Second)
c.L.Unlock()
}
}
10개의 항목이 추가되었으며, 마지막 두 개의 항목을 큐에서 꺼내기 전에 종료된다. wait의 조건에 따라 큐의 사이즈가 2가 되는 순간 1초의 대기시간이 걸리면서 remove 함수가 진행된다.
여기서 signal이라는 새로운 메서드가 사용되는데 이는 cond의 wait에 대기 중인 고 루틴에게 신호를 보낸다. 이외에도 Broadcast라고 하는 메서드도 있다.
런타임은 신호가 오기를 기다리는 고 루틴의 목록을 fifo(선입선출)의 형태를 유지한다. Signal 신호는 이중 가장 오래 기다린 고 루틴을 찾아서 알려주는 반면 Broadcast는 모든 고루 틴들에게 신호를 보낸다.
2-4 Once
지난번 싱글턴 디자인 패턴에서도 등장하던 친구다. 이름에서 알 수 있듯이 함수를 정확하게 한 번만 호출하는 기능을 한다.
func Test_Once(t *testing.T) {
var count int
increment := func() {
count++
}
decrement := func() {
count--
}
var once sync.Once
once.Do(increment)
once.Do(decrement)
fmt.Println(count)
}
이것의 결과는 0이 아닌 1이 나오게 된다. Do에 전달되는 함수의 호출 횟수가 아닌 Do 가 실행하는 횟수만을 계산하기 때문에 1의 결괏값을 받게 된다.
2-5 Pool
pool 은 동시에 실행해도 안전한 책체 풀 패턴의 구현이다.
일반적으로 데이터베이스 의 연결과 같은 비용이 많이 드는 것의 생성을 제한해 고정된 수의 개체만 생성하도록 하지만, 이러한 요청 혹은 연산이 얼마나 될지 알 수가 없다. 이런 경우 pool 은 매우 효과적으로 고 루틴 안에서 사용할 수 있다.
Get 메서드를 호출해 리턴 가능한 인스턴스가 pool 내에 있는지 확인하고, 그렇지 않으면 새 인스턴스를 만드는 new 멤버 변수를 호출한다. 이후 사용이 끝나면 반환을 위해 put을 호출한다.
func Test_Pool1(t *testing.T) {
myPool := &sync.Pool{
New: func() interface{} {
fmt.Println("Creating new instance")
return struct{}{}
},
}
myPool.Get()
instance := myPool.Get()
myPool.Put(instance)
myPool.Get()
}
Go 에는 가비지컬렉터가 존재하며 인스턴스 된 객체는 자동으로 정리된다. 그렇기에 객체의 캐시를 준비해야 하는 경우에 유용하게 사용할 수 있다. 호스트의 메모리르 보호하던, 사전로딩을 통해 신속한 결괏값을 표현할 때 사용된다.
한 가지 의문점이 들 수도 있다. sync 패키지에는 map 구조체 도 지원을 하고 있다. 고 루틴 간의 안전하게 데이터를 공유할 수 있게 하기 위해서 존재한다. 구조체 안에는 mutex, reader 등등 private으로 선언되어 있어 자체적으로 메서드 안에서 안전한 데이터 공유를 지원하게 해 준다. 그렇다면 sync.Map을 이용해서 저 캐시 기능을 구현가능하지 않겠는가 라는 의문점이 생길 수도 있다.
위에서 언급했듯이 gc에 의해 이 pool 된 객체들은 제거된다. map을 이용해서 구현하게 된다면 계속 메모리에 들고 있어야 한다는 의미가 되고 이는 메모리 또한 관리해야 하는 치명적인 번거로움을 유발할 수도 있다.
pool 은 고비용 객체를 여러 번 재사용할 수 있도록 해주며, GC의 사이클이 실행되면 임시저장소에서 모든 객체가 수거된다. 이는 메모리 누수 방지에도 효과적이다. 따라서 Map과 pool 은 다른 동장방식과 다른 목적을 가지고 사용된다는 의미이다. 이를 혼동해서 사용하지 말자.
'Go > 고루틴' 카테고리의 다른 글
[Concurrency in Go] 4장 Go의 동시성 패턴 -2 (2) | 2023.06.12 |
---|---|
[Concurrency in Go] 4장 Go의 동시성 패턴 (0) | 2023.06.10 |
[Concurrency in Go] 3장 Go의 동시성 구성요소-2 (0) | 2023.06.08 |
[Concurrency in Go] 2장 코드모델링 (1) | 2023.05.31 |
[Concurrency in Go] 1장 동시성 소개 (0) | 2023.05.29 |