고루틴
Go에서는 동시에 수행되는 작업을 고루틴이라고 한다. 프로그램이 시작된 뒤 유일한 고루틴은 main 함수를 호출하는 것이므로 이를 메인 고루틴이라고 한다. 새 고루틴은 go문에 의해 생성된다. 문법적으로 go문은 키워드 go가 앞에 붙는 일반 함수 또는 메소드 호출이다.
func handleConn(c net.Conn) {
input := bufio.NewScanner(c)
for input.Scan() {
go echo(c, input.Text(), 1*time.Second)
}
c.close()
}
채널
고루틴이 Go 프로그램의 동작이라면 채널은 고루틴 간의 연결이다. 채널은 한 고루틴이 다른 고루틴으로 값을 보내기 위한 통신 메커니즘이다. 각 채널은 채널의 요소 타입이라는 특정 타입 값의 통로다.
ch := make(chan int) // ch는 'chan int' 타입을 갖는다.
채널은 맵과 마찬가지로 make로 생성된 데이터 구조에 대한 참조다. 채널을 복사하거나 함수의 인자로 전달할 때는 참조를 복사하기 때문에 호출자와 피호출자는 같은 데이터 구조를 참조한다. 다른 참조 타입과 마찬가지로 채널의 제로 값은 nil이다. 같은 타입의 두 채널은 ==로 비교할 수 있고, nil과도 비교할 수 있다. 채널에는 합쳐서 통신이라 부르는 두 개의 주요 작업인 송신과 수신이 있다. 송신 구문은 한 고루틴에서 채널을 통해 그에 대응하는 수신 표현식이 있는 다른 고루틴으로 값을 전달한다. 두 작업 모두 아래와 같이 <- 연산자로 작성한다.
ch <- x // 송신 구문
x = <-ch //할당문 안의 수신 표현식
<-ch // 수신 구문; 결과는 버려짐
close(ch)
버퍼 없는 채널
버퍼 없는 채널에 대한 송신 작업은 동일 채널상에서 이에 대응하는 고루틴이 값의 수신을 완료해 두 고루틴이 재개할 수 있을 때까지 보내는 고루틴을 중단시킨다. 반대로 수신 작업이 먼저 시도됐다면 수신하는 고루틴은 동일 채널상의 다른 고루틴이 송신을 수행할 때까지 중단된다. 버퍼 없는 채널에서의 통신은 송신과 수신 고루틴이 동기화되게 한다. 이 때문에 버퍼 없는 채널은 동기 채널이라고도 한다.
func main() {
conn, err := net.Dial("tcp", "localhost:8000")
if err != nil {
log.Fatal(err)
}
done := make(chan struct{})
go func() {
io.Copy(os.Stdout, conn)
log.Println("done")
done <- struct{}{}
}
mustCopy(conn, os.Stdin)
conn.Close()
<-done
}
위의 코드에서 이벤트에 추가적인 정보가 없고 그 유일한 목적이 동기화일 때는 요소 타입이 struct{}인 채널을 사용해 이 부분을 강조하지만, 그 목적에는 done <- 1이 done <- stuct{}{}보다 간결하므로 보통 bool 채널이나 int 채널을 사용한다.
파이프 라인
채널을 통해 한 고루틴의 출력을 다른 고루틴의 입력으로 연결할 수 있다. 이를 파이프라인이라 한다.
func main() {
naturals := make(chan int)
squares := make(chan int)
go func() {
for x := 0; x < 100; x++ {
naturals <- x
}
close(naturals)
}()
go func() {
for x := range naturals {
squares <- x * x
}
close(squares)
}()
for x := range squares {
fmt.Println(x)
}
}
단방향 채널 타입
채널이 함수의 파라미터로 주어지면 거의 항상 받기 전용이나 보내기 전용으로 사용된다. Go의 타입 시스템은 이러한 의도를 문서화하고 오용을 방지하기 위해 보내기 동작이나 받기 동작 중 한 가지만 노출하는 단방향 채널 타입을 제공한다. 양방향 채널에서 단방향 채널 타입으로의 변환은 어떤 할당문에서든 할 수 있지만 되돌릴 수는 없다.
func counter(out chan<- int) {
for x := 0; x < 100; x++ {
out <- x
}
close(out)
}
func() squarer(out chan<- int, in <-chan int) {
for x := range in {
out <- x * x
}
close(out)
}
func printer(in <-chan int) {
for x := range in {
fmt.Println(x)
}
}
func main() {
naturals := make(chan int)
squares := make(chan int)
go counter(naturals)
go squarer(squares, naturals)
printer(squares)
}
버퍼 채널
버퍼 채널은 요소의 큐를 갖고 있다. 이 큐의 최대 크기는 make로 만들 때 용량 인자에 따라 결정된다. 버퍼 채널에서 송신 작업은 큐의 뒤쪽으로 요소를 삽입하고, 수신 작업은 큐의 앞쪽에서 요소를 제거한다. 채널이 가득 찬 경우 송신 작업은 다른 고루틴의 수신 작업으로 공간이 생길 때까지 대기한다. 반대로 채널이 비어있는 경우의 수신 작업은 다른 고루틴에서 값이 송신될 때까지 대기한다. 버퍼되지 않은 채널에서는 모든 송신 작업이 수신 작업과 동기화 되므로 더 강력한 동기화를 보장한다. 버퍼 채널에서는 두 작업이 분리된다. 버퍼 용량을 충분히 할당하지 못한 경우 프로그램이 교착 상태에 빠지게 된다.
고루틴 유출
병렬 루프
func makeThumbnails(filenames <-chan string) int64 {
sizes := make(chan int64)
var wg sync.WaitGroup
for f := range filename {
wg.Add(1)
go func(f string) {
defer wg.Done() // wg.Add(-1)
thumb, err := thumbnail.ImageFile(f)
if err != nil {
log.Println(err)
return
}
info, _ := os.Stat(thumb)
sizes <- info.Size()
}(f)
}
go func() {
wg.Wait()
close(sizes)
}()
var total int64
for size := range sizes {
total += size
}
return total
}
select를 통한 다중화
func main() {
fmt.Println("Commencing countdown. Press return to abort.")
abort := make(chan struct{})
go func() {
os.Stdin.Read([]byte, 1))
abort <- struct{}{}
}()
tick := time.Tick(1 * time.Second)
for countdown := 10; countdown > 0; countdown-- {
fmt.Println(countdown)
select {
case <-tick: //nothing
case <-abort:
fmt.Println("Launch aborted!")
return
}
}
launch()
}
고루틴 취소
때로는 고루틴이 현재 수행 중인 작업을 중지하게 지시할 필요가 있다. 취소의 경우에는 채널에 이벤트를 브로드캐스트해 여러 고루틴에서 취소 이벤트가 발생한 것을 볼 수 있고 나중에도 이벤트가 발생했었다는 것을 볼 수 있는 안정적인 메커니즘이 필요하다.
경쟁 상태
하나의 고루틴으로 구성된 순차 프로그램에서의 실행 순서는 잘 알려진 대로 프로그램 로직에 의해 결정된다. 하지만 두 개 이상의 고루틴이 있는 프로그램에서 각 고루틴 안의 실행은 잘 알려진 순서를 따르지만 일반적으로 한 고루틴 안의 이벤트가 다른 고루틴 안의 이벤트보다 먼저일지 나중일지, 혹은 동시에 일어날지에 대해서는 알 수 없다. 한 이벤트가 다른 이벤트보다 먼저 일어난다는 확신이 없을 때 동시에 일어난다고 한다. 경쟁 상태는 프로그램이 여러 고루틴의 작업 간 간섭으로 인해 올바른 결과를 반환하지 못하는 상태를 말한다. 경쟁 상태는 프로그램에 숨어 있다가 높은 부하, 특정 컴파일러, 플랫폼, 아키텍처 등의 특별한 경우에만 가끔 나타나기 때문에 매우 위험하다. 데이터 경쟁은 두 개 이상의 고루틴이 같은 변수를 동시에 접근하고, 그 중 최소 한개의 접근에서 변수를 갱신할 때 일어난다.
상호 배제: sync.Mutex
import "sync"
var (
mu sync.Mutex
balance int
)
func Deposit(amount int) {
mu.Lock()
balance = balance + amount
mu.Unlock()
}
func Balance() int {
mu.Lock()
b := balance
mu.Unlock()
return b
}
읽기/쓰기 뮤텍스: sync.RWMutex
var mu sync.RWMutex
var balance int
func Balance() int {
mu.RLock()
defer mu.RUnlock()
return balance
}
게으른 초기화: sync.Once
var loadIconsOnce sync.Once
var icons map[string]image.Image
func Icon(name string) image.Image {
loadIconOnce.Do(loadIcons)
return icons[name]
}
경쟁 상태 검출
go build -race
go run -race
go test -race
컴파일러가 실행 중 공유 변수에 대한 모든 접근을 해당 변수를 읽거나 쓰는 고루틴의 식별자와 함께 효과적으로 기록하게 수정한 버전의 애플리케이션을 빌드하거나 부가적인 코드로 테스트한다. 또한, 수정된 프로그램은 go 구문, 채널 연산, (*sync.Mutex).Lock, (*sync.WaitGroup).Wait 등의 모든 동기화 이벤트를 기록한다.
고루틴과 스레드
가변 스택
각 OS의 스레드는 고정 크기의 메모리 블록을 현재 진행 중인 함수 호출의 지역 변수를 저장하거나 다른 함수가 호출될 때 일시적으로 중지시키기 위한 작업 영역인 스택으로 사용한다. 반면 고루틴은 일반적으로 2KB의 작은 스택으로 시작하여 필요한 만큼 늘어나고 줄어든다.
고루틴 스케줄링
OS 스레드는 OS 커널에 의해 스케줄된다. 매 밀리초마다 하드웨어 타이머가 프로세서를 인터셉트해 커널 함수 scheduler가 호출되게 한다. 이 기능은 현재 실행 중인 스레드를 일시적으로 중단하고 메모리의 레지스터를 저장한 후 스레드 목록을 조회해 다음에 수행할 스레드를 결정하고, 메모리에서 해당 스레드의 레지스터를 복원한 후 복원된 스레드의 수행을 재개한다. Go 런타임은 n개의 OS 스레드에 있는 m개의 고루틴을 다중화하는 m:n 스케줄링 기법의 자체 스케줄러를 포함하고 있다. Go 스케줄러의 역할은 커널 스케줄러와 유사하지만 단일 Go 프로그램의 고루틴에 국한된다. Go 스케줄러는 운영체제의 스레드 스케줄러와는 다르게 하드웨어 타이머에 의해 주기적으로 호출되지 않고 특정한 Go 언어 기반에 의해 묵시적으로 호출된다.
GOMAXPROCS
Go 스케줄러는 GOMAXPROCS 파라미터를 사용해 동시에 얼마나 많은 OS 스레드에서 Go 코드를 수행할지 결정한다. 기본 값은 시스템의 CPU 개수이다.
고루틴에는 식별자가 없다
'Go' 카테고리의 다른 글
[Design Pattern] 추상 팩토리(Abstract Factory), 프로토타입(Prototype) 패턴 (0) | 2022.11.23 |
---|---|
[Design Pattern] 싱글톤(Singleton), 빌더(Builder), 팩토리 메소드(Factory Method) 패턴 (0) | 2022.11.21 |
Go의 메소드와 인터페이스 (0) | 2022.01.07 |
Go의 복합 타입과 함수 (0) | 2021.12.16 |
Go 프로그램의 기본 구성 요소 (0) | 2021.12.13 |