Привет, Хабр! Меня зовут Макс, я 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-мероприятий, практикумов и интенсивов. 

Комментарии (8)


  1. iLya2IK
    05.06.2024 09:03

    Отличная статья для новичков. Не понял только пример с sync.Pool. Фактически в варианте default не создаются и не удаляются новые структуры данных, в отличие от варианта pool, где, как минимум, генерируется (alloc) новая ссылка на слайс. Поэтому default не может быть медленнее pool. Представленный ниже вариант бенчмарка дает результат уже в пользу варианта default. Есть подозрение, что наблюдаемое замедление связано с особенностями в инициализации глобальных переменных при запуске тестов. Если не прав - поправьте - очень интересно разобраться.

    func BenchmarkDefault(b *testing.B) {
    	var dataDefault = make([]int, 0, 10000)
    	for i := 0; i < b.N; i++ {
    		dataDefault = processDefault(dataDefault[:])
    	}
    }
    
    // Классическая работа.
    func processDefault(dataDefault []int) []int {
    	// Некоторая обработка данных.
    	for i := 0; i < 10000; i++ {
    		dataDefault = append(dataDefault, i)
    	}
    
    	// Очистка.
    	return dataDefault[:0]
    }


    1. SSul Автор
      05.06.2024 09:03

      Рад, что вам было полезно!

      sync.Pool предназначен для повторного использования объектов между горутинами, благодаря чему нет необходимости выделять новую память. Мы берем уже выделенную память, используем и кладем назад.

      В первую очередь, sync.Pool снижает нагрузку на сборщик мусора (не отслеживает и не очищает объекты). В случае использования обычного слайса сборщик мусора будет очищать уже использованные объекты. То есть sync.Pool выгодно использовать, когда приложение требует работы с короткоживущими объектами.

      Я запустил ваш бенчмарк — и да, в этом случае sync.Pool будет немного, но все же проигрывать в скорости (протестировал на двух устройствах)

      func BenchmarkDefault(b *testing.B) {
          var dataDefault = make([]int, 0, 10000)
          for i := 0; i < b.N; i++ {
             dataDefault = processDefault(dataDefault[:])
          }
      }
      
      func BenchmarkPool(b *testing.B) {
          for i := 0; i < b.N; i++ {
             data := dataPool.Get().([]int)
             data = processPool(data)
             dataPool.Put(data)
          }
      }
      
      goos: linux
      goarch: amd64
      pkg: test/benchmarks
      cpu: 11th Gen Intel(R) Core(TM) i5-11400H @ 2.70GHz
      BenchmarkDefault-12       356412              3235 ns/op
      BenchmarkPool-12             341449              3453 ns/op
      
      goos: windows
      goarch: amd64
      pkg: awesomeProject
      cpu: Intel(R) Core(TM) i5-6600 CPU @ 3.30GHz
      BenchmarkDefault-4           207806              5642 ns/opBenchmarkPool-4           207349              5705 ns/op


    1. siberianlaika
      05.06.2024 09:03

      Пример в статье описан не особо удачно, по-моему в таком виде это не отражает работы с sync.Pool. Либо там скипнули код теста, где это может быть было отражено, судя по результатам бенчмарка. А так в примере создается слайс на 10000 int, но для GC это всего одна структура слайса с массивом int внутри. Каждый одиночный int внутри массива не будет рассматриваться GC вообще. К тому же это глобальная переменная, со временем жизни на всё время программи и её вообще GC трогать не будет.

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

      Если через sync.Pool объяснить, что эти слайсы реюзаемые и помещать их в пул при выходе через sync.Put(), то в функции перед тем как аллоцировать новый массив и создавать слайс поверх него будет произведена попытка достать старый уже готовый из пула. Это сэкономит время на make([]int, 10000), вместо новой аллокации sync.Get() подсунет старый из пула, из тех что уже не нужны для обработки (в функции их вернили в пул), но GC их ещё не успел собрать. Если там более сложная структура или требующая аллокации значительной памяти, то экономия времени может быть значительной.

      При этом стоит понимать, что пул это не кеш, объекты в нем не будут храниться и вытесняться по памяти. На очередном срабатывании GC он может вычистить из пула вообще всё. То есть, если конкурентность низкая и для данного примера со слайсами не требуется заводить много этих слайсов в небольшой момент времени, то проку от пула не будет, это только лишняя обертка вокруг создания структур.

      Итого, суть sync.Pool в снижении времени работы GC, при генерации с высокой конкурентностью сложных структур или структур требующих аллокации значительной памяти.

      Вот нашел лучшее описание про sync.Pool: https://habr.com/ru/articles/277137/ -- примеры похожие, но обьяснение (перевод из документации) выглядит толковее, чем в текущей статье.


  1. siberianlaika
    05.06.2024 09:03

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

    У буферизованных и небуферизованных каналов просто разное назначение, дело тут не в пропускной способности. Если логика программы такова, что запись в канал в принципе выше, чем скорость вычитывания, то с любым размером буфера ваши каналы превратятся в небуферизованные -- после их заполнения, просто большой буфер заполнится через время, a приложение отъест больше памяти без всякой прибавки в скорости работы. В большинстве кейсов как раз применимы именно небуферизованные каналы, а буфер надо увеличивать, только при понимании, что это даст в конкретном случае и даст ли вообще.

    Подумал и не стал ставить лайк статье. Автору респект и +1 за попытку и -1 за реализацию, сорри.


    1. SSul Автор
      05.06.2024 09:03

      Спасибо, ценное замечание. В этой статье sync.Pool не рассматривается в полной мере, как и некоторые остальные приемы. Был предоставлен минимум, необходимый для понимания. Если пользователя заинтересует этот инструмент, он пойдет и изучит его подробнее. В примере с sync.Pool я и правда не показал всю работу, но это сделано было в угоду простоте.

      Если бы я в каждом примере дополнительно сервера запускал или с БД работал, было бы труднее донести информацию. А так люди, которые впервые увидят этот же sync.Pool, заинтересуются и начнут искать иные источники информации для понимания. Отдельное спасибо за ссылку на статью!


      1. SSul Автор
        05.06.2024 09:03

        Выше написал ответ на первый комментарий, на второй тоже есть что добавить:

        зачастую можно встретить задачи, где оба вида каналов можно использовать, Если часто приходится перекидываться сообщениями, через каналы лишнее выделение буфера будет однозначно плюсом. В статье и не сказано, что следует использовать только буферизованные каналы, речь идет о случаях, где увеличение буфера не будет пустой траты памяти


  1. Sanchous98
    05.06.2024 09:03

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


    1. SSul Автор
      05.06.2024 09:03

      Вы полностью правы. Grow выделяет нужную память под срез, который находится внутри strings.Builder, чем ускоряет конкатенацию в моем примере в целых два раза