Cómo investigué las fugas de memoria en Go usando pprof en una base de código grande

He estado trabajando con Go durante la mayor parte del año, implementando una infraestructura de cadena de bloques escalable en Orbs, y ha sido un año emocionante. A lo largo de 2018, investigamos qué idioma elegir para nuestra implementación de blockchain. Esto nos llevó a elegir Go porque entendemos que tiene una buena comunidad y un increíble conjunto de herramientas.

En las últimas semanas estamos entrando en las etapas finales de integración de nuestro sistema. Como en cualquier sistema grande, pueden ocurrir problemas de etapa posterior que incluyen problemas de rendimiento, en pérdidas de memoria específicas. Mientras íbamos integrando el sistema, nos dimos cuenta de que habíamos encontrado uno. En este artículo, tocaré los detalles de cómo investigar una fuga de memoria en Go, detallando los pasos que se tomaron para encontrarla, comprenderla y resolverla.

El conjunto de herramientas que ofrece Golang es excepcional pero tiene sus limitaciones. Tocando estos primero, el más importante es la capacidad limitada para investigar volcados de núcleo completos. Un volcado de núcleo completo sería la imagen de la memoria (o memoria del usuario) tomada por el proceso que ejecuta el programa.

Podemos imaginar el mapeo de la memoria como un árbol, y atravesar ese árbol nos llevaría a través de las diferentes asignaciones de objetos y las relaciones. Esto significa que lo que sea que esté en la raíz es la razón para 'retener' la memoria y no para GC (recolección de basura). Dado que en Go no hay una forma sencilla de analizar el volcado de núcleo completo, es difícil llegar a las raíces de un objeto que no obtiene GC-ed.

En el momento de escribir este artículo, no pudimos encontrar ninguna herramienta en línea que nos pueda ayudar con eso. Dado que existe un formato de volcado de núcleo y una forma bastante sencilla de exportarlo desde el paquete de depuración, es posible que haya uno utilizado en Google. Al buscar en línea, parece que está en la tubería de Golang, lo que crea un visor de volcado de núcleo, pero no parece que nadie esté trabajando en ello. Dicho esto, incluso sin acceso a dicha solución, con las herramientas existentes normalmente podemos llegar a la causa raíz.

Pérdidas de memoria

Las pérdidas de memoria o la presión de la memoria pueden presentarse de muchas formas en todo el sistema. Por lo general, los abordamos como errores, pero a veces su causa raíz puede estar en las decisiones de diseño.

A medida que construimos nuestro sistema bajo principios de diseño emergentes, no se cree que tales consideraciones sean importantes y eso está bien. Es más importante construir el sistema de una manera que evite optimizaciones prematuras y le permita realizarlas más adelante a medida que el código madura, en lugar de diseñarlo en exceso desde el principio. Aún así, algunos ejemplos comunes de cómo se materializan problemas de presión de memoria son:

  • Demasiadas asignaciones, representación de datos incorrecta
  • Uso intensivo de reflejos o cuerdas
  • Usando globales
  • Goroutines huérfanos e interminables

En Go, la forma más sencilla de crear una pérdida de memoria es definir una variable global, una matriz y agregar datos a esa matriz. Esta gran publicación de blog describe ese caso de una buena manera.

Entonces, ¿por qué estoy escribiendo esta publicación? Cuando estaba investigando este caso, encontré muchos recursos sobre pérdidas de memoria. Sin embargo, en realidad, los sistemas tienen más de 50 líneas de código y una sola estructura. En tales casos, encontrar el origen de un problema de memoria es mucho más complejo de lo que describe ese ejemplo.

Golang nos brinda una herramienta increíble llamada pprof. Esta herramienta, cuando se domina, puede ayudar a investigar y, muy probablemente, a encontrar cualquier problema de memoria. Otro propósito que tiene es investigar problemas de CPU, pero no entraré en nada relacionado con la CPU en esta publicación.

ir herramienta pprof

Cubrir todo lo que hace esta herramienta requerirá más de una publicación de blog. Una cosa que tomó un tiempo fue descubrir cómo usar esta herramienta para obtener algo que se pueda procesar. Concentraré esta publicación en la característica relacionada con la memoria.

El pprofpaquete crea un archivo de volcado de muestra de montón, que luego puede analizar / visualizar para darle un mapa de ambos:

  • Asignaciones de memoria actuales
  • Asignaciones de memoria totales (acumulativas)

La herramienta tiene la capacidad de comparar instantáneas. Esto puede permitirle comparar una visualización de diferencia de tiempo de lo que sucedió ahora y hace 30 segundos, por ejemplo. Para escenarios de estrés, esto puede ser útil para ayudar a localizar áreas problemáticas de su código.

perfiles pprof

La forma en que funciona pprof es utilizando perfiles.

Un perfil es una colección de seguimientos de pila que muestra las secuencias de llamadas que llevaron a instancias de un evento en particular, como la asignación.

El archivo runtime / pprof / pprof.go contiene la información detallada y la implementación de los perfiles.

Go tiene varios perfiles integrados para que los usemos en casos comunes:

  • goroutine: apila el seguimiento de todas las goroutines actuales
  • montón: una muestra de las asignaciones de memoria de los objetos en vivo
  • asignaciones: una muestra de todas las asignaciones de memoria anteriores
  • threadcreate: seguimientos de pila que llevaron a la creación de nuevos subprocesos del sistema operativo
  • block: seguimientos de pila que llevaron al bloqueo en primitivas de sincronización
  • mutex: apila los rastros de los titulares de mutex contendidos

Al analizar los problemas de memoria, nos concentraremos en el perfil del montón. El perfil de asignación es idéntico en lo que respecta a la recopilación de datos que realiza. La diferencia entre los dos es la forma en que la herramienta pprof lee allí a la hora de inicio. El perfil de Allocs iniciará pprof en un modo que muestra el número total de bytes asignados desde que comenzó el programa (incluidos los bytes recolectados de basura). Usualmente usaremos ese modo cuando intentemos hacer nuestro código más eficiente.

El montón

En resumen, aquí es donde el SO (sistema operativo) almacena la memoria de los objetos que utiliza nuestro código. Esta es la memoria que luego se 'recolecta basura' o se libera manualmente en lenguajes que no son recolectados de basura.

El montón no es el único lugar donde ocurren las asignaciones de memoria, también se asigna algo de memoria en la pila. El propósito de Stack es a corto plazo. En Go, la pila se usa generalmente para asignaciones que ocurren dentro del cierre de una función. Otro lugar donde Go usa la pila es cuando el compilador "sabe" cuánta memoria debe reservarse antes del tiempo de ejecución (por ejemplo, matrices de tamaño fijo). Hay una forma de ejecutar el compilador Go para que genere un análisis de dónde las asignaciones 'escapan' de la pila al montón, pero no lo tocaré en esta publicación.

Si bien los datos de la pila deben 'liberarse' y gc-ed, los datos de la pila no. Esto significa que es mucho más eficiente utilizar la pila siempre que sea posible.

Este es un resumen de las diferentes ubicaciones donde ocurre la asignación de memoria. Hay mucho más, pero esto estará fuera del alcance de esta publicación.

Obtener datos del montón con pprof

Hay dos formas principales de obtener los datos para esta herramienta. El primero generalmente será parte de una prueba o una rama e incluye la importación runtime/pprofy luego la llamada pprof.WriteHeapProfile(some_file)para escribir la información del montón.

Tenga en cuenta que WriteHeapProfilees azúcar sintáctico para ejecutar:

// lookup takes a profile namepprof.Lookup("heap").WriteTo(some_file, 0)

Según los documentos, WriteHeapProfileexiste para compatibilidad con versiones anteriores. El resto de los perfiles no tienen tales atajos y debes usar la Lookup()función para obtener sus datos de perfil.

El segundo, que es el más interesante, es habilitarlo a través de HTTP (puntos finales basados ​​en web). Esto le permite extraer los datos ad hoc, de un contenedor en ejecución en su entorno de prueba / e2e o incluso de 'producción'. Este es un lugar más donde sobresale el tiempo de ejecución y el conjunto de herramientas de Go. La documentación completa del paquete se encuentra aquí, pero el TL; DR es que deberá agregarlo a su código como tal:

import ( "net/http" _ "net/http/pprof")
...
func main() { ... http.ListenAndServe("localhost:8080", nil)}

El "efecto secundario" de la importación net/http/pprofes el registro de los puntos finales pprof en la raíz del servidor web /debug/pprof. Ahora, usando curl, podemos obtener los archivos de información del montón para investigar:

curl -sK -v //localhost:8080/debug/pprof/heap > heap.out

http.ListenAndServe()Solo es necesario agregar lo anterior si su programa no tenía un oyente http antes. Si tiene uno, se enganchará y no será necesario volver a escuchar. También hay formas de configurarlo usando un ServeMux.HandleFunc()que tendría más sentido para un programa habilitado para http más complejo.

Usando pprof

Así que hemos recopilado los datos, ¿ahora qué? Como se mencionó anteriormente, existen dos estrategias principales de análisis de memoria con pprof. Uno está mirando las asignaciones actuales (bytes o recuento de objetos), llamado inuse. El otro está mirando todos los bytes asignados o el recuento de objetos durante el tiempo de ejecución del programa llamado alloc. Esto significa que, independientemente de si fue gc-ed, una suma de todo lo muestreado.

Este es un buen lugar para reiterar que el perfil de montón es una muestra de asignaciones de memoria . pprofdetrás de escena está utilizando la runtime.MemProfilefunción, que de forma predeterminada recopila información de asignación en cada 512 KB de bytes asignados. Es posible cambiar MemProfile para recopilar información sobre todos los objetos. Tenga en cuenta que lo más probable es que esto ralentice su aplicación.

Esto significa que, de forma predeterminada, existe la posibilidad de que ocurra un problema con objetos más pequeños que pasarán desapercibidos para pprof. Para una base de código grande / programa de larga ejecución, esto no es un problema.

Una vez que recopilamos el archivo de perfil, es hora de cargarlo en la consola interactiva que ofrece pprof. Hágalo ejecutando:

> go tool pprof heap.out

Observemos la información mostrada

Type: inuse_spaceTime: Jan 22, 2019 at 1:08pm (IST)Entering interactive mode (type "help" for commands, "o" for options)(pprof)

Lo importante a tener en cuenta aquí es el Type: inuse_space. Esto significa que estamos viendo datos de asignación de un momento específico (cuando capturamos el perfil). El tipo es el valor de configuración de sample_indexy los valores posibles son:

  • inuse_space: cantidad de memoria asignada y aún no liberada
  • inuse_object s: cantidad de objetos asignados y aún no liberados
  • alloc_space: cantidad total de memoria asignada (independientemente de la liberación)
  • alloc_objects: cantidad total de objetos asignados (independientemente de los liberados)

Ahora escriba topel interactivo, la salida serán los principales consumidores de memoria

Podemos ver una línea que nos informa Dropped Nodes, esto significa que están filtrados. Un nodo es una entrada de objeto o un 'nodo' en el árbol. Descartar nodos es una buena idea para reducir algo de ruido, pero a veces puede ocultar la causa raíz de un problema de memoria. Veremos un ejemplo de eso mientras continuamos nuestra investigación.

Si desea incluir todos los datos del perfil, agregue la -nodefraction=0opción cuando ejecute pprof o escriba nodefraction=0el interactivo.

En la lista generada podemos ver dos valores flaty cum.

  • flat significa que la memoria asignada por esta función y está retenida por esa función
  • cum significa que la memoria fue asignada por esta función o función que llamó a la pila

Esta información por sí sola a veces puede ayudarnos a comprender si hay un problema. Tomemos, por ejemplo, un caso en el que una función es responsable de asignar mucha memoria pero no la mantiene. Esto significaría que algún otro objeto apunta a esa memoria y la mantiene asignada, lo que significa que podemos tener un problema de diseño del sistema o un error.

Otro buen truco de topla ventana interactiva es que realmente se está ejecutando top10. El comando superior admite el topNformato donde Nestá el número de entradas que desea ver. En el caso pegado arriba, escribir, top70por ejemplo, daría salida a todos los nodos.

Visualizaciones

Si bien topNproporciona una lista textual, hay varias opciones de visualización muy útiles que vienen con pprof. Es posible escribir pngo gify muchos más (consulte go tool pprof -helpla lista completa).

En nuestro sistema, la salida visual predeterminada se parece a:

Esto puede resultar intimidante al principio, pero es la visualización de los flujos de asignación de memoria (según los seguimientos de la pila) en un programa. Leer el gráfico no es tan complicado como parece. Un cuadrado blanco con un número muestra el espacio asignado (y la cantidad acumulada de memoria que está ocupando en este momento en el borde del gráfico), y cada rectángulo más ancho muestra la función de asignación.

Tenga en cuenta que en la imagen de arriba, quité un png de un inuse_spacemodo de ejecución. Muchas veces también debería echarle un vistazo inuse_objects, ya que puede ayudar a encontrar problemas de asignación.

Profundizando, encontrando una causa raíz

Hasta ahora, pudimos comprender qué es la asignación de memoria en nuestra aplicación durante el tiempo de ejecución. Esto nos ayuda a tener una idea de cómo se comporta (o se comporta mal) nuestro programa.

En nuestro caso, pudimos ver que la memoria es retenida por membuffers, que es nuestra biblioteca de serialización de datos. Esto no significa que tengamos una pérdida de memoria en ese segmento de código, significa que esa función está reteniendo la memoria. Es importante entender cómo leer el gráfico y la salida pprof en general. En este caso, entendiendo que cuando serializamos datos, lo que significa que asignamos memoria a estructuras y objetos primitivos (int, string), nunca se libera.

Saltando a conclusiones o malinterpretando el gráfico, podríamos haber asumido que uno de los nodos en el camino hacia la serialización es responsable de retener la memoria, por ejemplo:

En algún lugar de la cadena podemos ver nuestra biblioteca de registro, responsable de> 50 MB de memoria asignada. Esta es la memoria que es asignada por funciones llamadas por nuestro registrador. Pensándolo bien, esto es lo que se espera. El registrador causa asignaciones de memoria ya que necesita serializar datos para enviarlos al registro y, por lo tanto, está causando asignaciones de memoria en el proceso.

También podemos ver que en la ruta de asignación, la memoria solo se retiene mediante serialización y nada más. Además, la cantidad de memoria retenida por el registrador es aproximadamente el 30% del total. Lo anterior nos dice que lo más probable es que el problema no esté en el registrador. Si fuera al 100%, o algo parecido, deberíamos haber estado buscando allí, pero no lo es. Lo que podría significar es que se está registrando algo que no debería estar, pero no es una pérdida de memoria por parte del registrador.

Este es un buen momento para introducir otro pprofcomando llamado list. Acepta una expresión regular que será un filtro de qué listar. La 'lista' es en realidad el código fuente anotado relacionado con la asignación. En el contexto del registrador que estamos viendo, ejecutaremos list RequestNewcomo nos gustaría ver las llamadas realizadas al registrador. Estas llamadas provienen de dos funciones que comienzan con el mismo prefijo.

Podemos ver que las asignaciones realizadas se encuentran en la cumcolumna, lo que significa que la memoria asignada se retiene en la pila de llamadas. Esto se correlaciona con lo que también muestra el gráfico. En ese punto, es fácil ver que la razón por la que el registrador estaba asignando la memoria es porque le enviamos el objeto 'bloque' completo. Necesitaba serializar algunas partes como mínimo (nuestros objetos son objetos membuffer, que siempre implementan alguna String()función). ¿Es un mensaje de registro útil o una buena práctica? Probablemente no, pero no es una pérdida de memoria, ni en el extremo del registrador ni en el código que llamó al registrador.

listpuede encontrar el código fuente cuando lo busque en su GOPATHentorno. En los casos en que la raíz que está buscando no coincida, lo que depende de su máquina de compilación, puede usar la -trim_pathopción. Esto ayudará a solucionarlo y le permitirá ver el código fuente anotado. Recuerde configurar su git en la confirmación correcta que se estaba ejecutando cuando se capturó el perfil del montón.

Entonces, ¿por qué se retiene la memoria?

El trasfondo de esta investigación fue la sospecha de que tenemos un problema: una pérdida de memoria. Llegamos a esa noción cuando vimos que el consumo de memoria era mayor de lo que esperaríamos que necesitara el sistema. Además de eso, vimos que aumentaba cada vez más, lo que fue otro fuerte indicador de 'hay un problema aquí'.

En este punto, en el caso de Java o .Net, abriríamos un análisis o generador de perfiles de 'gc roots' y llegaríamos al objeto real que hace referencia a esos datos y está creando la fuga. Como se explicó, esto no es exactamente posible con Go, tanto por un problema de herramientas como por la representación de memoria de bajo nivel de Go.

Sin entrar en detalles, no creemos que Go retenga qué objeto está almacenado en qué dirección (excepto para los punteros, tal vez). Esto significa que, en realidad, comprender qué dirección de memoria representa qué miembro de su objeto (estructura) requerirá algún tipo de asignación a la salida de un perfil de montón. Hablando de la teoría, esto podría significar que antes de realizar un volcado de núcleo completo, también se debe tomar un perfil de pila para que las direcciones se puedan asignar a la línea de asignación y al archivo y, por lo tanto, al objeto representado en la memoria.

En este punto, debido a que estamos familiarizados con nuestro sistema, fue fácil entender que esto ya no es un error. Fue (casi) por diseño. Pero continuemos explorando cómo obtener la información de las herramientas (pprof) para encontrar la causa raíz.

Al configurar nodefraction=0, veremos el mapa completo de los objetos asignados, incluidos los más pequeños. Veamos la salida:

Tenemos dos nuevos subárboles. Recordando de nuevo, el perfil de pila pprof está muestreando asignaciones de memoria. Para nuestro sistema que funciona, no nos falta ninguna información importante. El árbol nuevo más largo, en verde, que está completamente desconectado del resto del sistema es el corredor de pruebas, no es interesante.

El más corto, en azul, que tiene un borde que lo conecta a todo el sistema es inMemoryBlockPersistance. Ese nombre también explica la 'filtración' que imaginamos que tenemos. Este es el backend de datos, que almacena todos los datos en la memoria y no persiste en el disco. Lo bueno de notar es que pudimos ver inmediatamente que sostiene dos objetos grandes. ¿Por qué dos? Porque podemos ver que el objeto tiene un tamaño de 1,28 MB y la función retiene 2,57 MB, es decir, dos de ellos.

El problema se comprende bien en este punto. Podríamos haber usado delve (el depurador) para ver que esta es la matriz que contiene todos los bloques para el controlador de persistencia en memoria que tenemos.

Entonces, ¿qué podemos arreglar?

Bueno, eso apestaba, fue un error humano. Si bien el proceso fue educar (y compartir es cuidar), no mejoramos, ¿o sí?

Había una cosa que todavía 'olía' sobre este montón de información. Los datos deserializados ocupaban demasiada memoria, ¿por qué 142 MB para algo que debería ocupar sustancialmente menos? . . pprof puede responder a eso; en realidad, existe para responder exactamente a tales preguntas.

Para ver el código fuente anotado de la función, ejecutaremos list lazy. Usamos lazy, como es el nombre de la función que estamos buscando lazyCalcOffsets()y sabemos que ninguna otra función en nuestro código comienza con lazy. Escribir list lazyCalcOffsetstambién funcionaría, por supuesto.

Podemos ver dos datos interesantes. Nuevamente, recuerde que el perfil del montón de pprof muestra información sobre asignaciones. Podemos ver que tanto el flatcomo el cumnúmero son iguales. Esto indica que la memoria asignada también es retenida por estos puntos de asignación.

A continuación, podemos ver que make () está tomando algo de memoria. Eso tiene sentido, es el puntero a la estructura de datos. Sin embargo, también vemos que la asignación en la línea 43 está ocupando memoria, lo que significa que crea una asignación.

Esto nos enseñó sobre mapas, donde una asignación a un mapa no es una asignación de variable sencilla. Este artículo explica con gran detalle cómo funciona el mapa. En resumen, un mapa tiene una sobrecarga, y cuantos más elementos, más grande será esta sobrecarga cuando se compara con un segmento.

Lo siguiente debe tomarse con un grano de sal: estaría bien decir que el uso de a map[int]T, cuando los datos no son escasos o se pueden convertir en índices secuenciales, generalmente debe intentarse con una implementación de corte si el consumo de memoria es una consideración relevante . Sin embargo, una porción grande, cuando se expande, puede ralentizar una operación, mientras que en un mapa esta ralentización será insignificante. No existe una fórmula mágica para las optimizaciones.

En el código anterior, después de verificar cómo usamos ese mapa, nos dimos cuenta de que, si bien imaginamos que era una matriz dispersa, resultó no tan escasa. Esto coincide con el argumento anterior e inmediatamente pudimos ver que una pequeña refactorización de cambiar el mapa a un segmento es realmente posible, y podría hacer que ese código sea más eficiente en memoria. Así que lo cambiamos a:

Tan simple como eso, en lugar de usar un mapa, ahora estamos usando un sector. Debido a la forma en que recibimos los datos que se cargan de forma diferida en él, y cómo luego accedemos a esos datos, además de estas dos líneas y la estructura que contiene esos datos, no se requirió ningún otro cambio de código. ¿Qué le hizo al consumo de memoria?

Echemos un vistazo a benchcmpsolo un par de pruebas.

Las pruebas de lectura inicializan la estructura de datos, que crea las asignaciones. Podemos ver que el tiempo de ejecución mejoró en ~ 30%, las asignaciones se redujeron en un 50% y el consumo de memoria en> 90% (!)

Dado que el mapa, ahora segmento, nunca se llenó con muchos elementos, los números muestran prácticamente lo que veremos en producción. Depende de la entropía de los datos, pero puede haber casos en los que tanto las asignaciones como las mejoras en el consumo de memoria hubieran sido aún mayores.

Mirando de pprofnuevo y tomando un perfil de pila de la misma prueba, veremos que ahora el consumo de memoria se ha reducido en un ~ 90%.

La conclusión será que, para conjuntos de datos más pequeños, no debería utilizar mapas en los que bastaría con cortes, ya que los mapas tienen una gran sobrecarga.

Volcado de núcleo completo

Como se mencionó, aquí es donde vemos la mayor limitación con las herramientas en este momento. Cuando estábamos investigando este problema nos obsesionamos con poder llegar al objeto raíz, sin mucho éxito. Go evoluciona con el tiempo a un gran ritmo, pero esa evolución tiene un precio en el caso del volcado completo o la representación de memoria. El formato de volcado de pila completo, a medida que cambia, no es compatible con versiones anteriores. La última versión descrita aquí y para escribir un volcado de pila completo, puede usar debug.WriteHeapDump().

Aunque en este momento no nos encontramos "atascados" porque no existe una buena solución para explorar los vertederos completos. pprofrespondió todas nuestras preguntas hasta ahora.

Tenga en cuenta que Internet recuerda mucha información que ya no es relevante. Aquí hay algunas cosas que debe ignorar si va a intentar abrir un volcado completo usted mismo, a partir de go1.11:

  • No hay forma de abrir y depurar un volcado de núcleo completo en MacOS, solo Linux.
  • Las herramientas en //github.com/randall77/hprof son para Go1.3, existe una bifurcación para 1.7+ pero tampoco funciona correctamente (incompleta).
  • viewcore en //github.com/golang/debug/tree/master/cmd/viewcore realmente no se compila. Es bastante fácil de arreglar (los paquetes internos apuntan a golang.org y no a github.com), pero tampoco funciona , no en MacOS, tal vez en Linux.
  • También //github.com/randall77/corelib falla en MacOS

interfaz de usuario pprof

Un último detalle a tener en cuenta cuando se trata de pprof, es su función de interfaz de usuario. Puede ahorrar mucho tiempo al iniciar una investigación sobre cualquier problema relacionado con un perfil tomado con pprof.

go tool pprof -http=:8080 heap.out

En ese momento debería abrir el navegador web. Si no lo hace, busque el puerto en el que lo configuró. Le permite cambiar las opciones y obtener comentarios visuales mucho más rápido de lo que puede hacerlo desde la línea de comandos. Una forma muy útil de consumir la información.

La interfaz de usuario realmente me familiarizó con los gráficos de llama, que exponen las áreas culpables del código muy rápidamente.

Conclusión

Go es un lenguaje emocionante con un conjunto de herramientas muy rico, hay mucho más que puedes hacer con pprof. Esta publicación no toca en absoluto los perfiles de CPU, por ejemplo.

Algunas otras buenas lecturas:

  • //rakyll.org/archive/ - Creo que este es uno de los principales contribuyentes en torno al monitoreo del rendimiento, muchas buenas publicaciones en su blog.
  • //github.com/google/gops: escrita por JBD (que ejecuta rakyll.org), esta herramienta garantiza su propia publicación de blog.
  • //medium.com/@cep21/using-go-1-10-new-trace-features-to-debug-an-integration-test-1dc39e4e812d - go tool traceque trata sobre la creación de perfiles de CPU, esta es una gran publicación sobre esa función de creación de perfiles .