Backend with Golang

Goroutine

아직개구리 2023. 5. 1. 00:47

참고자료 

1. https://go.dev/tour/concurrency/1 


Goroutine이란? 

Go 언어로 동시에 실행되는 모든 활동을 고루틴이라고 한다. 

Golang에서 실행 되는 모든 프로그램은 goroutine에서 실행된다. 메인 함수도 고루틴에서 실행된다. golang의 모든 프로그램은 반드시 하나 이상의 고루틴을 가지고 있다.

경량 스레드 라고 함. 

 

go 키워드를 사용해서 함수를 호출하고, 고루틴을 실행할 수 있다. 

go Function()

 

main 고루틴이 종료되면 서브 고루틴도 모두 함께 종료된다.  서브 고루틴이 어느정도 시간이 걸릴지 보통 모르기 때문에 subgoroutine이 끝날때까지 main goroutine을 유지할 수 있도록 하는 WaitGroup을 제공한다. 

Waitgroup counter가 모두 0이 될때까지 block한다. 

var wg sync.WaitGroup 

func main() {
    wg.Add(2) 
    go PrintAlphabet() // 이 함수 안에 wg.Done() 
    go PrintNumbers()
    wg.Wait() 
}

Goroutine 동작 원리 (TBU)

프로세스가 생성되면 CPU 스케쥴러는 프로세스가 할 일을 CPU에 전달하고 실제 작업은 CPU가 수행한다. CPU가 처리하는 작업의 단위는 프로세스로부터 전달받은 스레드인 것이다. 

 

프로세스 내부의 영역은 두가지로 나뉠 수 있다. 프로세스가 실행되는 동안 바뀌지 않는 코드, 데이터, 파일 등을 정적영역이라고 하고, 쓰레드가 작업을 하며 값이 바뀌거나 새로 만들어지거나 사라지는 영역을 동적영역이라하고, 대표적인 예는 레지스터 값, 스택, 힙 등이 있다. 

멀티 쓰레드는 동적영역을 따로 가지고, 정적 영역을 공유하는 것이다. 

 

여기서 이야기 하는 것은 소프트웨어 스레드 (https://thinkty.net/updates/threads/ )이다. 하드웨어 쓰레드는 CPU안에 있는 것을 의미한다. 멀티 코어 뿐만 아니라, 하나의 코어가 멀티 쓰레드로 이루어져있다. 하드웨어 쓰레드의 장점에 대해 설명해보면, 일단 CPU 가 instruction 을 실행할 때, 어떤 instruction들은 memory를 읽어 나중에 사용하기 위해서 register에 저장해놓을 수 있다. 실제로 CPU의 실행 속도에 비교했을 때 value를 메모리에서 읽어오는 것이 빠르지 않다는 것을 알 수 있는데, 이것떄문에 memory에 접근 하는 시간때문에 bottleneck 이 되어 memory stall이 일어날 수 있다. memory에 접근하는 속도가 CPU 실행 속도와 비슷하다고 하더라도, cached miss 때문에 memory stall이 일어날 수 있다. 때때로 찾는 데이터가 caches에 없을 때가 있으면, 메모리에 접근해서 가져와야해서 더 오래걸리게 되고, 이것을 cache miss라고 한다. memory fetch를 기다리는 시간동안 CPU가 실행되지 않고 있으면, 다른 instruction을 실행할 수가 없다는 걸 뜻하는데, 이를 줄이기 위해서 hardware thread를 추가적으로 사용하는 것이다. 

software thread가 hardware thread에 분배되는 과정은 OS에 의해 관리된다. 일반적으로 OS는 hardware threads를 하나의 CPU라고 해석하고 software threads를 round-robin, priority queue등의 알고리즘으로 hardware threads에 schedule한다.

 

다시 소프트웨어 쓰레드로 돌아와서, 앞으로 말하게 될 스레드는 소프트웨어 스레드를 의미한다. 

스레드는 OS에 메모리를 요청하고, 사용하면 반환하는 생성, 소멸 과정에 비용이 상당히 들어간다. 대조적으로 고루틴은 Go 런타임에 의해 생성, 소멸되어 매우 적은 비용으로 이루어짐. 3개의 레지스터 program counter, stack pointer, DX 만 save/restore 작업을 하므로 상대적으로 쓰레드보다 context switching비용이 적게 든다. 

 

https://ykarma1996.tistory.com/188

https://velog.io/@khsb2012/go-goroutine

https://ssup2.github.io/theory_analysis/Golang_Goroutine_Scheduling/

 

 

동시성 프로그래밍의 주의점

고루틴과 같이 쓰레드를 이용하는 프로그래밍을 동시성 프로그래밍이라고 한다. 함수가 동일한 메모리 주소의 값을 변경하거나 같은 자원에 대해서 접근할 때, 위와 같이 동시성 문제가 발생할 수 있다. 

 

이를 해결하기 위해서 mutex를 사용하는데, 이 mutex가 불러 일으킬 문제들은 아래와 같다. 

1.  병렬처리를 통해 얻는 성능향상이 없을 수 있고, 과도한 lock으로 성능이 하락될 수 있음

2. 고루틴을 완전히 멈추게 만드는 Deadlock 문제가 발생할 수 있음. 

 

데드락 (https://en.wikipedia.org/wiki/Dining_philosophers_problem) 순서가 간단히 말하면 아래와 같은데,  

왼쪽포크가 사용가능할때까지 대기 -> 사용가능하다면 집어든다. 

오른쪽 포크가 사용가능할 때까지 대기 -> 사용가능하다면 집어든다

양쪽의 포크를 잡으면 일정 시간만큼 식사를 한다. 

오른쪽 포크를 내려놓는다. 

왼쪽포크를 내려놓는다.  

 

-> 결과적으로 포크가 사용가능해질 때까지 영원히 기다리는 교착 상태에 빠질 수 있다.


Goroutines 

Goroutines run in the same address space, access to shared memory must be synchronized.

sync 패키지가 사용하기 좋은 primitives를 제공해준다. 

 

Channels : Channels 는 channel operator <- 를 사용해서 value를 보내고 받을 수 있는 typed conduit 이다. 

arrow 의 방향대로 data가 흐른다.

기본적으로 다른쪽이 준비될 때까지 block을 보내고 받는다. 

c := make(chan int) // map과 slice에서 channel도 마찬가지로 사용하기 전에 생성되어야 한다.

기본적으로 다른 쪽이 준비가 될때까지는 block을  전송하고 받는다. 

slice의 sum을 구하는데, 두개의 goroutine으로 나눠서하고 channel을 통해서 값을 전송하고, 받을 수 있게 된다. 

go sum(s[:len(s)/2], c)
go sum(s[len(s)/2:], c)
x, y := <-c, <-c 
//receive from c 이때 두개의 goroutine중에 먼저 끝나는 것이 무엇인지에 따라 x,y가 달라질 수 있음

채널은 결과적으로 고루틴 사이에서 값을 주고받는 통로 역할을 하고, 송/수신자가 서로를 기다리는 속성이 있기 때문에, 고루틴의 흐름을 제어할 수 있다. 결과적으로 데이터를 주고 받을 때까지 해당 고루틴을 종료하지 않아서 별도의 lock 이 필요하지 않게 된다.

 

a,b,result 를 선언한 곳인 main 함수를 고루틴A라고 하고, a+b를 실행하는 곳을 고루틴 B라고 하자. 고루틴 B에서 채널을 통해서 a+b를 전달한 뒤에 A에서 수신하고 나서 바로 main 함수가 종료된다면, B는 끝까지 실행되지 않고 종료될 것이다. 데이터 송/수신 시점이 함수가 끝나는 것과 상관이 없다는 것을 주의해야한다. 

 

Buffered Channels 

:  채널은 고루틴간의 데이터 송/수신을 위해 존재하기 때문에, 채널을 위해서는 고루틴이 꼭 필요하다. 수신 루틴, 송신 루틴이 분리되어, 각자가 데이터를 보내고 받을때까지 대기하는 것이다. 하지만 이때, 수신 루틴이 없다면 어떻게 될까? 

func main() {
	c := make(chan string)	
	c <- "Hello goorm!" // all goroutines are asleep이라 데드락 ! 
	fmt.Println(<-c)
}

데드락 상황, 둘 이상의 프로세스가 서로 가진 한정된 자원을 요청하는 경우 발생하는 것으로, 프로세스가 전진되지 못하고 모든 프로세스가 대기 상태가 되는 것이다. main()함수에서 고루틴이 무한 대기 상태가 되었을 때 데드락이 발생한다.  

 

channel은 buffered 될 수 있는데, 이는 송/수신자를 연결하는 통로 중간에 데이터를 잠깐 저장할 수 있게 되는 것이다. 

송신 루틴 버퍼가 가득차면 대기, 수신 루틴은 버퍼에 값이 없으면 대기한다. 

ch := make(chan int, 100)

 

Range and Close: 

v, ok := <-ch //수신할 데이터가 없거나, 채널이 닫혔을 때 ok=false

- for i := range c 는 channel이 닫힐때까지 반복된다. 

- 송신자만이 채널을 닫을 수 있다. 

- channel은 file이 아니다. close하지 않아도 된다는 뜻이다. 필요한 떄는 단지 receiver에게 더이상 받을 게 없다고 알려야 할 때인데, 이럴 때 보통 range loop을 끝내야하기 때문.  

 

select 

: select statement 는 goroutine을 여러개의 communication operation이 끝날때까지 기다리도록 한다. 

실행 흐름을 제어하는 것이다. select문을 사용해 채널 데이터 송/수신 효율을 높일 수 있다. 

 

- 송신된 채널이 있을때, 채널을 수신하는 기능을 하는 것 뿐만이 아니라 데이터를 send할 수도 있다.

- 채널에 데이터를 보내는 case가 있다면 항상 send하고, 채널에 데이터가 수신됐다면, 데이터를 받는 case가 실행된다. 둘다 있을 때 받을 데이터가 없으면 계속 send하는 case가 실행되는 것이다. 

 

Sync.Mutex 

Channel 이  goroutine 사이의 communication 을 위해서 좋다는 것을 알게 되었다. 그런데, communication이 따로 필요없고, variable에 접근할 때, 하나의 goroutine만 접근하는 것만 보장하면 된다면? 

Mutual Exclusion이라는 개념인데, 공유 불가능한 자원의 동시 사용을 피하기 위해 사용되는 알고리즘을 뜻한다. 그리고 conventional 하게 data structure 이름을 mutex라고 한다. 

sync.Mutex를 사용하고, Lock과 Unlock 두개의 methods를 제공한다. 

Lock과 Unlock 에 둘러쌓인 코드가 mutual exclusion에서 실행되어야 하는 곳이다. 

defer는 mutex가 method안에서 unlock되는 것을 확인하기 위해 사용된다. 

 

package main

import (
	"fmt"
	"sync"
	"time"
)

// SafeCounter is safe to use concurrently.
type SafeCounter struct {
	mu sync.Mutex
	v  map[string]int
}

// Inc increments the counter for the given key.
func (c *SafeCounter) Inc(key string) {
	c.mu.Lock()
	// Lock so only one goroutine at a time can access the map c.v.
	c.v[key]++
	c.mu.Unlock()
}

// Value returns the current value of the counter for the given key.
func (c *SafeCounter) Value(key string) int {
	c.mu.Lock()
	// Lock so only one goroutine at a time can access the map c.v.
	defer c.mu.Unlock()
	return c.v[key]
}

func main() {
	c := SafeCounter{v: make(map[string]int)}
	for i := 0; i < 1000; i++ {
		go c.Inc("somekey")
	}

	time.Sleep(time.Second)
	fmt.Println(c.Value("somekey"))
}

1000개의 고루틴을 실행시켜서 somekey의 value를 1씩 증가시키게 되는데, 이때 mutex를 사용함으로써 하나의 고루틴만이 접근할 수 있도록 하는 것이다. 


프로세스와 스레드 

프로세스는 코드로 작성된 프로그램이 메모리에 적재되어 사용할 수 있는 상태. 운영체제로부터 자원을 할당받는 작업의 단위이고, 스레드는 프로세스가 할당받은 자원을 이용하는 실행의 단위이다. 멀티 프로세스는 하나의 프로세스가 죽더라도 다른 프로세스에 영향을 주지 않아서 안정성이 높지만, 멀티 스레드보다 많은 memory공간과 cpu시간을 차지하는 단점이 있다. 

 

쓰레드는 프로그램 내에서의 실행 흐름을 의미한다. 프로그램은 일반적으로 하나의 thread를 가지지만, 경우에 따라 여러개일 경우도 있다. 멀티 쓰레드인 경우에는 OS에서 thread를 관리하고, thread 개수가 cpu보다 많은경우, thread를 교체하면서 CPU를 사용하도록 하는데, 이를 context switching 이라고 한다. (멀티 프로세스에서도 적용됨.) 전환 비용이 발생하므로 성능이 저하될 수도 있다. CPU개수가 쓰레드 개수와 동일하면 context switching이 일어나지 않으므로 성능에 아무 문제가 발생하지 않는다. 

 

OS가 시스템 자원을 효율적으로 관리하기 위해 thread를 사용한다. 프로세스 간의 통신보다 스레드간의 통신비용이 적으므로 작업들 간 통신의 부담이 줄어든다. 이렇게 자원의 효율성이 증가하기도 하지만, 쓰레드 간의 자원 공유는 동기화 문제가 발생할 수 있다. 서로 다른 쓰레드가 프로세스의 메모리를 공유하기 때문에 동시에 자원에 접근하는 상황이 생기게 되고, 이런 경쟁상황으로 인해 발생하는 문제가 동기화 문제이다. 해결 방법은 Mutex나 Semaphore를 사용할 수 있다. Mutex는 하나의 쓰레드만 공유자원에 접근할 수 있게 해서 경쟁상황을 방지하는 것이고, Semaphore는 지정한 수만큼의 쓰레드만 공유자원에 접근할 수 있게 하는 것이다. 

 

Critical section : 둘 이상의 프로세스나 쓰레드가 동시에 동일한 자원에 접근하게 되는 프로그램 코드 부분을 의미한다. 각 프로세스나 쓰레드가 임계구역에서 일을 수행하는 동안 다른 프로세스나 쓰레드가 그 임계 영역에 들어갈 수 없어야 한다. 원자적으로 실행되어야 한다.  

 

 아무리 쓰레드를 많이 생성하더라도 진정으로 병렬처리 될 수있는 thread갯수는 cpu코어 수와 동일하다. 요청에 대한 대기시간이 길면 멀티쓰레드의 장점이 줄어들 수 있다. 


### Goroutine 문법 

for {} -> repeated forever

 

 

 

'Backend with Golang' 카테고리의 다른 글

MySQL - Transaction  (0) 2023.05.09
Docker-compose.yml 파일 작성하기 (TBU)  (0) 2023.05.09
Go: JSON  (0) 2023.05.03
Go's Syntax  (0) 2023.04.27
Docker 기본 개념  (1) 2023.04.26