Go에서는 goroutine과 channel이라는 기능을 통해 동시성을 지원한다. 고루틴은 Go 프로그램에서 독립적으로 실행할 수 있는 최소 단위이다. 채널을 통해 고루틴 끼리 데이터를 효율적으로 데이터를 주고 받을 수 있다. 따라서 채널은 고루틴끼리 통신하기 위한 참조점인셈이다. Go 프로그램에서 실행되는 부분은 모두 고루틴으로 처리한다.
프로세스, 스레드, Goroutine
프로세스란 명령어와 사용자 데이터, 시스템 영역, 그리고 실행 과정에 수집한 다양한 종류의 리소스로 구성된 독립적인 실행 단위다. 반면 프로그램은 이러한 프로세스의 명령어와 사용자 데이터를 초기화하는 데 사용할 명령어와 데이터를 담은 파일이다.
스레드는 프로그램이나 프로세스보다 좀 더 가볍고 작은 실행단위다. 스레드는 프로세스에 의해 생성되며 독립적인 제어 흐름과 스택을 갖는다. 스레드와 프로세스의 차이를 한 마디로 표현하면, 프로세스는 바이너리 파일을 실행한 것이고, 스레드는 프로세스의 일부분이다.
Goroutine은 Go 프로그램에서 동시에 실행할 수 있는 최소 단위다. 여기서 최소라는 표혀이 굉장히 중요하다. 고루틴은 유닉스 프로세스처럼 독립적인 개체가 아니다. 유닉스 프로세스안에 살고 있는 스레드 안에서 존재한다. 고루틴의 가장 큰 장점은 굉장히 가벼워서 수천~수만 개를 구동해도 아무런 문제가 없다.
고루티은 스레드보다 가벼워서 좋다. 고루틴은 특정한 프로세스 환경이 있어야 존재할 수 있다. 따라서 고루틴을 생성하려면 최소한 한 개 이상의 스레드를 가진 프로세스가 필요하다. 프로세스와 스레드는 유닉스에서 관리해주지만 고루틴은 고언어와 개발자가 관리해줘야한다.
Go 스케줄러
유닉스 커널 스케줄러는 프로그램에 있는 스레드를 실행하는 일을 담당한다. 한편 Go 런타임에도 자체적으로 스케줄러가 있는데, 이는 고루틴을 실행하는 일을 담당한다. 고 스케줄러는 m:n 스케줄링이라 불리는 기술을 사용한다. 여기서 m은 골틴의 개수이고, n은 고루틴을 멀티플렉싱할 OS의 스레드 개수다. Go 스케줄러는 Go 런타임의 구성 요소로 Go 프로그램을 구성하는 고루틴을 실행시킬 방법과 순서를 정한다.
Go 스케줄러는 한 프로그램 안에 있는 고루틴만 다루기 때문에 커널의 스케줄러에 비해 훨씬 간결하고, 효율적이고, 빠르게 작동한다.
동시성과 병렬성
병렬성은 특정한 종류의 개체들이 동시에 실행되는 것을 가리키는 반면, 동시성은 가능하다면 서로 독립적으로 실행할 수 있도록 컴포넌트의 구조를 구성하는 방식을 뜻한다.
동시성 설계가 제대로 된 시스템이라면 동시성을 갖춘 개체를 추가할수록 병렬로 실행할 수 있는 일이 늘어나기 때문에 전반적인 시스템의 처리 속도가 빨라진다. 따라서 문제를 동시성의 관점에서 잘 표현하고 구현해야만 병렬성을 제대로 실현할 수 있다.
Go 루틴
고루틴을 정의하려면 함수 이름이나 익명 함수를 정의하는 문장 앞에 go 키워드를 적으면 된다. 그 함수는 즉시 리턴하고, 실제 동작은 백그라운드에서 고루틴 형태로 실행하면서 원래 수행하던 프로그램 흐름은 계속 이어진다.
고루틴이 실제로 실행되는 순서를 예측할 수 없다. OS의 스케줄러와 Go 런타임의 스케줄러에 따라 얼마든지 달라질 수 있기 때문이다.
package main
import (
"fmt"
"time"
)
func function() {
for i := 0; i < 10; i++ {
fmt.Print(i)
}
fmt.Println()
}
func main() {
go function()
go func() {
for i := 10; i < 20; i++ {
fmt.Print(i, " ")
}
}()
time.Sleep(1 * time.Second)
}
$ go run foo.go
10 11 12 13 14 15 16 17 18 19 0123456789
실제로 실행 시킬 때마다 결과가 다를 수 있다. 이를 통해 고루틴을 사용할 때 특별히 신경써주지 않으면 실행 순서를 제어할 수 없다는 것을 알 수 있다. 따라서 원하는 순서로 실행되게 하려면 별도로 코드를 작성해야한다.
Go 루틴 여러 개 생성하기
package main
import (
"flag"
"fmt"
"time"
)
func main() {
n := flag.Int("n", 10, "# of gorutines")
flag.Parse()
count := *n
for i := 0; i < count; i++ {
go func(x int) {
fmt.Printf("%d ", x)
}(i)
}
time.Sleep(time.Second)
fmt.Println("\n끝")
}/
$ go run foo.go
4 3 6 5 8 2 1 0 9 7
끝
순서를 알 수 없을 정도로 비결정적인 방식으로 실행 된다.
고루틴이 마칠 때까지 기다리기
package main
import (
"flag"
"fmt"
"sync"
)
func main() {
n := flag.Int("n", 20, "# of gorutines")
flag.Parse()
count := *n
var waitGroup sync.WaitGroup
fmt.Printf("%#v\n", waitGroup)
for i := 0; i < count; i++ {
waitGroup.Add(1)
go func(x int) {
defer waitGroup.Done()
fmt.Printf("%d ", x)
}(i)
}
fmt.Printf("%#v\n", waitGroup)
waitGroup.Wait()
fmt.Println("\nExiting")
}
$ go run foo.go
sync.WaitGroup{noCopy:sync.noCopy{}, state1:[3]uint32{0x0, 0x0, 0x0}}
sync.WaitGroup{noCopy:sync.noCopy{}, state1:[3]uint32{0x0, 0x14, 0x0}}
5 0 3 2 4 12 6 7 13 19 14 15 16 17 18 10 9 8 11 1
Exiting
sync.WaitGroup변수를 정의한다. sync.WaitGroup 그룹에 속한 고루틴의 수는 sync.Add()의 호출 횟수에 따라 정의된다.
원하는 개수만큼 고루틴을 생성하는 부분은 for 문으로 작성한다.
sync.Add()를 호출할 때마다 sync.WaitGroup의 변수의 카운터가 증가한다. 주의해야할 점은 go 키워드 전에 sync.Add(1)를 호출해야한다. 그래야 경쟁상태가 발생하는 것을 피할 수 있다. 각각의 고루틴이 작업을 마칠 대마다 sync.Done() 함수가 실행된다. 이 함수를 통해 sync.WaitGroup 변수의 카운터 값을 감소시킨다.
sync.Wait()을 호출하면 sync.WaitGroup 변수에 있는 카운터가 0이 될 때까지 실행을 멈춘다.
Add()와 Done() 호출 회수가 일치하지 않을시
만약 add함수가 한번 더 호출이 되면 sync.Wait()을 호출한 부분에서 sync.Done()이 더 호출되기를 끝없이 기다려 데드락이 발생하게 된다.
반대의 경우에서는 카운터가 음수가 되어 에러가 발생한다.
채널
채널이란 주로 고루틴끼리 데이터를 주고 받기 위해 사용하는 통신 메커니즘이다.
- 각 채널마다 특정한 데이터 타입으로만 교환할 수 있다. 이를 채널의 원소 타입이라 부른다.
- 채널이 정상적으로 작동하려면 채널로 데이터를 보내는 상대방이 있어야 한다. 채널ㅇㄹ 새로 선언하는 문장은 chan 키워드로 표시하고, 채널을 닫으려면 close() 함수를 호출해야 한다.
- 채널을 함수의 매개변수로 사용할 때 반드시 채널의 방향을 명시해야 한다. 그래야 데이터를 받는 채널에 실수로 데이터르 ㄹ보내거나 그 반대로 하는 일을 방지할 수 있어서 프로그램을 더 견고하게 만들 수 있다.
package main
import (
"fmt"
"time"
)
func writeToChannel(c chan int, x int) {
fmt.Println(x)
c <- x
close(c)
fmt.Println(x)
}
func main() {
c := make(chan int)
go writeToChannel(c, 10)
time.Sleep(time.Second)
}
$ go run foo.go
10
함수 매개변수를 채널로 선언하기 위해 chan 키워드르 ㄹ붙였다. c ← x 라는 문장으로 x라는 값을 c라는 채널로 쓴다고 명시한다. 그리고 close()로 채널을 닫는다.
writeToChannel 함수는 값을 단 한번만 출력했다. 그 이유는 두번째 출력 문장은 실행될 수 없기 때문이다. 채널의 작동 원리를 생각하면 그 이유는 간단하다. c라는 채널에 쓴 값을 아무도 읽지 않기 때문에, wirteChannel()함수의 실행은 c←x 라는 문장에서 막혀버린다. 그래서 time.Sleep()함수가 끝나면 writeChannel을 기다리지 않고 종료한다.
←c라는 문장을 통해 c라는 채널에서 값 하나를 읽을 수 있다.
package main
import (
"fmt"
"time"
)
func writeToChannel(c chan int, x int) {
fmt.Println("1", x)
c <- x
close(c)
fmt.Println("2", x)
}
func main() {
c := make(chan int)
go writeToChannel(c, 10)
time.Sleep(time.Second)
fmt.Println("Read:", <-c)
time.Sleep(time.Second)
_, ok := <-c
if ok {
fmt.Println("Chanel is Open")
} else {
fmt.Println("Channel is Closed")
}
}
채널을 읽을 수도 있고 또한 열렸는지 확인할 수 있다.
$ go run foo.go
1 10
Read: 10
2 10
Channel is Closed
함수 매개변수로 지정한 채널
매개변수에서 또한 방향을 지정할 수 있다. 이러한 방향을 가지는 채널을 단방향 채널이라 부른다.
func f1(c chan int, x int) {
fmt.Println(x)
c <- x
}
func f2(c chan<- int, x int) {
fmt.Println(x)
c <- x
}
두 함수 모두 기능은 같지만 정의하는 방식이 다르다. f2같은 경우는 쓰기 전용으로만 사용할 수 있다. 반대도 마찬가지이다.
파이프라인
파이프라인이란 고루틴과 채널을 연결하는 기법으로 채널로 데이터를 전송하는 방식으로 한쪽 고루틴의 출력을 다른 고루틴의 입력으로 연결할 수 있다.
파이프라인의 장점은 데이터 흐름을 일정하게 구현할 수 있다는 점이다. 고루틴이나 채널은 다른 작업이 끝날 때까지 기다릴 필요 없이 실행을 시작할 수 있기 때문이다. 또한 주고 받는 값을 일일이 변수에 저장할 필요가 없기 때문에 메모리 공간도 절약하 수 있다.
package main
import (
"fmt"
"math/rand"
"os"
"strconv"
"time"
)
var CLOSEA = false
var DATA = make(map[int]bool)
func random(min, max int) int {
return rand.Intn(max-min) + min
}
func first(min, max int, out chan<- int) {
for {
if CLOSEA {
close(out)
return
}
out <- random(min, max)
}
}
func second(out chan<- int, in <-chan int) {
for x := range in {
fmt.Print(x, " ")
_, ok := DATA[x]
if ok {
CLOSEA = true
} else {
DATA[x] = true
out <- x
}
}
fmt.Println()
close(out)
}
func third(in <-chan int) {
var sum int
sum = 0
for x2 := range in {
sum += x2
}
fmt.Printf("The sum of the random numbers is %d\n", sum)
}
func main() {
n1, _ := strconv.Atoi(os.Args[1])
n2, _ := strconv.Atoi(os.Args[2])
rand.Seed(time.Now().UnixNano())
A := make(chan int)
B := make(chan int)
go first(n1, n2, A)
go second(B, A)
third(B)
}
$ go run foo.go 1 10
1 6 3 9 8 2 7 9
The sum of the random numbers is 36
- first()함수는 CLOSEA 값이 true가 될 때까지 무한 루프를 돌고, true가 되면 out 채널을 닫는다.
- second() 함수는 in 채널로부터 데이터를 받아서 out 채널로 보낸다. 이미 맵에 있는 난수를 발견하면 CLOSEA 값을 true로 변경하고, Out 채널로 숫자로 보내지 않는다.
- third()함수는 in 채널로 부터 값을 계속 읽는다. second() 함수에 의해 이 채널이 닫히면 데이터를 읽지 않고 결과를 화면에 출력한다.
'Go 언어 공부' 카테고리의 다른 글
[GO 마스터하기] 09-동시성2 (0) | 2020.09.22 |
---|---|
[GO 마스터하기] 08-유닉스 시스템콜 (0) | 2020.09.21 |
[GO 마스터하기] 07-리플렉션과 인터페이스 (0) | 2020.09.21 |
[GO 마스터하기] 06-Go Package (0) | 2020.09.21 |
[GO 마스터하기] 05-Go 자료구조 (0) | 2020.09.16 |