대표적인 동기화(synchronization) 객체는 다음과 같다
뮤텍스는 여러 고루틴이 공유하는 데이터를 보호할 때 사용하며 sync 패키지에서 제공하는 뮤텍스 구조체와 함수는 다음과 같다
sync.Mutex
func (m *Mutex) Lock(): 뮤텍스 잠금
func (m *Mutex) Unlock(): 뮤텍스 잠금 해제
다음은 고루틴 두 개에서 각각 1,000번씩 슬라이스에 값을 추가
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU()) // 모든 CPU 사용
var data = []int{} // int형 슬라이스 생성
go func() { // 고루틴에서
for i := 0; i < 1000; i++ { // 1000번 반복하면서
data = append(data, 1) // data 슬라이스에 1을 추가
runtime.Gosched() // 다른 고루틴이 CPU를 사용할 수 있도록 양보
}
}()
go func() { // 고루틴에서
for i := 0; i < 1000; i++ { // 1000번 반복하면서
data = append(data, 1) // data 슬라이스에 1을 추가
runtime.Gosched() // 다른 고루틴이 CPU를 사용할 수 있도록 양보
}
}()
time.Sleep(2 * time.Second) // 2초 대기
fmt.Println(len(data)) // data 슬라이스의 길이 출력
}
1883 (매번 달라질 수 있음)
실행을 해보면 대략 1800~1990 사이의 값이 나온다. data 슬라이스에 1을 2,000번 추가했으므로 data의 길이가 2000이 되어야 하는데 그렇지가 않다. 두 고루틴이 경합을 벌이면서 동시에 data에 접근했기 때문에 append 함수가 정확하게 처리되지 않은 상황이다 이러한 상황을 경쟁 조건(Race condition)이라고 한다
runtime.Gosched 함수는 다른 고루틴이 CPU를 사용할 수 있도록 양보(yield)한다. 지금까지 time.Sleep 함수를 사용했지만 runtime.Gosched 함수가 좀 더 명확하다
이제 data 슬라이스를 뮤텍스로 보호한다
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU()) // 모든 CPU 사용
var data = []int{}
var mutex = new(sync.Mutex)
go func() { // 고루틴에서
for i := 0; i < 1000; i++ { // 1000번 반복하면서
mutex.Lock() // 뮤텍스 잠금, data 슬라이스 보호 시작
data = append(data, 1) // data 슬라이스에 1을 추가
mutex.Unlock() // 뮤텍스 잠금 해제, data 슬라이스 보호 종료
runtime.Gosched() // 다른 고루틴이 CPU를 사용할 수 있도록 양보
}
}()
go func() { // 고루틴에서
for i := 0; i < 1000; i++ { // 1000번 반복하면서
mutex.Lock() // 뮤텍스 잠금, data 슬라이스 보호 시작
data = append(data, 1) // data 슬라이스에 1을 추가
mutex.Unlock() // 뮤텍스 잠금 해제, data 슬라이스 보호 종료
runtime.Gosched() // 다른 고루틴이 CPU를 사용할 수 있도록 양보
}
}()
time.Sleep(2 * time.Second) // 2초 대기
fmt.Println(len(data)) // data 슬라이스의 길이 출력
}
여기서는 data 슬라이스를 보호할 것이므로 두 고루틴 모두 data = append(data, 1) 부분 위 아래로 Lock, Unlock 함수를 사용한다. 이제 실행을 해보면 정확히 2000이 출력이된다.
읽기, 쓰기 뮤텍스는 읽기 동작과 쓰기 동작을 나누어 잠금(락)을 걸 수 있다
읽기 락(Read Lock): 읽기 락끼리는 서로를 막지 않는다. 하지만 읽기 시도 중에 값이 바뀌면 안 되므로 쓰기 락은 막는다
쓰기 락(Write Lock): 쓰기 시도 중에 다른 곳에서 이전 값을 읽으면 안 되고, 다른 곳에서 값을 바꾸면 안 되므로 읽기, 쓰기 락 모두 막는다.
sync 패키지에서 제공하는 읽기, 쓰기 뮤텍스 구조체와 함수는 다음과 같다.
sync.RWMutex
func (rw RWMutex) Lock(), func (rw RWMutex) Unlock(): 쓰기 뮤텍스 잠금, 잠금 해제
func (rw RWMutex) RLock(), func (rw RWMutex) RUnlock(): 읽기 뮤텍스 잠금 및 잠금 해제
읽기 쓰기 뮤텍스를 사용하지 않고 고루틴에서 값을 출력
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU()) // 모든 CPU 사용
var data int = 0
go func() { // 값을 쓰는 고루틴
for i := 0; i < 3; i++ {
data += 1 // data에 값 쓰기
fmt.Println("write : ", data) // data 값을 출력
time.Sleep(10 * time.Millisecond) // 10 밀리초 대기
}
}()
go func() { // 값을 읽는 고루틴
for i := 0; i < 3; i++ {
fmt.Println("read 1 : ", data) // data 값을 출력(읽기)
time.Sleep(1 * time.Second) // 1초 대기
}
}()
go func() { // 값을 읽는 고루틴
for i := 0; i < 3; i++ {
fmt.Println("read 2 : ", data) // data 값을 출력(읽기)
time.Sleep(2 * time.Second) // 2초 대기
}
}()
time.Sleep(10 * time.Second) // 10초 동안 프로그램 실행
}
read 1 : 1
read 2 : 1
write : 1
write : 2
write : 3
read 1 : 3
read 1 : 3
read 2 : 3
read 2 : 3
]package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU()) // 모든 CPU 사용
var data int = 0
var rwMutex = new(sync.RWMutex) // 읽기, 쓰기 뮤텍스 생성
go func() { // 값을 쓰는 고루틴
for i := 0; i < 3; i++ {
rwMutex.Lock() // 쓰기 뮤텍스 잠금, 쓰기 보호 시작
data += 1 // data에 값 쓰기
fmt.Println("write : ", data) // data 값을 출력
time.Sleep(10 * time.Millisecond) // 10 밀리초 대기
rwMutex.Unlock() // 쓰기 뮤텍스 잠금 해제, 쓰기 보호 종료
}
}()
go func() { // 값을 읽는 고루틴
for i := 0; i < 3; i++ {
rwMutex.RLock() // 읽기 뮤텍스 잠금, 읽기 보호 시작
fmt.Println("read 1 : ", data) // data 값을 출력(읽기)
time.Sleep(1 * time.Second) // 1초 대기
rwMutex.RUnlock() // 읽기 뮤텍스 잠금 해제, 읽기 보호 종료
}
}()
go func() { // 값을 읽는 고루틴
for i := 0; i < 3; i++ {
rwMutex.RLock() // 읽기 뮤텍스 잠금, 읽기 보호 시작
fmt.Println("read 2 : ", data) // data 값을 출력(읽기)
time.Sleep(2 * time.Second) // 2초 대기
rwMutex.RUnlock() // 읽기 뮤텍스 잠금 해제, 읽기 보호 종료
}
}()
time.Sleep(10 * time.Second) // 10초 동안 프로그램 실행
}
읽기, 쓰기 뮤텍스는 sync.RWMutex를 할당한 뒤에 고루틴에서 RLock, RUnlock, Lock, Unlock 함수로 사용한다. 읽기 동작을 시작할 부분에서 RLock 함수를 사용하고, 읽기 동작을 끝낼 부분에서 RUnlock 함수를 사용한다. 그리고 쓰기 동작을 시작할 부분에서 Lock 함수를 사용하고, 쓰기 동작을 끝낼 부분에서 Unlock 함수를 사용한다.
RLock, RUnlock, Lock, Unlock 함수는 반드시 짝을 맞춰야 하며 짝이 맞지 않으면 데드락(deadlock, 교착 상태)이 발생하므로 주의해야한다.
실행해보면 각각의 순서는 매번 달라지지만 실행되는 모양은 규칙적이다. 즉 read 1, read 2 읽기 동작이 모두 끝나야 write 쓰기 동작이 시작된다. 마찬가지로 쓰기 동작이 끝나야 읽기 동작이 시작된다. 읽기 동작끼리는 서로를 막지 않으므로 항상 동시에 실행된다.
read 1 : 0
read 2 : 0 ← read 1, read 2 읽기 동작이 모두 끝나야
write : 1 ← write 쓰기 동작이 시작됨
read 1 : 1
read 2 : 1
write : 2
read 2 : 2
read 1 : 2
write : 3
조건 변수는 대기하고 있는 객체 하나만 깨우거나 여러 개를 동시에 깨울 때 사용한다.
sync 패키지에서 제공하는 조건 변수의 함수는 다음과 같다.
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU()) // 모든 CPU 사용
var mutex = new(sync.Mutex) // 뮤텍스 생성
var cond = sync.NewCond(mutex) // 뮤텍스를 이용하여 조건 변수 생성
c := make(chan bool, 3) // 비동기 채널 생성
for i := 0; i < 3; i++ {
go func(n int) { // 고루틴 3개 생성
mutex.Lock() // 뮤텍스 잠금, cond.Wait() 보호 시작
c <- true // 채널 c에 true를 보냄
fmt.Println("wait begin : ", n)
cond.Wait() // 조건 변수 대기
fmt.Println("wait end : ", n)
mutex.Unlock() // 뮤텍스 잠금 해제, cond.Wait() 보호 종료
}(i)
}
for i := 0; i < 3; i++ {
<-c // 채널에서 값을 꺼냄, 고루틴 3개가 모두 실행될 때까지 기다림
}
for i := 0; i < 3; i++ {
mutex.Lock() // 뮤텍스 잠금, cond.Signal() 보호 시작
fmt.Println("signal : ", i)
cond.Signal() // 대기하고 있는 고루틴을 하나씩 깨움
mutex.Unlock() // 뮤텍스 잠금 해제, cond.Signal() 보고 종료
}
fmt.Scanln()
}
wait begin : 0
wait begin : 1
wait begin : 2
signal : 0
signal : 1
signal : 2
wait end : 0
wait end : 1
wait end : 2
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU()) // 모든 CPU 사용
var mutex = new(sync.Mutex) // 뮤텍스 생성
var cond = sync.NewCond(mutex) // 뮤텍스를 이용하여 조건 변수 생성
c := make(chan bool, 3) // 비동기 채널 생성
for i := 0; i < 3; i++ {
go func(n int) { // 고루틴 3개 생성
mutex.Lock() // 뮤텍스 잠금, cond.Wait() 보호 시작
c <- true // 채널 c에 true를 보냄
fmt.Println("wait begin : ", n)
cond.Wait() // 조건 변수 대기
fmt.Println("wait end : ", n)
mutex.Unlock() // 뮤텍스 잠금 해제, cond.Wait() 보호 종료
}(i)
}
for i := 0; i < 3; i++ {
<-c // 채널에서 값을 꺼냄, 고루틴 3개가 모두 실행될 때까지 기다림
}
mutex.Lock() // 뮤텍스 잠금, cond.Broadcast() 보호 시작
fmt.Println("broadcast")
cond.Broadcast() // 대기하고 있는 모든 고루틴을 깨움
mutex.Unlock() // 뮤텍스 잠금 해제, cond.Signal() 보고 종료
fmt.Scanln()
}
마찬가지로 대기할 때는 고루틴에서 Wait 함수를 사용한다. 대기하고 있는 모든 고루틴을 깨울 때는 Broadcast 함수를 사용하면 된다.
여기서는 Broadcast 함수를 사용한 즉시 대기하고 있던 고루틴 3개가 깨어난다.
wait begin : 1
wait begin : 2
wait begin : 0
broadcast
wait end : 2
wait end : 1
wait end : 0
Once를 사용하면 함수를 한 번만 실행할 수 있다. 다음은 고루틴 안에서 Hello, world!를 출력한다.
sync 패키지에서 제공하는 Once의 구조체와 함수는 다음과 같다
package main
import (
"fmt"
"runtime"
"sync"
)
func hello() {
fmt.Println("Hello, world!")
}
func main() {
runtime.GOMAXPROCS(runtime.NumCPU()) // 모든 CPU 사용
once := new(sync.Once) // Once 생성
for i := 0; i < 3; i++ {
go func(n int) { // 고루틴 3개 생성
fmt.Println("goroutine : ", n)
once.Do(hello) // 고루틴은 3개지만 hello 함수를 한 번만 실행
}(i)
}
fmt.Scanln()
}
Once는 sync.Once를 할당한 뒤에 Do 함수로 사용한다. Do 함수에는 실행할 함수 이름을 지정하거나, 클로저 형태로 함수를 지정할 수 있다. Once는 어떤 상황이든 상관없이 지정된 함수를 딱 한 번만 실행시킨다
여기서는 고루틴을 3개 실행시키고 각각 Once의 Do 함수로 hello 함수를 실행시켰지만 실제로는 한 번만 실행되었다.
goroutine : 1
Hello, world!
goroutine : 0
goroutine : 2
풀은 객체(메모리)를 사용한 후 보관해두었다가 다시 사용하게 해주는 기능이다. 객체를 반복해서 할당하면 메모리 사용량도 늘어나고, 메모리를 해제해야 하는 가비지 컬렉터에게도 부담이 된다. 즉, 풀은 일종의 캐시라고 할 수 있으며 메모리 할당과 해제 횟수를 줄여 성능을 높이고자 할 때 사용한다. 그리고 풀은 여러 고루틴에서 동시에 사용할 수 있다.
sync 패키지에서 제공하는 풀의 구조체와 함수는 다음과 같다.
package main
import (
"fmt"
"math/rand"
"runtime"
"sync"
)
type Data struct { // Data 구조체 정의
tag string // 풀 태그
buffer []int // 데이터 저장용 슬라이스
}
func main() {
runtime.GOMAXPROCS(runtime.NumCPU()) // 모든 CPU 사용
pool := sync.Pool{ // 풀 할당
New: func() interface{} { // Get 함수를 사용했을 때 호출될 함수 정의
data := new(Data) // 새 메모리 할당
data.tag = "new" // 태그 설정
data.buffer = make([]int, 10) // 슬라이스 공간 할당
return data // 할당한 메모리(객체) 리턴
},
}
for i := 0; i < 10; i++ {
go func() { // 고루틴 10개 생성
data := pool.Get().(*Data) // 풀에서 *Data 타입으로 데이터를 가져옴
for index := range data.buffer {
data.buffer[index] = rand.Intn(100) // 슬라이스에 랜덤 값 저장
}
fmt.Println(data) // data 내용 출력
data.tag = "used" // 객체가 사용되었다는 태그 설정
pool.Put(data) // 풀에 객체를 보관
}()
}
for i := 0; i < 10; i++ {
go func() { // 고루틴 10개 생성
data := pool.Get().(*Data) // 풀에서 *Data 타입으로 데이터를 가져옴
n := 0
for index := range data.buffer {
data.buffer[index] = n // 슬라이스에 짝수 저장
n += 2
}
fmt.Println(data) // data 내용 출력
data.tag = "used" // 객체가 사용되었다는 태그 설정
pool.Put(data) // 풀에 객체 보관
}()
}
fmt.Scanln()
}
pool := sync.Pool{ // 풀 할당
New: func() interface{} { // Get 함수를 사용했을 때 호출될 함수 정의
data := new(Data) // 새 메모리 할당
data.tag = "new" // 태그 설정
data.buffer = make([]int, 10) // 슬라이스 공간 할당
return data // 할당한 메모리(객체) 리턴
},
}
data := pool.Get().(*Data) // 풀에서 *Data 타입으로 데이터를 가져옴
for index := range data.buffer {
data.buffer[index] = rand.Intn(100) // 슬라이스에 랜덤 값 저장
}
fmt.Println(data) // data 내용 출력
data.tag = "used" // 객체가 사용되었다는 태그 설정
pool.Put(data)
&{new [81 87 47 59 81 18 25 40 56 0]} ← 객체가 새로 할당된 경우
&{used [0 2 4 6 8 10 12 14 16 18]}
&{used [41 8 87 31 29 56 37 31 85 26]}
&{new [94 11 62 89 28 74 11 45 37 6]}
&{used [13 90 94 63 33 47 78 24 59 53]} ← 풀에 보관된 객체를 사용
&{used [57 21 89 99 0 5 88 38 3 55]}
&{used [51 10 5 56 66 28 61 2 83 46]}
&{used [63 76 2 18 47 94 77 63 96 20]}
&{used [0 2 4 6 8 10 12 14 16 18]}
&{used [23 53 37 33 41 59 33 43 91 2]}
&{used [0 2 4 6 8 10 12 14 16 18]}
&{used [0 2 4 6 8 10 12 14 16 18]}
&{used [0 2 4 6 8 10 12 14 16 18]}
&{used [0 2 4 6 8 10 12 14 16 18]}
&{used [0 2 4 6 8 10 12 14 16 18]}
&{used [0 2 4 6 8 10 12 14 16 18]}
&{used [0 2 4 6 8 10 12 14 16 18]}
&{used [0 2 4 6 8 10 12 14 16 18]}
&{new [95 66 28 58 47 47 87 88 90 15]}
&{new [78 36 46 7 40 3 52 43 5 98]}
대기 그룹은 고루틴이 모두 끝날 때까지 기다릴 때 사용합니다. 앞에서는 time.Sleep, fmt.Scanln 함수를 사용하여 고루틴이 끝날 때까지 임시로 대기했습니다. 이번에는 대기 그룹을 사용하여 고루틴이 끝날 때까지 기다려보겠습니다.
sync 패키지에서 제공하는 대기 그룹의 구조체와 함수는 다음과 같습니다.
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU()) // 모든 CPU 사용
wg := new(sync.WaitGroup) // 대기 그룹 생성
for i := 0; i < 10; i++ {
wg.Add(1) // 반복할 때마다 wg.Add 함수로 1씩 추가
go func(n int) { // 고루틴 10개 생성
fmt.Println(n)
wg.Done() // 고루틴이 끝났다는 것을 알려줌
}(i)
}
wg.Wait() // 모든 고루틴이 끝날 때까지 기다림
fmt.Println("the end")
}
대기 그룹은 sync.WaitGroup을 할당 한 뒤에 Add, Done, Wait 함수로 사용한다. 고루틴을 생성할 때 Add 함수로 고루틴 개수를 추가해준다 그리고 고루틴 안에서 Done 함수를 사용하여 고루틴이 끝났다는 것을 알려준다. 마지막으로 Wait 함수를 사용하여 모든 고루틴이 끝날 때까지 기다린다.
Add 함수에 설정한 값과 Done 함수가 호출되는 횟수는 같아야 한다. 즉 Add(3)으로 설정했다면 Done 함수는 3번 호출되야 합니다. 이 횟수가 맞지 않으면 패닉이 발생하므로 주의한다.
Done 함수는 다음과 같이 defer와 함께 사용해서 지연 호출로도 사용할 수 있다.
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done() // 고루틴이 끝나기 직전에 wg.Done 함수 호출
fmt.Println(n)
}(i)
}
원자적 연산은 더 이상 쪼갤 수 없는 연산이라는 뜻이다. 따라서 여러 스레드(고루틴), CPU 코어에서 같은 변수(메모리)를 수정할 때 서로 영향을 받지 않고 안전하게 연산할 수 있다. 보통 원자적 연산은 CPU의 명령어를 직접 사용하여 구현되어 있디.
고루틴을 사용하여 정수형 변수를 2,000번은 더하고, 1,000번은 빼보겠습니다
]package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU()) // 모든 CPU 사용
var data int32 = 0
wg := new(sync.WaitGroup)
for i := 0; i < 2000; i++ {
wg.Add(1)
go func() { // 고루틴 2,000개 생성
data += 1 // 1씩 더함
wg.Done()
}()
}
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() { // 고루틴 1,000개 생성
data -= 1 // 1씩 뺌
wg.Done()
}()
}
wg.Wait()
fmt.Println(data)
}
948 (매번 달라질 수 있음)
실행해보면 0 + 2000 - 1000은 1000이 되어야하는데 그렇지가 않다(실행할 때마다, 시스템마다 실행 결과는 달라질 수 있습니다). 여러 변수에 고루틴이 동시에 접근하면서 정확하게 연산이 되지 않았기 때문이다
이번에는 원자적 연산을 사용하여 계산해보겟다.
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU()) // 모든 CPU 사용
var data int32 = 0
wg := new(sync.WaitGroup)
for i := 0; i < 2000; i++ {
wg.Add(1)
go func() { // 고루틴 2,000개 생성
atomic.AddInt32(&data, 1) // 원자적 연산으로 1씩 더함
wg.Done()
}()
}
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() { // 고루틴 1,000개 생성
atomic.AddInt32(&data, -1) // 원자적 연산으로 1씩 뺌
wg.Done()
}()
}
wg.Wait()
fmt.Println(data)
}
1000
이제 정확하게 1000이 출력된다.
원자적 연산에는 메모리 주소와 수정할 값을 넣습니다. 따라서 atomic.AddInt32(&data, 1)처럼 & (참조)를 사용하여 data 변수의 메모리 주소를 대입한다.
다음은 sync/atomic 패키지에서 제공하는 원자적 연산의 종류입니다.