"Rust no tiene una ABI estable"

Translations: en - Tags: gnome, rust

He visto varias personas en GNOME (a menudo, gente que ha trabajado durante mucho tiempo con bibliotecas escritas en C) expresar su preocupación sobre cosas parecidas a éstas:

  1. El código en Rust ya compilado no tiene una ABI (application binary interface) estable.
  2. Entonces, no podemos tener bibliotecas compartidas como lo hacen las distribuciones de Linux de forma tradicional.
  3. Además, Rust incluye toda la biblioteca estándar con cada binario que compila, lo cual hace que las bibliotecas compiladas con Rust sean enormes.

Éstas son inquietudes perfectamente válidas, y deben responderse por la gente como yo que propone que deberíamos tener bibliotecas de infraestructura del sistema hechas en Rust.

Entonces, comencemos.

La primera parte de este artículo es una introducción rápida a las bibliotecas compartidas y cómo las usan las distribuciones de Linux. Si ya conoces de esto, puedes saltarte a la sección "Rust no tiene una ABI estable".

¿Cómo usan las distribuciones las bibliotecas compartidas?

Si se ejecutan varios programas al mismo tiempo y éstos usan la misma biblioteca compartida (por ejemplo, libgtk-3.so), el sistema operativo puede cargar en memoria una sola copia de la biblioteca y hacer que los programas compartan las partes de código y datos sólo-lectura por medio de la magia de la memoria virtual.

En teoría, si se le hace un arreglo de un bug a una biblioteca sin cambiar su interfaz de programación, uno puede recompilar la biblioteca, poner el binario .so nuevo en /usr/lib o donde sea, y listo: los programas que dependen de la biblioteca no necesitan recompilarse.

Si las bibliotecas limitan su interfaz pública a una ABI en C (application binary interface, interfaz binaria de aplicaciones), entonces se vuelven fáciles de utilizar desde otros lenguajes de programación. Esos lenguajes no tienen que complicarse con cosas como los nombres representativos para los símbolos de una biblioteca en C++ (symbol name mangling), manejo de excepciones, constructores y todo eso. Casi cualquier lenguaje tiene una C FFI (C Foreign Function Interface, interfaz de funciones foráneas en C), lo cual en pocas palabras significa "déjame llamar funciones de C sin mucho problema".

Desde el punto de vista de una biblioteca, ¿qué es una ABI? La Wikipedia dice, "es la interfaz entre dos módulos de programa, uno de los cuales es, a menudo, una librería o sistema operativo, a nivel de lenguaje de máquina. Una ABI determina detalles como la forma de llamar a las funciones, en qué formato binario se debería pasar la información de un componente de programa al siguiente [...] tamaños, disposición y alineamiento de los tipos de datos. La convención de llamada [...]" es la forma en que se llama a una función a nivel del lenguaje de máquina; hay que modificar el puntero de ejecución y el puntero a la pila, pasar algunos argumentos en registros y otros en la pila, etc. Son cosas de muy bajo nivel, de las que se encargan los compiladores. Cada arquitectura de máquina o sistema operativo define una ABI estándar para C.

Para las bibliotecas, entendemos que la ABI es la forma en que se llaman las interfases de esa biblioteca en lenguaje de máquina. ¿Qué funciones están disponibles como símbolos públicos en el archivo .so? ¿A qué valores numéricos corresponden los valores de un enum en C, para que se les puedan pasar como números a las funciones? ¿Qué orden tienen los argumentos de cada función y qué tipos tienen? ¿Cuál es el tamaño en memoria de los structs, y el orden y tipos y espacio extra para alinear los argumentos que se les pasan a las funciones? ¿Los argumentos se pasan en registros del CPU o en la pila en memoria? ¿Quién limpia la pila después de llamar una función, el código que la llama o la función misma?

Arreglos para bugs y arreglos de seguridad

Las distribuciones de Linux hacen un montón de trabajo para que sea posible tener sólo una versión de cada biblioteca compartida que está instalada en el sistema: una sola copia de libjpeg.so, una sola copia de libpng.so, una sola de libc.so, etc.

Esto ayuda mucho cuando hay que hacer una actualización para arreglar un bug, ya sea de seguridad o no. Los usuarios pueden sólo descargar el paquete que corresponde a la biblioteca, el cual al instalarse pondrá un nuevo .so en el lugar adecuado, y el software que depende de esa biblioteca no necesita actualizarse.

Esto es posible solamente si el bug en verdad sólo cambia el código interno sin cambiar la conducta o la interfaz de programación de la biblioteca. Si arreglar el bug implica cambios en el API o ABI públicos, entonces ya se jodió la cosa: todo el software que depende de esa biblioteca debe ser recompilado. Los autores "irresponsables" de bibliotecas aprenden de esto muy rápido cuando las distribuciones se quejan de un cambio de este estilo, o no aprenden y quedan para siempre etiquetados por las distros como "esa biblioteca irresponsable" que siempre requiere manejo especial para no romper el software que depende de ella.

Nota: a veces es más complicado. Poppler (la biblioteca para renderizar PDFs) viene con dos APIs estables, uno basado en Glib para programas en C, y uno en basado en Qt para programas en C++. Sin embargo, hay software como texlive que se brinca estas APIs estables y que usa las interfases internas de Poppler, que por supuesto cambian todo el tiempo a medida que se desarrolla la biblioteca. Esto hace que texlive se rompa cada vez que Poppler modifica su código interno. ¡Alguien debería extender la API pública y estable para que texlive no tuviera que llamar las funciones internas de Poppler!

Bibliotecas empaquetadas junto con el software

A veces no es que haya autores irresponsables de bibliotecas, sino que la gente que usa las bibliotecas se topa con que con el tiempo, la conducta de las bibliotecas cambia sutilmente, incluso aunque no cambien su API o ABI. Obtienen mejores resultados si empaquetan una versión específica de las bibliotecas junto con su software, porque así al hacer sus pruebas tienen una garantía de cómo se van a comportar las bibliotecas de las que depende su software.

Las distribuciones de Linux siempre se quejan de esto, y suelen parchar el software a mano para forzarlo a utilizar las bibliotecas compartidas del sistema, o consiguen que los autores del software les acepten parches para que a la hora de compilarlo haya opciones como --use-system-libjpeg sin tener que parcharlo después.

Esto no funciona muy bien si la versión de una biblioteca que estaba empaquetada con el software tiene parches extra que no están en la biblioteca de la distro. O vice-versa; puede ser que funcione mejor sí utilizar la biblioteca de la distro, si es que tiene parches adicionales a los que traía la biblioteca empaquetada. ¡Quién sabe! Todo esto es específico a cada caso.

Rust no tiene una ABI estable

Así es, de fábrica no tiene una, porque el equipo del compilador quiere tener la libertad de cambiar la disposición de los datos en memoria y las convenciones de llamada desde-Rust-hacia-Rust, muchas veces por razones de desempeño. Por ejemplo, Rust no garantiza que el orden en memoria de los campos de un struct sea igual al orden en que están escritos en el código:

struct Foo {
    bar: bool,
    baz: f64,
    beep: bool,
    qux: i32,
}

El compilador tiene la libertad de re-ordenar los campos en memoria como mejor le parezca. Tal vez decide poner los dos campos de tipo bool uno junto al otro, para ahorrarse espacio extra causado por los requerimientos de alineación del CPU. Tal vez hace análisis estático del código, o usa análisis a partir de instrumentaciones del programa en ejecución, para decidir que un orden u otro de los campos sería el más eficiente.

¡Pero podemos cambiar esto! Vamos a ver primero la disposición de los datos, y luego las convenciones de llamada.

Disposición de los datos en C versus Rust

El que sigue es el mismo struct que el de arriba, pero con un atributo #[repr(C) adicional:

#[repr(C)]
struct Foo {
    bar: bool,
    baz: f64,
    beep: bool,
    qux: i32,
}

Con ese atributo, el struct tendrá la misma disposición en memoria que este struct en C:

#include <stdbool.h>
#include <stdint.h>

struct Foo {
    bool bar;
    double baz;
    bool beep;
    int32_t qux;
}

(Nota: es una pena que gboolean no es lo mismo que bool, pero eso es porque gboolean es más viejo que el estándar C99, y por supuesto los estándares de hace 20 años son demasiado nuevos para usarse de manera regular. (Nota de la nota: desde que escribí ese otro artículo, la repr(C) de Rust para el tipo bool es de hecho idéntica al bool de C99; ya no es algo indefinido.))

Incluso los enums de Rust que contienen datos se pueden disponer en memoria de forma que se puedan usar desde C y C++:

#[repr(C, u8)]
enum MiEnum {
    A(u32),
    B(f32, bool),
}

Esto quiere decir, usa la misma representación en memoria que se usaría en C, con un u8 para el discriminante del enum. Se vería así:

#include <stdbool.h>
#include <stdint.h>

enum MiEnumEtiqueta {
        A,
        B
};

typedef uint32_t MiEnumDatosA;

typedef struct {
        float x;
        bool y;
} MiEnumDatosB;

typedef union {
        MiEnumDatosA a;
        MiEnumDatosB b;
} MyEnumDatos;

typedef struct {
        uint8_t etiqueta;
        MiEnumDatos datos;
} MiEnum;

Los detalles de la disposición de los datos están descritas en la sección Alternative Representations del Rustonomicon y los Unsafe Code Guidelines.

Convenciones de llamada

Las convenciones de llamada de una ABI se refieren a cómo se llama a las funciones en lenguaje de máquina, cómo se pasan los argumentos en registros o en la pila y cómo se devuelve el valor de salida. La página de Wikipedia sobre las convenciones de llamada en X86 tiene una tablita muy buena para recordarnos cómo funcionan; es útil cuando tienes que mirar código en ensamblador en un debugger de bajo nivel.

Ya he escrito sobre cómo es posible escribir código en Rust que exporte funciones que se puedan llamar desde C; uno debe utilizar extern "C" en la definición de una función y el atributo #[no_mangle] para que el nombre del símbolo se mantenga sin cambios. Por ejemplo, es así como librsvg puede tener lo siguiente:

#[no_mangle]
pub unsafe extern "C" fn rsvg_handle_new_from_file(
    filename: *const libc::c_char,
    error: *mut *mut glib_sys::GError,
) -> *const RsvgHandle {
    // ...
}

Que se compila a lo mismo que un compilador en C haría para esto:

RsvgHandle *rsvg_handle_new_from_file (const gchar *filename, GError **error);

(Nota: librsvg todavía usa una biblioteca intermedia de pedacitos en C que lo único que hacen es llamar inmediatamente a las funciones exportadas desde Rust. Por fortuna ya existen herramientas para producir un .so directo desde Rust que no me ha dado tiempo de probar. ¡Se acepta ayuda gustosamente!)

Resumen de la ABI hasta ahora

Es decisión de uno exportar una ABI estable de C desde una biblioteca escrita en Rust. Hay un poco de lío con respecto a cómo se disponen en memoria los tipos en C, porque el sistema de tipos de Rust es mucho más sofisticado, pero con un poquito de ingenio se pueden resolver las cosas. No es más ingenio del requerido para diseñar y mantener una API o ABI estables en C a secas.

Y aquí voy a combinar la segunda inquietud del principio — "no podemos tener bibliotecas compartidas como lo hacen las distribuciones de Linux de forma tradicional" — sí que podemos, en términos de API/ABI, pero sigue leyendo.

Rust incluye toda la biblioteca estándar en cada .so que se compila desde Rust

Es decir, incluye todas las dependencias escritas en Rust de manera estática. Esto produce un .so bastante grande:

  • librsvg-2.so (versión 2.40.21, sólo C) - 1408840 bytes
  • librsvg-2.so (versión 2.49.3, sólo Rust) - 9899120 bytes

¿Qué diablos? ¿De dónde viene todo eso?

(Y estoy haciendo trampa: esto es con habilitar las optimizaciones en tiempo de enlazado, y con correr strip(1) en el .so. Si sólo hubiera hecho autogen.sh && make sería incluso más grande.)

Este último .so tiene la biblioteca estándar de Rust ahí metida (o por lo menos las partes que sí se usan dentro de librsvg), además de todas las dependencias en Rust (cssparser, selectors, nalgebra, glib-rs, cairo-rs, locale_config, rayon, xml5ever, y un montón de crates). Podría explicar por qué cada una es necesaria:

  • cssparser - librsvg necesita procesar CSS.
  • selectors - librsvg necesita saber qué selectores de CSS se aplican a cada elemento del SVG.
  • nalgebra - el código para los filtros de SVG utiliza vectores y matrices.
  • glib-rs, cairo-rs - dibujar con Cairo y exportar tipos de GObject.
  • locale_config - para que funcionen los SVGs que incluyen traducciones.
  • rayon - para que los filtros puedan usar todos los núcleos de tu CPU en vez de procesar un pixel a la vez.
  • Etcétera. ¡SVG es un estándar grande y requiere de mucho código adicional!

¿Es esto un problema?

O de manera más precisa, ¿por qué ocurre esto, y por qué la gente lo percibe como un problema?

APIs/ABIs estables y las distribuciones de Linux

Las distribuciones de Linux hacen un montón de trabajo para garantizar que sólo hay una copia de cada "biblioteca del sistema" en la compu. Hay Sólo Una Copia de /usr/lib/libc.so, Sólo Una Copia de /usr/lib/libjpeg.so, etc., y cada paquete se compila con opciones especiales para que en verdad sólo utilicen las copias únicas del sistema, en vez de sus propias versiones empaquetadas. Si no hay esas opciones especiales, las distros parchan los paquetes para que usen las bibliotecas del sistema.

De cierta forma, esto funciona bien para las distribuciones:

  • Un bug en una biblioteca se puede arreglar en un solo lugar, y todas las aplicaciones que la usan obtienen el arreglo al bug de forma automática.

  • Un bug de seguridad se puede arreglar en un solo lugar, y en teoría no es necesario re-auditar todas las aplicaciones que usan la biblioteca.

Si mantienes una biblioteca que aparece en las distribuciones de Linux, y rompes la ABI, te van a llegar quejas muy rápidamente por parte de la distro.

Esto es bueno porque produce autores responsables que aprenden a escribir bibliotecas en las que se puede confiar. Es así como Inkscape/GIMP pueden tener un toolkit gráfico (GTK) del que pueden depender.

También es malo, porque fomenta el estancamiento a largo plazo. Es como llegamos a tener un API horrible, inseguro y propenso a errores como el de libjpeg, que nunca se puede mejorar porque requeriría de cambios en todas las aplicaciones que lo usan. Es la razón por la que gboolean todavía es un int de 32 bits después de veintitantos años de existir, aun cuando todo el resto del ecosistema alrededor de C ha decidido que los booleanos ocupan 1 byte. Es como Inkscape/GIMP tardan muchos años en moverse de GTK2 a GTK3 (bueno, eso es por falta de desarrolladores pagados, pero ocurre porque mantenemos APIs estables hasta el fin de los tiempos).

Sin embargo, una API/ABI estable a largo plazo tiene muchísimo valor. Es la razón por la que la API de Windows son las joyas de la corona; es la razón por la que la gente puede confiar en que glib y glibc no van a romper su código durante muchos años y así los pueden dar por sentado, como infraestructura que siempre está ahí para usarse.

Pero sólo existe una ABI estable

Y es la ABI de C. Incluso las bibliotecas en C++ tienen problemas con esto, y la gente a veces escribe las entrañas de una biblioteca en C++ para hacerse la vida fácil, pero exportan una API/ABI estable en C para consumo general.

Los lenguajes de alto nivel como Python tienen muchos problemas para llamar código en C++ precisamente por problemas de la ABI.

De hecho, en GNOME hemos ido más allá

En GNOME hemos construido un lindo universo en miniatura donde la Introspección de GObject es de hecho una ABI en C con un montón de metadatos extra que se generan automáticamente, y la hacen amigable a otros lenguajes de programación.

Sin embargo, aun así dependemos de la ABI de C en el nivel más básico. Puedes mirar este hilo de twitter sobre una hipótesis de cómo mejorar el ABI de C desde Rust; no tiene desperdicio.

Una sola copia de las bibliotecas con ABI en C

Bien, volvamos a esto. ¿Qué precio estamos pagando por poder tener una sola copia de cada biblioteca en el sistema, que por necesidad, deben exportar un ABI en C?

  • Código que se puede llamar fácilmente desde C, tal vez desde C++, y con poca a mucha dificultad desde CUALQUIER OTRA COSA. Dado que la mayoría de las aplicaciones nuevas no se escriben en C, tal vez debamos re-pensar nuestras prioridades.

  • Ningún soporte en el lenguaje para cosas como tipos genéricos o visibilidad de los campos, que ni siquiera son cosas exclusivas de los "lenguajes modernos". Incluso las plantillas de C++ se compilan y ligan de forma estática dentro del código que las llama, porque no hay forma de pasar información como el tamaño de T en Arreglo<T> a través de la ABI de C. ¿Querías un struct con unos campos públicos y otros privados? No se puede con esa ABI.

  • Nada de información sobre qué parte del código es la dueña de los datos ni cómo copiarlos o liberarlos, a menos de que uno lea la documentación de la API en C cuidadosamente. ¿La función a la que llamas libera la memoria de sus argumentos, o lo haces tú? ¿Cómo se hace, con free() o g_free() o mi_cosa_free()? ¿O quien llama a la función sólo le presta una referencia? ¿Se pueden copiar los datos bit por bit o debe llamarse una función especial para copiarlos? La Introspección de GObject trae toda esta información en sus metadatos, pero la ABI de C no tiene ni idea y sólo transfiere punteros de aquí para allá.

Más en qué pensar: este hilo de twitter dice lo siguiente sobre la ABI de C++: "Además, la ABI importa en términos de si es práctico o no cumplir con los requerimientos de la licencia LGPL, al comparar la situación hoy en día con la que se pensaba cuando un proyecto escogió la LGPL hace años. El estándar de C++, por supuesto, no habla de la LGPL. La LGPL tiene consecuencias diferentes para Rust o Go que para C y Java. Es obvio que se escribió pensando en C."

Monomorfización y crecimiento desmedido por las plantillas

Mientras que C++ tiene el problema de "mucho código de plantillas en los archivos de encabezados (headers)", Rust tiene el problema que el monomorfizar el código con tipos genéricos genera mucho código compilado. Hay truquitos para evitar esto y todos son decisión del autor de la biblioteca o crate. Ambos problemas tienen la misma causa: el código con plantillas o genéricos debe recompilarse para cada uso específico, y por lo tanto no puede vivir en una biblioteca compartida.

Además, mira este artículo maravilloso de cómo se implementan los genéricos en lenguajes diferentes, y piensa que la ABI en C significa que no podemos usar NADA de eso.

Además, mira Cómo pudo Swift tener enlazado dinámico pero Rust no, que deja mucho en qué pensar. Esto es muy aproximadamente equivalente a los tipos "boxed" de GObject; quien llama a una función mantiene los valores en memoria dinámica, pero conocen la disposición en memoria de los datos gracias a los metadatos de GObject, mientras que las bibliotecas son libres de mantener los datos en memoria dinámica o en la pila para su propio uso.

¿Todas las bibliotecas deberían exportar APIs con genéricos y tipos exóticos?

¡No!

Seguramente querremos un tipo de bajo nivel para representar un arreglo lineal de valores, un Vec<T> que se sustituye compilado en línea en todos lados para mayor velocidad, y con código que sabe explícitamente el tamaño de cada elemento del arreglo. El acceso a un elemento puede ponerse en línea como una sola instrucción de lenguaje de máquina.

Pero no todo el código requiere esta clase de velocidad bruta, con todo en línea en todas partes. Está perfectamente bien pasar referencias o punteros a las cosas, y hacer llamadas de funciones según una tabla de funciones dinámica si es que no estás en un bucle súper veloz. Esto lo hacemos todo el tiempo en GObject.

Tamaño de las bibliotecas

No tengo una respuesta realmente buena para responder al tamaño de librsvg. Si gnome-shell integra mi rama que convierte los estilos CSS a Rust, también va a ver crecer bastante el tamaño de sus ejecutables.

Mi meta es tener un crate de Rust que tanto librsvg como gnome-shell puedan utilizar para lo que necesitan de manejo de CSS, pero todavía no tengo idea de si esto puede ser una biblioteca compartida o un crate normal de Rust. Tal vez se pueda tener una biblioteca muy general para CSS, con la cual la aplicación registra las propiedades que quiere procesar... ¿se puede hacer esto como una biblioteca compartida sin reinventar libcroco? Todavía no lo sé. Ya veremos.

Una metáfora que no he explorado a fondo

Si cada aplicación o paquete para usuarios finales es más o menos como un organismo vivo, con sus propios ciclos y conductas y órganos (bibliotecas de las que depende)...

¿Por qué quieren las distros que todos los organismos que viven en tu compu utilicen el Único Servicio de Pulmones del Mundo, el Único Servicio de Estómgo del Mundo, el Único Servicio de Hígado del Mundo?

Digo, en vez de dejar que cada organismo tenga su propia versión ligeramente diferente de esos órganos, especializada para cada uno de ellos. Los humanos sabemos cómo hacer campañas de vacunación y toda la cosa; tal vez necesitamos mejores herramientas para arreglar bugs en donde se necesite.

Ya sé que esta metáfora no es perfecta y que así no es como funcionan las cosas en realidad, pero me deja pensando.