Привет, Хабр!

Каждая миллисекунда имеет значение, микрооптимизация это must have, особенно на языке Go.

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

Особенности Golang

Компилируемость позволяет Golang преобразовывать ваш код в нативный машинный код для целевой платформы. Такой подход обеспечивает высокую производительность и оптимизацию ресурсов. Это как раз то, что нужно для создания быстрых и эффективных приложений. Компиляция кода в нативный машинный код означает, что ваше приложение будет работать более эффективно, используя меньше ресурсов системы, и, что немаловажно, обеспечивая более высокую скорость выполнения по сравнению с интерпретируемыми языками.

Goroutines

Goroutines - это функции или методы, выполняемые параллельно с другими goroutines в том же адресном пространстве. Это легковесные потоки, которые управляются Go runtime. Они занимают значительно меньше памяти по сравнению с традиционными потоками и могут быть созданы в боьших количествах без значительных затрат ресурсов системы.

Преимущества Goroutines:

  1. Легковесность: Goroutines занимают намного меньше памяти, чем традиционные потоки. Они начинаются с маленького стека, который может динамически расширяться и сжиматься.

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

  3. Простота использования: Создание и управление goroutines в Go значительно проще, чем управление потоками в других языках программирования.

Простой пример:

package main

import (
	"fmt"
	"time"
)

func say(s string) {
	for i := 0; i < 5; i++ {
		time.Sleep(100 * time.Millisecond)
		fmt.Println(s)
	}
}

func main() {
	go say("world")
	say("hello")
}

Запускаем функцию say в goroutine с помощью ключевого слова go. Это позволяет функции say("world") выполняться параллельно с say("hello"). Вы увидите, что вывод между "hello" и "world" будет череоваться, что демонстрирует параллельное выполнение.

Синхронизация Goroutines

Часто возникает необходимость в синхронизации работы между разными goroutines. Для этого в Go есть механизмы, такие как каналы (channels) и WaitGroup.

package main

import (
	"fmt"
	"sync"
	"time"
)

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Printf("Worker %d starting\n", id)
	time.Sleep(time.Second)
	fmt.Printf("Worker %d done\n", id)
}

func main() {
	var wg sync.WaitGroup
	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go worker(i, &wg)
	}
	wg.Wait()
}

Используем sync.WaitGroup для ожидания завершения всех goroutines. Каждый вызов worker увеличивает счетчик WaitGroup, и wg.Wait() блокирует выполнение до тех пор, пока счетчик не опустится до нуля.

Более подробно с горутинами можете ознакомиться в нашей статье.

Указатели в Go

Указатель в Go — это переменная, значение которой является адресом другой переменной в памяти. Указатели играют ключевую роль в управлении памятью и обработке данных, позволяя работать непосредственно с памятью и избегать ненужного копирования данных.

Основы указателей:

  1. Создание указателя: Используется оператор & для получения адреса переменной.

  2. Разыменование указателя: Используется оператор * для доступа к значению по адресу, на который указывает указатель.

В Go можно передать объект функции либо по значению, либо по указателю. Передача по значению создает копиюобъекта, в то время как передача по указателю позволяет функции работать непосредственно с объектом, не создавая его копию.

Передача по значению:

  • Каждый раз создается новая копия данных.

  • Изменения, сделанные в функции, не отражаются на исходных данных.

  • Более безопасно, но может быть менее эффективно для больших структур данных.

Передача по указателю:

  • Работа идет непосредственно с данными, а не с их копией.

  • Изменения в данных влияют на исходные данные.

  • Эффективнее для больших структур данных, но требует более аккуратного управления.

Передача по значению

package main

import "fmt"

func updateValue(val int) {
	val += 10
}

func main() {
	x := 20
	updateValue(x)
	fmt.Println(x) // Выводит 20, так как x не изменяется
}

Изменения, внесенные в функции updateValue, не отражаются на переменной x, поскольку x передается по значению.

Передача по указателю

package main

import "fmt"

func updatePointer(val *int) {
	*val += 10
}

func main() {
	x := 20
	updatePointer(&x)
	fmt.Println(x) // Выводит 30, так как x изменяется через указатель
}

Функция updatePointer получает указатель на x и изменяет значение x, используя разыменование указателя.

Бенчмаркинг в Golang

Бенчмаркинг - это не просто дополнительная функция, а фундаментальная часть экосистемы Golang. Встроенный в язык пакет testing предоставляет мощные средства для написания бенчмарков. Это позволяет не только тестировать функциональность своего кода, но и оценивать его производительность в различных условиях. Бенчмарки в Golang - это не что иное, как функции, начинающиеся с Benchmark, которые выполняют определенный код определенное количество раз, позволяя замерять время выполнения и производительность.

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

Конкатенация строк в Go

Конкатенация строк — это процесс соединения двух или более строк в одну. В Golang существует несколько способов сделать это, включая использование оператора +, функции fmt.Sprintf, и метода strings.Builder. Каждый из этих методов имеет свои преимущества и недостатки с точки зрения производительности, которые могут быть выявлены с помощью бенчмарков.

Простая конкатенация с оператором +

package main

import (
    "testing"
)

func BenchmarkConcatOperator(b *testing.B) {
    str1 := "Hello, "
    str2 := "World!"

    for i := 0; i < b.N; i++ {
        _ = str1 + str2
    }
}

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

Использование fmt.Sprintf

package main

import (
    "fmt"
    "testing"
)

func BenchmarkSprintf(b *testing.B) {
    str1 := "Hello, "
    str2 := "World!"

    for i := 0; i < b.N; i++ {
        _ = fmt.Sprintf("%s%s", str1, str2)
    }
}

fmt.Sprintf предоставляет более гибкий способ конкатенации, позволяя вставлять переменные в строку. Однако, этот метод может быть менее производительным из-за дополнительной обработки форматирования.

Использование strings.Builder

package main

import (
    "strings"
    "testing"
)

func BenchmarkStringBuilder(b *testing.B) {
    str1 := "Hello, "
    str2 := "World!"

    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        sb.WriteString(str1)
        sb.WriteString(str2)
        _ = sb.String()
    }
}

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

Для запуска этих бенчмарков, нужно использовать команду go test -bench=.. После запуска, Go выполнит каждый бенчмарк и предоставит информацию о времени выполнения и количестве операций в секунду. Анализируя эти результаты, можно определить, какой метод конкатенации строк является наиболее эффективным в различных сценариях.

pprof предоставляет детальное представление о том, как ваше приложение использует системные ресурсы, включая CPU и память, что позволяет выявить узкие места и потенциальные области для улучшения.

pprof — это инструмент для визуализации и анализа данных профилирования, встроенный в runtime Go. Он собирает данные о производительности вашего приложения, такие как время выполнения функций и использование памяти, и предоставляет их в виде легко читаемых отчетов.

Профайлинг с pprof включает в себя несколько этапов:

  1. Интеграция pprof в ваше приложение:
    Чтобы использовать pprof, необходимо импортировать пакет net/http/pprof в ваше приложение. Это автоматически добавляет обработчики pprof к вашему HTTP-серверу

  2. Сбор данных профилирования:
    Данные профилирования могут быть собраны во время выполнения вашего приложения. Вы можете профилировать различные аспекты, такие как использование CPU или памяти.

  3. Анализ результатов:
    После сбора данных вы можете визуализировать их с помощью инструментов pprof для анализа и выявления узких мест.

Простой HTTP-сервер с pprof

package main

import (
    "log"
    "net/http"
    _ "net/http/pprof"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Привет от ОТУС!"))
    })

    log.Println("Сервер запущен на :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

В этом примере мы создаем простой HTTP-сервер и интегрируем pprof. Теперь вы можете посещать http://localhost:8080/debug/pprof/ для доступа к данным профилирования.

Сбор данных CPU профиля

Для сбора CPU профиля, можно использовать:

go tool pprof http://localhost:8080/debug/pprof/profile

Эта команда запустит профилирование CPU на 30 секунд (по умолчанию) и сохранит профиль для дальнейшего анализа.

После сбора профиля вы можете анализировать его с помощью различных команд в pprof, таких как top, которая показывает функции, использующие наибольшее количество CPU, или web, которая визуализирует профиль в виде графа вызовов.

Использование ресурсов и аллокация памяти

Профилирование использования CPU и памяти помогает выявить функции и операции, которые наиболее требовательны к ресурсам. Для этого pprof предоставляет два основных типа профилей: CPU профиль и профиль памяти (heap profile).

CPU профилирование позволяет увидеть, какие функции потребляют больше всего процессорного времени. Это дает представление о том, какие части кода наиболее интенсивно используют CPU.

import (
    "net/http"
    _ "net/http/pprof"
)

func main() {
    go func() {
        http.ListenAndServe("localhost:6060", nil)
    }()
}

Затем вы можете собрать CPU профиль, используя следующую команду:

go tool pprof http://localhost:6060/debug/pprof/profile

Профилирование памяти (Heap Profiling)

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

go tool pprof http://localhost:6060/debug/pprof/heap

Блокировки и потоки

pprof также предлагает профилирование блокировок и потоков, которые могут помочь выявить проблемы с конкурентностью и параллелизмом в приложении.

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

import (
    "runtime"
    "net/http"
    _ "net/http/pprof"
)

func main() {
    runtime.SetMutexProfileFraction(1)
    go func() {
        http.ListenAndServe("localhost:6060", nil)
    }()
    // ваш код
}

Профиль блокировок собирается так:

go tool pprof http://localhost:6060/debug/pprof/mutex

Потоки (Goroutine Profiling)

Профилирование потоков (goroutines) помогает понять, как распределяются и используются goroutines в вашем приложении. Это может выявить места, где goroutines накапливаются или блокируются.

go tool pprof http://localhost:6060/debug/pprof/goroutine

sync.Pool

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

sync.Pool предоставляет два основных метода: Get и Put. Метод Get используется для получения объекта из пула. Если пул пуст, Get автоматически создает новый объект с помощью функции, предоставленной в New. Метод Put используется для возвращения объекта в пул для последующего повторного использования.

Пул буферов

Один из частых примеров использования sync.Pool - это пул буферов для временных данных, например, при формировании строк.

package main

import (
    "bytes"
    "fmt"
    "sync"
)

var bufPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

func getBuffer() *bytes.Buffer {
    return bufPool.Get().(*bytes.Buffer)
}

func putBuffer(buf *bytes.Buffer) {
    buf.Reset()
    bufPool.Put(buf)
}

func main() {
    buf := getBuffer()
    defer putBuffer(buf)
    buf.WriteString("Hello, ")
    buf.WriteString("World!")
    fmt.Println(buf.String())
}

В этом примере, мы создаем пул для объектов bytes.Buffer. Каждый раз, когда нам нужен буфер, мы берем его из пула, используем и возвращаем обратно, что позволяет избежать ненужных аллокаций.

Пул сложных объектов

sync.Pool также полезен для более сложных объектов, которые дорогостоящи в создании или инициализации.

package main

import (
    "fmt"
    "sync"
)

type ComplexObject struct {
    // представим, что здесь много полей
}

var objectPool = sync.Pool{
    New: func() interface{} {
        // дорогостоящая операция инициализации
        return &ComplexObject{}
    },
}

func getObject() *ComplexObject {
    return objectPool.Get().(*ComplexObject)
}

func putObject(obj *ComplexObject) {
    objectPool.Put(obj)
}

func main() {
    obj := getObject()
    // использование obj
    putObject(obj) // возврат в пул для повторного использования
}

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

Заключение

Микрооптимизации в Golang позволяет максимизировать эффективность и производительность приложений. Не забывайте помнить балансе между оптимизацией и читаемостью кода, а также о том, что оптимизации следует применять с умом, основываясь на реальных измерениях и анализе производительности.

Больше практических навыков и лайфхаков вы можете получить от практикующих экспертов отрасли в рамках онлайн-курса Golang Developer. Professional. А тех, кого интересуют другие языки программирования, приглашаю ознакомиться с каталогом курсов, в котором каждый найдет подходящее направление.

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


  1. noRoman
    22.11.2023 13:58

    Конкатенация строк.
    Примеры простые и тут хорошо видно кто выигрывает)

    goos: linux
    goarch: amd64
    cpu: Intel(R) Core(TM) i5-7400 CPU @ 3.00GHz
    BenchmarkConcatOperator-4  62212492  19.09 ns/op  0 B/op   0 allocs/op
    BenchmarkSprintf-4         6850207   172.6 ns/op  48 B/op  3 allocs/op
    BenchmarkStringBuilder-4   19044630  53.52 ns/op  24 B/op  2 allocs/op


    1. mrobespierre
      22.11.2023 13:58

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


      1. noRoman
        22.11.2023 13:58
        +1

        и я про то. Статья больше в заблуждение вводит


  1. kowack
    22.11.2023 13:58
    +11

    Ну так а микрооптимизации где? Про что статья?)


  1. dsh2dsh
    22.11.2023 13:58
    +1

    Каждая миллисекунда имеет значение, микрооптимизация это must have, особенно на языке Go.

    Серьезно, б..... Как только вы обращаетесь хоть к одному внешнему сервису, по сети, а вы будете это делать, иначе у вас сферический конь в вакууме, забудьте слово "микрооптимизации".


  1. gohrytt
    22.11.2023 13:58
    +2

    Убрать из названия статьи слова про микрооптимизации и текст будет неплох для новичков.


  1. Genre-slayer
    22.11.2023 13:58
    +1

    А как же strings.Join?


  1. titan_pc
    22.11.2023 13:58

    А как же складывать строки байтами через unsafe?


  1. Sanchous98
    22.11.2023 13:58

    Коротко об этой статье: галопом по Европам. Про память очень скудно: сказали про значения и указатели, но ни слова о stack, heap и escape analysis, а стоило, потому что ключевая разница в производительности заключается именно в том, в какой области памяти будет выделено значение. Не сказано, в каких случаях escape analysis может НЕ выделить память в heap для значения доступного по указателю, когда копирование значения быстрее, а когда медленнее чем аллокация.

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

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


  1. Rorian
    22.11.2023 13:58

    На мой взгляд, заголовок статьи вводит в заблуждение. Речь в ней почти не идет о микрооптимизациях кода. Большая часть текста посвящена Go и его общему устройству.


  1. Anarchist
    22.11.2023 13:58

    Хороший язык на "го" не назовут.