Skip to main content

Строки

Как не убить производительность работой со строками

Как не убить производительность работой со строками

Работа со строками в Go может незаметно съесть всю память и убить производительность. Разбираем, как избежать ненужных аллокаций и ускорить код в разы.

В Go строки неизменяемые. Каждое изменение строки = новая аллокация в памяти.

Пример проблемного кода:

func BuildURL(host, path, query string) string {
    url := "https://"     // Аллокация #1
    url += host           // Аллокация #2
    url += path           // Аллокация #3
    url += "?" + query    // Аллокация #4
    return url
}

Решение 1: strings.Builder с предвыделением

Всегда используйте strings.Builder вместо конкатенации через +:

// N аллокаций
func ConcatWrong(parts []string) string {
    result := ""
    for _, p := range parts {
        result += p  // Новая аллокация на каждой итерации!
    }
    return result
}

// 1 аллокация
func ConcatRight(parts []string) string {
    var b strings.Builder
    
    // Посчитать нужный размер заранее
    totalLen := 0
    for _, p := range parts {
        totalLen += len(p)
    }
    b.Grow(totalLen)  // Выделить память один раз
    
    // Писать без аллокаций
    for _, p := range parts {
        b.WriteString(p)
    }
    return b.String()
}

Решение 2: Переиспользование буферов через sync.Pool

Даже strings.Builder делает аллокацию при вызове .String(). Чтобы избежать этого, переиспользуйте буферы:

var bufferPool = sync.Pool{
    New: func() interface{} {
        b := make([]byte, 0, 4096)  // 4KB буфер
        return &b
    },
}

func GetBuffer() *[]byte {
    return bufferPool.Get().(*[]byte)
}

func PutBuffer(b *[]byte) {
    *b = (*b)[:0]  // Сбросить длину, сохранить capacity
    bufferPool.Put(b)
}

func BuildString(parts []string) string {
    buf := GetBuffer()
    defer PutBuffer(buf)
    
    for _, p := range parts {
        *buf = append(*buf, p...)
    }
    
    // Zero-copy конвертация (Go 1.20+)
    return unsafe.String(unsafe.SliceData(*buf), len(*buf))
}

Решение 3: Стековые массивы для предсказуемых размеров

Для строк фиксированного размера используйте массивы на стеке:

// heap allocation
func FormatIPWrong(a, b, c, d byte) string {
    return fmt.Sprintf("%d.%d.%d.%d", a, b, c, d)
}

// stack allocation
func FormatIPRight(a, b, c, d byte) string {
    var buf [15]byte  // Макс длина IP: "255.255.255.255"
    i := 0
    
    i += writeUint8(buf[i:], a)
    buf[i] = '.'
    i++
    i += writeUint8(buf[i:], b)
    buf[i] = '.'
    i++
    i += writeUint8(buf[i:], c)
    buf[i] = '.'
    i++
    i += writeUint8(buf[i:], d)
    
    return string(buf[:i])
}

func writeUint8(buf []byte, v byte) int {
    if v >= 100 {
        buf[0] = '0' + v/100
        buf[1] = '0' + (v/10)%10
        buf[2] = '0' + v%10
        return 3
    } else if v >= 10 {
        buf[0] = '0' + v/10
        buf[1] = '0' + v%10
        return 2
    }
    buf[0] = '0' + v
    return 1
}

Решение 4: String Interning для дедупликации

Если у вас много одинаковых строк (например, в логах), используйте string interning:

type Interner struct {
    mu   sync.RWMutex
    pool map[string]string
}

func (i *Interner) Intern(s string) string {
    i.mu.RLock()
    if cached, ok := i.pool[s]; ok {
        i.mu.RUnlock()
        return cached
    }
    i.mu.RUnlock()
    
    i.mu.Lock()
    defer i.mu.Unlock()
    
    if cached, ok := i.pool[s]; ok {
        return cached
    }
    
    i.pool[s] = s
    return s
}

// Использование
var logLevelInterner = NewInterner()

func ProcessLog(level string) {
    level = logLevelInterner.Intern(level)
    // Теперь все "info" указывают на одну строку в памяти
}

Как профилировать:

# Посмотреть аллокации памяти
go test -bench=. -benchmem

# Проверить escape analysis
go build -gcflags="-m" 2>&1 | grep "string"

# Memory profiling
go test -bench=. -memprofile=mem.prof
go tool pprof -alloc_space mem.prof

Главное правило: всегда измеряйте! Не доверяйте интуиции — используйте бенчмарки.