출시·고도화 중
Go 안내서 · 6/6
동시성은 Go를 대표하는 기능입니다. 언어에 고루틴과 채널이 들어 있어서, 여러 작업을 함께 진행하는 코드를 적은 양의 코드로 읽기 쉽게 쓸 수 있습니다. Go 커뮤니티의 격언 "메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라"가 이 설계를 잘 보여 줍니다. 이 장에서는 고루틴, 채널, select, sync 패키지, 그리고 context를 차례로 살펴봅니다.
고루틴은 Go 런타임이 관리하는 가벼운 실행 흐름입니다. 함수 호출 앞에 go를 붙이면 새 고루틴에서 실행되고, 호출한 쪽은 기다리지 않고 바로 다음 줄로 넘어갑니다. 고루틴은 작은 스택으로 시작해 필요할 때 늘어나므로 수천, 수만 개를 만들어도 부담이 적습니다. 런타임 스케줄러가 고루틴을 운영체제 스레드에 나눠 배치합니다.
func worker(id int) {
fmt.Println("작업 시작", id)
time.Sleep(100 * time.Millisecond)
fmt.Println("작업 끝", id)
}
func main() {
for i := 1; i <= 3; i++ {
go worker(i)
}
time.Sleep(time.Second) // 나쁜 예: 고루틴을 기다리는 올바른 방법은 아래에서 다룬다
}main 함수가 끝나면 다른 고루틴이 남아 있어도 프로그램이 종료됩니다. 그래서 고루틴이 끝나기를 기다리는 장치가 필요하며, 위처럼 time.Sleep으로 기다리는 방식은 쓰면 안 됩니다.
sync.WaitGroup은 여러 고루틴이 모두 끝날 때까지 기다리는 가장 간단한 도구입니다. 시작 전에 Add로 개수를 늘리고, 각 고루틴이 끝날 때 Done을 부르고, 기다리는 쪽은 Wait을 부릅니다.
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
worker(id)
}(i)
}
wg.Wait()
fmt.Println("모두 끝났습니다")
}Add는 반드시 고루틴을 시작하기 전에, 고루틴 바깥에서 불러야 합니다. 고루틴 안에서 부르면 Wait이 먼저 실행되어 기다리지 않고 지나칠 수 있습니다.
채널은 고루틴 사이에 값을 주고받는 통로입니다. ch <- v로 보내고 v := <-ch로 받습니다. 버퍼 없는 채널은 보내는 쪽과 받는 쪽이 만날 때까지 둘 다 멈추므로 동기화 역할도 합니다. 버퍼 있는 채널(make(chan T, n))은 버퍼가 찰 때까지 보내는 쪽이 멈추지 않습니다.
func produce(n int, out chan<- int) { // 보내기 전용 채널
for i := 0; i < n; i++ {
out <- i * i
}
close(out) // 더 보낼 값이 없음을 알린다
}
func main() {
ch := make(chan int, 4)
go produce(5, ch)
for v := range ch { // close 될 때까지 받는다
fmt.Println(v)
}
v, ok := <-ch // 닫힌 채널: 제로 값과 false
fmt.Println(v, ok)
}채널은 보내는 쪽이 닫습니다. 닫힌 채널에 보내면 패닉이 나고, 닫힌 채널에서 받으면 즉시 제로 값과 false가 나옵니다. 함수 매개변수에 chan<- T(보내기 전용)나 <-chan T(받기 전용)를 쓰면 잘못된 방향의 사용을 컴파일러가 막아 줍니다.
select는 여러 채널 연산 가운데 먼저 준비된 것을 하나 실행합니다. 여러 개가 동시에 준비되면 무작위로 하나를 고릅니다. 시간 제한과 함께 쓰면 응답이 늦을 때 포기하는 코드를 쉽게 만들 수 있고, default를 두면 기다리지 않는 시도가 됩니다.
func main() {
result := make(chan string)
go func() {
time.Sleep(2 * time.Second)
result <- "완료"
}()
select {
case r := <-result:
fmt.Println(r)
case <-time.After(time.Second):
fmt.Println("시간 초과")
}
}이 예에서는 시간 초과로 끝난 뒤 보내는 고루틴이 받을 사람이 없어 영원히 멈춥니다. 이런 고루틴 누수를 막으려면 make(chan string, 1)처럼 버퍼를 하나 두거나, 아래의 context로 취소를 알립니다.
여러 고루틴이 같은 변수를 동시에 읽고 쓰면 데이터 경쟁이 생깁니다. 채널로 소유권을 넘기기 어색한 공유 상태에는 sync.Mutex로 임계 구역을 보호합니다. 읽기가 훨씬 많다면 sync.RWMutex를 고려합니다.
type Counter struct {
mu sync.Mutex
n map[string]int
}
func (c *Counter) Inc(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.n[key]++
}
func main() {
c := Counter{n: make(map[string]int)}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Inc("hits")
}()
}
wg.Wait()
fmt.Println(c.n["hits"]) // 100
}뮤텍스를 담은 구조체는 복사하면 안 되므로 포인터 수신자로 다룹니다. go test -race나 go run -race로 실행하면 데이터 경쟁을 실행 중에 찾아 줍니다.
context.Context는 취소 신호, 마감 시간, 요청 범위의 값을 함수 호출 사슬과 고루틴에 전달합니다. 관례상 첫 번째 매개변수 ctx로 받습니다. 상위에서 취소하면 그 아래 모든 작업이 ctx.Done() 채널로 알림을 받습니다.
func fetch(ctx context.Context, id int) (string, error) {
select {
case <-time.After(time.Duration(id) * 300 * time.Millisecond): // 오래 걸리는 작업 흉내
return fmt.Sprintf("결과 %d", id), nil
case <-ctx.Done():
return "", ctx.Err() // context.DeadlineExceeded 또는 context.Canceled
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel() // 자원 해제를 위해 항상 부른다
for id := 1; id <= 5; id++ {
r, err := fetch(ctx, id)
if err != nil {
fmt.Println(id, "중단:", err)
continue
}
fmt.Println(r)
}
}net/http의 요청, database/sql의 쿼리 등 표준 라이브러리의 많은 API가 context를 받습니다. 고루틴을 시작할 때는 언제, 어떻게 끝나는지를 함께 설계하고, 그 수단으로 context를 넘기는 것이 좋은 습관입니다.
go f()로 고루틴을 시작하고, sync.WaitGroup으로 끝나기를 기다립니다.close하고 받는 쪽은 range로 받습니다.select로 여러 채널과 시간 제한을 함께 기다립니다.sync.Mutex로 보호하고 -race로 경쟁을 검사합니다.context.Context를 첫 매개변수로 넘겨 전달합니다.더 깊이 공부하려면 다음 공식 문서를 참고합니다.
sync, context 등 패키지 문서
댓글 0개
로그인 · 로그인하면 댓글을 남길 수 있습니다.
첫 댓글을 남겨 보세요.