golang

Оптимизация Go: как повысить скорость и эффективность кода

  • четверг, 6 июня 2024 г. в 00:00:08
https://habr.com/ru/companies/simbirsoft/articles/819015/

Привет, Хабр! Меня зовут Макс, я Go-разработчик в компании SimbirSoft. Язык Go (Golang) стремительно набирает популярность, он всё чаще внедряется в существующие программные решения, а также встречается в стеке новых проектов. Высокая производительность и скорость работы – его главные преимущества, поэтому для реализации бизнес-задач он подходит как нельзя кстати. Go легко поддерживается и отлично годится для создания MVP, из-за чего востребованность в нём растёт.

Но чтобы этот язык программирования был действительно производительным, разработчикам необходимо учитывать некоторые тонкости работы с ним. Иначе ваше приложение станет таким же медленным, как айтишник без чашки кофе :) В этой статье мы с вами на примерах разберём часто возникающие ситуации при работе с Go, а также рассмотрим приёмы, которые позволят повысить производительность кода. Надеюсь, разработчики уровня джуниор и мидл смогут почерпнуть для себя что-то полезное. 

Мы начинаем! 

Содержание

  1. Строки

  2. sync.Pool

  3. Утечки

  4. Аллокация

  5. Каналы

  6. Context

  7. Итоги

Строки

Разработчикам часто приходится работать со строками. Важно помнить, что строка – это массив байт, а значит,  неизменный тип данных. Для решения проблем, связанных с частой конкатенации строк, был создан тип strings.Builder, который хранит в себе слайс байт и записывает все данные именно в него. Но это не единственный способ ускорить работу со строками. Представим, что надо отправить сообщение на почту. Информацию об имени пользователя, действии и коде для подтверждения храним в строке.

// sumStringWithBuilder конкатенирует строки
// с помощью strings.Builder.
func sumStringWithBuilder() string {
    var sb strings.Builder
    name := "Simba"
    action := "Change Password"
    code := "41251"

    sb.WriteString("Name: ")
    sb.WriteString(name)
    sb.WriteString("Action: ")
    sb.WriteString(action)
    sb.WriteString("Code: ")
    sb.WriteString(code)

    return sb.String()
}

// sumStringInMoreStrings конкатенирует строки
// средствами типа string.
func sumStringInMoreStrings() string {
    var str string
    name := "Simba"
    action := "Change Password"
    code := "41251"

    str = str + "Name: "
    str = str + name
    str = str + "Action: "
    str = str + action
    str = str + "Code: "
    str = str + code

    return str
}

// sumStringInOneString конкатенирует строки
// средствами типа string.
func sumStringInOneString() string {
    var str string
    name := "Simba"
    action := "Change Password"
    code := "41251"

    str = str +
       "Name: " + name +
       "Action: " + action +
       "Code: " + code

    return str
}

Можно предположить, что функция sumStringWithBuilder() отработает быстрее sumStringInMoreStrings() – и это так. Но что по поводу третьей функции – sumStringInOneString()? Она отличается лишь тем, что все строки конкатенируются сразу в одно действие. Запустим бенчмарки (n – наносекунда).

go test -bench . -count=10 | tee sumstring.txt
benchstat sumstring.txt

goos: linux
goarch: amd64
pkg: test/benchmarks
cpu: 11th Gen Intel(R) Core(TM) i5-11400H @ 2.70GHz
                          │ sumstring.txt │
                          │    sec/op     │
SumStringWithBuilder-12      111.0n ± 23%
SumStringInMoreStrings-12    153.2n ±  7%
SumStringInOneString-12      59.18n ± 15%
geomean                      100.2n

Как и предполагалось, использование strings.Builder оказалось эффективно. Но также видно, что sumStringInOneString() отработал быстрее, чем sumStringInMoreStrings().

Так как в Go строки неизменны, то при каждой операции конкатенации создаётся новый объект строки. Но в случае, когда записываются все строки в одно действие, они конкатенируются в одну строку во время компиляции, поэтому создаётся только один объект строки.

sync.Pool

Сборщик мусора начинает работу через определенный промежуток времени. По умолчанию он запускается, когда лимит выделенной памяти, установленный им, превышен, далее лимит увеличивается. Частый вызов сборки мусора может замедлить процесс работы программы. Поэтому если приходится работать с многократным количеством аллокаций, записи и очистки памяти, можно использовать sync.Pool. Он отлично подходит для избавления от ненужных данных и переиспользования выделенной памяти.

// Создание слайса.
var dataDefault = make([]int, 0, 10000)

// Классическая работа.
func processDefault() {
    // Некоторая обработка данных.
    for i := 0; i < 10000; i++ {
       dataDefault = append(dataDefault, i)
    }

    // Очистка.
    dataDefault = dataDefault[:0]
}

// Создание пула.
var dataPool = sync.Pool{
    New: func() any {
       return make([]int, 0, 10000)
    },
}

// Работа с пулом.
func processPool() {
    data := dataPool.Get().([]int)
    // Некоторая обработка данных
    for i := 0; i < 10000; i++ {
       data = append(data, i)
    }

    // Очистка.
    data = data[:0]
    dataPool.Put(data)
}

В примере есть две функции – одна работает со слайсом, другая создает слайс из пула и работает с ним. Запустим бенчмарки (µ – микросекунда).

go test -bench . -count=10 | tee pool.txt
benchstat sumstring.txt

goos: linux
goarch: amd64
pkg: test/benchmarks
cpu: 11th Gen Intel(R) Core(TM) i5-11400H @ 2.70GHz
           │  pool.txt   │
           │   sec/op    │
Default-12   17.84µ ± 4%
Pool-12      3.299µ ± 4%
geomean      7.670µ

Результат с разницей около 5 раз радует, но следует помнить, что sync.Pool – не панацея. В некоторых случаях, например, когда мы возвращаем памяти больше, чем взяли, лучше обойтись без него.

Утечки

Это ситуации, когда приложение продолжает удерживать ссылку на объект после того, как он стал ненужным. В Go сборщик мусора отвечает за уничтожение ссылок. Следовательно, утечка памяти – это ситуация, когда сборщик мусора не может очистить более ненужные ресурсы. Работа с данными, которые занимают много места в памяти замедляет приложение. Поэтому обратим внимание на частые ситуации со слайсами и мапами. 

Для вывода используемой памяти в некоторый момент программы я использую runtime.MemStats:

// printAlloc выводит инофрмацию о затраченных ресурсах.
func printAlloc() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("%d KB\n", m.Alloc/1024)
}

Представим, что нам приходится работать со слайсом, допустим, в миллион значений, после мы избавляемся от большей части этих данных и используем оставшуюся, где теоретически у нас могут храниться важные данные. Далее требуется переиспользовать этот слайс для каких-то целей:

// Создается и заполняется слайс.
var data []int
for i := 0; i < 1_000_000; i++ {
    data = append(data, i)
}

// Обрезается ненужная часть.
data = data[:256]

printAlloc()
// Вызывается сборщик мусора.
runtime.GC()
printAlloc()

data = append(data, 1010)

Результатом будет: 

14990 KB
8400 KB

Вызываем printAlloc() для вывода информации о затраченных ресурсах, далее сборщик мусора и снова pirntAlloc(). Но сборщик мусора ничего не очистил – это происходит из-за того, что data будет ссылаться на массив, в котором хранятся миллион значений. Чтобы исправить данную проблему, можно использовать функцию copy:

// Создается и заполняется слайс.
var data []int
for i := 0; i < 1_000_000; i++ {
    data = append(data, i)
}

// Обрезается ненужная часть.
newData := make([]int, 256)
copy(newData, data)
data = newData

printAlloc()
// Вызывается сборщик мусора.
runtime.GC()
printAlloc()

data = append(data, 1010)

Итог:

14993 KB
155 KB

Очевидно, что работа со слайсом, занимающим 155 KB, будет быстрее, то же самое можно реализовать и с мапой в Go: 

// Создается и заполняется мапа.
data := make(map[int]int)
for i := 0; i < 1_000_000; i++ {
    data[i] = i
}

// Удаляются ненужные ключи со значениями.
for i := 0; i < 999_744; i++ {
    delete(data, i)
}

printAlloc()
// Вызывается сборщик мусора.
runtime.GC()
printAlloc()

data[0] = 0

Получаем:

61972 KB
39365 KB

Создаётся мапа, добавляются и удаляются элементы. После удаления ключей со значениями бакеты (buckets) внутри мапы остаются, что занимает память. Чтобы решить эту проблему, можно создать новую мапу, переписать все значения и переназначить мапу:

// Создается и заполняется мапа.
data := make(map[int]int)
for i := 0; i < 1_000_000; i++ {
    data[i] = i
}

// Удаляются ненужные ключи со значениями.
for i := 0; i < 999_744; i++ {
    delete(data, i)
}

// Перезаписывается в новую мапу.
newData := make(map[int]int)
for k := range data {
    newData[k] = data[k]
}
data = newData

printAlloc()
// Вызывается сборщик мусора.
runtime.GC()
printAlloc()

data[0] = 0

Результат:

62013 KB
156 KB

Утечки в Go могут возникнуть не только из-за мап и слайсов, но именно эти случаи чаще встречаются на практике. Чтобы избежать утечек и оптимизировать систему, стоит использовать профилирование (pprof).

Аллокация

Слайсы и мапы можно заранее аллоцировать, то есть, выделить память под них. Также есть возможность задать слайсам длину и ёмкость, а мапам – число элементов, от которого выстраивается количество бакетов (buckets). В Go слайс ссылается на массив, и когда количество элементов превышает длину массива, создаётся новый массив и ссылка на него. А мапа же, в свою очередь, создаёт новые ячейки памяти – бакеты и распределяет по ним значения. Ниже примеры функций, где показан пример выделения памяти и работы с ней:

// Не аллоцированный слайс.
func sliceNotAllocated() []int {
    var data []int
    for i := 0; i < 320; i++ {
       data = append(data, i)
    }
    return data
}

// Аллоцированный слайс.
func sliceAlloc() []int {
    data := make([]int, 0, 320)
    for i := 0; i < 320; i++ {
       data = append(data, i)
    }
    return data
}

// Не аллоцированная мапа.
func mapNotAllocated() map[int]int {
    data := map[int]int{}
    for i := 0; i < 320; i++ {
       data[i] = i
    }
    return data
}

// Аллоцированная мапа.
func mapAlloc() map[int]int {
    data := make(map[int]int, 320)
    for i := 0; i < 320; i++ {
       data[i] = i
    }
    return data
}

Запустим бенчмарки (n – наносекунда, µ – микросекунда): 

go test -bench . -count=10 | tee slice_alloc.txt
go test -bench . -count=10 | tee map_alloc.txt
benchstat slice_alloc.txt
benchstat map_alloc.txt

goos: linux
goarch: amd64
pkg: test/benchmarks
cpu: 11th Gen Intel(R) Core(TM) i5-11400H @ 2.70GHz
                 │ slice_alloc.txt │
                 │     sec/op      │
AllocSlice-12          127.2n ± 1%
NotAllocSlice-12       1.242µ ± 0%
geomean                397.5n


goos: linux
goarch: amd64
pkg: test/benchmarks
cpu: 11th Gen Intel(R) Core(TM) i5-11400H @ 2.70GHz
               │ map_alloc.txt │
               │    sec/op     │
AllocMap-12        12.19µ ± 8%
NotAllocMap-12     23.05µ ± 8%
geomean            16.76µ

В результате можем отметить, что для слайсов аллокация памяти ускорила работу аж в 10 раз, а мапа – в 2 раза. Важно, что в зависимости от количества данных будет меняться разница в скорости. Но если известно количество элементов, которые будут использоваться, то аллокация памяти будет намного производительней – и по скорости и по памяти. 

Каналы

Каналы используются для общения между горутинами. По умолчанию, каналы в Go хранят лишь одну ячейку памяти для указанного типа. Известно, что такой канал после отправки сообщения блокируется в ожидании прочтения. Можно ускорить процесс работы, сделав их буферизированными, то есть с возможностью хранить больше элементов.

// dataChan - слайс для работы с небуферизированным каналом.
var dataChan = make([]int, 0, 1000)

// senderChan отправляет данные в канал.
func senderChan(ch chan int) {
    for i := 0; i < 1000; i++ {
       ch <- i
    }
    close(ch)
}

// receiverChan принимает данные с канала.
func receiverChan(ch chan int) {
    for {
       val, ok := <-ch
       if !ok {
          break
       }
       dataChan = append(dataChan, val)
    }
}

// ReceiveChan запускает обработку данных из канала.
func ReceiveChan() {
    ch := make(chan int)
    go senderChan(ch)
    receiverChan(ch)
}

Создаём канал, который принимает в себя тысячу элементов и считывает значения. При постоянном потоке данных работа с небуферизированным каналом может замедлить работу, ведь сторона читателя может быть занята другим процессом. Для решения этой проблемы мы создаём буферизированный канал:

// dataChan - слайс для работы с буферизированном каналом.
var dataBuff = make([]int, 0, 1000)

// senderChan отправляет данные в буферизированный канал.
func senderBuffChan(ch chan int) {
    for i := 0; i < 1000; i++ {
       ch <- i
    }
    close(ch)
}

// receiverBuffChan принимает данные с буферизированного канала.
func receiverBuffChan(ch chan int) {
    for val := range ch {
       dataBuff = append(dataBuff, val)
    }
}

// ReceiveBuffChan запускает обработку данных из буферизированного канала.
func ReceiveBuffChan() {
    ch := make(chan int, 1000)
    go senderBuffChan(ch)
    receiverBuffChan(ch)
}

Запустим бенчмарки (µ– микросекунда):

go test -bench . -count=10 | tee buffchan.txt
benchstat buffchan.txt

goos: linux
goarch: amd64
pkg: test/benchmarks
cpu: 11th Gen Intel(R) Core(TM) i5-11400H @ 2.70GHz
            │ buffchan.txt │
            │    sec/op    │
Chan-12       141.5µ ±  9%
BuffChan-12   53.16µ ± 33%
geomean       86.74µ

Буферизированный канал оказался почти в 3 раза быстрее! Хороший результат :) Но не стоит забывать, что канал с буфером из тысячи значений будет занимать больше памяти, чем без него.

Context

Пакет Context используется для отмены операций. Если программе больше не требуется выполнять какую-либо задачу, то её можно отменить. Вот пример каракаса такой ситуации:

// doTask выполняет некоторую работу.
func doTask(ctx context.Context) {
 for {
  select {

  case <-ctx.Done():
   fmt.Println("Операция отменена")
   return

  default:
   // Выполняется какая-то задачу.
   time.Sleep(1 * time.Second)
   fmt.Println("Выполняем задачу...")
  }
 }
}

func main() {
 // Создание контекста.
 ctx, cancel := context.WithCancel(context.Background())

 go doTask(ctx)

 // Через 3 секунды отменяется операция.
 time.Sleep(3 * time.Second)
 cancel()

 // Ожидание, чтобы увидеть вывод.
 time.Sleep(1 * time.Second)
}

Через 3 секунды после запуска команды отменяется работа горутины doTask(). Но что если отмена операции зависит не от времени? Пример ниже:

// doSome делает что-то с данными.
func doSome(cancel context.CancelFunc, data []int) {
    defer func() {
       if err := recover(); err != nil {
          cancel()
       }
    }()
    data[11] = 0
}

// doTask выполняет некоторую работу.
func doTask(ctx context.Context) {
    for {
       select {

       case <-ctx.Done():
          fmt.Println("Операция отменена")
          return

       default:
          // Выполняется какая-то задачу.
          fmt.Println("Выполняем задачу...")
       }
    }
}

func main() {
    // Создание контекста.
    ctx, cancel := context.WithCancel(context.Background())

    go doTask(ctx)
    time.Sleep(15000 * time.Nanosecond)

    // Создание какого-либо действия.
    data := make([]int, 10)
    go doSome(cancel, data)

    // Ожидание, чтобы увидеть вывод.
    time.Sleep(1 * time.Second)
}

Теперь, передавая context.CancelFunc в функцию doSome(), если входные данные не верны, возникнет ошибка. Тогда doTask() будет завершена и не будет делать ненужной работы. Context отлично помогает сэкономить память, за счёт чего увеличивается скорость.

Итоги

Приложения на Go пишутся довольно быстро, поэтому так же быстро они должны и работать. В этой статье мы с вами рассмотрели лишь малую часть того, что влияет на скорость программ. Но всем этим можно управлять и создавать высоконагруженные приложения. Язык Go всё чаще внедряется в проекты для ускорения каких-либо процессов. 

Владельцам бизнеса и IT-специалистам на заметку: если нужно быстро, надежно и качественно реализовать программный продукт, то использование Go – это отличное решение. 

Для закрепления материала рекомендую книгу Тева Харшани «100 ошибок Go и как их избежать».

А ещё можно оценить свои знания языка Go с помощью наших тестов. Дерзай :) 

1 часть 

2 часть 

3 часть

Спасибо за внимание!

Больше авторских материалов для Go-разработчиков читай в соцсетях SimbirSoft – ВКонтакте и Telegram. Там мы также публикуем актуальные вакансии, анонсы IT-мероприятий, практикумов и интенсивов.