golang 기초 -고루틴

한나리·2025년 1월 16일

Go

목록 보기
13/19
post-thumbnail

고루틴

고루틴은 GO언어에서 관리하는 경량 스레드.
함수나 명령을 동시에 수행할 때 사용
여러 고루틴을 갖는 프로그램을 코딩하는 것을 동시성 프로그래밍이라고 함
고루틴은 경량 스레드로 함수나 명령을 동시에 실행할 때 사용하는데, main()함수 역시 고루틴에 의해서 실행됨.

고루틴의 효과

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

스레드

스레드는 프로세스 안의 세부 작업 단위
프로세스는 스레드를 한 개 이상 가지고 있으며, 스레드가 하나면 싱글 스레드 프로세스, 여럿이면 멀티 스레드 프로세스

스레드는 실행 흐름
초기 컴퓨터에서 사용한 천공카드 다발을 스레드로 볼 수 있음. 이 명령어가 적힌 종이 다발을 CPU가 한 줄씩 읽어서 수행하는 게 컴퓨터이고 CPU코어는 한 번에 하나의 명령어 다발 즉 스레드를 수행

원래 CPU 코어는 한 번에 한 명령밖에 수행 못함
그렇지만 싱글코어 CPU에서도 여러 프로그램을 한꺼번에 실행할 수 있음. CPU코어가 스레드를 빠르게 전환해가면서 수행하기 때문에 사용자 입장에서는 마치 동시에 수행하는 것 처럼 보임
스레드를 빠르게 전환하는 것 → 컨텍스트 스위칭

컨텍스트 스위칭와 컨텍스트 스위칭 비용

CPU 코어가 여러 스레드를 전환하면서 수행하는 것을 컨텍스트 스위칭이라고 하고 이때 드는 비용을 컨텍스트 스위칭 비용이라고 함.

스레드를 전환하려면 현재 상태를 보관해야 하고, 다시 스레드가 전환되어 돌아올 때 마지막 실행한 상태부터 이어서 수행하는데, 이때 스레드의 명령 포인터, 스택 메모리 등의 정보를 저장하게됨. 이를 스레드 컨텍스트 라고 함.

스레드가 전환될 때마다 스레드 컨텍스트를 저장하고 복원하기 때문에 스레드 전환 비용이 들게됨. 그래서 적정 개수를 넘어 한 번에 너무 많은 스레드를 수행하면 성능이 저하됨. (보통 코어 개수의 두 배 이상 스레드를 만들면 스위칭 비용이 많이 발생한다고 말함)
하지만, ‼️GO 언어에서는 CPU 코어마다 OS 스레드를 하나만 할당해서 사용하기 때문에 컨텍스트 스위칭 비용이 발생하지 않음‼️

고루틴 사용

모든 프로그램은 고루틴을 최소 하나는 가지고 있음 (메인 루틴)
이 고루틴은 main() 함수와 함께 시작되고, main() 함수가 종료되면 종료됨.
또, 메인 루틴이 종료되면 프로그램 또한 종료

고루틴을 추가로 생성하는 구문

go 함수_호출

go 키워드를 쓰고 함수를 호출하면 해당 함수를 수행하는 새로운 고루틴을 생성.
호출된 함수는 현재 고루틴이 아닌 새로운 고루틴에서 수행

package main

import (
	"fmt"
	"time"
)

func PrintHangul() {
	hangul := []rune{'가', '나', '다', '라', '마', '바', '사'}
	for _, v := range hangul {
		time.Sleep(300 * time.Millisecond)
		fmt.Printf("%c ", v)
	}
}

func PrintNumbers() {
	for i := 1; i <= 5; i++ {
		time.Sleep(400 * time.Millisecond)
		fmt.Printf("%d ", i)
	}
}

func main() { //메인 고루틴 생성
	go PrintHangul() //새로운 고루틴 생성
	go PrintNumbers()

	time.Sleep(3 * time.Second) // 3초간 대기
}

//결과123 라 마 45

위 예시 같은 경우 고루틴이 3개가 동시에 실행되어야 하는데, 코어 개수가 3개 이상이 되지 않으면 3개의 고루틴을 동시에 실행시킬 코어가 부족해서 동시에 실행되지는 않지만 동시에 실행이 되는 것 처럼 보임
PrintHangul()은 300ms 간격으로 '가'부터 '사'까지 출력하고 PrintNumbers()은 400ms 간격으로 1부터 5까지 출력함. main() 함수는 총 3초간 대기

서브 고루틴이 종료될 때까지 대기

위 예제는 PrintHangul()과 PrintNumbers() 함수가 걸리는 시간을 알기 때문에 main() 고루틴이 3초를 대기하는 것으로 모든 실행을 보장.

그러나 늘 함수 실행 시간을 알 수 있는 것은 아니기때문에 sync 패키지를 이용함

" sync 패키지의 WaitGroup "

var wg sync.WaitGroup

wg.Add(3) //작업 개수 설정
wg.Done() // 작업이 완료될 때 마다 호출
wg.Wait() // 모든 작업이 완료될 때까지 대기

Add() 메서드를 통해 완료해야 하는 작업 개수를 설정하고 각 작업이 완료될 때 마다 Done() 메서드를 호출하여 남은 작업 개수를 하나씩 줄여줌
Wait()은 전체 작업이 모두 완료될 때 까지 대기

package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup //WaitGroup 객체

func SumAtoB(a, b int) {
	sum := 0
	for i := a; i <= b; i++ {
		sum += i
	}

	fmt.Printf("%d부터 %d까지 합계는 %d 입니다. \n", a, b, sum)
	wg.Done() //작업이 완료됨을 표시. wg의 남은 작업 개수를 1씩 감소시킴

}

func main() {
	wg.Add(10) //총 작업 개수 설정
	for i := 0; i < 10; i++ {
		go SumAtoB(1, 1000000000)
	}

	wg.Wait() //모든 작업이 완료되길 기다림
	fmt.Println("모든 계산 완료")
}

main()함수와 SumAtoB()함수 양쪽에서 사용할 패키지 전역 변수로 waitGroup객체를 만들고, 작업을 시작하기에 앞서 총 작업 개수를 설정

고루틴을 10개 만들거라서 10을 설정하고, 각 루틴에서 SumAtoB()함수를 완료할 때 wg.Done()을 호출해서 wg의 남은 작업 개수를 1씩 감소

wg.Wait()을 하면 모든 작업이 완료될 때까지 종료하지 않고 대기

남은 작업 개수가 0이 되는 순간 Wait() 메서드가 끝나고 다음 줄로 넘어가게 됨.
(이 때, 고루틴을 9개 만들면 for문이 10까지 되어 있어도 9번 실행 후 Wait() 메서드 종료)

고루틴 동작 방법

고루틴은 명령을 수행하는 단일 흐름으로 OS스레드(운영체제가 제공하는 스레드)를 이용하는 경량 스레드임.
GO 언어에서는 CPU코어, OS스레드, 고루틴을 서로 조율하여 사용해 고루틴을 효율적으로 다룸

아래의 예시는 2개의 코어를 가진 컴퓨터에서 고루틴이 어떻게 동작하는지 설명

고루틴이 하나일 때

모든 명령은 OS 스레드를 통해서 CPU 코어에서 실행
GO로 만든 프로그램 역시 OS 위에서 돌아가기 때문에 명령을 수행하려면 OS 스레드를 만들어서 명령을 실행해야 함

main()루틴만 존재하면 OS 스레드를 하나 만들어 첫 번째 코어와 연결. 그리고 OS 스레드에서 고루틴을 실행

고루틴이 두 개일 때

두 번째 고루틴이 생성되고 첫 번째 코어가 첫 번째 고루틴을 실행하고 있지만, 두 번째 코어가 남아 있기 때문에 두 번째 OS 스레드를 생성하여 두 번째 고루틴을 실행

고루틴이 세 개일 때

첫 번째 코어에서 첫 번째 고루틴을 실행, 두 번째 코어에서 두 번째 고루틴을 실행중이기 때문에 남는 코어가 없음. 그래서 세 번째 고루틴용 스레드를 만들지 않고 남는 코어가 생길 때까지 대기.
즉, 세 번째 고루틴은 남는 코어가 생길 때까지 실행되지 않고 멈춰있음

만약 두 번째 고루틴이 모두 실행 완료되면 두 번째 고루틴은 사라지게 되고 두 번째 코어가 비게 되면, 대기하던 세 번째 고루틴이 여기서 실행

시스템 콜 호출 시

시스템 콜
운영체제가 지원하는 서비스를 호출할 경우를 말함. 대표적으로 네트워크 기능등

시스템 콜을 호출하면 운영체제에서 해당 서비스가 완료될 때까지 대기해야 함.
예를 들어 네트워크로 데이터를 읽을 때는 데이터가 들어올 때까지 대기상태

이런 대기 상태인 고루틴에 CPU 코어와 OS 스레드를 할당하면 CPU 자원 낭비가 발생함. 그래서 GO 언어에서는 이런 상태에 들어간 루틴을 대기 상태로 보내고, 실행을 기다리는 다른 루틴에 CPU 코어와 OS 스레드를 할당하여 실행될 수 있게 함

앞의 세 번째 고루틴이 네트워크 수신 대기 상태이고 대기 목록에 네 번째 고루틴이 있다면, 세 번째 고루틴을 대기 목록으로 보내고 네 번째 고루틴이 OS 스레드에서 실행 됨

동작 원리에 대한 장점

컨텍스트 스위칭 비용은 CPU 코어가 스레드를 변경할 때 발생하는데, 고루틴을 이용하면 코어와 스레드는 변경되지 않고 오직 고루틴만 옮겨 다니기 때문에 컨텍스트 스위칭 비용이 발생하지 않음. OS 스레드를 직접 사용하는 다른 언어에서는 스레드 개수가 많아지면 컨텍스트 스위칭 비용이 증가되기 때문에 프로그램 성능이 떨어지지만, GO언어에서는 고루틴이 증가되어도 컨텍스트 스위칭 비용이 발생하지 않기 때문에 수백, 수천 고루틴을 마음껏 만들어서 쓸 수 있음.

동시성 프로그래밍 주의점

동시성 프로그래밍의 문제점은 동일한 메모리 지원에 여러 고루틴이 접근할 때 발생
고루틴은 각 CPU코어에서 별도로 동작하지만 같은 메모리 공간에 동시에 접근해서 값을 변경시킬 수 있음.

  • 같은 자원에 여러 고루틴이 접근해서 동시성 문제를 일으키는 예제 (무한루프 코드)
package main

import (
	"fmt"
	"sync"
	"time"
)

type Account struct {
	Balance int
}

func main() {
	var wg sync.WaitGroup

	account := &Account{0} //잔고 0
	wg.Add(10) //WaitGroup 객체 생성

	for i := 0; i < 10; i++ { //고루틴 10개 생성
		go func() {
			for {
				DepositAndWithdraw(account)
			}
			wg.Done()
		}()
	}
	wg.Wait()
}

func DepositAndWithdraw(account *Account) {
	if account.Balance < 0 { // 잔고가 0 미만이면 패닉
		panic(fmt.Sprintf("Balance should not be negative value: %d", account.Balance))
	}
	account.Balance += 1000      //1000 입금
	time.Sleep(time.Millisecond) // 잠시 쉬고
	account.Balance -= 1000      // 1000 인출
}

여러 고루틴에서 통장에 동시에 접근해 1000을 입금하고 다시 1000을 출금해 동시성 문제가 발생하는 코드이다.

함수 리터럴 고루틴 10개가 생성되고, 각 고루틴은 DepositAndWithdraw() 함수를 무한히 호출.
즉, 생성된 고루틴은 입금과 출금을 무한 반복

DepositAndWithdraw() 함수는 먼저 잔고가 0원 미만인지 체크하고 잔고가 0 이상이면 1000을 입금, 1ms 쉬고 1000을 출금

1000을 입금하고 1000을 출금하기 때문에 잔고는 절대 0 미안으로 내려가서는 안됨. 하지만 이 예제를 실행하고 시간이 지나면 잔고가 0원이 되면서 패닉이 발생하여 프로그램이 종료

문제의 원인은 "account.Balance += 1000"
이 코드는 먼저 Balance값을 읽고 1000을 더해서 Balance에 다시 저장하는 두단계로 이루어져 있는데, 이때 첫 번째 단계가 완료되기 전에 다른 고루틴이 첫 번째 단계를 수행하면 두 고루틴은 똑같은 값을 읽어서 1000씩 더해 다시 Balance에 저장.

즉, 고루틴 2개가 각각 입금을 했는데 한 번 입금한 효과밖에 나지 않게됨. 이 상태에서 출금이 각각 이루어지면 잔고가 -가 되므로 패닉에 걸림

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

동시성 문제를 해결하기 위한 가장 단순한 해결 방법은 "한 고루틴에서 값을 변경할 때 다른 고루틴이 건들지 못하게 하는 것"

뮤텍스는 mutual exclusion의 약자이며 우리말로 상호 배제로 직역
역할은 자원 접근 권한
자원 접근 권한을 얻은 고루틴이 실행될 동안, 다른 고루틴은 실행되지 않고, 실행된 고루틴의 작업이 끝날 경우 자원 접근 권한을 반납하는 것

뮤텍스의 Lock() 메서드를 호출해 뮤텍스를 획득할 수 있음
이미 Lock() 메서드를 호출해서 다른 고루틴이 뮤텍스를 획득했다면 나중에 호출한 고루틴은 앞서 획득한 뮤텍스가 반납될 때까지 대기.
사용중이던 뮤텍스는 Unlock() 메서드를 호출해서 반납하고, 이후 대기하던 고루틴 중 하나가 뮤텍스를 획득

  • 뮤텍스를 이용해 위의 동시성 문제를 일으키는 코드를 수정
package main

import (
	"fmt"
	"sync"
	"time"
)

var mutex sync.Mutex //패키지 전역 변수 뮤텍스

type Account struct {
	Balance int
}

func main() {
	var wg sync.WaitGroup

	account := &Account{0}
	wg.Add(10)

	for i := 0; i < 10; i++ {
		go func() {
			for {
				DepositAndWithdraw(account)
			}
			wg.Done()
		}()
	}
	wg.Wait()
}

func DepositAndWithdraw(account *Account) {
	mutex.Lock()         // 뮤텍스 획득
	defer mutex.Unlock() // defer를 사용한 Unlock()

	if account.Balance < 0 {
		panic(fmt.Sprintf("Balance should not be negative value: %d", account.Balance))
	}
	account.Balance += 1000 //1000 입금
	time.Sleep(time.Millisecond) // 잠시 쉬고
	account.Balance -= 1000      // 1000 인출
}

이번 예제는 출력 결과가 없다. 패닉이 발생되지 않는다. 잔고가 절대 0 미만으로 떨어지지 않기 때문

DepositAndWithdraw() 함수에서 mutex.Lock() 메서드를 호출해서 뮤텍스를 호출. 만약 다른 고루틴이 이미 뮤텍스를 획득했다면 뮤텍스를 놓을 때까지 대기

defer를 사용해서 함수 종료 전에 뮤텍스 Unlock() 메서드가 호출될 수 있도록 보장.

한 번 획득한 뮤텍스는 반드시 Unlock()을 호출해서 반납해야 함

func DepositAndWithdraw(account *Account) {
	mutex.Lock()         // 뮤텍스를 확보할 때까지 대기
	defer mutex.Unlock() // 이하 로직은 뮤텍스를 확보한 단 하나의 고루틴만 실행
	...
}

뮤텍스는 동시에 고루틴 하나만 확보할 수 있음. 따라서 mutex.Lock() 메서드를 먼저 차지한 고루틴만 잔고를 수정. 따라서 잔고는 절대 0 미만으로 내려가지 않음

뮤텍스와 데드락

뮤텍스를 사용하면 동시성 프로그래밍 문제를 해결할 수 있지만, 또 다른 문제가 발생할 수 있음

1️⃣ 동시성 프로그래밍으로 얻는 성능 향상을 얻을 수 없음
: 뮤텍스는 오직 하나의 고루틴만 공유 자원에 접근할 수 있도록 제한. 따라서 여러 고루틴 중 뮤텍스를 획득한 고루틴만 실행. 성능을 향상시키려고 동시성 프로그램을 구현했지만 성능 향상을 얻지 못함

2️⃣ 데드락 발생할 수 있음
: 데드락은 프로그램을 완전히 멈추게 만들어 버리는 문제. 어떤 고루틴은 원하는 만큼 뮤텍스를 확보하지 못해서 무한히 대기하게 되는 경우를 데드락이라고 함

  • 데드락이 발생하는 경우 예제
package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

var wg sync.WaitGroup

func main() {

	rand.Seed(time.Now().UnixNano())

	wg.Add(2)
	fork := &sync.Mutex{}
	spoon := &sync.Mutex{}

	go diningProblem("A", fork, spoon, "포크", "수저")
	go diningProblem("B", spoon, fork, "수저", "포크")

	wg.Wait()
}

func diningProblem(name string, first, second *sync.Mutex, firstName, secondName string) {
	for i := 0; i < 100; i++ {
		fmt.Printf("%s 밥을 먹으려 합니다. \n", name)
		first.Lock()
		fmt.Printf("%s  %s 획득 \n", name, firstName)
		second.Lock()
		fmt.Printf("%s  %s 획득 \n", name, secondName)

		fmt.Printf("%s 밥을 먹습니다. \n", name)
		time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)

		second.Unlock()
		first.Unlock()

	}
	wg.Done()

}

//결과
B 밥을 먹으려 합니다. 
B  수저 획득 
A 밥을 먹으려 합니다. 
B  포크 획득 
B 밥을 먹습니다. 
B 밥을 먹으려 합니다. 
B  수저 획득 
A  포크 획득 
fatal error: all goroutines are asleep - deadlock!

포크와 수저 뮤텍스 두 개를 만듦
A는 포크를 먼저 들고 B는 수저를 먼저 드는 로직

diningProblem() 함수에서 첫 번째 뮤텍스와 두 번째 뮤텍스를 모두 획득한 경우에 밥을 먹고 두 뮤텍스를 반납

위 실행 예를 보면 A는 포크를 획득했고, B는 수저를 획득했지만 서로 두 번째 뮤텍스를 획득하지 못해 무한히 대기하게 되고, GO 언어에서는 데드락을 감지하고 에러를 반환.

지금까지 내용을 정리하면,

  • 멀티코어 컴퓨터에서는 여러 고루틴을 사용하여 성능을 향상시킬 수 있음
  • 하지만 같은 메모리를 여러 고루틴이 접근하면 프로그램이 꼬임 - 동시성 문제
  • 뮤텍스를 이용하면 동시에 고루틴 하나만 접근하도록 조정해 꼬이는 문제를 막을 수 있음
  • 그러나 뮤텍스를 잘못 사용하면 성능 향상도 못하고 데드락에 빠질 수 있음

뮤텍스를 사용할 때는, 뮤텍스가 꼬이지 않도록 좁은 범위에서 데드락에 걸리지 않는지 철저히 확인해서 사용해야 함.
→ google cloud profiler과 같은 프로파일링 툴을 이용해서 뮤텍스 검사를 할 수 있음

또 다른 자원 관리 기법

모든 문제는 같은 자원을 여러 고루틴이 접근하기 때문에 발생
만약 각 고루틴이 같은 자원에 접근하지 않으면 애당초 문제가 발생하지 않음
즉, 여전히 멀티코어의 이점을 얻으면서 뮤텍스를 쓰지 않아도 되어서 뮤텍스로 인한 문제도 발생하지 않게 됨

각 고루틴이 서로 다른 자원에 접근하게 만드는 방법
1️⃣ 영역을 나누는 방법
2️⃣ 역할을 나누는 방법

첫 번째 영역을 나누는 방법

  • 영역을 나누는 예제
package main

import (
	"fmt"
	"sync"
	"time"
)

type Job interface { //Job 인터페이스
	Do()
}

type SquareJob struct {
	index int
}

func (j *SquareJob) Do() {
	fmt.Printf("%d 작업 시작 \n", j.index) // 각 작업
	time.Sleep(1 * time.Second)

	fmt.Printf("%d 작업 완료 - 결과 %d\n", j.index, j.index*j.index)
}

func main() {
	var jobList [10]Job
	for i := 0; i < 10; i++ { //10가지 작업 할당
		jobList[i] = &SquareJob{i}

	}

	var wg sync.WaitGroup
	wg.Add(10)

	for i := 0; i < 10; i++ {
		job := jobList[i]
		go func() { //각 작업을 고루틴으로 실행
			job.Do()
			wg.Done()
		}()
	}
	wg.Wait()
}
  • 각 작업을 나타내는 Job 인터페이스를 정의
  • Job 인터페이스는 Do() 메서드만 가지고 있음
  • Job 인터페이스를 구현할 SquareJob 구조체를 생성
  • 단순 1초 대기 후 제곱값을 표시하지만, 실제 프로그램에서는 파일 읽기나 복작한 계산 등 구체적인 작업이 될 수 있음
  • 처음에 10가지 작업을 배열에 할당
  • 각 작업을 고루틴으로 실행

각 고루틴은 할당된 작업만 하므로 고루틴 간 간섭이 발생하지 않음. 그래서 뮤텍스가 필요하지 않음

예를 들면, 100개 파일을 읽어서 분석할 때는 파일별로 고루틴을 할당해서 수행하면 됨

두 번째 역할을 나누는 방법

채널과 연관지어 다음 챕터에서 후술

profile
내가 떠나기 전까지는 망하지 마라, 블록체인 개발자

0개의 댓글