본문 바로가기
Language/Go

Effective Go - Concurrency - 2

by ocwokocw 2022. 6. 1.

- 출처: https://go.dev/doc/effective_go#channels

- Channels

channel은 map처럼 make 문법을 통해서 할당하며 결과값은 데이터 구조에 대한 참조 역할을 한다. 만약 2번째 인자로 integer parameter를 주면 channel에 대한 버퍼 사이즈를 설정한다. 기본값은 0이며, 0이면 버퍼를 갖지 않는(unbuffered) 동기화 channel을 의미한다.
 
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
 
unbuffered channel은 2개의 goroutine이 서로의 상태를 아는것을 보장해주는 동기화 방식으로 값을 통신한다.
 
Go에서는 channel을 다양하게 사용할 수 있다. 정렬을 백그라운드작업으로 수행하는 경우를 생각해보자. channel을 이용하면 백그라운드로 시작된 goroutine 정렬작업의 완료여부를 알 수 있다.
 
c := make(chan int)  // Allocate a channel.
// Start the sort in a goroutine; when it completes, signal on the channel.
go func() {
    list.Sort()
    c <- 1  // Send a signal; value does not matter.
}()
doSomethingForAWhile()
<-c   // Wait for sort to finish; discard sent value.
 
수신자는 데이터를 수신할때까지 block 된다. 만약 channel이 unbuffered라면 송신자는 수신자가 값을 수신할 때까지 block된다. 만약 channel이 buffer를 갖고 있으면 송신자는 값이 buffer로 복사될때까지만 block 되고, buffer가 가득차면 수신자가 값을 회수할때까지 대기한다.
 
buffered channel을 이용하면 처리량을 제한하는것과 같은 semaphore 동작을 구현할 수 있다. 아래 코드는 들어오는 요청이 모두 값을 channel로 보내는 handle 함수로 전달되고, 다음 consumer를 위한 "semaphore"를 준비하기 위해 channel로 부터 값을 받는다. channel buffer의 수용량은 process를 동시에 호출하는 수로 제한된다.
 
var sem = make(chan int, MaxOutstanding)

func handle(r *Request) {
    sem <- 1    // Wait for active queue to drain.
    process(r)  // May take a long time.
    <-sem       // Done; enable next request to run.
}

func Serve(queue chan *Request) {
    for {
        req := <-queue
        go handle(req)  // Don't wait for handle to finish.
    }
}
 
한번 MaxOutstanding handler가 process를 실행하면, 기존에 실행되고 있던 handler중 하나가 완료되고 buffer로 부터 값을 수신할때까지는 채워진 channel buffer로 값을 전송하는것을 차단하게 된다.
 
완벽에 보이지만 이 설계에는 문제점이 있는데, Serve 함수는 MaxOutstanding 만큼만 실행할 수 있긴 하지만 들어오는 모든 요청에 대해서는 새로운 goroutine을 생성한다는것이다. 그 결과 요청이 너무 빨리 유입되면 설정한 임계치보다 더 많이 소비되는 문제가 발생할 수 있다. 이런 상황을 방지하려면 Serve를 goroutine의 외부에서 제어하도록 아래 코드처럼 변경해야 한다. 나중에 고쳐야할점이 있다는것을 감안하고 보길 바란다.
 
func Serve(queue chan *Request) {
    for req := range queue {
        sem <- 1
        go func() {
            process(req) // Buggy; see explanation below.
            <-sem
        }()
    }
}
 
위의 코드가 완벽한것 같지만 함정이 하나 있는데, 각 iteration 마다 req 변수가 재사용되어 모든 goroutine에서 req 변수를 공유한다는것이다. 그래서 각 goroutine이 처리하는 req 변수가 유일성을 갖도록(Java의 경우 익명함수 내에서 참조되는 변수를 final로 선언해야 하는것과 같은 기조) 변경해야 한다. 이를 보장해주기 위해서 req 변수를 closure 함수의 인자로 넘겨줘야 한다.
 
func Serve(queue chan *Request) {
    for req := range queue {
        sem <- 1
        go func(req *Request) {
            process(req)
            <-sem
        }(req)
    }
}
 
아니면 아래처럼 같은 이름의 변수를 새로 할당해주면 된다.
 
func Serve(queue chan *Request) {
    for req := range queue {
        req := req // Create new instance of req for the goroutine.
        sem <- 1
        go func() {
            process(req)
            <-sem
        }()
    }
}
 
req에 할당한 부분이 어색하게 느껴질 수도 있다. 하지만 Go에서 이렇게 사용하는것은 합법적이라고 할 수 있다. 같은 변수명으로 할당하여 loop 변수를 가렸지만 각 goroutine에서는 고유성을 갖는다.
 
요청 channel로 부터 읽는 handle goroutine의 수를 처음부터 지정하는 방식으로도 자원을 관리할 수 있다. goroutine의 수는 동시에 호출하는 process의 수를 제한한다. Serve 함수는 또한 종료신호에 대한 channel도 받아들인다. goroutine이 시작되고 나면 해당 channel로 부터의 수신도 block된다.
 
func handle(queue chan *Request) {
    for r := range queue {
        process(r)
    }
}

func Serve(clientRequests chan *Request, quit chan bool) {
    // Start handlers
    for i := 0; i < MaxOutstanding; i++ {
        go handle(clientRequests)
    }
    <-quit  // Wait to be told to exit.
}
 

- Channels of channels

Go에서 channel의 가장 큰 특징중의 하나는 다른 type들 처럼 전달되거나 할당될 수 있는 1급 값이라는 사실이다. 이런 특징을 잘 활용하면 안전한 병렬 역다중화를 구현할 수 있다.
 
앞에서 다루었던 handle은 요청을 다루는 이상적인 handler 이긴 했지만 해당 handler가 다루는 type에 대해서는 언급하지 않았었다. 만약 해당 type이 응답하기 위한 channel을 포함하고 있다면 각 client는 해당 응답으로 자신의 경로를 제공할 수 있다. 아래는 Request type을 정의한것이다.
 
type Request struct {
    args        []int
    f           func([]int) int
    resultChan  chan int
}
 
client는 request 객체 내의 응답을 받기 위한 channel 뿐만 아니라 함수와 그 인자를 제공한다.
 
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)
 
server 쪽에서는 handler 함수만 변경해주면 된다.
 
func handle(queue chan *Request) {
    for req := range queue {
        req.resultChan <- req.f(req.args)
    }
}
 
여태까지 작성한 예제 들을 실질적으로 사용하기 위해서는 더 작성해야할 코드가 많지만 요청량 제한(rate-limited), 병렬처리, non-blocking RPC 시스템을 위한 구조를 파악하는데는 참고할만하다.
 
 

'Language > Go' 카테고리의 다른 글

Effective Go - Errors  (0) 2022.06.05
Effective Go - Concurrency - 3  (0) 2022.06.02
Effective Go - Concurrency - 1  (0) 2022.06.01
Effective Go - Embedding  (0) 2022.05.29
Effective Go - Interfaces and other types  (0) 2022.05.26

댓글