Tucker의 Go 언어 프로그래밍 책과 유튜브를 통해 학습 중입니다.
스레드(Thread)
- 프로세스 안의 세부 작업 단위
- 프로세스는 스레드를 한 개 이상 가지고 있음.
- 스레드가 하나면 싱글 스레드 프로세스, 여럿이면 멀티 스레드 프로세스라고 함.
→ 초기 컴퓨터에서 사용한 천공카드 다발을 스레드라고 부를 수 있고, 명령어가 적힌 종이 다발을 CPU가 한 줄씩 읽어서 수행하는게 컴퓨터
CPU 코어는 한 번에 하나의 명령어 다발 = 스레드를 수행할 수 있음.
- 원래 CPU 코어는 한 번에 한 명령어 밖에 수행할 수 없음
- 스레드가 CPU 코어를 빠르게 교대로 점유하면 동시에 모든 스레드가 실행되는 것 처럼 보임.
* 컨텍스트 스위칭(Context Switching) 비용
- CPU 코어가 여러 스레드를 전환하면서 수행하면 더 많은 비용(=성능)이 들어가는데 이를 컨텍스트 스위칭 비용이라고 함.
- 스레드를 전환하려면 현재 상태를 보관해야 다시 스레드가 전환되어 돌아올 때 마지막 실행한 상태부터 이어서 실행할 수 있음.
- 스레드의 명령 포인터(Instruction Pointer), 스택 메모리 등의 정보를 저장하는데 이를 스레드 컨텍스트(Thread Context) 라고 함.
→ 스레드가 전환될 때마다 스레드 컨텍스트를 저장하고 복원하기 때문에 스레드 전환 비용(=성능 저하)이 들게 됨.
= 적정 개수를 넘어 한 번에 많은 스레드를 수행하면 성능 저하됨(보통 코어 개수의 두 배 이상 드레드를 만드는 경우)
고루틴(Go Routine)
- 모든 프로그램은 고루틴을 최소한 하나는 가지고 있는데 바로 메인 루틴(main())
- 고루틴은 main() 함수와 함께 시작되고, main() 함수가 종료되면 종료 됨.
= 메인 루틴이 종료되면 프로그램도 종료 됨.
go 함수_호출
→ 새로운 고루틴을 생성할 때 위와 같은 구문을 사용함.
package main
import (
"fmt"
"time"
)
func PrintHangul() {
hanguls := []rune{'가', '나', '다', '라', '마', '바', '사'}
for _, v := range hanguls {
time.Sleep(300 * time.Millisecond) // 1sec = 1000 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초간 대기
}
// 결과
가 1 나 2 다 라 3 마 4 바 5 사 // 하나씩 출력됨
- Go 키워드를 사용해 고루틴을 생성하고, PringHangul()과 PrintNumbers()함수는 각기 다른 새로운 고루틴에서 실행되어 동시에 실행됨.
= 코어 개수가 3개 이상이 되지 않으면 이 세 고루틴을 동시에 실행시킬 코어가 부족하여 동시에 실행되지 않지만, 동시에 실행되는 거처럼 보임.
- PrintHangul()은 300ms 간격으로 가부터 사까지 출력하고, PrintNumbers()는 400ms 간격으로 1부터 5까지 출력
main() 함수는 총 3초간 대기하게 됨.
- 메인 루틴에서 PrintHangul()과 PrintNumbers()가 완료될 때까지 3초간 대기함.
→ main()에서 3초간 대기하지 않는 경우, 메인 함수가 종료되면 많은 고루틴이 생성되어있어도 모두 즉시 종료되고, 프로그램이 종료 됨.
= 프로그램은 메인 함수에서 시작해서 메인 함수에서 끝이 남.
* 서브 고루틴이 종료될 때까지 기다리기
- 예제는 PringHangul()과 PrintNumbers() 함수가 걸리는 시간을 알기 때문에 main() 고루틴이 3초를 대기하는 것으로 모든 실행을 보장.
- 고루틴이 종료될 때까지 대기하기 위해서는 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() // 작업이 완료됨을 표시
}
func main() {
wg.Add(10) // 총 작업 개수 설정
for i := 0; i < 10; i++ {
go SumAtoB(1, 1000000000)
}
wg.Wait() // 모든 작업이 완료되길 기다림
fmt.Println("모든 계산이 완료됐습니다.")
}
// 출력
1부터 1000000000까지 합계는 500000000500000000입니다.
1부터 1000000000까지 합계는 500000000500000000입니다.
1부터 1000000000까지 합계는 500000000500000000입니다.
1부터 1000000000까지 합계는 500000000500000000입니다.
1부터 1000000000까지 합계는 500000000500000000입니다.
1부터 1000000000까지 합계는 500000000500000000입니다.
1부터 1000000000까지 합계는 500000000500000000입니다.
1부터 1000000000까지 합계는 500000000500000000입니다.
1부터 1000000000까지 합계는 500000000500000000입니다.
1부터 1000000000까지 합계는 500000000500000000입니다.
모든 계산이 완료됐습니다.
- main() 함수와 SumAtoB() 함수 양쪽에서 사용할 패키지 전역 변수로 waitGroup 객체를 만듦.
- 작업 시작 전에 총 작업 개수를 설정 = 10개로 설정
- 각 루틴에서 SumAtoB()함수를 완료할 때 wg.Done()을 호출되어 wg의 남은 작업 개수를 1씩 감소함.
- 남은 작업 개수가 0이 되는 순간 Wait() 메서드가 끝나고 다음 줄로 넘어가게 됨.
- 위 예제는 총 10개의 고 루틴을 생성하기 때문에 실행하면 순간적으로 많은 연산이 수행되어 CPU 코어가 바빠짐.
+ 처음에 한 번만 돌리고 연달아 5-6번 실행하니 core util이 거의 60%이상까지 증가한 것을 볼 수 있음.
고루틴 동작 방법
- 고루틴은 명령을 수행하는 단일 흐름으로 OS 스레드*를 이용하는 경량 스레드(Lightweight thread)임.
* OS 스레드: 운영체제가 제공하는 스레드를 의미
* 고루틴이 하나일 때
- 모든 명령은 IS 스레드를 통해 CPU 코어에서 실행됨.
- Go로 만든 프로그램도 OS 위에서 돌아가기 때문에 명령을 수행하려면 OS 스레드를 만들어서 명령을 실행해야 함.
- main() 루틴만 존재하면 OS스레드를 하나 만들어 첫 번째 코어와 연결하고, OS 스레드에서 고루틴을 실행하게 됨.
* 고루틴이 두 개일 때
- 두 번째 고루틴이 생성되고, 두 번째 코어가 남아있으면 두 번째 OS 스레드를 생성하여 두 번째 고루틴을 실행할 수 있음.
= 기존에 실행 중이던 첫 번째 고루틴과 두 번째 고루틴이 동시에 실행되고 있음.
* 코어가 2개, 고루틴 3개일 때
- 첫 번째, 두 번째 코어는 각각 고루틴을 실행 중이라 남는 코어가 없어 남는 코어가 생길 때까지 대기함.
= 즉, 세 번째 고루틴은 남은 코어가 생길때 까지 실행되지 않고 멈춰있음.
- 만약, 두 번째 고루틴이 모두 실행 완료되면, 고루틴 2는 사라지게 되고 코어 2가 비게 됨.
→ 이때 대기하던 고루틴 3이 실행 됨!
* 시스템 콜 호출 시
- 시스템 콜이란, 운영체제가 지원하는 서비스를 호출할 때를 말함. ex) 네트워크 기능 등
- 시스템 콜을 호출하면 운영체제에서 해당 서비스가 완료될 때까지 대기해야 함.
ex) 네트워크로 데이터를 읽을 때는 데이터가 들어올 때까지 대기 상태가 됨.
- Go언어에서는 시스템 콜을 하면, 실행을 기다리던 고루틴과 동작하던 루틴을 스위칭하여 실행을 기다리던 고루틴을 CPU 코어와 OS 스레드에
할당하여 실행될 수 있게 함.
* 시스템 콜: 프로그램에서 OS에 읽고/쓰기 등을 요청하는 것(시스템 콜이 일어나면 OS가 끝났다고 알려줄 때까지 프로그램이 대기함.)
✓ 고루틴 동작 원리의 장점은 컨텍스트 스위칭 비용이 발생하지 않는 점!
- 컨텍스트 스위칭은 CPU 코어가 스레드를 변경할 때 발생하는데, 고루틴을 이용하면 코어와 스레드는 변경되지 않고(OS단),
오직 고루틴만 옮겨다니기 때문(고루틴이 교체되는 것도 컨텍스트 스위칭임)
= 코어가 스레드를 변경하지 않기 때문에 컨텍스트 스위칭 비용이 발생하지 않음.
- 고루틴을 교체하는 컨텍스트 스위칭 스택 사이즈가 OS 스택 사이즈보다 작음(경량).
- 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원 출금
}
// 결과
panic: Balance should not be negative value: -1000
goroutine 7 [running]:
...
→ 여러 고루틴에서 통장에 동시에 접근해 1000원을 입금하고, 다시 1000원을 출금해 동시성 문제가 발생하는 예제임.
- 먼저 잔고가 0원인 통장을 만들어 WaitGroup 객체를 생성하고, 함수 리터럴 고루틴 10개를 만듦.
- 각 고루틴은 DepositAndWithdraw() 함수를 무한히 호출함. = 생성된 고루틴은 입금과 출금을 무한 반복하게 됨.
- DepositAndWithdraw() 함수는 먼저 잔고가 0원 미만인지 체크하고, 잔고가 0 이상이면 1000원을 입금하고 1ms 쉬고,
1000원을 출금함.
⇢ 1000원을 입금하고 출금하기 때문에 잔고는 절대 0원 미만으로 내려가서는 안됨.
하지만, 예제 실행 후 시간이 지나면 잔고가 0원이 되며 패닉이 발생하여 프로그램 종료!
- 문제 원인은 account.Balance += 1000 코드에 있고, 해당 코드는 먼저 Balance 값을 읽고 1000을 더해 Balance에 다시
저장하는 두 단계로 이뤄짐.
→ 첫 번째 단계가 완료되기 전에 다른 고루틴이 첫 번째 단계를 수행하면 두 고루틴은 똑같은 값을 읽어 1000씩 더해 다시 Balance에 저장함.
= 고루틴 2개가 각각 입금을 했는데 한 번 입금한 효과밖에 나지 않게 되어 이 상태애서 출금이 각각 이루어지면 잔고가 -가 됨.
뮤텍스를 이용한 동시성 문제 해결
- 가장 단순한 방법은 한 고루틴에서 값을 변경할 때 다른 고루틴이 건들지 못하게 하는 것!
= 뮤텍스(Mutex)를 이용하면 자원 접근 권한을 통제할 수 있음.
- 뮤텍스는 Mutual Exclusion의 약자로(상호배제), 이것의 역할은 자원 접근 권한이라고 볼 수 있음.
- 뮤텍스 동작 방식은 뮤텍스의 Lock() 메서드를 호출해 뮤텍스를 획득할 수 있음.
- 이미 Lock() 메서드를 호출해 다른 고루틴이 뮤텍스를 득했다면, 나중에 호출한 고루틴은 앞 고루틴이 뮤텍스 반납할 때까지 대기함.
사용 중이던 뮤텍스는 Unlock() 메서드를 호출해 반납하고, 이후 대기하던 고루틴 중 하나가 뮤텍스를 획득하게 됨.
* 예제를 듣고 생각해보니, 어렸을 때 의자 하나를 두고 빙글빙글 돌다가 의자를 먼저 차지한 사람이 앉고,
또 다시 빙글빙글 돌다가 의자를 차지한 사람이 또 의자에 앉고 이런식이라고 생각하면 될 거 같다고 느꼈다!
(tucker님이 들어주신 예시가 더 정확하겠지만...)
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
time.Sleep(time.Millisecond)
account.Balance -= 1000
}
// 결과
출력 결과 없음.
프로그램 종료를 위해 ctrl+c로 종료
- 패키지 전역 변수로 뮤텍스를 만들고, DeposiAndWithdraw() 함수에서 mutex.Lock() 메서드를 호출해 뮤텍스를 획득함.
= 만약 다른 고루틴이 이미 뮤텍스를 획득했다면(한 개만 획득), 뮤텍스를 놓을 때까지 기다리게 됨(나머지 고루틴 대기).
- defer를 사용해서 함수 종료 전에 뮤텍스 Unlock() 메서드가 호출될 수 있도록 보장
★ 한 번 획득한 뮤텍스는 반드시 Unlock()을 호출해서 반납함!! ★
- 뮤텍스는 동시에 고루틴 하나만 확보할 수 있고, mutex.Lock() 메서드를 먼저 차지한 고루틴만 잔고를 수정할 수 있음.
- 따라서, 잔고는 절대 0 미만으로 내려가지 않음!
뮤텍스의 문제점
1. 동시성 프로그래밍으로 인한 성능 향상을 얻을 수 없다. 심지어 과도한 락킹으로 성능이 하락되기도 함.
→ 여러 개의 고루틴이 동시에 (여러 작업)실행되면, 성능이 오르는데 Lock을 하면 제한된 성능이 되기 때문.
- Lock 을 획득하고 반납하는 과정도 성능을 사용함.
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, "포크", "수저") // A는 포크 먼저
go diningProblem("B", spoon, fork, "수저", "포크") // B는 수저 먼저
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 수저 획득
B 포크 획득
B 밥을 먹습니다.
A 밥을 먹으려 합니다.
A 포크 획득
A 수저 획득
A 밥을 먹습니다.
B 밥을 먹으려 합니다.
A 밥을 먹으려 합니다.
A 포크 획득
B 수저 획득
fatal error: all goroutines are asleep - deadlock!
...
- 포크와 수저 뮤텍스를 만들고, A는 포크 먼저, B는 수저 먼저 들게 구성
- diningProblem() 함수에서는 첫 번째 뮤텍스와 두 번재 뮤텍스를 모두 획득한 경우에 밥을 먹고 두 뮤텍스를 반납함.
- 예제 실행 시, A는 포크를 획득하고, B는 수저를 획득했지만 서로 두 번째 뮤텍스를 획득하지 못해 무한히 대기하게 되고,
Go 언어에서는 데드락을 감지하고 에러를 반환하게 됨.
→ 아래와 같이 수정하면, 데드락이 발생하지 않고 수행이 잘 됨.
// AS-IS
go diningProblem("A", fork, spoon, "포크", "수저")
go diningProblem("B", spoon, fork, "수저", "포크")
// TO-BE
go diningProblem("A", spoon, fork, "수저", "포크")
go diningProblem("B", spoon, fork, "수저", "포크")
✓ 실제 프로그래밍에서는 뮤텍스들이 복잡하게 꼬여있어서 단순히 순서만 변경해서는 해결할 수 없는 경우가 있음.
그래서 데드락 문제는 동시성 프로그래밍에서 해결하기 힘든 난제임...🥲
!!!! 뮤텍스는 매우 조심히 사용해야 한다 !!!!
또 다른 자원관리 기법
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()
}
// 결과
9 작업 시작
2 작업 시작
0 작업 시작
1 작업 시작
6 작업 시작
5 작업 시작
4 작업 시작
3 작업 시작
8 작업 시작
7 작업 시작
9 작업 완료 - 결과: 81
4 작업 완료 - 결과: 16
0 작업 완료 - 결과: 0
1 작업 완료 - 결과: 1
6 작업 완료 - 결과: 36
7 작업 완료 - 결과: 49
3 작업 완료 - 결과: 9
8 작업 완료 - 결과: 64
2 작업 완료 - 결과: 4
5 작업 완료 - 결과: 25
- 각 작업을 나타내는 Job 인터페이스를 정의하고, Job 인터페이스는 Do() 메서드만 가지고 있음.
- Job 인터페이스를 구현한 SquareJob 구조체를 만들고, 단순하게 1초 대기 후 제곱값을 표시하지만,
실제 프로그램에서는 파일 읽기나 복잡한 계산 등 구체적인 작업이 될 수 있음.
- 처음에 각 10가지 작업을 배열에 할당하고, 각 작업을 고루틴으로 실행함.
각 고루틴은 할당된 작업만 하므로 고루틴 간 간섭이 발생하지 않음 = 뮤텍스 필요 없음.
두 번째 방법인 역할을 나누는 방법은 다음 장에서 채널과 함께~~~~👋🏻
'Language > Golang' 카테고리의 다른 글
Golang (Go언어) 채널과 컨텍스트(Channel & Context) (0) | 2024.12.11 |
---|---|
Golang (Go언어) 에러 핸들링 (2) | 2024.12.05 |
Golang (Go언어) 자료 구조(Data Structure) 2/2 (0) | 2024.12.03 |
Golang (Go언어) 자료 구조(Data Structure) 1/2 (1) | 2024.12.02 |
Golang (Go언어) 함수고급편 (1) | 2024.11.29 |