본문 바로가기
Language/Go

Go - pipelines and cancellation

by ocwokocw 2022. 10. 21.

- 출처: https://go.dev/blog/pipelines

- 개요

Go 언어는 동시성을 잘 지원하는 언어라서 I/O나 CPU를 효율적으로 사용하는 스트리밍 데이터 파이프라인 구축이 수월하다. 하지만 파이프라인을 구축하다보면 인지하거나 처리하기 어려운 오류 혹은 미묘한 부분이 발생할 수 있는데 이를 자세히 알아보고 어떻게 깔끔하게 처리 하는지 알아본다.

- 파이프라인이란?

공식적인 정의는 없지만 비공식적으로는 채널에 의해 연결되는 단계(Stage) 들이 연속적으로 연결된것이라고 할 수 있다. 각 단계에서 고루틴은 아래와 같은 동작을 취한다.
 
  • inbound 채널을 통해 upstream 으로 부터 값들을 수신한다.
  • 해당 데이터에 대해 일부기능을 수행하는데 일반적으로는 데이터를 생성하는 행위이다.
  • outbound 채널을 통해 downstream 으로 데이터를 송신한다.
 
단계들의 제일 처음과 마지막을 제외하면 각 단계는 inbound와 outbound 채널을 갖고 있다. 제일 첫 단계를 source 나 producer 라고도 하며 제일 마지막 단계를 sink 나 consumer 라고 부르기도 한다.
 

- 제곱(Square) 예제

어떤 수를 넣으면 제곱된 수를 반환하는 기능을 파이프라인으로 구축해보자. 파이프라인은 3 단계로 구성되어있다.
 
제일 첫 단계 "gen" 이라는 함수인데 정수 목록을 함수의 인자로 주면 함수는 정수를 하나씩 방출하는 채널을 반환한다. 정수를 채널로 보내는 고루틴이 시작되며 모든 값을 전송하고 나면 채널을 닫는다.
 
func TestPP(t *testing.T) {
	c := gen(2, 3, 4)
	for n := range c {
		fmt.Println("num: ", n)
	}
}

func gen(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		for _, num := range nums {
			out <- num
		}
		close(out)
	}()
	return out
}
 
다음 단계는 "sq"라는 함수이다. 채널을 인자로 받아 정수를 제곱한 결과로 계산하고 방출하는 채널을 반환한다. inbound 채널이 닫히고 제곱한 결과를 downstream으로(다음 단계로) 모두 보내면 outbound 채널을 닫는다.
 
func sq(c <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		for num := range c {
			out <- num * num
		}
		close(out)
	}()
	return out
}
 
마지막으로 main 함수에서는 파이프라인을 설정하고 마지막 단계를 실행한다. 2단계로 부터 채널을 받아 해당 채널이 닫힐때까지 전송받은 요소를 하나씩 출력한다. 아래 코드는 main 대신 Test 함수로 작성하였다.
 
func TestPP(t *testing.T) {
	c := gen(2, 3, 4)
	out := sq(c)
	fmt.Println("result: ", <-out)
	fmt.Println("result: ", <-out)
	fmt.Println("result: ", <-out)
}
 
sq의 경우 inbound 와 outbound 채널 유형이 같은데 이럴 경우 반복적용이 가능하다. 또 요소를 채널이 반환할 데이터의 개수에 맞춰서 하나씩 출력하지 않고 range 문을 통해서 채널의 모든 요소들을  출력할 수 있다.
 
func TestPP(t *testing.T) {
	for result := range sq(sq(gen(2, 3, 4))) {
		fmt.Println("result: ", result)
	}
}
 

- Fan-out, Fan-in

여러 개의 함수에서 같은 채널로 부터 데이터를 해당 채널이 닫힐때까지 읽을 수 있는데 이를 "fan-out" 이라고 한다. 이를 응용하면 작업 그룹끼리 작업을 분산하여 처리할 수 있다. 반대로 여러 개의 채널로 부터 읽어서 하나의 채널로 보내는 함수를 작성할 수도 있는데 이를 "fan-in" 이라고 한다.
 
사실 fan-out과 fan-in은 채널에만 국한된 개념이 아니라 네트워크나 소프트웨어 공학에서 모듈 컴포넌트가 다른 모듈에 의존하거나 자신에게 의존하는 정도를 계산할때도 사용 되는 일반적인 개념이다.
 
이전 예제를 아래와 같이 변경해볼것이다.
 
  • fan-out 적용: gen이 반환한 채널을 읽는 2개의 sq 함수를 실행시켜본다.
  • fan-in 적용: 2개의 sq 함수가 반환한 채널로부터 결과를 읽어서 하나의 채널로 내보내는 새로운 함수 "merge"를 작성한다.
 
merge 함수는 여러 개의 채널을 받아 고루틴을 이용하여 하나의 채널로 변환한다. 고루틴에서는 inbound 채널들의 값을 복사하여 단일 outbound 채널로 보낸다. 모든 "output" 고루틴을 시작하면 out 채널로 모든 전송을 완료한 후 닫기 위한 고루틴을 시작한다.
 
func TestPP(t *testing.T) {
	in := gen(2, 3, 4)

	c1 := sq(in)
	c2 := sq(in)

	for result := range merge(c1, c2) {
		fmt.Println("result: ", result)
	}
}
 
채널을 사용할 때 한 가지 주의할 점은 닫힌 채널에 데이터를 전송하면 panic이 발생한다는 점이다. 그래서 채널을 닫기전에 모든 데이터를 보내주는것을 확실히 해주는게 중요한데 sync.WaitGroup을 이용하면 이를 손쉽게 구현할 수 있다.
 
func merge(cs ...<-chan int) <-chan int {
	out := make(chan int)

	wg := sync.WaitGroup{}
	output := func(c <-chan int) {
		for n := range c {
			out <- n
		}
		wg.Done()
	}

	wg.Add(len(cs))
	for _, c := range cs {
		go output(c)
	}

	go func() {
		wg.Wait()
		close(out)
	}()
	return out
}
 
코드가 길진 않지만 고루틴이 있다보니 헷갈릴 수가 있는데 가장 중요한것은 두 부분이다. for 문으로 순회하면서 out 채널로 쓰는 고루틴을 실행하고 아래 고루틴에서는 for문에서 생성한 고루틴 내부에서 out 채널로 모든 데이터를 보낼때까지 기다려주는데 이때 sync.WaitGroup을 활용했다.
 

- Stopping short

작성된 파이프라인 함수는 아래와 같은 패턴을 지닌다.
 
  • 모든 전송을 마친 후 outbound 채널을 닫는다.
  • 채널이 닫힐때까지 inbound 채널로 부터 값들을 수신한다.
 
하지만 실제 product 코드를 작성하다보면 파이프라인의 각 단계에서 반드시 모든 데이터를 수신하는것은 아니다. 모든 데이터를 수신하지 않고 도중에 중지해야 하는 아래와 같은 상황이 있을 수 있다.
 
  • 데이터를 모두 받지 않고 일부만 받아도 다음으로 진행할 수 있다.
  • 수신한 값들 중 error를 나타내는 값이 존재한다.
 
위의 두 사항 모두 남은 값들을 기다릴 필요가 없게 되는데 이런 상황이 되면 이전 단계(upstream)에게 데이터를 그만 보내라고 알려줘야 한다.
 
여태까지 작성한 예제는 모든 데이터를 수신하지 않으면 이전 단계의 고루틴은 데이터를 영원히 보내려고 시도하기 때문에 block되는 현상이 벌어진다. 이 예제를 실행하면서 main이나 테스트 함수가 정상적으로 끝나는데 왜 고루틴에 의해서 블락 된다는거지? 라는 의문을 가질 수 있다. 
 
비록 원문 블로그에서는 설명하고 있지 않지만 만약 테스트를 위한 함수가 아니라 계속 실행되면서 요청을 받아 응답을 하는 경우 이런 파이프라인 로직은 어떤 하나의 기능을 처리할것이다. 이런 예제와 달리 프로그램이 종료되지 않아서 고루틴도 살아있을 것이므로 이렇게 표현한것 같다.
 
out := merge(c1, c2)
fmt.Println(<-out)
return
 
이렇게 되면 리소스 유출이 발생하는데 고루틴은 메모리와 실행 리소스를 소비하며 고루틴 스택의 힙 참조가 GC되야할 리소스를 참조하고 있게 된다.
 
그래서 downstream 에서 모든 값을 수신하지 못할때에도 종료될 수 있도록 upstream을 수정해야 한다. outbound 채널에 버퍼를 적용하면 이를 해결할 수 있다. 버퍼가 적용된 채널은 정해진 크기의 개수를 가지는데 보내는 시점에 해당 채널에 버퍼 여유 공간이 남아 있다면 데이터를 보내고 연산이 즉시 종료된다.
 
func TestBC(t *testing.T) {
	c := make(chan int, 2)
	done := make(chan struct{})
	go func() {
		c <- 1
		fmt.Println("1 was sent")
		c <- 2
		fmt.Println("2 was sent")
		c <- 3
		fmt.Println("3 was sent")
		close(done)
	}()

	select {
	case <-done:
		fmt.Println("done")
	case <-time.After(10 * time.Second):
		fmt.Println("deadline")
	}
}
 
위의 테스트를 실행해보면 버퍼가 2인 채널에 3개값을 전송하려고 하고 해당 채널로 부터 데이터를 받는 부분이 없기 때문에 마지막 3 값을 보내려고 할때 block이 걸린다. 결국 10초가 되어 deadline이 출력되고 종료된다.
 
=== RUN   TestBC
1 was sent
2 was sent
deadline
--- PASS: TestBC (10.00s)
PASS
 
3을 주석처리 해서 테스트를 다시 실행하면 done이 출력된다.
 
만약 채널을 생성할 시점에 보낼 값의 개수를 미리 알 수 있다면 버퍼를 적용하면 간편하다. gen 함수도 고루틴 없이 작성이 가능해진다.
 
func gen(nums ...int) <-chan int {
	out := make(chan int, len(nums))
	// go func() {
	for _, num := range nums {
		out <- num
	}
	close(out)
	// }()
	return out
}
 
하지만 gen에 추가적인 값을 넘기거나 downstream 에서 적은 수의 데이터를 수신받으면 데이터를 보내려는 고루틴들은 계속 송신을 시도 한다.

- 명시적인 취소

main 에서 out 으로 부터 모든 값을 수신하지 않는다면 upstream(데이터를 보내는 이전 단계)의 고루틴들에게 데이터를 더이상 보내지 말라고 알려줘야 한다.
 
이런 동작을 구현하기 위해 done 이라는 채널에 값을 보낸다. 잠재적으로 최대 2명의 송신자가 있다고 가정될 경우 done 채널로 2번의 종료 신호를 보내야 송신자들 모두가 block 에서 벗어날것이다.
 
func TestPP(t *testing.T) {
	in := gen(2, 3)

	c1 := sq(in)
	c2 := sq(in)

	done := make(chan struct{}, 2)
	out := merge(done, c1, c2)
	fmt.Println(<-out)

	done <- struct{}{}
	done <- struct{}{}
	return
}
 
done 채널을 parameter로 받는 곳에서는 데이터를 보내는 고루틴에서 select 절을 응용해서 값을 보내거나 혹은 done 채널로 부터 값을 받아서 취소해야 한다. 여기서 done의 채널이 struct{} 형인 이유는 Go에서는 단순히 어떤 신호만 전송하는 경우 가장 작은 데이터형인 struct{}를 권장하고 있다.
 
func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {
	wg := sync.WaitGroup{}
	out := make(chan int)

	output := func(c <-chan int) {
		for n := range c {
			select {
			case out <- n:
			case <-done:
			}
		}
		wg.Done()
	}
 
여기서도 문제가 있는데 downstream 에서 수신자들이 블락될 수 있는 upstream의 송신자들의 수를 알아야 한다는 점이다. 이런 수를 추적헤야하는건 error 발생 가능성을 높일 수 있으므로 좀 더 일반적인 방향으로 수정해야 한다.
 
결국 알 수 없는 수의 고루틴들에게 downstream 으로 값을 그만 보내라고 알려줘야 한다. 앞서 done 채널에 버퍼를 지정한 방식말고 채널 close를 이용하여 구현이 가능하다. 닫힌 채널에서 값을 수신하면 연산은 즉시 수행되며, 이전에 보낸 모든 값이 수시된 후에는 zero value를 생성한다. (https://go.dev/ref/spec#Receive_operator)
 
즉 main에서 done 채널을 닫음으로써 모든 block된 송신자들을 해제할 수 있게 된다. 해당 채널을 close 함으로써 일종의 브로드캐스팅하는 효과가 있다. 이것이 가능하려면 모든 파이프라인 함수가 done 채널을 인자로 받고 main 에서는 defer를 이용하여 main에서 모든 반환경로에서 done 채널을 닫도록 보장해주는것이 중요하다. 
 
func TestPP(t *testing.T) {
	done := make(chan struct{})
	defer close(done)

	in := gen(done, 2, 3)

	c1 := sq(done, in)
	c2 := sq(done, in)

	out := merge(done, c1, c2)
	fmt.Println(<-out)
	return
}
...
func sq(done <-chan struct{}, c <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for num := range c {
			select {
			case out <- num * num:
			case <-done:
				return
			}
		}
	}()
	return out
}

func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {
	wg := sync.WaitGroup{}
	out := make(chan int)

	output := func(c <-chan int) {
		defer wg.Done()
		for n := range c {
			select {
			case out <- n:
			case <-done:
				return
			}
		}
	}

	wg.Add(len(cs))
	for _, c := range cs {
		go output(c)
	}

	go func() {
		wg.Wait()
		close(out)
	}()
	return out
}
 
gen 함수는 생략했지만 sq 함수와 동일한 방식으로 처리해주면 된다.
 
결국 파이프라인을 생성하기 위한 가이드라인은 아래와 같다.
 
  • 각 단계에서 데이터를 모두 전송하면 outbound 채널을 닫는다.
  • 각 단계에서 데이터를 보내는 채널이 닫히거나 송신자들이 unblock 될 때까지 inbound 채널로 부터 값을 수신한다.
 
파이프라인에서 송신자가 unblock 된다는건 모든 값이 송신되게 버퍼를 충분히 주거나 수신하는쪽에서 채널을 취소하면 명시적으로 송신자들에게 신호를 보내는것을 의미한다.
 
 

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

Go - context (blog)  (0) 2022.11.13
package name base, util, or common  (0) 2022.06.26
Empty struct  (0) 2022.06.21
Zero value  (0) 2022.06.17
Error handling을 간단하게  (0) 2022.06.16

댓글