Devin.KR

Go · 기본

작고 명확한 Go 프로그램

sync 와 context - 기다리기·잠그기·취소하기

WaitGroup, Mutex, 경쟁 탐지기 go run -race, context.WithTimeout·취소 전파

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서는 고루틴(goroutine)을 띄우고 채널로 결과를 주고받았다. 이 장에서는 고루틴이 여러 개 돌 때 반드시 마주치는 세 가지 일을 다룬다. 하나는 일이 끝날 때까지 기다리는 일이고, 다른 하나는 여러 고루틴이 함께 쓰는 값을 안전하게 고치는 일이며, 마지막은 오래 걸리는 일을 제때 멈추는 일이다. 표준 라이브러리의 sync 패키지와 context 패키지가 이 세 가지를 맡는다.

  • sync.WaitGroup 으로 여러 고루틴이 끝나기를 기다린다.
  • sync.Mutex 로 공유하는 값을 한 번에 한 고루틴만 고치게 한다.
  • go run -race 로 잠금 누락을 실행 중에 찾아낸다.
  • context.WithTimeout 과 context.WithCancel 로 시간 제한과 취소를 만들고, 취소가 자식에게 전파되는 모습을 확인한다.

문제 상황

동네 대여소 프로그램에 사람이 몰리는 시간대가 생겼다. 퇴근 시간에 스무 명이 거의 동시에 앱에서 대여 버튼을 누르는데, 그 대여소에는 자전거가 열두 대뿐이다. 요청마다 고루틴 하나가 처리한다고 하자. 모든 고루틴이 같은 재고 숫자 하나를 읽고 줄이기 때문에, 순서를 맞추지 않으면 열두 대인 재고에서 열세 건이 성공하는 일이 생길 수 있다.

정산 쪽에는 다른 문제가 있다. 밤마다 여러 대여소의 하루 매출을 동시에 모으는데, 어느 한 대여소의 집계가 멈춘 듯 느려지면 전체 정산이 끝나지 않는다. 기다리는 시간에 상한을 두고, 시간이 지나면 남은 일을 정리하고 넘어가야 한다.

정리하면 세 가지 요구다. 끝날 때까지 기다린다. 공유하는 값은 순서를 맞춰 고친다. 늦어지는 일은 제때 그만둔다.

WaitGroup 과 Mutex - 기다리기와 잠그기

WaitGroup: 끝날 때까지 기다린다

main 함수가 끝나면 아직 돌던 고루틴도 함께 사라진다. 그래서 고루틴을 띄운 쪽은 그 일이 끝났는지 알아야 한다. 앞 장에서는 채널로 결과를 받으며 자연스럽게 기다렸지만, 돌려받을 값이 없는 일도 있다. 이럴 때 sync.WaitGroup 을 쓴다. 안에 숫자 하나를 가진 카운터라고 생각하면 된다.

  • Add(n): 기다릴 일이 n개 늘었다고 알린다.
  • Done(): 일 하나가 끝났다고 알린다. 카운터를 1 줄인다.
  • Wait(): 카운터가 0이 될 때까지 멈춰 선다.

규칙은 하나다. Add 는 고루틴을 띄우기 전에, 띄우는 쪽에서 부른다. 고루틴 안에서 부르면 Wait 가 먼저 지나가 버릴 수 있다. Done 은 함수 첫머리에서 defer 로 예약해 두면 도중에 반환해도 빠지지 않는다. Go 1.25 부터는 wg.Go(f) 메서드가 추가되어 Add(1), 고루틴 시작, Done 예약을 한 번에 해 준다. 이 장은 동작 원리가 보이도록 Add 와 Done 을 직접 쓴다.

Mutex: 한 번에 한 고루틴만 들어간다

여러 고루틴이 같은 변수를 읽고 쓰면 결과가 실행할 때마다 달라질 수 있다. 이런 상황을 데이터 경쟁(data race)이라고 한다. 재고를 줄이는 bikes-- 는 한 줄이지만 실제로는 읽기, 빼기, 쓰기 세 단계다. 두 고루틴이 같은 값을 읽은 뒤 각자 쓰면 한 번의 감소가 사라진다. 아래 그림은 재고가 1대일 때 벌어지는 일을 시간 순서로 보여 준다.

잠금이 없으면 두 고루틴이 같은 재고 1을 읽고 각자 0을 써서 대여가 두 건 성공한다.

해결책은 그 구간을 잠그는 것이다. sync.Mutex 는 상호 배제(mutual exclusion) 잠금이다. Lock() 을 부른 고루틴만 구간에 들어가고, 나머지는 Unlock() 이 불릴 때까지 Lock() 에서 기다린다. 잠금은 보호할 값과 같은 구조체에 두는 것이 읽기 쉽다.

type Station struct {
	mu     sync.Mutex
	bikes  int
	rented int
}

메서드는 Lock 직후에 defer s.mu.Unlock() 을 적는다. 중간에 return 이 여러 번 있어도 잠금이 풀리기 때문이다. 뮤텍스는 복사하면 안 되므로, 뮤텍스를 가진 구조체의 메서드는 포인터 리시버로 만든다. 구조체를 다룬 장에서 본 값 리시버와 포인터 리시버의 차이가 여기서 실제 버그로 나타난다.

경쟁 탐지기: go run -race

데이터 경쟁은 눈으로 찾기 어렵다. 결과가 대체로 맞게 나오다가 가끔만 틀리기 때문이다. Go 는 이를 실행 중에 찾아 주는 경쟁 탐지기(race detector)를 도구에 넣어 두었다. 방법은 명령에 -race 를 붙이는 것이다.

go run -race main.go
go test -race ./...

같은 변수에 잠금 없이 접근한 두 지점이 실제로 실행되면 WARNING: DATA RACE 로 시작하는 보고서가 나온다. 보고서에는 읽은 고루틴과 쓴 고루틴의 소스 위치가 각각 적혀 있고, 경쟁이 발견된 프로그램은 종료 코드 66 으로 끝난다. 탐지기는 실행된 경로만 검사하므로, 문제가 없다는 출력이 곧 안전하다는 증명은 아니다. 대신 프로그램이 느려지고 메모리를 더 쓰므로 개발과 시험 단계에서 켜는 것이 보통이다. 컴파일에 C 도구 체인이 필요한 환경도 있다.

context - 취소와 시간 제한

context 를 만드는 함수

context.Context 는 "이 일을 언제까지 해야 하는가, 그만두라는 신호가 왔는가"를 함수 사이에 전달하는 값이다. 시작점은 context.Background() 이고, 여기서 파생해 새 context 를 만든다. 파생하는 함수는 취소용 함수 cancel 을 함께 돌려준다.

context 를 만드는 함수와 끝나는 조건
함수끝나는 조건Err() 값
WithCancel(parent)cancel() 호출context.Canceled
WithTimeout(parent, d)d 시간이 지나거나 cancel() 호출context.DeadlineExceeded 또는 Canceled
WithDeadline(parent, t)시각 t 가 되거나 cancel() 호출context.DeadlineExceeded 또는 Canceled

시간 제한을 걸었더라도 일이 일찍 끝났다면 cancel() 을 불러 타이머와 내부 자원을 곧바로 돌려주어야 한다. 그래서 만든 직후 defer cancel() 을 적는 것이 관용구다.

Done 채널로 취소를 알아챈다

context 는 Done() 이라는 채널을 준다. 취소되거나 시간이 다 되면 이 채널이 닫힌다. 닫힌 채널에서는 읽기가 즉시 끝나므로 select 로 "일이 끝났다"와 "그만두라는 신호"를 나란히 기다릴 수 있다. 신호를 받은 뒤 ctx.Err() 를 보면 이유를 알 수 있다.

select {
case <-time.After(work):
	// 일을 마쳤다
case <-ctx.Done():
	return ctx.Err()
}

오류는 errors.Is(err, context.DeadlineExceeded) 처럼 비교한다. 오류를 다룬 장에서 본 대로, 다른 오류에 감싸여 있어도 찾아 준다.

취소는 부모에서 자식으로 전파된다

context 는 나무 모양이다. 부모에서 파생한 자식은 부모가 취소되면 함께 취소된다. 반대로 자식이 취소되거나 시간이 다 되어도 부모는 영향을 받지 않는다. 그래서 요청 하나를 처리하는 최상위 함수가 context 를 만들고, 그 아래 함수들이 그것을 첫 번째 매개변수로 받아 내려보내면, 위에서 한 번 취소하는 것으로 아래 일이 모두 멈춘다.

부모 context 를 취소하면 그 아래 자식 context 들이 모두 함께 취소된다.

함수 시그니처에서 context 는 관례상 첫 번째 매개변수이고 이름은 ctx 다. 구조체 필드에 넣어 두지 않고 호출마다 넘긴다.

완성 코드

대여소 하나에서 스무 명이 열두 대를 두고 경쟁하는 장면, 세 대여소의 정산에 시간 제한을 거는 장면, 부모 취소가 자식에게 전해지는 장면을 한 파일에 담았다. 누가 자전거를 받는지는 실행마다 달라지므로, 출력에는 순서와 무관한 집계만 싣는다.

package main

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"
)

const farePerRide = 1000

type Station struct {
	mu     sync.Mutex
	bikes  int
	rented int
}

func (s *Station) Rent() bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.bikes == 0 {
		return false
	}
	s.bikes--
	s.rented++
	return true
}

func (s *Station) Status() (bikes, rented int) {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.bikes, s.rented
}

func rentAll(s *Station, riders int) int {
	var wg sync.WaitGroup
	results := make([]bool, riders)
	for i := range riders {
		wg.Add(1)
		go func() {
			defer wg.Done()
			results[i] = s.Rent()
		}()
	}
	wg.Wait()
	ok := 0
	for _, r := range results {
		if r {
			ok++
		}
	}
	return ok
}

type job struct {
	name  string
	rides int
	work  time.Duration
}

type settlement struct {
	name string
	won  int
	err  error
}

func settle(ctx context.Context, j job) (int, error) {
	select {
	case <-time.After(j.work):
		return j.rides * farePerRide, nil
	case <-ctx.Done():
		return 0, ctx.Err()
	}
}

func settleAll(ctx context.Context, jobs []job) []settlement {
	results := make([]settlement, len(jobs))
	var wg sync.WaitGroup
	for i, j := range jobs {
		wg.Add(1)
		go func() {
			defer wg.Done()
			won, err := settle(ctx, j)
			results[i] = settlement{j.name, won, err}
		}()
	}
	wg.Wait()
	return results
}

func demoRent() {
	fmt.Println("== 1. 대여 경쟁 ==")
	st := &Station{bikes: 12}
	const riders = 20
	ok := rentAll(st, riders)
	bikes, rented := st.Status()
	fmt.Printf("자전거 12대, 대여 시도 %d명\n", riders)
	fmt.Printf("성공 %d건, 실패 %d건\n", ok, riders-ok)
	fmt.Printf("남은 자전거 %d대, 대여 기록 %d건\n", bikes, rented)
}

func demoTimeout() {
	fmt.Println("== 2. 시간 제한 정산 ==")
	jobs := []job{
		{"서쪽역", 8, 10 * time.Millisecond},
		{"동쪽역", 5, 30 * time.Millisecond},
		{"북쪽역", 3, 2 * time.Second},
	}
	ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
	defer cancel()

	total := 0
	for _, r := range settleAll(ctx, jobs) {
		if errors.Is(r.err, context.DeadlineExceeded) {
			fmt.Printf("%s: 시간 초과\n", r.name)
			continue
		}
		fmt.Printf("%s: %d원\n", r.name, r.won)
		total += r.won
	}
	fmt.Printf("합계 %d원\n", total)
}

func demoCancel() {
	fmt.Println("== 3. 취소 전파 ==")
	parent, cancelParent := context.WithCancel(context.Background())
	child, cancelChild := context.WithTimeout(parent, time.Hour)
	defer cancelChild()

	fmt.Println("취소 전 자식 오류:", child.Err())
	cancelParent()
	<-child.Done()
	fmt.Println("취소 후 자식 오류:", child.Err())
	fmt.Println("시간 초과인가:", errors.Is(child.Err(), context.DeadlineExceeded))
}

func main() {
	demoRent()
	demoTimeout()
	demoCancel()
}

줄별 해설

Station 구조체. mu 는 bikes 와 rented 를 지키는 잠금이다. 뮤텍스의 제로값은 잠기지 않은 상태로 바로 쓸 수 있어서 따로 초기화하지 않는다. 변수와 제로값을 다룬 장에서 본 성질이 여기서도 이어진다.

Rent 메서드. 포인터 리시버이고, 첫 줄에서 잠근 뒤 defer 로 풀림을 예약한다. 재고 확인과 감소가 하나의 잠금 구간 안에 있으므로, 확인한 뒤 다른 고루틴이 끼어들 수 없다. 재고가 0이면 false 를 돌려주며 defer 덕분에 이 경로에서도 잠금이 풀린다.

Status 메서드. 읽기만 하는 메서드에도 잠금이 필요하다. 다른 고루틴이 쓰는 도중에 읽는 것도 데이터 경쟁이기 때문이다. 이름 있는 반환값 두 개를 쓰는 형태는 함수 장에서 다룬 것이다.

rentAll 함수. results 는 사람 수만큼 칸을 미리 만든 슬라이스다. 고루틴마다 자기 번호 i 의 칸에만 쓰므로 서로 겹치지 않아 잠금이 필요 없다. 반복 변수 i 는 반복마다 새로 만들어지므로 고루틴이 각자의 값을 본다. wg.Add(1) 은 go 문 앞에서 부르고, wg.Wait() 가 돌아온 뒤에 결과를 센다. Wait 가 끝났다는 것은 모든 Done 이 불렸다는 뜻이고, 그 이전의 쓰기가 이후의 읽기에 보이도록 Go 메모리 모델이 보장한다.

settle 함수. select 가 정산 완료(time.After)와 취소 신호(ctx.Done()) 중 먼저 오는 쪽을 택한다. 여기서 time.After(j.work) 는 일하는 시간을 흉내 낸다. 취소되면 ctx.Err() 를 그대로 돌려준다.

settleAll 함수. 대여소마다 고루틴을 하나씩 띄우고 같은 ctx 를 나눠 준다. 결과는 대여소 번호 순서의 칸에 쓰기 때문에 어느 고루틴이 먼저 끝나든 출력 순서가 고정된다.

demoTimeout 함수. 300밀리초 제한이 걸린 context 를 만들고 곧바로 defer cancel() 을 적었다. 서쪽역과 동쪽역은 각각 10, 30밀리초에 끝나 제한 안에 들어오고, 북쪽역은 2초가 걸려 제한에 걸린다. 제한과 작업 시간의 차이를 크게 잡았기 때문에 컴퓨터가 조금 느려도 결과가 바뀌지 않는다.

demoCancel 함수. 부모는 WithCancel, 자식은 부모에서 파생한 한 시간짜리 WithTimeout 이다. 취소 전에 자식의 Err() 는 nil 이다. cancelParent() 를 부르면 자식의 Done() 이 닫히고, 이유는 시간 초과가 아니라 취소다. 자식의 cancelChild 도 defer 로 남겨 둔 것은 어떤 경로로 끝나든 자원을 돌려주는 습관을 보이기 위해서다.

실행 결과

main.go 를 저장한 디렉터리에서 실행한다. 모듈이 없다면 앞서 배운 대로 go mod init bike 를 먼저 한다.

$ go run main.go
== 1. 대여 경쟁 ==
자전거 12대, 대여 시도 20명
성공 12건, 실패 8건
남은 자전거 0대, 대여 기록 12건
== 2. 시간 제한 정산 ==
서쪽역: 8000원
동쪽역: 5000원
북쪽역: 시간 초과
합계 13000원
== 3. 취소 전파 ==
취소 전 자식 오류: <nil>
취소 후 자식 오류: context canceled
시간 초과인가: false

경쟁 탐지기를 켜고 실행해도 출력은 같다. 잠금을 빠뜨린 곳이 없으므로 경고가 나오지 않는다.

$ go run -race main.go

실무에서 자주 틀리는 것

1. Add 를 고루틴 안에서 부른다

고루틴이 실제로 시작하기 전에 Wait 가 카운터 0을 보고 지나가 버릴 수 있다.

// 틀린 코드
for i := range 5 {
	go func() {
		wg.Add(1)
		defer wg.Done()
		work(i)
	}()
}
wg.Wait()
// 고친 코드
for i := range 5 {
	wg.Add(1)
	go func() {
		defer wg.Done()
		work(i)
	}()
}
wg.Wait()

2. 뮤텍스가 든 구조체를 값 리시버로 쓴다

값 리시버는 호출할 때마다 구조체를 복사한다. 뮤텍스도 복사본이 되어 고루틴마다 다른 잠금을 잡으므로 아무것도 보호하지 못한다. 이 경우 go vet 이 잠금 복사를 경고한다.

// 틀린 코드
func (s Station) Rent() bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	// ...
}
// 고친 코드
func (s *Station) Rent() bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	// ...
}

3. cancel 함수를 버린다

취소 함수를 _ 로 버리면 시간 제한이 끝나기 전까지 타이머 자원이 남는다. go vet 이 이 실수도 알려 준다.

// 틀린 코드
ctx, _ := context.WithTimeout(context.Background(), time.Second)
// 고친 코드
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

4. 반환 경로마다 Unlock 을 손으로 부른다

반환 지점이 늘어나면 하나쯤 빠뜨리기 쉽다. 빠뜨린 경로를 지나면 잠금이 영원히 풀리지 않아 다른 고루틴이 모두 멈춘다.

// 틀린 코드
func (s *Station) Rent() bool {
	s.mu.Lock()
	if s.bikes == 0 {
		return false // Unlock 을 빠뜨렸다
	}
	s.bikes--
	s.mu.Unlock()
	return true
}
// 고친 코드
func (s *Station) Rent() bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.bikes == 0 {
		return false
	}
	s.bikes--
	return true
}

한눈에 보기

이 장에서 쓴 도구와 쓰는 때
도구하는 일쓰는 때주의
sync.WaitGroup고루틴이 끝나기를 기다린다돌려받을 값이 없는 일을 나눠 맡길 때Add 는 go 앞에서 부른다
sync.Mutex구간에 한 고루틴만 들인다여러 고루틴이 같은 값을 고칠 때포인터 리시버, defer Unlock
go run -race데이터 경쟁을 찾는다개발과 시험 단계실행된 경로만 검사한다
context.WithTimeout제한 시간을 건다느려질 수 있는 일defer cancel() 을 함께 적는다
context.WithCancel직접 취소한다한 곳의 실패로 나머지를 멈출 때취소는 자식에게만 전파된다
context 의 오류 값과 확인 방법
상황ctx.Err() 값확인 방법
진행 중nilctx.Err() == nil
cancel() 호출context.Cancelederrors.Is(err, context.Canceled)
제한 시간 초과context.DeadlineExceedederrors.Is(err, context.DeadlineExceeded)

세부 사항은 sync 패키지 문서, context 패키지 문서, 경쟁 탐지기 안내에서 확인할 수 있다. 다음 장의 net/http 서버에서는 요청마다 context 가 이미 붙어 오므로, 이 장의 취소 개념이 그대로 쓰인다.

연습 문제

  1. Station 에 자전거를 반납하는 Return() 메서드를 추가하라. 재고를 1 늘리고 대여 기록은 1 줄인다. 반납 고루틴 열 개를 WaitGroup 으로 기다리는 코드도 작성하라. 재고 12대에서 먼저 열두 건을 모두 대여한 상태로 시작한다.
  2. demoTimeout 의 제한 시간을 5밀리초로 줄이면 출력이 어떻게 바뀌는지 예상하라.
  3. Station.Rent 에서 잠금 두 줄을 지우고 go run -race main.go 를 실행하면 어떤 일이 생기는지 설명하라. 결과 집계가 우연히 맞게 나와도 탐지기가 보고하는 이유도 적어라.
  4. settleAll 이 실행되는 도중에 부모 context 를 직접 취소하면 각 대여소의 오류는 무엇이 되는가? errors.Is 로 취소를 구분해 "취소됨"이라고 출력하는 분기를 demoTimeout 에 추가하라.

정답과 해설

1번. 반납도 재고와 기록을 함께 고치므로 같은 뮤텍스로 감싼다.

func (s *Station) Return() {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.bikes++
	s.rented--
}

func returnAll(s *Station, n int) {
	var wg sync.WaitGroup
	for range n {
		wg.Add(1)
		go func() {
			defer wg.Done()
			s.Return()
		}()
	}
	wg.Wait()
}

열두 건을 대여한 상태에서 열 건을 반납하면 재고 10대, 기록 2건이 된다. 같은 값을 여러 고루틴이 고치는 구간은 대여든 반납이든 같은 잠금을 써야 서로를 막는다. 한쪽만 잠그면 보호가 되지 않는다.

2번. 세 대여소의 작업 시간이 10, 30밀리초와 2초이므로 5밀리초 제한에서는 모두 시간 안에 끝나지 못한다. 세 줄이 모두 "시간 초과"로 나오고 합계는 0원이 된다. 정산 함수들이 ctx.Done() 을 함께 기다리기 때문에 제한이 짧아지면 결과가 곧바로 달라진다.

3번. 두 고루틴이 잠금 없이 s.bikes 와 s.rented 를 읽고 쓰므로 WARNING: DATA RACE 보고서가 나오고 종료 코드는 66이 된다. 보고서에는 읽기와 쓰기가 일어난 소스 줄이 각각 표시된다. 탐지기는 결과값이 맞았는지가 아니라 동기화 없이 같은 메모리에 접근했는지를 기록으로 따져 보기 때문에, 집계가 우연히 맞아도 경쟁을 보고한다. 결과가 맞는 것과 코드가 옳은 것은 다르다.

4번. 취소된 대여소는 context.Canceled 를 돌려받는다. 시간 초과 분기 아래에 다음을 더하면 된다.

if errors.Is(r.err, context.Canceled) {
	fmt.Printf("%s: 취소됨\n", r.name)
	continue
}

이때 WithTimeout 으로 만든 context 라도 시간이 다 되기 전에 부모가 취소되면 오류는 DeadlineExceeded 가 아니라 Canceled 다. 실행이 두 이유로 끝날 수 있으므로 두 분기를 모두 두는 것이 안전하다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.