Golang: Como a observabilidade e profiling revelaram um throttling quase indetectável

Matheus - Oct 9 - - Dev Community

Em um projeto pessoal com Go, que obtém informações de ativos financeiros da Bovespa.
O sistema faz uso intenso de concorrência e paralelismo com goroutines, atualizando as informações de ativos(junto com os cálculos do negócio) a cada 8 segundos.
Inicialmente, não aparecia nenhum erro ou warning, mas percebi que algumas goroutines estavam levando mais tempo do que outras para serem executadas.

Para ser mais específico, enquanto o tempo p99 estava em 0.03 ms, em alguns momentos, ele aumentava para 0.9 ms. Isso me levou a investigar mais a fundo o problema.

Descobri que estava usando uma pool de goroutines com semáforo, que foi criada com base na variável GOMAXPROCS.
No entanto, percebi que havia um problema com essa abordagem.

Quando utilizamos a variável GOMAXPROCS, ela não captura corretamente a quantidade de núcleos disponíveis no container. Se o container tiver menos núcleos disponíveis do que o total da VM, ele considera o total da VM. Por exemplo, minha VM possui 8 núcleos disponíveis, mas o container tinha apenas 4. Isso resultou na criação de 8 goroutines para serem executadas ao mesmo tempo, causando throttling.

Após muita pesquisa durante a madrugada, encontrei uma biblioteca desenvolvida pela Uber que ajusta automaticamente a variável GOMAXPROCS de forma mais eficiente, independentemente de estar em container ou não. Essa solução se mostrou extremamente estável e eficiente: automaxprocs

GitHub logo uber-go / automaxprocs

Automatically set GOMAXPROCS to match Linux container CPU quota.

automaxprocs GoDoc Build Status Coverage Status

Automatically set GOMAXPROCS to match Linux container CPU quota.

Installation

go get -u go.uber.org/automaxprocs

Quick Start

import _ "go.uber.org/automaxprocs"

func main() {
  // Your application logic here.
}
Enter fullscreen mode Exit fullscreen mode

Performance

Data measured from Uber's internal load balancer. We ran the load balancer with 200% CPU quota (i.e., 2 cores):

GOMAXPROCS RPS P50 (ms) P99.9 (ms)
1 28,893.18 1.46 19.70
2 (equal to quota) 44,715.07 0.84 26.38
3 44,212.93 0.66 30.07
4 41,071.15 0.57 42.94
8 33,111.69 0.43 64.32
Default (24) 22,191.40 0.45 76.19

When GOMAXPROCS is increased above the CPU quota, we see P50 decrease slightly, but see significant increases to P99. We also see that the total RPS handled also decreases.

When GOMAXPROCS is higher than the CPU quota allocated, we also saw significant throttling:

$ cat /sys/fs/cgroup/cpu,cpuacct/system.slice/[...]/cpu.stat
nr_periods 42227334
nr_throttled 131923
throttled_time 88613212216618

Once GOMAXPROCS was reduced to match the CPU quota, we saw no CPU throttling.

.

Após implementar o uso dessa biblioteca, o problema foi resolvido, e agora o tempo p99 se manteve em 0.02 ms constantemente. Essa experiência destacou a importância da observabilidade e do profiling em sistemas concorrentes.

A seguir um exemplo bem simples, mas que consegue demonstrar a diferença de desempenho.

Utilizando o pacote nativo de testes e benckmak do Go, criei dois arquivos:

benchmarking_with_enhancement_test.go:

package main

import (
    _ "go.uber.org/automaxprocs"
    "runtime"
    "sync"
    "testing"
)

// BenchmarkWithEnhancement Função com melhoria, para adicionar o indice do loop em um array de inteiro
func BenchmarkWithEnhancement(b *testing.B) {
    // Obtém o número de CPUs disponíveis
    numCPUs := runtime.NumCPU()
    // Define o máximo de CPUs para serem usadas pelo programa
    maxGoroutines := runtime.GOMAXPROCS(numCPUs)
    // Criação do semáforo
    semaphore := make(chan struct{}, maxGoroutines)

    var (
        // Espera para grupo de goroutines finalizar
        wg sync.WaitGroup
        // Propriade
        mu sync.Mutex
        // Lista para armazenar inteiros
        list []int
    )

    // Loop com mihão de indices
    for i := 0; i < 1000000; i++ {
        semaphore <- struct{}{}
        // Adiciona ao waitGroup que existe mais uma goroutine para ser executada
        wg.Add(1)

        // Atribui a função a uma nova goroutine
        go func(i int) {
            // Quando a função finalizar, informa o semáforo e finaliza um registro do waitGroup
            defer func() {
                <-semaphore
                wg.Done()
            }()
            // Faz o bloqueio do array para outra goroutine não sobreescrever
            mu.Lock()
            // Adiciona o indice, em mais uma posição no array
            list = append(list, i)
            // Desbloqueia o array
            mu.Unlock()
        }(i)
    }
}

Enter fullscreen mode Exit fullscreen mode

benchmarking_without_enhancement_test.go:

package main

import (
    "runtime"
    "sync"
    "testing"
)

// BenchmarkWithoutEnhancement Função sem a melhoria, para adicionar o indice do loop em um array de inteiro
func BenchmarkWithoutEnhancement(b *testing.B) {
    // Obtém o número de CPUs disponíveis
    numCPUs := runtime.NumCPU()
    // Define o máximo de CPUs para serem usadas pelo programa
    maxGoroutines := runtime.GOMAXPROCS(numCPUs)
    // Criação do semáforo
    semaphore := make(chan struct{}, maxGoroutines)

    var (
        // Espera para grupo de goroutines finalizar
        wg sync.WaitGroup
        // Propriade
        mu sync.Mutex
        // Lista para armazenar inteiros
        list []int
    )

    // Loop com mihão de indices
    for i := 0; i < 1000000; i++ {
        semaphore <- struct{}{}
        // Adiciona ao waitGroup que existe mais uma goroutine para ser executada
        wg.Add(1)

        // Atribui a função a uma nova goroutine
        go func(i int) {
            // Quando a função finalizar, informa o semáforo e finaliza um registro do waitGroup
            defer func() {
                <-semaphore
                wg.Done()
            }()
            // Faz o bloqueio do array para outra goroutine não sobreescrever
            mu.Lock()
            // Adiciona o indice, em mais uma posição no array
            list = append(list, i)
            // Desbloqueia o array
            mu.Unlock()
        }(i)
    }
}

Enter fullscreen mode Exit fullscreen mode

A diferença entra elas, é que uma esta com a importação de biblioteca da Uber.

Ao executar o benchmark passando que seriam usados 2 CPUs, o resultado foi:

Image description

ns/op: fornece uma média em nanosegundos de quanto tempo leva para executar uma operação específica.

Percebam, que o total disponível da minha CPU são 8 núcleos, e foi o que a propriedade runtime.NumCPU() retornou. Porém, como na execução do benchmark, defini que o uso seriam de apenas duas CPUs, a o arquivo que não utilizou a automaxprocs, definiu que o limite de execução por vez, seriam de 8 goroutines, enquanto o mais eficiente seriam 2, pois dessa maneira se usa menos alocação deixa mais eficiente a execução.

Então, fica nítido a importância de observabilidade e proffiling das nossas aplicações.

. . .
Terabox Video Player