golang 스터디 7주차(고루틴과 동시성 프로그래밍 / 채널과 컨텍스트)

2023-12-12
10min
Golang

Chapter 24. 고루틴과 동시성 프로그래밍

고루틴은 Go 언어에서 관리하는 경량 쓰레드다. 함수나 명령을 동시에 수행할 때 사용한다. 고루틴을 사용하면 아래와 같은 효과를 얻을 수 있다.

  1. 여러 작업을 동시에 수행할 수 있다.
  2. 외부 라이브러리에 의존하지 않고 동시성 프로그래밍을 구현할 수 있다.
  3. 멀티코어 환경에서 CPU 를 더 효율적으로 사용해 작업을 빠르게 완료할 수 있다.
  4. 기존 OS 스레드에서 발생되는 컨텍스트 스위칭에 따른 성능 손실을 최소화해서 효율적으로 사용한다.
  5. 고루틴 간 메모리 간섭으로 인해 발생하는 문제점에 주의해야한다.

Go 언어에서는 CPU 코어마다 쓰레드를 하나만 할당해서 사용하기 때문에 쓰레드 전환간의 컨텍스트 스위칭 비용이 발생하지 않는다.

24.2 고루틴 사용

go
// go 함수 호출 로 고루틴 생성 package main import "fmt" func printInGoRoutine() { fmt.Println("in go routine") } func main() { go printInGouRoutine() fmt.Printf("----- end main ----") } /// 출력 결과: : ----- end main ----
go
// go 함수 호출 로 고루틴 생성 package main import "fmt" func printInGoRoutine() { fmt.Println("in go routine") } func main() { go printInGouRoutine() fmt.Printf("----- end main ----") } /// 출력 결과: : ----- end main ----

printInGoRoutine 함수를 만들고 함수에서 stdout 으로 출력을 시켰다. 하지만, 고루틴 함수에서의 출력은 터미널에 보이지 않는다. 왜냐면 main 함수에서는 고루틴 함수를 기다려주지 않기 때문이다.

24.2.1 서브 고루틴이 종료될 때까지 기다리기

go
package main import ( "fmt" "sync" ) func printInGouRoutine(wg *sync.WaitGroup) { fmt.Println("in go routine") wg.Done() } func main() { var wg sync.WaitGroup wg.Add(1) go printInGouRoutine(&wg) wg.Wait() fmt.Printf("----- end main ----") } // 출력 결과 // in go routine // ----- end main ----
go
package main import ( "fmt" "sync" ) func printInGouRoutine(wg *sync.WaitGroup) { fmt.Println("in go routine") wg.Done() } func main() { var wg sync.WaitGroup wg.Add(1) go printInGouRoutine(&wg) wg.Wait() fmt.Printf("----- end main ----") } // 출력 결과 // in go routine // ----- end main ----

sync 패키지의 WaitGroup 을 이용했다. 자세한 내용은 sync WaitGroup 공식문서에서 확인할 수 있다.

24.4 동시성 프로그래밍 주의점

동시성 프로그램의 문제점은 동일한 메모리 자원에 여러 고루틴이 접근할 때 발생한다. 이런 경우 주로 뮤텍스를 사용해 동시성 문제를 해결하게 된다.

아래의 코드는 하나의 변수를 여러개의 고루틴이 공유해서 사용할 때의 경우를 보여준다. amount 변수의 값은 + 100 을 하고 바로 -100 을 하기 때문에 함수의 결과는 언제나 0이 보장되어야하지만, 동시에 여러 고루틴에서 값에 변화를 일으키기 때문에 0이 보장되지 않는다.

go
package main import ( "sync" "time" ) func plusAndMinus(amount *int) { *amount += 100 time.Sleep(time.Millisecond) if *amount < 100 || *amount > 1000 { panic("err") } *amount -= 100 } func main() { var wg sync.WaitGroup amount := 0 wg.Add(10) for i := 0; i < 10; i++ { go func() { for { plusAndMinus(&amount) } wg.Done() }() } wg.Wait() } // 결과: panic: err
go
package main import ( "sync" "time" ) func plusAndMinus(amount *int) { *amount += 100 time.Sleep(time.Millisecond) if *amount < 100 || *amount > 1000 { panic("err") } *amount -= 100 } func main() { var wg sync.WaitGroup amount := 0 wg.Add(10) for i := 0; i < 10; i++ { go func() { for { plusAndMinus(&amount) } wg.Done() }() } wg.Wait() } // 결과: panic: err

24.5 뮤텍스를 이용한 동시성 문제 해결

뮤텍스는 Mutual Exclusion 상호배제의 약자. 뮤텍스는 아주 단순하다. 임계 구역의 권한을 획득하고, 획득을 포기하는 과정이 전부.

  • lock 획득한다. 여러 고루틴이 lock 을 사용하면 먼저 사용한 고루틴이 순서로 가져감
  • unlock 제어권을 놔줌. 줄 서 있는 다른 고루틴이 가져감 뮤텍스를 활용해서 위의 문제 해결
go
package main import ( "sync" "time" ) var mutex sync.Mutex func plusAndMinus(amount *int) { mutex.Lock() defer mutex.Unlock() *amount += 100 time.Sleep(time.Millisecond) if *amount < 100 || *amount > 1000 { panic("err") } *amount -= 100 } func main() { var wg sync.WaitGroup amount := 0 wg.Add(10) for i := 0; i < 10; i++ { go func() { for { plusAndMinus(&amount) } wg.Done() }() } wg.Wait() }
go
package main import ( "sync" "time" ) var mutex sync.Mutex func plusAndMinus(amount *int) { mutex.Lock() defer mutex.Unlock() *amount += 100 time.Sleep(time.Millisecond) if *amount < 100 || *amount > 1000 { panic("err") } *amount -= 100 } func main() { var wg sync.WaitGroup amount := 0 wg.Add(10) for i := 0; i < 10; i++ { go func() { for { plusAndMinus(&amount) } wg.Done() }() } wg.Wait() }

이제 절대 끝나지 않는다..

25. 채널과 컨텍스트

채널과 컨텍스트는 Go 언어에서 동시성 프로그래밍을 도와준다. 채널은 고루틴 간 메시지를 전달하는 메시지큐다. 채널을 사용하면 뮤텍스 없이 동시성 프로그래밍이 가능하다. 컨텍스트는 고루틴에 작업을 요청할 때 작업 취소나 작업 시간 등을 설정할 수 있는 작업 명세서 역할을 한다. 채널과 컨텍스트를 사용해 특정 데이터를 전달하거나 특정 시간 동안만 작업을 요청하거나 작업 취소를 요청할 수 있다.

25.1 채널 사용

채널은 고루틴끼리 메시지를 전달할 수 있는 메시지큐.

25.1.1 채널 인스턴스 생성

채널 인스턴스를 만드는 방법은 아래와같다.

go
var messages chan string = make(chan string)
go
var messages chan string = make(chan string)

채널은 슬라이스 / 맵과 같이 make 함수로 만들 수 있다. chan 이 타입 string 과 같이 사용되었음에 주목. 위의 messages 는 string 타입 메시지를 전달하는 채널 타입

이렇게 만들어진 채널에 데이터를 넣기 위해서는 <- 연산자를 사용한다. 반대로 데이터를 빼는것도 가능하다.

go
// NOTE: 채널에 데이터 전송 messages = "This is messages" // NOTE: 채널에서 데이터 빼오기 var msg string = <- messages
go
// NOTE: 채널에 데이터 전송 messages = "This is messages" // NOTE: 채널에서 데이터 빼오기 var msg string = <- messages

25.1.4 채널 크기

채널을 생성할 때 크기를 명시적으로 주지 않으면 크기가 0 인 채널이 만들어진다. 채널의 크기가 0 이라는건 데이터를 보관할 곳이 없기 때문에 데이터를 빼 갈 때 까지 계속 기다리게 된다.

go
package main import ( "fmt" ) func main() { fmt.Printf("\n Case 0. endless wait \n") ch := make(chan int) ch <- 10 fmt.Println("--------- end main function -----------") }
go
package main import ( "fmt" ) func main() { fmt.Printf("\n Case 0. endless wait \n") ch := make(chan int) ch <- 10 fmt.Println("--------- end main function -----------") }

참고로 위 코드를 실행하면 에러가 뜨면서 종료된다. 왜냐면 어차피 무한 대기할 걸 알기 때문에 강제로 종료시킴

25.1.5 버퍼를 가진 채널

위의 예제에서 버퍼의 크기가 0 이면 데이터를 가져갈 때 까지 계속 대기한다는걸 봤다. 엄밀히 말하면 크기가 0 이어서 대기에 빠지는게 아니라 버퍼를 벗어난 데이터를 빼지 않았기 때문에 영원히 대기에 빠진것. 즉, 버퍼의 크기를 2로 해도 빠지지 않고 넣기만 한다면 위와 동일한 문제가 생긴다.

만약 버퍼의 크기가 2일 때 하나의 데이터가 들어온다면 어떻게 될까? 프로그램은 바로 종료된다. 아래를 보자

go
package main import ( "fmt" ) func main() { ch := make(chan int, 2) ch <- 10 fmt.Println("--------- end main function -----------") }
go
package main import ( "fmt" ) func main() { ch := make(chan int, 2) ch <- 10 fmt.Println("--------- end main function -----------") }

위의 예제와 다르게 크기를 2로 주고 메시지를 하나만 건네줬기 때문에 프로그램은 바로 종료.

25.1.6 채널에서 데이터 대기

채널을 다루는 기법으로 for 을 사용해보자. for xx := range channel 을 이용하면 채널에 값이 들어오기를 기다리고, 값이 들어오면 구문을 실행시킬 수 있다.

go
package main import "fmt" func execInGoroutine(ch chan int) { for n := range ch { fmt.Printf("in func...%d\n", n) } } func main() { fmt.Printf("-------- with buffer --------\n") ch := make(chan int) go execInGoroutine(ch) for i := 0; i < 10; i++ { fmt.Printf("-------- insert to channel %d\n", i) ch <- i } close(ch) }
go
package main import "fmt" func execInGoroutine(ch chan int) { for n := range ch { fmt.Printf("in func...%d\n", n) } } func main() { fmt.Printf("-------- with buffer --------\n") ch := make(chan int) go execInGoroutine(ch) for i := 0; i < 10; i++ { fmt.Printf("-------- insert to channel %d\n", i) ch <- i } close(ch) }

메인고루틴에서 반복문을 돌면서 채널에 값을 10 번 넣는다. 그러면 고루틴에서 채널을 통해 값을 받고, 그 결과를 출력한다. 만약 채널이 한개가 아니라 여러개면 어떻게 해야될까? 그럴 때는 select 를 사용하면 된다.

25.1.7 select 문

go
package main import "fmt" func execInGoroutine(ch chan int, quit chan bool) { for { select { case n := <-ch: fmt.Printf("------ in func %d\n---", n) case <-quit: fmt.Printf("------ quit -------------\n") return } } } func main() { fmt.Printf("-------- with buffer --------\n") ch := make(chan int) quit := make(chan bool) go execInGoroutine(ch, quit) for i := 0; i < 10; i++ { if i == 5 { quit <- true close(ch) return } fmt.Printf("-------- insert to channel %d\n", i) ch <- i } }
go
package main import "fmt" func execInGoroutine(ch chan int, quit chan bool) { for { select { case n := <-ch: fmt.Printf("------ in func %d\n---", n) case <-quit: fmt.Printf("------ quit -------------\n") return } } } func main() { fmt.Printf("-------- with buffer --------\n") ch := make(chan int) quit := make(chan bool) go execInGoroutine(ch, quit) for i := 0; i < 10; i++ { if i == 5 { quit <- true close(ch) return } fmt.Printf("-------- insert to channel %d\n", i) ch <- i } }

위의 예제를 i 가 5 가 되는 시점에 quit 채널을 통해 값이 들어가게 되므로, execInGoroutine 함수의 select 문의 quit 에 걸리게 된다. 그리고 ------quit------- 이 출력되고 프로그램은 종료된다.

25.2 컨텍스트 사용하기

contextcontext 패키지에서 제공하는 기능으로, 작업을 지시할 때 작업 가능 시간, 취소 등의 조건을 지시할 수 있는 명세서 역할을 한다.

go
package main import ( "context" "fmt" "sync" "time" ) var wg sync.WaitGroup func execInGoroutine(ctx context.Context) { tick := time.Tick(time.Second) for { select { case <-ctx.Done(): fmt.Println("done") wg.Done() return case <-tick: fmt.Println("tick") } } } func main() { wg.Add(1) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() go execInGoroutine(ctx) wg.Wait() }
go
package main import ( "context" "fmt" "sync" "time" ) var wg sync.WaitGroup func execInGoroutine(ctx context.Context) { tick := time.Tick(time.Second) for { select { case <-ctx.Done(): fmt.Println("done") wg.Done() return case <-tick: fmt.Println("tick") } } } func main() { wg.Add(1) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() go execInGoroutine(ctx) wg.Wait() }

context 에 3초의 타임아웃을 건 예제.