Skip to content

Análisis de Rendimiento

Cuando se completa un programa, nuestros requisitos no son solo que pueda ejecutarse, sino que también esperamos que sea una aplicación estable y eficiente. A través de diversas pruebas, podemos garantizar la mayor parte de la estabilidad del programa. Para determinar si el programa es eficiente, necesitamos realizar un análisis de rendimiento. En el contenido anterior, el único medio de análisis de rendimiento era usar Benchmark para probar el tiempo promedio de ejecución de una unidad funcional, la asignación de memoria, etc. Sin embargo, en la realidad, las necesidades de análisis de rendimiento de programas van mucho más allá de esto. A veces necesitamos analizar el uso general de CPU del programa, el uso de memoria, la asignación de heap, el estado de las goroutines, las rutas de código críticas, etc. Esto es algo que Benchmark no puede satisfacer. Afortunadamente, la cadena de herramientas de Go integra muchas herramientas de análisis de rendimiento para que los desarrolladores las usen. A continuación las explicaremos una por una.

Análisis de Escape

En Go, la asignación de memoria de las variables es decidida por el compilador, generalmente se asignan en el stack o en el heap. Si una variable que debería asignarse en el stack se asigna en el heap, esta situación se llama escape. El análisis de escape consiste en analizar la situación de asignación de memoria en el programa. Dado que se realiza durante la compilación, es un tipo de análisis estático.

TIP

Visita el artículo Asignación de Memoria para entender cómo Go asigna memoria específicamente.

Referencia a Punteros Locales

go
package main

func main() {
  GetPerson()
}

type Person struct {
  Name string
  Mom  *Person
}

func GetPerson() Person {
  mom := Person{Name: "lili"}
  son := Person{Name: "jack", Mom: &mom}
  return son
}

En la función GetPerson se crea la variable mom. Dado que se crea dentro de la función, originalmente debería asignarse en el stack. Pero es referenciada por el campo Mom de son, y son se devuelve como valor de retorno de la función, por lo que el compilador la asigna en el heap. Este es un ejemplo muy simple, por lo que no requiere mucho esfuerzo entenderlo. Pero si es un proyecto más grande con decenas de miles de líneas de código, el análisis manual no sería tan fácil. Por eso necesitamos usar herramientas para realizar el análisis de escape. Como se mencionó anteriormente, la asignación de memoria es dirigida por el compilador, por lo que el análisis de escape también lo realiza el compilador. Es muy simple de usar, solo necesitas ejecutar el siguiente comando:

bash
$ go build -gcflags="-m -m -l"

gcflags son los parámetros del compilador gc:

  • -m, imprime sugerencias de optimización de código. Si aparece dos veces, se muestra con más detalle
  • -l, deshabilita la optimización de inline

La salida es la siguiente:

bash
$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:13:2: mom escapes to heap:
./main.go:13:2:   flow: son = &mom:
./main.go:13:2:     from &mom (address-of) at ./main.go:14:35
./main.go:13:2:     from Person{...} (struct literal element) at ./main.go:14:15
./main.go:13:2:     from son := Person{...} (assign) at ./main.go:14:6
./main.go:13:2:   flow: ~r0 = son:
./main.go:13:2:     from return son (return) at ./main.go:15:2
./main.go:13:2: moved to heap: mom

El compilador nos dice claramente que la variable mom escapó. La razón es que el valor de retorno incluye un puntero local de la función. Además de esta situación, hay otros casos que pueden causar escape.

::: tips

Si te interesan los detalles del análisis de escape, puedes encontrar más información en la biblioteca estándar cmd/compile/internal/escape/escape.go.

:::

Referencia por Cierre

Si un cierre referencia una variable fuera de la función, esa variable también escapará al heap. Esto es fácil de entender.

go
package main

func main() {
  a := make([]string, 0)
  do(func() []string {
    return a
  })
}

func do(f func() []string) []string {
  return f()
}

Salida:

$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:10:9: f does not escape
./main.go:4:2: main capturing by value: a (addr=false assign=false width=24)
./main.go:4:11: make([]string, 0) escapes to heap:
./main.go:4:11:   flow: a = &{storage for make([]string, 0)}:
./main.go:4:11:     from make([]string, 0) (spill) at ./main.go:4:11
./main.go:4:11:     from a := make([]string, 0) (assign) at ./main.go:4:4
./main.go:4:11:   flow: ~r0 = a:
./main.go:4:11:     from return a (return) at ./main.go:6:3
./main.go:4:11: make([]string, 0) escapes to heap
./main.go:5:5: func literal does not escape

Espacio Insuficiente

Cuando el espacio del stack es insuficiente, también ocurre escape. El siguiente slice solicita una capacidad de 1<<15:

go
package main

func main() {
  _ = make([]int, 0, 1<<15)
}

Salida:

$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:4:10: make([]int, 0, 32768) escapes to heap:
./main.go:4:10:   flow: {heap} = &{storage for make([]int, 0, 32768)}:
./main.go:4:10:     from make([]int, 0, 32768) (too large for stack) at ./main.go:4:10
./main.go:4:10: make([]int, 0, 32768) escapes to heap

Longitud Desconocida

Cuando la longitud de un slice es una variable, debido a que su longitud es desconocida, ocurrirá escape (map no lo hace):

go
package main

func main() {
  n := 100
  _ = make([]int, n)
}

Salida:

$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:5:10: make([]int, n) escapes to heap:
./main.go:5:10:   flow: {heap} = &{storage for make([]int, n)}:
./main.go:5:10:     from make([]int, n) (non-constant size) at ./main.go:5:10
./main.go:5:10: make([]int, n) escapes to heap

Hay otra situación especial: cuando los parámetros de una función son de tipo ...any, también puede ocurrir escape:

go
package main

import "fmt"

func main() {
  n := 100
  fmt.Println(n)
}

Salida:

$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:7:14: n escapes to heap:
./main.go:7:14:   flow: {storage for ... argument} = &{storage for n}:
./main.go:7:14:     from n (spill) at ./main.go:7:14
./main.go:7:14:     from ... argument (slice-literal-element) at ./main.go:7:13
./main.go:7:14:   flow: {heap} = {storage for ... argument}:
./main.go:7:14:     from ... argument (spill) at ./main.go:7:13
./main.go:7:14:     from fmt.Println(... argument...) (call parameter) at ./main.go:7:13
./main.go:7:13: ... argument does not escape
./main.go:7:14: n escapes to heap

La razón por la que realizamos análisis de escape y controlamos tan detalladamente la asignación de memoria es principalmente para reducir la presión sobre el GC. Sin embargo, Go no es C; la decisión final sobre la asignación de memoria sigue en manos del compilador. Excepto en casos de requisitos de rendimiento extremos, la mayoría de las veces no necesitamos enfocarnos demasiado en los detalles de la asignación de memoria. Después de todo, el propósito del GC es liberar a los desarrolladores.

Pequeño detalle

Para algunos tipos de referencia, cuando confirmamos que ya no los usaremos, podemos establecerlos en nil para indicarle al GC que puede回收arlos.

go
type Writer struct {
  buf []byte
}

func (w Writer) Close() error {
  w.buff = nil
  return nil
}

pprof

pprof (program profiling) es una herramienta poderosa para el análisis de rendimiento de programas. Realiza un muestreo parcial de los datos en tiempo de ejecución del programa, cubriendo CPU, memoria, goroutines, locks, información de stack y muchos otros aspectos. Luego usa herramientas para analizar y mostrar los resultados de los datos muestreados.

Por lo tanto, el uso de pprof solo tiene dos pasos:

  1. Recopilar datos
  2. Analizar resultados

Recopilación

Hay dos formas de recopilar datos: automática y manual, cada una con sus ventajas y desventajas. Antes de esto, escribamos una función simple para simular el consumo de memoria y CPU:

go
func Do() {
  for i := 0; i < 10; i++ {
    slice := makeSlice()
    sortSlice(slice)
  }
}

func makeSlice() []int {
  var s []int
  for range 1 << 24 {
    s = append(s, rand.Int())
  }
  return s
}

func sortSlice(s []int) {
  slices.Sort(s)
}

Manual

La recopilación manual se controla mediante código. Sus ventajas son que es controlable, flexible y personalizable. Para usar pprof directamente en el código, necesitas importar el paquete runtime/pprof:

go
package main

import (
  "log"
  "os"
  "runtime/pprof"
)

func main() {
    Do()
  w, _ := os.Create("heap.pb")
  heapProfile := pprof.Lookup("heap")
  err := heapProfile.WriteTo(w, 0)
  if err != nil {
    log.Fatal(err)
  }
}

Los parámetros soportados por pprof.Lookup se muestran en el siguiente código:

go
profiles.m = map[string]*Profile{
    "goroutine":    goroutineProfile,
    "threadcreate": threadcreateProfile,
    "heap":         heapProfile,
    "allocs":       allocsProfile,
    "block":        blockProfile,
    "mutex":        mutexProfile,
}

Esta función escribe los datos recopilados en un archivo especificado. El número pasado al escribir tiene los siguientes significados:

  • 0, escribe datos Protobuf comprimidos, sin legibilidad
  • 1, escribe datos en formato de texto, legible. Esto es lo que devuelve la interfaz HTTP
  • 2, solo disponible para goroutine, imprime información de stack estilo panic

Para recopilar datos de CPU, se debe usar la función pprof.StartCPUProfile por separado. Requiere cierto tiempo para muestrear y sus datos brutos no son legibles:

go
package main

import (
  "log"
  "os"
  "runtime/pprof"
  "time"
)

func main() {
    Do()
  w, _ := os.Create("cpu.out")
  err := pprof.StartCPUProfile(w)
  if err != nil {
    log.Fatal(err)
  }
  time.Sleep(time.Second * 10)
  pprof.StopCPUProfile()
}

Recopilar datos de trace es similar:

go
package main

import (
  "log"
  "os"
  "runtime/trace"
  "time"
)

func main() {
    Do()
  w, _ := os.Create("trace.out")
  err := trace.Start(w)
  if err != nil {
    log.Fatal(err)
  }
  time.Sleep(time.Second * 10)
  trace.Stop()
}

Automático

El paquete net/http/pprof envuelve las funciones de análisis anteriores en interfaces HTTP y las registra en la ruta predeterminada:

go
package pprof

import ...

func init() {
    http.HandleFunc("/debug/pprof/", Index)
    http.HandleFunc("/debug/pprof/cmdline", Cmdline)
    http.HandleFunc("/debug/pprof/profile", Profile)
    http.HandleFunc("/debug/pprof/symbol", Symbol)
    http.HandleFunc("/debug/pprof/trace", Trace)
}

Esto nos permite ejecutar la recopilación de datos de pprof con un solo paso:

go
package main

import (
  "net/http"
    // Recuerda importar este paquete
  _ "net/http/pprof"
)

func main() {
    go func(){
        http.ListenAndServe(":8080", nil)
    }()
    for {
        Do()
    }
}

Ahora abre el navegador y visita http://127.0.0.1:8080/debug/pprof, aparecerá una página como esta:

En la página hay varias opciones disponibles, que representan:

  • allocs: muestreo de asignación de memoria
  • block: seguimiento de bloqueos de primitivas de sincronización
  • cmdline: invocación de línea de comandos del programa actual
  • goroutine: seguimiento de todas las goroutines
  • heap: muestreo de asignación de memoria para objetos activos
  • mutex: seguimiento de información relacionada con mutex
  • profile: análisis de CPU, analiza durante un tiempo y descarga un archivo
  • threadcreate: análisis de las razones que causan la creación de nuevos threads del SO
  • trace: seguimiento de la ejecución del programa actual, también descarga un archivo

La mayoría de estos datos no tienen alta legibilidad; se usan principalmente para que las herramientas los analicen, como se muestra a continuación:

El trabajo de análisis específico se dejará para más adelante. Además de las dos opciones profile y trace, si quieres descargar archivos de datos en la página web, puedes eliminar el parámetro query debug=1. También puedes integrar estas interfaces en tu propio enrutador en lugar de usar la ruta predeterminada:

go
package main

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

func main() {
  mux := http.NewServeMux()
  mux.HandleFunc("/trace", pprof.Trace)
  servre := &http.Server{
    Addr:    ":8080",
    Handler: mux,
  }
  servre.ListenAndServe()
}

De esta manera, también puedes integrarlos en otros frameworks web como gin, iris, etc.

Análisis

Después de obtener los archivos de datos recopilados, hay dos formas de analizarlos: línea de comandos o página web. Ambos necesitan usar la herramienta de línea de comandos pprof. Go integra esta herramienta por defecto, por lo que no necesitas descargarla adicionalmente.

Dirección de código abierto de pprof: google/pprof: pprof is a tool for visualization and analysis of profiling data (github.com)

Línea de Comandos

Usa el archivo de datos recopilado anteriormente como parámetro:

bash
$ go tool pprof heap.pb

Si los datos fueron recopilados por web, reemplaza el nombre del archivo con la URL web:

bash
$ go tool pprof -http :8080 http://127.0.0.1/debug/pprof/heap

Luego aparecerá una línea de comandos interactiva:

bash
15:27:38.3266862 +0800 CST
Type: inuse_space
Time: Apr 15, 2024 at 3:27pm (CST)
No samples were found with the default sample value type.
Try "sample_index" command to analyze different sample values.
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)

Escribe help para ver otros comandos:

  Commands:
    callgrind        Outputs a graph in callgrind format
    comments         Output all profile comments
    disasm           Output assembly listings annotated with samples
    dot              Outputs a graph in DOT format
    eog              Visualize graph through eog
    evince           Visualize graph through evince
  ...

Para ver datos en la línea de comandos, generalmente se usa el comando top. También puedes usar el comando traces, pero su salida es muy larga. El comando top solo da una idea general:

(pprof) top 5
Showing nodes accounting for 117.49MB, 100% of 117.49MB total
      flat  flat%   sum%        cum   cum%
  117.49MB   100%   100%   117.49MB   100%  main.makeSlice (inline)
         0     0%   100%   117.49MB   100%  main.Do
         0     0%   100%   117.49MB   100%  main.main
         0     0%   100%   117.49MB   100%  runtime.main

Introduzcamos brevemente algunos de estos indicadores (lo mismo para CPU):

  • flat: representa los recursos consumidos por la función actual
  • cum: suma total de recursos consumidos por la función actual y su cadena de llamadas posterior
  • flat%: flat/total
  • cum%: cum/total

Podemos ver claramente que la ocupación de memoria de toda la pila de llamadas es 117.49MB. Dado que la función Do en sí no hace nada, solo llama a otras funciones, su indicador flat es 0. La creación del slice es responsabilidad de la función makeSlice, por lo que su indicador flat es 100%.

Podemos convertir a formatos de visualización. pprof soporta muchos formatos como pdf, svg, png, gif, etc. (necesitas instalar Graphviz):

(pprof) png
Generating report in profile001.png

A través de la imagen podemos ver más claramente la situación de memoria de toda la pila de llamadas.

Usa el comando list para ver en forma de código fuente:

(pprof) list Do
Total: 117.49MB
ROUTINE ======================== main.Do in D:\WorkSpace\Code\GoLeran\golearn\example\main.go
         0   117.49MB (flat, cum)   100% of Total
         .          .     21:func Do() {
         .          .     22:   for i := 0; i < 10; i++ {
         .   117.49MB     23:           slice := makeSlice()
         .          .     24:           sortSlice(slice)
         .          .     25:   }
         .          .     26:}
         .          .     27:
         .          .     28:func makeSlice() []int {

Para imágenes y código fuente, también puedes usar los comandos web y weblist para verlos en el navegador.

Página Web

Antes de esto, para tener datos más diversos, modifiquemos las funciones de simulación:

go
func Do1() {
  for i := 0; i < 10; i++ {
    slice := makeSlice()
    sortSlice(slice)
  }
}

func Do2() {
  for i := 0; i < 10; i++ {
    slice := makeSlice()
    sortSlice(slice)
  }
}

func makeSlice() []int {
  var s []int
  for range 1 << 12 {
    s = append(s, rand.Int())
  }
  return s
}

func sortSlice(s []int) {
  slices.Sort(s)
}

El análisis web puede visualizar resultados, eliminando la necesidad de operar manualmente la línea de comandos. Para usar el análisis web, solo ejecuta el siguiente comando:

bash
$ go tool pprof -http :8080 heap.pb

Si los datos fueron recopilados por web, reemplaza el nombre del archivo con la URL web:

bash
$ go tool pprof -http :8080 http://127.0.0.1:9090/debug/pprof/heap
$ go tool pprof -http :8080 http://127.0.0.1:9090/debug/pprof/profile
$ go tool pprof -http :8080 http://127.0.0.1:9090/debug/pprof/goroutine

TIP

Para saber cómo analizar datos, visita pprof: How to read the graph para obtener más información.

En la página web hay un total de 6 elementos visibles:

  • Top, igual que el comando top
  • Graph, gráfico de líneas
  • Flame Graph, gráfico de llama
  • Peek
  • Source, ver código fuente
  • Disassemble, desensamblar

Para memoria, se pueden analizar cuatro dimensiones:

  • alloc_objects: cantidad total de objetos asignados actualmente, incluidos los liberados
  • alloc_spcae: todo el espacio de memoria asignado hasta ahora, incluido el liberado
  • inuse_objects: cantidad de objetos en uso
  • inuse_space: espacio de memoria en uso

Gráfico de análisis de memoria

Los nodos hoja blancos en la parte inferior de la imagen anterior representan la ocupación de objetos de diferentes tamaños.

Gráfico de análisis de CPU

Sobre el gráfico de líneas, hay algunos puntos a tener en cuenta:

  • Cuanto más oscuro es el color del bloque, mayor es la ocupación; cuanto más gruesa es la línea, mayor es la ocupación
  • Las líneas sólidas representan llamadas directas; las líneas punteadas representan que se omitieron algunas cadenas de llamadas

Gráfico de llama de memoria

Gráfico de llama de CPU

Para el gráfico de llama, de arriba hacia abajo es la cadena de llamadas; de izquierda a derecha es el porcentaje de ocupación cum.

trace

pprof se encarga principalmente de analizar el uso de recursos del programa, mientras que trace es más adecuado para rastrear los detalles de ejecución del programa. Sus archivos de datos son incompatibles entre sí. El comando go tool trace se usa para realizar el trabajo de análisis relacionado.

Si los datos fueron recopilados manualmente, puedes usar el nombre del archivo como parámetro:

$ go tool trace trace.out

Si fue recopilado automáticamente, es lo mismo:

bash
$ curl http://127.0.0.1:8080/debug/pprof/trace > trace.out && go tool trace trace.out

Después de ejecutarlo, se iniciará un servidor web:

2024/04/15 17:15:40 Preparing trace for viewer...
2024/04/15 17:15:40 Splitting trace for viewer...
2024/04/15 17:15:40 Opening browser. Trace viewer is listening on http://127.0.0.1:51805

Después de abrirlo, la página se verá aproximadamente así:

Esto contiene principalmente las siguientes partes. No es fácil entender estos datos:

  • Event timelines for running goroutines

    • trace by proc: muestra la línea de tiempo de las goroutines ejecutándose en ese procesador en cada momento

    • trace by thread: muestra la línea de tiempo de las goroutines ejecutándose en threads del SO en cada momento

    • Goroutine analysis: muestra información estadística relacionada con goroutines para cada grupo de funciones principales

  • Profiles

    • Network blocking profile: información de goroutines bloqueadas por IO de red
    • Synchronization blocking profile: información de goroutines bloqueadas por primitivas de sincronización
    • Syscall profile: información de goroutines bloqueadas por llamadas al sistema
  • User-defined tasks and regions

    • User-defined tasks: información de goroutines relacionadas con tareas definidas por el usuario
    • User-defined regions: información de goroutines relacionadas con regiones de código definidas por el usuario
  • Garbage collection metrics

    • Minimum mutator utilization: muestra el tiempo máximo reciente de GC

Golang editado por www.golangdev.cn