Go의 동시성 구성 요소

검프·2021년 7월 17일
5

Concurrency in Go

목록 보기
3/6
post-thumbnail

Go 동시성 프로그래밍의 내용을 참고하여 작성했습니다.

채널

채널Channel^{Channel}호어의 CSP 이론을 Go 언어에서 구현한 Go의 동기화 기본 요소입니다. 채널을 메모리 접근을 동기화하는 데 사용할 수도 있지만, 고루틴 간에 정보를 전달할 때 가장 적합합니다.

  • 채널은 데이터 흐름을 위한 통로 역할
  • 채널을 사용할 때는 값을 chan 변수에 전달
  • 프로그램의 여기저기에 채널에 대한 참조를 전달하고 채널의 값을 읽음
  • 프로그램의 서로 다른 부분들 간에 서로에 대해 알 필요가 없음

채널 생성

채널은 아래와 같이 생성합니다.

var dataStream chan interface{}      // 빈 인터페이스 타입의 채널 변수 선언
dataStream = make(chan interface{})  // make 함수를 이용해서 채널 인스턴스 생성

dataStream := make(chan interface{}) // 당연히 선언과 동시에 초기화하는 것도 가능합니다.

채널도 내장 함수인 make() 함수를 이용해서 인스턴스화합니다. make() 함수는 builtin 패키지에 설명되어 있습니다. 빈 인터페이스 타입의 채널이기 때문에 어떤 값이든 쓰고 읽을 수 있는 양방향 채널이 생성됐습니다.

읽기/쓰기 전용 채널 생성

채널은 값을 읽거나 쓰기만 할 수 있는 단방향의 데이터 흐름만 지원할 수도 있습니다. 이런 기능은 채널의 안전한 사용을 위해서 중요한데요, 잠시 후 살펴보겠습니다. 단방향 채널은 <- 연산자로 표현합니다. 아래는 읽기 전용 채널을 생성하는 방법입니다.

var dataStream <-chan interface{}      // 읽기 전용 채널 변수 선언
dataStream = make(<-chan interface{})  // 읽기 전용 채널 인스턴스 생성

dataStream := make(<-chan interface{}) // 읽기 전용 채널을 선언과 초기화

아래는 쓰기 전용 채널을 생성하는 방법입니다. 읽기 전용 채널은 <- 연산자를 chan 키워드 좌측에 표시했는데요, 쓰기 전용 채널은 <- 연산자를 chan 키워드 우측에 표시합니다.

var dataStream chan<- interface{}      // 쓰기 전용 채널 변수 선언
dataStream = make(chan<- interface{})  // 쓰기 전용 채널 인스턴스 생성

dataStream := make(chan<- interface{}) // 쓰기 전용 채널을 선언과 초기화

암시적인 채널 형변환

단방향 채널은 직접 인스턴스화하여 사용하는 경우보다 함수의 매개 변수나 리턴 타입으로 사용되는 경우가 더 자주 있습니다. Go 언어는 양방향 채널을 생성한 후 필요할 때 암시적으로 단방향 채널로 타입을 변환하여 사용하는 것이 가능합니다.

var receiveChan <-chan interface{}
var sendChan chan<- interface{}
dataStream := make(chan interface{})

// 유효한 암시적 형변환
receiveChan = dataStream
sendChan = dataStream

채널의 값 타입 제한

이제까지 예제에서는 chan interface{} 변수를 만들었는데, 빈 인터페이스 자리에 특정 타입을 명시하여 채널을 통해서 전달하는 값을 엄격히 제한할 수 있습니다.

intStream := make(chan int) // int 타입만 전송할 수 있는 채널 생성

채널에서 값 읽기/쓰기

채널에서 값을 읽고 쓸 때도 <- 연산자를 사용합니다. 화살표가 가리키는 방향으로 데이터가 흐른다고 생각하면 이해하기 쉽습니다.

  • 읽기는 <- 연산자를 채널 변수의 왼쪽에
  • 쓰기는 <- 연산자를 채널 변수의 오른쪽에
func main() {
	stringStream := make(chan string)
	go func() {
		stringStream <- "Hello Gump!" // stringStraem 채널에 문자열을 쓰기
	}()
	fmt.Println(<-stringStream) // stringStraem 채널에서 문자열을 읽기
}

<출력>
Hello Gump!

만약 읽기 전용 채널에 값을 쓰려고 하거나 쓰기 전용 채널에서 값을 읽으려고 하면 어떻게 될까요?

writeStream := make(chan<- interface{})
readStream := make(<-chan interface{})

// Invalid operation: <-writeStream (receive from the send-only type chan<- interface{})
<-writeStream

// Invalid operation: readStream <- struct{}{} (send to the receive-only type <-chan interface{})
readStream <- struct{}{}

Go 언어 컴파일러는 우리가 잘못된 읽기/쓰기 시도를 하고 있다고 알려줍니다. 이는 Go 언어 타입 시스템의 일부로 동시성 기본 요소들에 대해서도 타입 안전성이 유지되도록 해주고 있습니다.

채널 연산 시 대기

고루틴이 스케줄링됐다고 해서 프로세스가 종료되기 전에 고루틴이 실행되는 것이 보장되지는 않습니다. 그런데 아래 예제에서는 main 고루틴이 종료되기 전에 익명 고루틴이 종료되었습니다. 단순한 레이스 컨디션Race condition^{Race\ condition}이었을까요?

func main() {
	stringStream := make(chan string)
	go func() {
		stringStream <- "Hello Gump!" // stringStraem 채널에 문자열을 쓰기
	}()
	fmt.Println(<-stringStream) // stringStraem 채널에서 문자열을 읽기
}

<출력>
Hello Gump!

채널은 읽기/쓰기를 시도했을 때 이를 즉시 처리할 수 없는 경우 대기Block^{Block}합니다.

  • 가득 찬 채널에 쓰려고 하는 경우
  • 빈 채널에서 읽으려고 하는 경우

fmt.PrintlnstringStream 채널에서 값을 가져오기 때문에, 채널에 값이 입력될 때까지 main 고루틴이 대기합니다. 이후 익명 고루틴이 실행되고 stringStream에 문자열을 입력되면 main 고루틴이 다시 실행됩니다.

채널의 이런 특성 때문에 올바르게 사용하지 않을 경우 데드락Deadlock^{Deadlock}이 발생할 수 있습니다.

stringStream := make(chan string)
go func() {
	if 0 != 1 { // 올바르지 않은 조건에 의해 stringStream 채널에 값이 추가될 수 없음
		return
	}
	stringStream <- "Hello Gump!"
}()
fmt.Println(<-stringStream) // stringStream 채널에 값이 추가되기를 기다리면서 block

<출력>
fatal error: all goroutines are asleep - deadlock!
...

위 예제에서 익명 고루틴은 올바르지 못한 조건에 의해서 채널에 값을 추가하지 못한 상태로 종료됩니다. 이때 Go 런타임Runtime^{Runtime}은 마지막으로 남은 고루틴인 main 고루틴도 실행할 수 없는 상태임을 모니터링하고 데드락을 발생시킵니다.

채널 닫기

채널을 닫는다는 것은 더 이상 채널을 통해서 값이 전송되지 않는다는 것을 말합니다. 이를 통해서 값을 읽는 프로세스가 언제쯤 종료해야 할지, 또는 언제쯤 새로운 작업을 해야 할지 등을 알 수 있습니다. 이런 상태를 표현하는 특별한 메시지를 만들 수도 있지만, 이는 개발자의 할 일을 늘리게 됩니다. 채널을 닫는다는 것은

값을 읽는 쪽에서 더 이상 값을 쓰지 않을 것이니 하고 싶은 작업을 하시오.

라는 하나의 범용적인 신호라고 할 수 있습니다. 채널을 닫으려면 close() 내장 함수를 사용합니다.

valueStream := make(chan interface{})
close(valueStream)

닫힌 채널에서도 여전히 값을 읽어올 수 있습니다.

intStream := make(chan int)
close(intStream) // 아무런 값도 넣지않은 상태에서 채널을 닫음
intValue, ok := <- intStream
fmt.Printf("(%v): %v\n", ok, intValue)

<출력>
(false): 0

채널에서 <- 연산자를 이용해서 값을 읽을 때 닫힌 채널에서 읽어진 값인지를 나타내는 상태 값을 추가로 받을 수 있습니다. 첫 번째 값은 채널에서 읽어온 값이고, 두 번째 값은 채널인 닫힌 상태인지를 나타내는 bool 값입니다. 채널이 닫힌 상태라면 채널에서 읽어온 값은 채널 타입의 제로값Zero value^{Zero\ value}이 됩니다.

닫힌 채널에서도 값을 읽어올 수 있기 때문에 값을 쓰는 고루틴과 값을 읽는 고루틴 간에 1:N 관계를 형성할 수 있습니다.

range 키워드를 이용한 채널 이터레이션

for문에서 range 키워드의 인수로 채널을 받을 수 있으며, 채널이 닫히면 range 키워드는 자동으로 for문을 중단합니다. 이를 통해 간단한 방법으로 채널의 값을 이터레이션Iteration^{Iteration}할 수 있습니다.

intStream := make(chan int)
go func() {
	defer close(intStream) // 고루틴 종료전에 채널을 닫음
	for i := 1; i <= 5; i++ {
		intStream <- i
	}
}()

// range 키워드를 이용해 채널을 이터레이션. for문의 종료 조건이 필요 없음
for intValue := range intStream {
	fmt.Printf("%v ", intValue)
}

<출력>
1 2 3 4 5

채널 닫기로 여러 고루틴에 동시에 신호 보내기

채널을 닫는 것은 여러 고루틴에 동시에 신호를 보낼 수 있는 방법 중 하나입니다. 하나의 채널을 기다리고 있는 N개의 고루틴이 있다면, 각 고루틴의 대기를 해제하기 위해서 N번 채널에 쓰는 대신 채널을 닫아버려도 됩니다. 닫힌 채널도 계속 읽힐 수 있으므로 대기중인 고루틴이 많아도 문제가 되지 않습니다. 또한 N번의 쓰기를 하는 것보다 채널을 닫는 것이 빠르고 부하가 적습니다.

begin := make(chan interface{})
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
	wg.Add(1)
	go func(i int) {
		defer wg.Done()
		<-begin // 채널에 값이 입력되거나 닫힐때까지 대기
		fmt.Println(i, "has begun.")
	}(i)
}

fmt.Println("Unblocking goroutines...")
close(begin) // 채널을 닫으면 모든 고루틴이 대기상태에서 벗어남
wg.Wait()

<출력>
Unblocking goroutines...
1 has begun.
0 has begun.
3 has begun.
2 has begun.
4 has begun.

위 예제에서는 채널을 닫는 것으로 대기 중인 모든 고루틴을 깨웁니다. 유사한 일을 sync.Cond를 사용해서도 할 수 있습니다.

버퍼링된 채널

버퍼링된 채널Buffered channel^{Buffered\ channel}은 채널은 채널에서 수용 가능한 값의 수를 지정하여 생성한 채널을 의미합니다. 버퍼링된 채널에는 읽기가 수행되지 않은 상태의 채널에도 지정한 용량만큼의 쓰기를 수행할 수 있습니다. 버퍼링된 채널을 생성하는 방법은 아래와 같습니다.

var dataStream chan interface{}         // 타입 선언은 동일
dataStream = make(chan interface{}, 4)  // 용량이 4인 채널 생성

dataStream := make(chan interface{}, 4) // 용량을 설정하는 2번째 인수를 빼면 일반 채널과 동일

채널을 인스턴스화하는 고루틴이 버퍼링 여부를 결정합니다. 이는 채널의 동작 방법과 성능을 쉽게 추론할 수 있도록 채널의 생성이 채널에 데이터를 쓰는 고루틴과 밀접하게 결합되어야 한다는 의미입니다.

버퍼링 되지 않은 채널도 단순히 용량이 0으로 생성된 버퍼링 된 채널입니다. 아래 두 채널은 모두 용량이 0인 정수 채널로 동일한 기능을 갖습니다.

a := make(chan int)
b := make(chan int, 0)

앞에 <채널 연산 시 대기> 에서 채널이 가득 차 있거나 비어 있음에 대한 설명이 있었는데, 이는 채널의 용량과 관계가 있습니다. 버퍼링 된 채널은 고루틴 간에 통신할 수 있는 메모리 내의 FIFO 대기열입니다.

1)에서 채널을 생성하면 4의 용량을 갖는 빈 버퍼가 생성됩니다. 2) ~ 5)에서는 빈 공간만큼 채널에 값을 추가합니다. 6)에서는 더 이상 채널에 빈 공간이 없기 때문에 E 값을 쓰려고 시도한 고루틴이 대기 상태가 됩니다. 대기한 고루틴은 채널에 빈 공간이 생길때까지 대기 상태로 유지됩니다. 7)에서 채널로부터 읽기가 실행됩니다. 채널의 맨 앞에 존재했던 A가 제거되고 E값이 버퍼의 끝에 추가됩니다.

만약 버퍼링 된 채널이 비어 있는데 수신자가 있는 경우 버퍼가 무시되고 값이 송신자에서 수신자로 직접 전달됩니다.

var stdoutBuff bytes.Buffer         // 출력을 버퍼링하여 성능을 개선
defer stdoutBuff.WriteTo(os.Stdout) // 프로세스가 종료되기 전에 버퍼를 표준 출력에 출력

intStream := make(chan int, 4)
go func() {
	defer close(intStream)
	defer fmt.Fprintln(&stdoutBuff, "Producer Done.")
	for i := 0; i < 4; i++ {
		fmt.Fprintf(&stdoutBuff, "Sending: %d\n", i)
		intStream <- i
	}
}()

for intValue := range intStream {
	fmt.Fprintf(&stdoutBuff, "Received %v.\n", intValue)
}

<출력>
Sending: 0
Sending: 1
Sending: 2
Sending: 3
Producer Done.
Received 0.
Received 1.
Received 2.
Received 3.

버퍼링 된 채널을 사용해서 성능을 최적화하는 예제입니다. 버퍼링 된 채널의 용량을 익명 고루틴에서 추가할 값의 개수로 초기화하였습니다. 실제로 main 고루틴이 insStream의 값을 읽기 시도하기 전에 익명 고루틴이 intStream에 모든 값을 추가한 것을 확인할 수 있습니다. 즉, 익명 고루틴은 대기 상태 없이 모든 작업을 완료할 수 있었습니다. 이 예제 처럼 채널에 쓰는 고루틴이 얼마나 쓸지 알고 있는 경우 쓸 만큼의 용량을 가지고 있는 버퍼링 된 채널을 만드는 것이 도움이 될 수 있습니다. 물론 실제 문제를 풀 때는 여러 변수가 존재하기 때문에 예제처럼 간단하게 성능 최적화의 이득을 보기는 쉽지 않습니다.

nil 채널

채널 변수를 선언만 할 경우 제로값인 nil 값을 갖게 됩니다. 채널이 nil 값을 갖게 될 수 있으므로 nil 채널을 사용할 때의 상황에 대해서 알고 있어야 합니다.

var dataStream chan interface{}
<-dataStream

<출력>
fatal error: all goroutines are asleep - deadlock!

nil 채널에서 읽기를 시도하면 데드락에 빠지면서 패닉이 발생합니다. 항상 데드락에 빠지는 것은 아니지만 적어도 고루틴을 대기하게 만든다는 것을 알 수 있습니다.

var dataStream chan interface{}
dataStream <- struct{}{}

<출력>
fatal error: all goroutines are asleep - deadlock!

nil 채널에 쓰기를 시도해도 데드락이 발생합니다. nil 채널에 대한 읽기와 쓰기 모두 고루틴을 대기하게 만든다는 것을 알 수 있습니다. 그럼 nil 채널을 닫으면 어떻게 될까요?

var dataStream chan interface{}
close(dataStream)

<출력>
panic: close of nil channel

패닉이 발생합니다. 이는 프로그램을 의도치 않게 동작하도록 만듭니다. 항상 채널을 사용하기 전에 초기화 여부를 확인하는 것이 안전합니다.

연산채널 상태결과
읽기nil대기
 열려 있고 비어 있지 않음
 열려 있고 비어 있음대기
 닫혀 있음<제로값>, false
 쓰기 전용컴파일 에러
쓰기nil대기
 열려 있고 가득 참대기
 열려 있고 가득 차지 않음쓰기 값
 닫혀 있음패닉
 읽기 전용컴파일 에러
닫기nil패닉
 열려 있고 비어 있지 않음채널 닫힘. 채널의 모든 값이 빠져나가기 전까지 읽기 성공
그 이후에는 <제로값>, false
 열려 있고 비어 있음채널 닫힘. 읽기 연산은 제로값을 읽음
 닫혀 있음패닉
 읽기 전용컴파일 에러

표에서 패닉이 발생하는 연산에 대해서는 정확히 이해하고 안전하게 채널을 사용할 필요가 있습니다.

안전한 채널 사용 방법

채널을 안전하게 사용하려면 채널 소유권Ownership^{Ownership}을 명확히 해야 합니다. 채널의 소유권을 아래와 같이 정의할 것입니다.

  • 채널을 인스턴스화 함
  • 채널에 값을 씀
  • 채널을 닫음

단방향 채널 선언은 채널을 소유한 고루틴과 사용만 하는 고루틴을 구분할 수 있는 도구입니다. 채널 소유자는 채널에 대한 쓰기 접근 권한(chan or chan<-) 참조를 가지고 있으며, 채널 사용자는 읽기 전용(<-chan) 참조를 갖고 있습니다. 채널 소유한 고루틴은 반드시 다음을 수행해야 합니다.

  • 채널을 인스턴스화 함
  • 쓰기를 수행하거나 다른 고루틴으로 소유권을 넘김
  • 채널을 닫음
  • 위 세 가지를 캡슐화하고 이를 읽기 채널을 통해서 노출함

채널 소유자에게 이런 책임을 부여하면 아래와 같은 위험을 피할 수 있습니다.

  • 소유자가 채널을 초기화하기 때문에 nil 채널에 쓰는 것으로 인한 데드락 위험을 제거
  • 소유자가 채널을 초기화하기 때문에 nil 채널을 닫을 위험이 없음
  • 소유자가 채널이 닫히는 시기를 결정하기 때문에 닫힌 채널에 쓰는 것으로 인한 패닉의 위험을 없앨 수 있음
  • 소유자가 채널이 닫히는 시기를 결정하기 때문에 채널이 중복으로 닫히는 것으로 인한 패닉 위험을 없앨 수 있음
  • 소유자가 채널 생성 시점에 타입을 명시할 수 있음

이제 채널 사용자는 아래 두 가지 사항만 책임지면 됩니다.

  • 언제 채널이 닫히는지 아는 것
  • 어떤 이유로든 대기가 발생하면 책임 있게 처리하는 것

첫 번째 항목은 읽기 연산의 두 번째 리턴 값을 검사하면 간단히 해결됩니다. 두 번째 사항은 실제 프로그래밍 알고리즘에 따라서 달라지기 때문에 간단히 정의할 수 없습니다. 다만, 중요한 점은 채널의 소비자로서 읽기가 중단될 수 있고, 이에 대한 적절한 처리를 해야 한다는 사실입니다.

위에서 설명한 내용들을 구체적으로 이해하기 위해서 간단한 예제를 살펴보겠습니다.

chanOwner := func() <-chan int {
	resultStream := make(chan int, 5) // <1>
	go func() { // <2>
		defer close(resultStream) // <3>
		for i := 0; i <= 5; i++ {
			resultStream <- i
		}
	}()
	return resultStream // <4>
}

resultStream := chanOwner()
for result := range resultStream { // <5>
	fmt.Printf("Received: %d\n", result)
}
fmt.Println("Done receiving!")

<출력>
Received: 0
Received: 1
Received: 2
Received: 3
Received: 4
Received: 5
Done receiving!

<1~4>는 채널 소유자의 의무입니다. <1>에서는 버퍼링 된 채널을 생성합니다. 고루틴을 가급적 빠르게 완료하기 위해서 크기가 5인 채널을 생성합니다. <2>에서는 resultStream에 쓰기를 수행하는 익명 고루틴을 시작합니다. chanOwner() 함수 안의 익명 함수에서 고루틴을 생성해서 채널의 생성을 캡슐화합니다. <3>에서는 고루틴이 종료되면 resultStream이 닫히는 것을 보장해 줍니다. <4>에서는 채널을 리턴합니다. 리턴 값이 읽기 전용 채널로 선언되었으므로 암시적으로 읽기 전용 채널로 형변환되어 소비자에게 할당됩니다.

<5>에서 range 키워드를 통해 resultStream 채널을 이터레이션합니다. 소비자로서 채널 읽기를 위해 대기하고 채널이 닫히는 것만 신경쓰면 됩니다.

resultStream 채널의 수명주기Lifecycle^{Lifecycle}chanOwner() 함수 내에서 캡슐화되는 방식을 눈여겨봐야 합니다. 이를 통해 nil 채널이나 닫힌 채널에 대한 쓰기는 발생하지 않으며, 채널 닫기가 항상 한 번만 진행되도록 보장됩니다. 채널 소유의 범위를 좁게 유지할 수 있도록 프로그램을 설계하는 일이 중요합니다.

위에서 설명한 원칙을 따르면 이해하기 쉽고 성능이 좋은 프로그램을 만들 수 있습니다. 데드락이나 패닉이 발생하는 프로그램을 발견한다면, 채널 소유의 범위가 너무 크거나 소유권이 명확하지 않다는 것을 알 수 있을 겁니다.


profile
권구혁

0개의 댓글