출처 : https://go.dev/doc/effective_go#concurrency
Concurrency
드디어 고 언어의 꽃인 동시성 프로그래밍 챕터에 왔다. 지나오면서 고 언어에서 oop 를 하기위해 제공되는 여러 키워드 들을 살펴봤고 문법 과 실행 방식에 대해 공부했지만 이번에는 그 결이 다른 동시성 프로그래밍 이다.
바로 읽어보자.
Share by communicating
고 에서는 공유된 자원이 채널 을 통해 전달되는 기존 동시성 프로그래밍 의 방법과 궤를 달리한다고 한다.
오직 하나의 고루틴 에서만 주어진 시간 동안 값에 접근 할수있다. 이에 따른 데이터 레이스는 설계에 따라 발생할수 없다.
왜 ? 데이터 레이스란 하나의 값에 동시다발적으로 두개 혹은 그이상의 동시쓰레드 가 접근하려고 할때 발생 되는데 현재 문서에서 말하는 오직 하나의 고루틴에서만 접근하기 때문에 설계적으로 발생할수 없다고 한다.
고 에서 는 변수 주변에 뮤텍스 락을 걸어 수행할수 있다 다만 채널 을 사용해 접근한다면 ? 보다 명확하고 올바르게 프로그램을 더 쉽게 작성할수 있다.
Goroutines
기존 용어의 다양한 전달에 따라 고루틴으로 부르고 있다. 고루틴은 단순한 모델을 가지고 잇는데. 동일한 주소 공간을 가지고 함수 와 고루틴 이 동시실행되는 것을 의미한다.
고루틴 은 가벼우며, 스택 에 할당되는 값보다 조금 더 비용이 소모된다. 스택 은 메모리 할당 의 비용이 저렴하며, 많은 할당 즉 데이터가 요구 되어진다면, 힙메모리를 이용하기도 한다.
응 ? 문장이 매우 난해하다. 기본적으로 이 베이스가 있어야 한다. 스택 > 힙 메모리 공간 보다 빠르다. 그러나 힙 처럼 다량 을 한번에 할당하기에 제한이 있다. 그렇기에 고루틴은 경량 쓰레드 라는 별명이 있다 왜 ? 스택에 할당되기에 가벼워야하며 빠르기때문에
(스택 은 Cpu 단일 메모리 로 접근이 가능하기에 빠르다 주로 함수호출, 함수 반환값 주소, 임시변수 등이 할당됨)
글을 마저 읽다보면 The effect is similar to the Unix shell's & notation for running a command in the background
유닉스 쉘의 & 백그라운드 실행과 유사한 기능을 한다고 한다.
예시를 보자.
func Announce(message string, delay time.Duration) {
go func() {
time.Sleep(delay)
fmt.Println(message)
}() // Note the parentheses - must call the function.
}
우선 저 함수 선언 과 호출 부분 부터 눈에 가장 확실히 들어오는데 go 키워드를 앞에붙여주며 함수를 선언과 동시에 실행한다.
이건 Go 루틴의 일반적인 예시가 아니라고 한다 왜 ? 완료 신호를 보내줄수 없기 떄문에 이 고루틴 함수가 언제 끝나는지 모른다.
이에 따라 채널을 활용한 방법에 대해 이야기 하는데 예제 몇개를 더 작성해보면서 연습해보자.
func main() {
practice.F("Direct")
go practice.F("GoRoutine")
go func(msg string) {
for i := 0; i < 3; i++ {
fmt.Println(msg, ":", i)
}
}("anonymous")
time.Sleep(time.Second)
fmt.Println("Done")
}
Direct : 0
Direct : 1
Direct : 2
anonymous : 0
anonymous : 1
anonymous : 2
GoRoutine : 0
GoRoutine : 1
GoRoutine : 2
Done
이런 결과 값이 나온다. 그러나 여러번 실행하면 멀티쓰레드 답게 순서 보장없이 마구잡이로 나온다.
단 중간에 타임 슬립 이 있다 이게 왜 필요할까 ?
메인 펑션 또한 고루틴 함수이다. 이에 메인 펑션은 종료되면 ? 모든함수가 종료된다. 즉 저 고루틴 이 실행되는것을 기다려주지 않는다.
만약 저 중간 함수가 없다면 ? 메인 고루틴은 코드를 쭉읽어가면서 함수를 바로 종료한다.
그래서 이 위의 설명중 하나 완료신호 를 주는 방법 중 하나로 채널에 대해 언급하는 이유이다.
(이렇게 말고 sync.WaitGroup 을 이용해 저렇게 슬립을 안하고 이용하는 방법도 있다)
Channels
전에 make 함수를 이용해서 만들수 있는 타입 중에 채널이 언급된 적이있다. 즉 채널을 생성하기 위해서는 make 함수를 이용해 만들고 이에 대한 반환값은 ? 값벨류 이지만 값복사 가 아닌 레퍼런스 복사가 발생한다.
또한 채널의 버퍼사이즈를 설정해줄수 있다. 마치 슬라이스 사이즈를 설정하는것과 유사하다.
ci := make(chan int) // unbuffered channel of integers
cj := make(chan int, 0) // unbuffered channel of integers
cs := make(chan *os.File, 100) // buffered channel of pointers to Files
func main() {
c := make(chan int)
go func() {
fmt.Println("Go routine is Running")
for i := 0; i < 10; i++ {
c <- i * i
}
}()
for i := 0; i < 10; i++ {
fmt.Println("Value is : ", <-c)
}
fmt.Println("Function is done")
}
채널 에 데이터 를 집어넣고 그 갯수 만큼 뺴는 로직이다. 재미있는 부분이 있는데 <-c 채널에서 이렇게 데이터를 뽑는 부분에서 넣어주는 데이터 와 끝나는 데이터 가 일치하지 않으면 ? 채널을 받는 로직에서는 무한히 기다리면서 메인 고루틴 과의 데드락 을 야기한다.
언퍼버 채널 이라면 ? 송신자는 수신자가 데이터를 수신할떄 까지 기다리고, 수신자는 송신자가 데이터를 전달해줄때 까지 기다린다.
버퍼 채널 이라면 ? 송신자는 버퍼의 사이즈가 가득차기 전까지 계속 데이터를 보낸다. 만약 가득찼다면 수신자 중가 데이터를 원복할떄 까지 기다린이후 다시 재 진행 된다. 아래 예를 보자.
func main() {
// Buffered channel with buffer size 2
bufferedChannel := make(chan int, 5)
go func() {
for i := 1; i <= 5; i++ {
bufferedChannel <- i
fmt.Println("Sent value on buffered channel:", i)
}
close(bufferedChannel)
}()
for value := range bufferedChannel {
fmt.Println("Received value from buffered channel:", value)
}
fmt.Println("Buffered channel is done.")
// Unbuffered channel
unbufferedChannel := make(chan int)
go func() {
for i := 1; i <= 5; i++ {
unbufferedChannel <- i
fmt.Println("Sent value on unbuffered channel:", i)
}
close(unbufferedChannel)
}()
for value := range unbufferedChannel {
fmt.Println("Received value from unbuffered channel:", value)
}
fmt.Println("Unbuffered channel is done.")
}
Sent value on buffered channel: 1
Sent value on buffered channel: 2
Sent value on buffered channel: 3
Sent value on buffered channel: 4
Sent value on buffered channel: 5
Received value from buffered channel: 1
Received value from buffered channel: 2
Received value from buffered channel: 3
Received value from buffered channel: 4
Received value from buffered channel: 5
Buffered channel is done.
Sent value on unbuffered channel: 1
Received value from unbuffered channel: 1
Received value from unbuffered channel: 2
Sent value on unbuffered channel: 2
Sent value on unbuffered channel: 3
Received value from unbuffered channel: 3
Received value from unbuffered channel: 4
Sent value on unbuffered channel: 4
Sent value on unbuffered channel: 5
Received value from unbuffered channel: 5
Unbuffered channel is done.
보는 바와 같이 버퍼 와 언버퍼 간의 차이가 존재한다. 따라서 이런 두종류 의 버퍼에 대해서 선택할떄 여러가지 경우의수를 고려해야할것으로 보인다. 만약 리시버가 느리다면 ? 버퍼 채널이라면 ? 샌더는 자주 락에 걸려 성능저하 가 발생하고 메모리 의 사용 제한이 생긴다면 ? 버퍼 채널인 경우 적절한 사이즈 를 찾는데 있어 고심을 해야한다.
Channels of channels
이러한 채널 또한 타입으로 인정받고 넘길수 있다.
이전 예제로 서버에서 리퀘스트 를 유니크하게 유지하는 방법에 대한 예제를 들었는데
거기서 Request 의 타입에 대한 정의를 내리진 않았다.
type Request struct {
args []int
f func([]int) int
resultChan chan int
}
func sum(a []int) (s int) {
for _, v := range a {
s += v
}
return
}
request := &Request{[]int{3, 4, 5}, sum, make(chan int)}
// Send request
clientRequests <- request
// Wait for response.
fmt.Printf("answer: %d\n", <-request.resultChan)
클라이언트 는 위와 같은 함수를 제공하고 채널로 데이터를 보내주면 ?
func handle(queue chan *Request) {
for req := range queue {
req.resultChan <- req.f(req.args)
}
}
서버에서는 단순 리퀘스트 안 채널로 받은 함수의 결과값을 받아 넣어주면 된다.
Parallelization
채널 사용의 병렬화 이다. 어떻게 ? 멀티 코어에 각 계산의 조각 을 주고 실행하는것이다. 바로 가보자.
아이템 의 백터를 하는데 있어 비용이 많이들고, 각 값에 대해 모든 아이템은 독립적으로 수행된다고 해보자.
type Vector []float64
// Apply the operation to v[i], v[i+1] ... up to v[n-1].
func (v Vector) DoSome(i, n int, u Vector, c chan int) {
for ; i < n; i++ {
v[i] += u.Op(v[i])
}
c <- 1 // signal that this piece is done
}
그래서 우리는 총 실행되는 숫자의 갯수만 신경쓰면된다.
const numCPU = 4 // number of CPU cores
func (v Vector) DoAll(u Vector) {
c := make(chan int, numCPU) // Buffering optional but sensible.
for i := 0; i < numCPU; i++ {
go v.DoSome(i*len(v)/numCPU, (i+1)*len(v)/numCPU, u, c)
}
// Drain the channel.
for i := 0; i < numCPU; i++ {
<-c // wait for one task to complete
}
// All done.
}
여기서 강조하는 문구로는 동시성 과 병렬성을 혼동하지말라고 한다.
고는 동시성 언어이지, 병렬성을 위한 언어가 아니다. 그럼에도 불구하고 종종 구조 적 인 방법으로 병렬성 문제를 쉽게 해결할수 있지만. 고는 동시성 언어 이다. 모든 병렬적 문제를 해결하기 위해 알맞지 않을수도 있다. 라고 한다.
* 아침을 한다고 가정
동시성 => 계란을굽는다(), 식빵을 굽느다(),커피머신을 돌린다()
병렬성 => 팬(계란을 굽는다(), 베이컨을 굽는다())
로 분리할수 있다.
A leaky buffer
줄줄 새는 버퍼 라는 즉 관리되지 않는 버퍼 라는 타이틀이다.
var freeList = make(chan *Buffer, 100)
var serverChan = make(chan *Buffer)
func client() {
for {
var b *Buffer
// Grab a buffer if available; allocate if not.
select {
case b = <-freeList:
// Got one; nothing more to do.
default:
// None free, so allocate a new one.
b = new(Buffer)
}
load(b) // Read next message from the net.
serverChan <- b // Send to server.
}
}
고루틴 클라이언트 가 데이터 를 소스로 부터 반복적으로 받는다고 가정해보자 . 버퍼 하나를 잡고 리스트 로 부터 데이터를 잡아 넣어주고 준비가 되었다면 ? 서버채널 로 이 버퍼를 날려준다.
func server() {
for {
b := <-serverChan // Wait for work.
process(b)
// Reuse buffer if there's room.
select {
case freeList <- b:
// Buffer on free list; nothing more to do.
default:
// Free list full, just carry on.
}
}
}
서버 는 서버 채널로 부터 데이터를 가져와서 특정 로직을 거치고 그 채널에 메모리가 남아있다면 다시 리스트로 보내는 로직이다.
만약 freeList 가 가득차있다면 ? 현재 가져온 버퍼는 가비지 컬렉터의 수집대상이 된다.
이 패턴은 오브젝트를 다시 사용하는 단순한 패턴이다. 메모리 할당의 오버헤드 를 줄이기 위해 사용되는 방법중 하나라고 한다.
'Go > Go Basic' 카테고리의 다른 글
Ulitmate-Go-01 (string,메모리 패딩) (3) | 2023.08.19 |
---|---|
Go Interface, embedded (0) | 2023.02.28 |
Effective Go 03 (0) | 2023.02.10 |
Effective Go 02 (0) | 2023.02.09 |
Effective Go 01 (1) | 2023.02.08 |