wandres.dev
SABIO · libc: glibc vs musl

glibc frente a musl

Tamaño, rendimiento, extensiones GNU, enlace estático y la decisión de Alpine: por qué dos implementaciones del mismo estándar producen binarios, huellas y fallos radicalmente distintos.

⏱ 18 min

Las dos implementan el mismo estándar y las dos ejecutan tu código sin cambiar una línea. Y sin embargo el binario pesa distinto, arranca distinto, resuelve nombres distinto y falla distinto. Comparar glibc con musl no es elegir bando: es el ejercicio que te enseña dónde termina el lenguaje C y dónde empieza una decisión de ingeniería que alguien tomó por ti.

🎯 Al terminar esta lección sabrás
  • Contrastar las filosofías de diseño de glibc y musl y su reflejo en tamaño y complejidad.
  • Situar con precisión dónde gana cada una en rendimiento y por qué.
  • Reconocer las extensiones GNU y el coste de portabilidad de usarlas.
  • Explicar el problema del enlace estático con glibc y la lógica detrás de la elección de Alpine.

Dos filosofías, dos tamaños

glibc nació en 1988 como la libc del proyecto GNU y hoy ronda el millón y medio de líneas. Su objetivo declarado es la compatibilidad total: implementar el estándar C, POSIX, XSI, buena parte de BSD y System V, sus propias extensiones, y mantener funcionando binarios compilados hace veinte años. Para lograr esto último usa versionado de símbolos: un mismo nombre convive en varias versiones dentro del objeto compartido, de modo que un binario antiguo siga llamando a la semántica antigua.

musl apareció en 2011 con la tesis contraria: corrección, tamaño y simplicidad por encima de compatibilidad histórica. Unas noventa mil líneas, licencia MIT, sin versionado de símbolos, sin código heredado, y una implementación deliberadamente literal del estándar y de POSIX.

🐘

glibc

LGPL. Objeto compartido de varios megabytes. Compatibilidad hacia atrás garantizada, extensiones abundantes, rutas optimizadas por microarquitectura.

🪶

musl

MIT. Unos cientos de kilobytes enlazados estáticamente. Código auditable de principio a fin, sin extensiones gratuitas, pensada para enlace estático.

⚖️

La licencia importa

La LGPL obliga a permitir el reemplazo de la biblioteca, lo que complica distribuir binarios estáticos propietarios. MIT no impone nada de eso.

🔍

Auditabilidad

Un humano puede leer entera la implementación de musl. Nadie lee entera glibc. En sistemas críticos esa asimetría es un argumento.

Ese contraste se mide en un minuto: compila el mismo programa contra ambas y observa el tamaño del ejecutable estático resultante y las dependencias del dinámico.

gcc      -static -std=c23 hola.c -o hola-glibc
musl-gcc -static -std=c23 hola.c -o hola-musl
ls -l hola-glibc hola-musl     # de ~800 KiB a ~20 KiB, segun version

Rendimiento: dónde gana cada una

La respuesta honesta es que depende de la carga, y quien afirme lo contrario no ha medido. Hay tres frentes donde la diferencia es sistemática.

El primero son las funciones de cadena y memoria. glibc usa el mecanismo IFUNC: en el arranque, el enlazador dinámico ejecuta una función resolutora que inspecciona la microarquitectura y elige la variante de memcpy o strlen escrita a mano con las instrucciones vectoriales disponibles. musl usa implementaciones portables en C con muy pocas rutas específicas. En bucles dominados por copia de bloques grandes, glibc gana con holgura.

El segundo es la asignación de memoria bajo concurrencia. El asignador de glibc mantiene arenas por hilo y una caché sin cerrojo, mientras que mallocng de musl prioriza el endurecimiento frente a la corrupción de metadatos y la resistencia a la fragmentación. En cargas multihilo con asignación intensiva la diferencia ha llegado a ser de varios factores, y es la causa real de la fama de lentitud de ciertos intérpretes ejecutados sobre Alpine.

El tercero, a favor de musl, es el arranque y la huella. Menos código que mapear, menos reubicaciones, menos resolución de símbolos: procesos que se crean y mueren a millares —el modelo de un CGI, un contenedor efímero o una herramienta de línea de órdenes— arrancan de forma medible antes y consumen menos memoria residente.

hyperfine './prog-glibc' './prog-musl'          # tiempo total, incluido arranque
/usr/bin/time -v ./prog-musl | grep Maximum     # huella residente maxima

Conviene saber que la brecha del asignador es la más fácil de cerrar de las tres, porque malloc es sustituible sin tocar el código. Enlazar un asignador de terceros, o precargarlo en tiempo de ejecución, reemplaza la política de la libc sin renunciar al resto de ella. Es la forma habitual de conservar el tamaño de musl sin pagar su penalización en cargas multihilo.

LD_PRELOAD=/usr/lib/libjemalloc.so ./prog     # sustituye el asignador sin recompilar

Hay un cuarto frente, menos citado y a menudo el más importante en servicios de red: la resolución de nombres. glibc implementa NSS, un sistema de conmutación configurable por /etc/nsswitch.conf que puede consultar DNS, archivos locales, LDAP o mDNS mediante módulos cargados dinámicamente, y coordinarse con una caché externa. musl implementa un resolutor propio, directo y sin extensibilidad: lee /etc/resolv.conf, consulta los servidores y ya está. Es más simple, más auditable y no tiene caché, lo que en un contenedor que resuelve el mismo nombre miles de veces por minuto se nota. Versiones antiguas de musl además no reintentaban por TCP cuando la respuesta UDP venía truncada, un detalle que rompía consultas a registros grandes y que alimentó buena parte de la mala fama de Alpine en entornos con service discovery.

Extensiones GNU y el precio de usarlas

Muchas funciones que crees estándar no lo son. asprintf, strdupa, memmem, qsort_r con la firma de GNU, el especificador %m de printf que imprime el mensaje de errno, las funciones anidadas del compilador: todo eso es territorio GNU. Se activa definiendo _GNU_SOURCE antes de cualquier inclusión, y compila sin una queja mientras estés en glibc.

#define _GNU_SOURCE
#include <stdio.h>

int main(void) {
    char *s = NULL;
    if (asprintf(&s, "reserva y formatea en un paso: %d", 42) < 0) return 1;
    puts(s);
    free(s);
}

Detectar en qué libc estás para compilar condicionalmente parece la salida obvia, y aquí musl toma una decisión deliberada que conviene entender: no define ninguna macro que la identifique. glibc define __GLIBC__ y __GLIBC_MINOR__, pero musl se niega a ofrecer un equivalente, con el argumento explícito de que detectar la implementación en vez de la característica produce código frágil que envejece mal. La consecuencia práctica es que el patrón correcto no es preguntar por la libc, sino comprobar si el símbolo existe desde el sistema de compilación.

ldd --version | head -1              # en glibc imprime su version; en musl falla o difiere
getconf GNU_LIBC_VERSION             # solo existe en glibc

musl implementa algunas de esas extensiones por pragmatismo y rechaza otras por principio, y ahí es donde tu proyecto se rompe al cambiar de libc. El fallo típico no es sutil: un símbolo indefinido en el enlazado. Los casos más ásperos, sin embargo, son los que compilan y se comportan distinto. basename tiene dos semánticas incompatibles según incluyas una cabecera u otra. Las funciones de resolución de nombres de musl no usan NSS, el sistema de conmutación de servicios de nombres de glibc, así que ignoran configuraciones de LDAP o mDNS que en Debian funcionaban solas. Y el valor por defecto del tamaño de pila de un hilo es de 8 MiB en glibc frente a 128 KiB en musl: código con recursión moderada o buffers locales grandes desborda en Alpine y no en Ubuntu, con un SIGSEGV que no apunta a ninguna parte.

Enlace estático y la apuesta de Alpine

Aquí está la diferencia con más consecuencias prácticas. glibc puede enlazarse estáticamente, pero lo hace con una advertencia de por medio, porque partes suyas cargan objetos compartidos en tiempo de ejecución mediante dlopen: los módulos de NSS que resuelven usuarios y hosts, y los complementos de iconv que convierten codificaciones. Un binario estático de glibc que llame a getaddrinfo o a getpwnam intentará abrir en destino los mismos módulos que había en la máquina de compilación; si no están, o si son de otra versión, falla en ejecución. El binario es estático en todo salvo en lo que importaba.

musl no tiene ese agujero: su enlace estático es total y el binario resultante depende únicamente del contrato de syscalls del kernel.

flowchart LR
A[Fuente en C] --> B[Enlace estatico con glibc]
A --> C[Enlace estatico con musl]
B --> D[Binario que aun necesita modulos NSS en destino]
C --> E[Binario autocontenido, solo depende del kernel]
style D fill:#f38ba8,color:#11111b
style E fill:#a6e3a1,color:#11111b
musl-gcc -static -std=c23 servicio.c -o servicio
ldd servicio          # not a dynamic executable
file servicio         # statically linked

El enlace dinámico con glibc tiene su propia asimetría, y es la causa del error de despliegue más repetido de la industria. El versionado de símbolos garantiza compatibilidad hacia atrás: un binario antiguo corre sobre una glibc nueva. No garantiza nada hacia delante. Si compilas en una distribución reciente, tu binario queda enlazado a la versión más moderna de cada símbolo disponible, y al ejecutarlo en una máquina más antigua el enlazador dinámico no encuentra esa versión y aborta antes de llegar a main. Compilar en la distribución más antigua que quieras soportar no es superstición: es la consecuencia directa del modelo.

objdump -T ./prog | grep -o 'GLIBC_[0-9.]*' | sort -u | tail -3   # tu requisito minimo real
⚠️
El binario estático no elimina toda dependencia

Un ejecutable estático sigue dependiendo del contrato de syscalls del kernel y de su compatibilidad hacia atrás, que en Linux es excelente pero no infinita. Y renuncia a cosas reales: no recibe parches de seguridad de la libc sin recompilar, no admite interposición con LD_PRELOAD, y desactiva mecanismos que dependen del enlazador dinámico. Estático significa autocontenido, no invulnerable ni eterno.

Con esto la decisión de Alpine Linux se lee sola. Alpine se propuso una distribución mínima y endurecida, y musl le daba cuatro cosas a la vez: una imagen base de unos pocos megabytes frente a las decenas de una basada en glibc, licencia MIT sin las obligaciones de la LGPL para redistribuir binarios, una superficie de ataque de noventa mil líneas auditables, y enlace estático que funciona de verdad. Para una imagen de contenedor, donde cada megabyte se multiplica por cada capa y cada despliegue, esa suma era decisiva. El precio lo pagas cuando ejecutas cargas ajenas: binarios propietarios compilados contra glibc que no arrancan, extensiones GNU ausentes, resolución de nombres distinta y ese asignador que penaliza a los intérpretes multihilo. La propia comunidad de Alpine acabó publicando gcompat, una capa de compatibilidad que traduce el ABI de glibc sobre musl para rescatar esos binarios: útil, y también la mejor prueba de que la elección tenía consecuencias.

El criterio de decisión, entonces, no es cuál es mejor sino qué domina tu carga. Si distribuyes una herramienta autocontenida, si el tamaño de imagen es un coste real, o si necesitas auditar la superficie completa, musl es la respuesta técnica correcta. Si ejecutas software de terceros, dependes de extensiones GNU, de NSS o del rendimiento del asignador bajo muchos hilos, glibc lo es. Lo que no es defendible es no saber cuál estás usando.

Escribiste C contra una implementación, no contra el lenguaje

Este es el momento en que se cae una ilusión cómoda. Creías que tu programa hablaba con “C” y con “Linux”, y resulta que hablaba con glibc, que es una obra de ingeniería concreta, con autores, licencia, historia y compromisos. El estándar define la semántica observable; deja explícitamente sin definir el tamaño de pila de un hilo, la política del asignador, cómo se resuelve un nombre de host, si %m existe o qué ocurre al enlazar estáticamente. Todo eso lo rellena la implementación, y cada implementación lo rellena distinto. Por eso el mismo código correcto pasa las pruebas en Debian y revienta en Alpine sin que nadie haya escrito una línea errónea: no hay error, hay una dependencia oculta en decisiones no especificadas. La madurez consiste en invertir el razonamiento. En vez de preguntar cuál de las dos es mejor, pregunta qué supuestos no escritos está haciendo tu código y si son parte del contrato o del entorno. Esa pregunta se responde con lo que viene después: errno como convención, y las macros de test de características como forma de declarar por escrito contra qué estándar programas.

⚔️ Mide la diferencia
  1. Compila el mismo programa con gcc -static y con musl-gcc -static y compara tamaño, número de símbolos con nm y salida de ldd.
  2. Usa asprintf con _GNU_SOURCE, compila con ambas libc y documenta el error exacto si falla.
  3. Escribe un programa que lance un hilo con un array local de 256 KiB y ejecútalo en un contenedor de Alpine y en uno de Debian; explica el resultado.
  4. Compara con hyperfine el tiempo de arranque de un binario trivial en ambas libc y razona a qué se debe la diferencia.