1. GC(Garbage Collection)
Go는 개발하기 편하지만 GC의 사용으로 성능에 문제가 생길 수 있습니다.
Go는 일반적으로 확보된 메모리 크기가 2배가 될때 GC를 수행합니다.
(아래 포스팅의 5. Go GC Pacing 부분을 참고하세요)
성능에 영향을 줄이려면 GC의 수행을 줄이는게 좋습니다.
먼저, 메모리를 미리 확보해두는 방법이 있습니다.
sync.Pool을 사용하여 추가 메모리 할당 없이 관리할 수 있습니다.
둘째, Slice나 Map의 크기의 예측이 가능하다면 make 함수로 충분한 용량을 지정해두면
메모리 확보의 빈도를 줄일 수 있습니다.
셋째, GC Trigger 파라미터를 명시적으로 지정하는 것입니다.
GOGC 라는 환경변수를 사용해 할당된 메모리의 %로 GC trigger를 패턴을 조정할 수 있습니다.
GC의 정보를 확인하고 싶은 경우 GODEBG=gctrace=1이라는 디버그 플래그를 사용하거나
pprof을 사용해 메모리 사용 부분을 확인할 수 있습니다.
(아래 포스팅에서 관련 정보를 참고할 수 있습니다.)
2. Memory Copy
Go의 특성상 Client/Server로 많이 활용되기 때문에
Encoding/Decoding하는 경우가 많습니다.
특히, string에서 []byte 형식으로의 변환이 잦게 되는데 불필요한 변환으로
메모리 복사가 일어나는 것을 방지하는것이 좋습니다.
특히, Encoding/Decoding의 비용은 작지 않음을 늘 생각해야 합니다.
string 값과 []byte값은 변환 후에 가능한 재사용하는 것이 좋습니다.
3. Function Call
Function의 인수와 반환 값이 레지스터가 아니라 스택 전달하는 방법을 사용하여 C에 비해 느립니다.
Goroutine 문맥 전환에 따른 비용 때문이라고 합니다.
아주 작은 함수는 인라인처리가 가능합니다.
위의 부분은 1.17에서 개선되었습니다.
컴파일러가 스택 대신 레지스터를 사용하여 인수와 결과를 패싱하는 기법을 적용하였습니다.
자세한 사항은 Go 1.17 Release Notes에서 확인할 수 있습니다.
1.17도 꽤나 높은 버전이며 최신버전을 유지하지 않는 경우도 있으니 확인바랍니다.
4. Go Version Update
Go는 가장 활발하게 발전하고 있는 언어 중 하나입니다.
특히 타 언어에 비해 성능 부분을 많이 신경쓰고 개선하고 있습니다.
위 3번의 경우와 같이 버전업이 되면서 코드의 변화 없이 개선되는 경우가 심심치 않습니다.
이번 1.17 버전은 1.16에 비해 5%의 성능 향상과 2%의 바이너리 크기 감소를 이뤄냈다고 합니다.
기존 코드는 변경할 필요 없이 안전하게 사용할 수 있습니다.
물론 영향을 미치는 경우도 있기 때문에 적용하기 전에
무조건적인 최신화보다는 충분한 테스트와 이해 후에 적용하는것이 안전합니다.
5. Reflection
Reflection은 매우 강력한 기능입니다.
Runtime에 타입을 결정하고 접근할수 있게 해주죠.
하지만 Compile Time에서 수행할 일을 Runtime에 수행하는 기능이므로 성능에 부담을 줍니다.
또한, 코드를 읽고 이해하기 어렵게할 수 있으므로 가독성과 생산성 측면에서도 신중해야 하는 기능입니다.
6. String Concatenation
+ 연산자를 이용해 string을 붙히는 기능은 비효율적이고 성능 저하를 야기합니다.
s := ""
for i := 0; i < 100000; i++ {
s += "x"
}
fmt.Println(s)
bytes.Buffer를 사용하는 편이 더 효율적입니다.
var buffer bytes.Buffer
for i := 0; i < 100000; i++ {
buffer.WriteString("x")
}
s := buffer.String()
fmt.Println(s)
혹은 strings.Builder를 사용하면 보다 더 효율적입니다.
- bytes.Buffer는 범용성이 더 높고 strings.Builder가 Concatenation에 더 특화된 기능입니다.
var builder strings.Builder
for i := 0; i < 100000; i++ {
builder.WriteString("x")
}
s := builder.String()
fmt.Println(s)
7. Allocating for slice, map
크기가 예측되는 경우, slice의 크기를 선언해두는것이 append 보다 3배정도 더 성능이 좋습니다.
slice뿐만 아니라 최대한 메모리를 사전에 할당해두는것이 성능에 유리합니다.
func main() {
// Allocate a slice with a small capacity
start := time.Now()
s := make([]int, 0, 10)
for i := 0; i < 100000; i++ {
s = append(s, i)
}
elapsed := time.Since(start)
fmt.Printf("Allocating slice with small capacity: %v\n", elapsed) // 1.165208ms
// Allocate a slice with a larger capacity
start = time.Now()
s = make([]int, 0, 100000)
for i := 0; i < 100000; i++ {
s = append(s, i)
}
elapsed = time.Since(start)
fmt.Printf("Allocating slice with larger capacity: %v\n", elapsed) // 361.333µs
}
또한, map은 생각보다 성능이 좋지 않으니 꼭 필요한 상황이 아니면 피하는것이 좋습니다.
8. Interface
Interface로 추상화하여 쓰는거보다 직접 Struct를 사용하는것이 더 빠릅니다.
물론 매우 극적인 차이가 있는것은 아닙니다.
type Shape interface {
Area() float64
}
type Circle struct {
radius float64
}
func (c *Circle) Area() float64 {
return 3.14 * c.radius * c.radius
}
func main() {
// Use the Shape interface
start := time.Now()
var s Shape = &Circle{radius: 10}
for i := 0; i < 100000; i++ {
s.Area()
}
elapsed := time.Since(start)
fmt.Printf("Using Shape interface: %s\n", elapsed) // Using Shape interface: 358µs
// Use the Circle type directly
start = time.Now()
c := Circle{radius: 10}
for i := 0; i < 100000; i++ {
c.Area()
}
elapsed = time.Since(start)
fmt.Printf("Using Circle type directly: %s\n", elapsed) // Using Circle type directly: 341.917µs
}
Go의 Clean Architecture를 위해 Interface는 매우 중요하기 때문에 Performance가 중요한 App에서만 생각해주시면 될것 같습니다.
9. govet
govet을 사용해 버그나 성능 저하를 야기하는 코드를 확인할 수 있습니다.
go tool vet main.go
모두 적용하려면 아래 커맨드를 사용할 수 있습니다.
go tool vet -all
10. Goroutine
goroutine으로 병렬처리를 한다고 항상 빠른것은 아닙니다.
goroutine의 생성 비용이 매우 작은것은 맞지만 없는것은 아니므로 매우 짧고 단순한 처리는 sequential하게 처리하는것이 더 빠릅니다.
func BenchmarkGoroutine(b *testing.B) {
n := 10
var wg sync.WaitGroup
b.ResetTimer()
for i := 0; i < b.N; i++ {
wg.Add(n)
for j := 0; j < n; j++ {
go func() {
wg.Done()
}()
}
wg.Wait()
}
}
func BenchmarkSequential(b *testing.B) {
n := 10
var wg sync.WaitGroup
b.ResetTimer()
for i := 0; i < b.N; i++ {
wg.Add(n)
for j := 0; j < n; j++ {
func() {
wg.Done()
}()
}
wg.Wait()
}
}
11. Regular Expression
Go의 regexp 패키지는 성능이 좋지 않습니다.
성능이 필요한 케이스에서는 직접 치환하는 연산을 사용하는 편이 더 효과적입니다.
func BenchmarkStringMatchWithRegexp(b *testing.B) {
s := "0xDeadBeef"
re := regexp.MustCompile(`^0[xX][0-9A-Fa-f]+$`)
b.ResetTimer()
for i := 0; i < b.N; i++ {
re.MatchString(s)
}
}
func BenchmarkStringMatchWithoutRegexp(b *testing.B) {
s := "0xDeadBeef"
isHexString := func(s string) bool {
if len(s) < 3 || s[0] != '0' || s[1] != 'x' && s[1] != 'X' {
return false
}
for _, c := range s[2:] {
if c < '0' || '9' < c && c < 'A' || 'F' < c && c < 'a' || 'f' < c {
return false
}
}
return true
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
isHexString(s)
}
}
12. Indexing map
map은 매우 편리한 구조이지만 map의 indexing은 slice에 비해 수십배 정도 느립니다.
저장되는 key가 100개 넘는 정도에서 map의 접근 속도 일정의 혜택이 발휘됩니다.
100개가 되지 않는 경우, Binary Search로 Slice를 탐색하는것이 더 빠릅니다.
13. defer
defer는 조금 느립니다.
성능이 엄격하게 지켜져야 하고 panic 등으로 빠져나갈때 안전하다고 여겨지는 경우에는 defer를 제외하도록 합니다.
var mu = sync.Mutex{}
func withoutDefer() {
mu.Lock()
mu.Unlock()
}
func withDefer() {
mu.Lock()
defer mu.Unlock()
}
func BenchmarkWithoutDefer(b *testing.B) {
for i := 0; i < b.N; i++ {
withoutDefer()
}
}
func BenchmarkWithDefer(b *testing.B) {
for i := 0; i < b.N; i++ {
withDefer()
}
}
14. Channel
병렬 처리를 위해서는 channel을 사용하는거보다 mutex를 사용하는 것이 더 빠릅니다.
Go는 이러한 점들만 고려하여도 충분히 빠른 성능을 지원합니다.
성능 향상을 위해서 먼저 위의 사항을 기반으로 코드를 개선해보는것을 추천합니다.
'Programming > Go' 카테고리의 다른 글
Go/Golang - Visualizing Concurrency (0) | 2021.12.21 |
---|---|
Go/Golang Module 정리(작성 중) (0) | 2021.12.21 |
Go/Golang for sets: map[T]struct{} vs. map[T]bool (0) | 2021.11.29 |
[ko]Wire-Jacket: IoC Container of google/wire for cloud-native (0) | 2021.10.07 |
Go/Golang Release & pkg.go.dev package update (0) | 2021.10.06 |