Microoptimizations in Golang

Features of Golang

Compilability allows Golang to convert your code into native machine code for the target platform. This approach provides high performance and resource optimization. This is exactly what you need to create fast and efficient applications. Compiling your code to native machine code means your application will run more efficiently, using fewer system resources, and importantly, providing faster execution times than interpreted languages.

Goroutines

Goroutines are functions or methods that execute in parallel with other goroutines in the same address space. These are lightweight threads that are managed by the Go runtime. They take up significantly less memory than traditional threads and can be created in large quantities without consuming significant system resources.

Advantages of Goroutines:

  1. Lightness: Goroutines take up much less memory than traditional threads. They start with a small stack that can expand and contract dynamically.

  2. Fast context switching: Because goroutines are more lightweight, their context switches much faster, which improves application performance, especially in multi-threaded scenarios.

  3. Ease of use: Creating and managing goroutines in Go is much easier than managing threads in other programming languages.

Simple example:

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")
}

Running the function say in goroutine using keyword go. This allows the function say("world") run in parallel with say("hello"). You will see that the output between “hello” and “world” will alternate, demonstrating parallel execution.

Synchronizing Goroutines

Often there is a need to synchronize work between different goroutines. Go has mechanisms for this, such as channels and 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()
}

We use sync.WaitGroup to wait for all goroutines to complete. Every call worker increments the WaitGroup counter, and wg.Wait() blocks execution until the counter reaches zero.

You can learn more about goroutines in our article.

Pointers in Go

A pointer in Go is a variable whose value is the address of another variable in memory. Pointers play a key role in memory management and data processing, allowing you to work directly with memory and avoid unnecessary data copying.

Pointer Basics:

  1. Creating an index: Operator is used & to get the address of a variable.

  2. Pointer dereference: Operator is used * to access the value at the address pointed to by the pointer.

In Go, you can pass an object to a function either by value or by pointer. Passing by value creates a copy of the object, while passing by pointer allows a function to operate directly on the object without creating a copy of it.

Pass by value:

  • Each time a new copy of the data is created.

  • Changes made to the function are not reflected in the source data.

  • More secure, but may be less efficient for large data structures.

Passing by pointer:

  • The work is done directly with the data, and not with its copy.

  • Changes in data affect the original data.

  • More efficient for large data structures, but requires more careful management.

Pass by value

package main

import "fmt"

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

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

Changes made to features updateValueare not reflected in the variable xbecause the x passed by value.

Passing by pointer

package main

import "fmt"

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

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

Function updatePointer gets a pointer to x and changes the value xusing pointer dereferencing.

Benchmarking in Golang

Benchmarking is not just an additional feature, but a fundamental part of the Golang ecosystem. Package built into the language testing provides powerful tools for writing benchmarks. This allows you to not only test the functionality of your code, but also evaluate its performance under various conditions. Benchmarks in Golang are nothing but functions starting with Benchmarkwhich execute specific code a specific number of times, allowing you to measure execution time and performance.

Go also offers static code analysis tools such as vet And lintwhich analyze source code for common errors and practices that can lead to bugs or ineffective code.

String concatenation in Go

String concatenation is the process of joining two or more strings into one. There are several ways to do this in Golang, including using the operator +functions fmt.Sprintfand method strings.Builder. Each of these methods has its own advantages and disadvantages in terms of performance, which can be identified using benchmarks.

Simple concatenation with the + operator

package main

import (
    "testing"
)

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

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

In this example, we measure the performance of concatenating two strings using the operator +. This is a simple and commonly used method, but it can be ineffective when concatenating a large number of rows due to the need to constantly create new rows.

Using 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 provides a more flexible way of concatenation, allowing variables to be inserted into a string. However, this method may be less performant due to additional formatting processing.

Using 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 is a more efficient way to concatenate strings, especially when working with a large number of strings. This method minimizes the number of memory allocations, making it the preferred choice for high-performance operations.

To run these benchmarks, you need to use the command go test -bench=.. Once launched, Go will run each benchmark and provide information about execution time and operations per second. By analyzing these results, you can determine which string concatenation method is most effective in different scenarios.

pprof Provides detailed insight into how your application uses system resources, including CPU and memory, allowing you to identify bottlenecks and potential areas for improvement.

pprof is a tool for visualizing and analyzing profiling data built into runtime Go. It collects your application’s performance data, such as function execution times and memory usage, and provides them in easy-to-read reports.

Profiling with pprof includes several stages:

  1. Integration pprof to your application:
    To use pprofyou need to import the package net/http/pprof into your application. This automatically adds handlers pprof to your HTTP server

  2. Collection of profiling data:
    Profiling data may be collected while your application is running. You can profile various aspects such as CPU or memory usage.

  3. Analysis of results:
    After collecting data, you can visualize it using tools pprof to analyze and identify bottlenecks.

Simple HTTP server with 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))
}

In this example we create a simple HTTP server and integrate pprof. Now you can visit http://localhost:8080/debug/pprof/ to access profiling data.

CPU profile data collection

To collect a CPU profile, you can use:

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

This command will run CPU profiling for 30 seconds (default) and save the profile for further analysis.

After collecting a profile, you can analyze it using various commands in pprofsuch as topwhich shows the functions using the most CPU, or webwhich visualizes the profile as a call graph.

Resource Usage and Memory Allocation

Profiling CPU and memory usage helps identify the most resource-intensive functions and operations. For this pprof provides two main types of profiles: CPU profile and memory profile (heap profile).

CPU profiling allows you to see which functions are consuming the most CPU time. This gives you an idea of ​​which parts of the code are using the most CPU.

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

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

Then you can build the CPU profile using the following command:

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

Heap Profiling

Memory profiling helps identify places in the code where most allocations occur. This helps you understand how to manage memory more efficiently and identify potential memory leaks.

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

Locks and threads

pprof also offers locking and thread profiling, which can help identify concurrency and concurrency issues in an application.

Lock profiling allows you to see where frequent or lengthy locks are occurring, which could indicate contention problems or inefficient use of mutexes.

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

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

The locking profile is assembled like this:

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

Flows (Goroutine Profiling)

Thread profiling (goroutines) helps you understand how goroutines are distributed and used in your application. This can reveal places where goroutines are piling up or becoming blocked.

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

sync.Pool

sync.Pool is an object cache that can be used to store and reuse objects. Useful in scenarios where memory allocation for objects occurs frequently and the objects have a similar size or structure. Usage sync.Pool helps reduce the number of memory allocations, which in turn reduces the load on the garbage collector and improves application performance.

sync.Pool provides two main methods: Get And Put. Method Get used to get an object from the pool. If the pool is empty, Get automatically creates a new object using the function provided in New. Method Put used to return an object to the pool for later reuse.

Buffer pool

One of the common use cases sync.Pool is a pool of buffers for temporary data, for example, when generating strings.

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())
}

In this example, we create a pool for objects bytes.Buffer. Every time we need a buffer, we take it from the pool, use it and return it, which avoids unnecessary allocations.

Pool of complex objects

sync.Pool also useful for more complex objects that are expensive to create or initialize.

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) // возврат в пул для повторного использования
}

In this case, sync.Pool used to manage a pool of complex objects, which helps reduce the load on the garbage collector and improve application performance.

Conclusion

Micro-optimizations in Golang allow you to maximize the efficiency and performance of your applications. Remember to keep in mind the balance between optimization and code readability, and that optimizations should be applied wisely based on real-world performance measurements and analysis.

You can get more practical skills and life hacks from practicing industry experts as part of an online course Golang Developer. Professional. And those who are interested in other programming languages, I invite you to familiarize yourself with course catalogin which everyone will find a suitable direction.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *