고루틴과 채널로 다루는 동시성
고루틴과 채널로 다루는 동시성
Go에서 동시성을 이야기할 때 빼놓을 수 없는 두 가지가 바로 **고루틴(goroutine)**과 **채널(channel)**이에요. 고루틴은 같은 주소 공간 안에서 다른 고루틴들과 동시에 실행되는 가벼운 함수이고, 채널은 고루틴들이 값을 주고받는 통로예요. 흥미로운 점은 Go가 "메모리를 공유하면서 동시에 실행하라"가 아니라 **"통신으로 메모리를 공유하라"**는 방향을 권장한다는 거예요. 한 시점에 값에 접근하는 고루틴이 하나뿐이도록 채널로 값을 넘기면, 설계상으로 데이터 경쟁이 애초에 일어날 수 없게 만들 수 있어요.
이 페이지는 공식 Effective Go 문서의 동시성 절을 따라가며, 고루틴과 채널이 어떻게 동작하는지 그 쓰임새 중심으로 설명할게요.
통신으로 메모리를 공유하기
다른 언어에서는 여러 실행 흐름이 공유 변수에 올바르게 접근하도록 애써야 할 때가 많아요. Go는 다른 접근을 권장하는데, 공유할 값을 채널로 주고받고, 실행 흐름끼리는 값을 직접 공유하지 않는 방식이에요. 그 순간 그 값에 접근할 수 있는 고루틴은 하나뿐이라 데이터 경쟁이 생기지 않죠. 이 생각을 한마디로 줄인 슬로건이 유명해요.
메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라.
물론 이것을 지나치게 밀어붙일 필요는 없어요. 예컨대 참조 횟수를 셀 때는 정수 변수를 뮤텍스로 감싸는 편이 나을 수도 있어요. 하지만 높은 수준의 접근으로는, 채널로 접근을 제어하는 편이 명확하고 올바른 프로그램을 쓰기 더 쉬워요.
이 모델을 생각하는 한 방법은 단일 스레드 프로그램을 떠올리는 거예요. 그 프로그램은 동기화 기본 요소가 필요 없죠. 같은 걸 하나 더 실행해도 역시 필요 없고요. 그 둘이 서로 통신하기 시작하면 그 통신 자체가 동기화 수단이 돼요. Go의 동시성 접근은 Hoare의 CSP(Communicating Sequential Processes)에서 비롯했지만, 유닉스 파이프의 타입 안전한 일반화로도 볼 수 있어요.
고루틴
고루틴은 "스레드, 코루틴, 프로세스" 같은 기존 용어가 담는 뉘앙스가 어긋나서 붙인 이름이에요. 고루틴은 같은 주소 공간 안에서 다른 고루틴들과 동시에 실행되는 함수로, 스택 공간 하나를 할당하는 정도의 아주 작은 비용만 들어요. 스택은 작게 시작해서 필요할 때 힙을 할당(·해제)하며 자라요.
고루틴은 여러 OS 스레드 위에 다중화(multiplexed)돼서, 하나가 I/O를 기다리며 블록돼도 다른 것들은 계속 실행돼요. 함수(또는 메서드) 호출 앞에 go 키워드를 붙이면 그 호출이 새 고루틴에서 실행돼요. 호출이 끝나면 고루틴은 조용히 종료돼요.
go list.Sort() // run list.Sort concurrently; don't wait for it.
함수 리터럴(function literal)은 고루틴 호출에서 특히 유용해요. Go에서 함수 리터럴은 클로저라서, 함수가 참조하는 변수는 그것이 활성 상태인 동안 계속 살아 있음을 구현이 보장해요.
func Announce(message string, delay time.Duration) {
go func() {
time.Sleep(delay)
fmt.Println(message)
}() // Note the parentheses - must call the function.
}
이 예들은 완료를 알릴 방법이 없어서 실용적이진 않아요. 그런 신호가 필요할 때 채널을 써요.
채널
채널은 맵처럼 make로 할당하고, 결과 값이 밑바닥 자료구조에 대한 참조로 동작해요. 선택적 정수 인자를 주면 채널의 버퍼 크기를 정하고, 기본값은 0이라 버퍼가 없는 동기(synchronous) 채널이 돼요.
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
버퍼 없는 채널은 **통신(값 교환)과 동기화(두 계산이 알려진 상태에 있음을 보장)**를 한데 묶어요. 먼저 실행하던 정렬을 백그라운드로 돌리고, 채널로 완료를 기다리는 관용구를 볼게요.
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.
수신자는 데이터가 도착할 때까지 항상 블록돼요. 채널이 버퍼 없으면 송신자도 수신자가 값을 받을 때까지 블록되고, 버퍼가 있으면 버퍼에 값이 복사될 때까지만 블록돼요. 버퍼가 꽉 차 있으면 일부 수신자가 값을 꺼낼 때까지 기다리게 되죠.
버퍼 채널은 세마포어처럼 써서 처리량을 제한할 수도 있어요. 아래 예에서 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.
}
}
다만 Serve가 요청마다 고루틴을 새로 만들면 실제 실행은 MaxOutstanding개뿐인데도 리소스가 무한정 자랄 수 있어요. 그래서 고루틴의 생성을 게이트하는 방식으로 바꿀 수 있고요. 또 다른 좋은 방법은 고정된 수의 핸들러 고루틴을 미리 띄워 커먼 요청 채널을 읽게 하는 거예요 — 그 수가 process의 동시 호출 수를 제한해요.
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.
}
채널의 채널
Go의 중요한 성질 중 하나는 **채널이 일급 값(first-class value)**이라서 다른 값처럼 할당하고 주고받을 수 있다는 거예요. 이 성질의 흔한 쓰임이 안전한 병렬 디멀티플렉싱이에요.
요청 타입 안에 답신용 채널을 두면, 각 클라이언트가 자기만의 답을 받을 경로를 만들 수 있어요.
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)
}
}
이 코드는 실제로 쓸 만한 수준으로 만들려면 더 손봐야 하지만, 뮤텍스 하나 없이 한도를 둔 병렬·비블로킹 RPC 시스템의 뼈대를 보여줘요.
병렬화
독립적으로 실행할 수 있는 조각으로 계산을 나눌 수 있다면, 조각이 끝날 때마다 채널로 신호를 보내며 여러 CPU 코어에 병렬화할 수 있어요. 각 조각을 CPU당 하나씩 고루틴으로 띄우고, 완료 신호를 채널을 비우며 세는 방식이에요.
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.
}
CPU 수는 runtime.NumCPU()로 알아낼 수 있고, runtime.GOMAXPROCS(0)는 사용자가 지정한 동시 실행 코어 수를 알려줘요. 여기서 **동시성(concurrency)**과 **병렬성(parallelism)**을 헷갈리지 말아야 해요. 동시성은 프로그램을 독립적으로 실행되는 구성요소로 구조화하는 것이고, 병렬성은 여러 CPU에서 효율을 위해 계산을 병렬 실행하는 것이에요. Go는 병렬 언어라기보다 동시 언어(concurrent language)예요.
예제: 누수 버킷 프리 리스트
동시성 도구는 비동시적 아이디어도 더 쉽게 표현하게 해줘요. 클라이언트 고루틴이 소스에서 데이터를 계속 받아 버퍼를 할당·해제하지 않도록, 버퍼 채널로 프리 리스트를 나타낼 수 있어요. select의 default 절은 준비된 case가 없을 때 실행돼서, 아래 코드의 select는 절대 블록하지 않아요.
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.
}
}
서버는 메시지를 받아 처리하고 버퍼를 프리 리스트에 되돌려보내요. 리스트가 꽉 찼으면 버퍼는 가비지 컬렉터가 회수하게 내버려 두죠. 이렇게 몇 줄로 누수 버킷 프리 리스트를 만들 수 있고, 회계(bookkeeping)는 버퍼 채널과 가비지 컬렉터가 맡아요.
더 알아보기 (Learn more)
- Effective Go — Concurrency (원문)
- The Go Memory Model: 채널·고루틴 사이의 happens-before 관계
- Share Memory By Communicating (Codewalk): 고루틴·채널을 직접 쓰는 예제