출처 : https://go.dev/doc/effective_go#concurrency

 

Effective Go - The Go Programming Language

Effective Go Introduction Go is a new language. Although it borrows ideas from existing languages, it has unusual properties that make effective Go programs different in character from programs written in its relatives. A straightforward translation of a C

go.dev

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

+ Recent posts