Variables globales: por qué son veneno
Qué rompe exactamente una variable global —firmas que mienten, hilos, pruebas y optimizaciones perdidas—, cómo se usa extern correctamente, por qué la inicialización es el problema difícil, y qué poner en su lugar.
“No uses variables globales” es de los consejos más repetidos y peor explicados de la programación. Repetido como tabú no sirve de nada: quien no entiende el mecanismo acaba escribiendo la misma global disfrazada de static de archivo, o peor, evitándola en sitios donde era la respuesta correcta. Este nivel desmonta el eslogan en sus cuatro daños concretos —firmas que mienten, hilos, pruebas y optimizaciones perdidas—, enseña la única forma correcta de compartir un objeto entre unidades de traducción, y llega al problema que casi nadie sabe resolver: la inicialización.
- Enunciar con precisión los cuatro daños que causa un objeto con linkage externo.
- Declarar y definir correctamente estado compartido con
extern, sin definición múltiple. - Entender por qué la inicialización de objetos estáticos es el punto realmente difícil.
- Sustituir estado global por contexto explícito, estado de módulo o almacenamiento por hilo.
Los cuatro daños, en orden de gravedad
El primero y más profundo es que la firma miente. Una función cuyo comportamiento depende de una variable global tiene una entrada que su prototipo no declara y a menudo una salida que tampoco. Deja de poder razonarse localmente: para saber qué hace hay que conocer todo el programa y el orden en que se ejecutó. Todo el resto de los problemas se derivan de este.
El segundo son los hilos. Dos hilos que acceden al mismo objeto sin sincronización, con al menos uno escribiendo, constituyen una carrera de datos, y una carrera de datos es comportamiento indefinido — no “un valor raro”, sino un programa sin significado. El estado global es exactamente el estado que se comparte por accidente entre hilos que nadie diseñó para compartirlo.
Conviene notar que el problema no se limita a los hilos que tú crees. Un manejador de señal puede interrumpir tu programa entre dos instrucciones cualesquiera, y lo único que el estándar permite tocar desde él es un objeto de tipo sig_atomic_t calificado volatile; cualquier otra variable global manipulada desde un manejador es exactamente la misma clase de error, con la agravante de que ocurre en un solo hilo y por eso nadie lo busca ahí.
El tercero son las pruebas. Un módulo con estado global tiene una única instancia por proceso: no puedes crear dos configuraciones distintas, no puedes aislar un caso de prueba del anterior, y el orden de ejecución de las pruebas empieza a importar. El síntoma clásico es la suite que pasa entera y falla al ejecutar un solo caso.
El cuarto es el que casi nadie menciona y es medible: el optimizador se rinde. Un objeto con linkage externo puede ser leído o modificado por cualquier función de cualquier unidad de traducción, así que el compilador está obligado a releerlo de memoria después de cada llamada que no pueda analizar.
extern int limite; /* linkage externo: cualquiera puede tocarlo */
void procesar(int *v, int n) {
for (int i = 0; i < n; i++)
if (v[i] > limite) /* recarga 'limite' en cada vuelta */
registrar(v[i]); /* porque registrar() podria cambiarlo */
}
Basta marcar limite como static para que el compilador vea todos sus usos y pueda mantenerlo en un registro. Reducir el linkage no es solo higiene: es información que el optimizador usa. En una biblioteca compartida el efecto se agrava, porque un objeto exportado puede ser interpuesto por otro cargado antes, así que ni siquiera los accesos internos pueden resolverse directamente y todos pagan una indirección a través de la tabla del enlazador dinámico.
Hay un quinto daño que solo aparece cuando distribuyes código: cada objeto global exportado forma parte de la ABI. Su nombre, su tipo y su tamaño quedan grabados en los binarios de tus usuarios, de modo que cambiar un int por un long, renombrarlo o retirarlo rompe programas que ya no puedes recompilar. Una función te deja margen para cambiar la implementación detrás de la firma; una variable expuesta no deja ninguno, porque lo expuesto es la representación. Por eso las bibliotecas maduras exponen funciones de acceso incluso cuando por dentro solo leen una variable.
La forma correcta de compartir un objeto es una declaración con extern en la cabecera y una sola definición en un archivo de implementación. Definirlo en la cabecera produce tantas definiciones como consumidores. Durante décadas eso pareció funcionar por culpa de las definiciones tentativas y del antiguo -fcommon, que fusionaba los símbolos en silencio; desde GCC 10 el valor por defecto es -fno-common y aparece el error de definición múltiple que siempre debió aparecer.
/* config.h */
#pragma once
extern int config_limite; /* DECLARA: promete que existe */
/* config.c */
#include "config.h"
int config_limite = 100; /* DEFINE: exactamente una vez en el programa */
El problema de la inicialización
Aquí está la parte técnicamente difícil, y conviene empezar por lo que C hace bien. Los objetos con duración estática se inicializan antes del arranque del programa: a cero si no pones inicializador —van a la sección .bss, que no ocupa espacio en el ejecutable— y al valor indicado si lo pones, que debe ser una expresión constante. No puedes llamar a una función para inicializar una global.
Esa distinción entre las dos secciones se puede ver, y ver el tamaño de cada una dice más sobre la salud de un módulo que muchas métricas:
gcc -std=c23 -c config.c -o config.o
size config.o # texto, datos inicializados y bss
nm -S --size-sort config.o | tail # los objetos globales mas grandes
Esa restricción parece una carencia y es una vacuna. C++ sí permite inicializadores dinámicos en objetos estáticos, y a cambio sufre el célebre problema del orden de inicialización entre unidades de traducción: el orden relativo no está especificado, así que un objeto puede usar otro que aún no existe, y el fallo depende del enlazador. En C ese problema no existe por construcción, porque no hay nada que ejecutar antes de main.
El precio es que la inicialización que sí requiere trabajo —abrir un archivo, leer el entorno, reservar memoria— tiene que ocurrir en algún momento, y ahí es donde la gente reinventa el desastre.
| Estrategia | Qué pasa realmente |
|---|---|
Función init llamada al principio de main |
Correcta y explícita. El orden lo decides tú, que es justo lo que quieres |
| Inicialización perezosa con una bandera | Carrera de datos en cuanto haya dos hilos: dos entran a la vez y ambos inicializan |
| Perezosa con doble comprobación a mano | Rota sin operaciones atómicas: no hay orden de memoria que garantice ver el objeto ya construido |
| Atributo de constructor del compilador | Se ejecuta antes de main, pero el orden entre unidades no está especificado: mismo problema que C++ |
call_once de la biblioteca de hilos |
La forma portable y correcta de inicializar una vez en presencia de hilos |
Merece la pena entender por qué la doble comprobación escrita a mano está rota, porque es un error que se comete con la mejor de las intenciones. El razonamiento intuitivo es: si la bandera ya está puesta, el objeto ya existe, así que puedo leerlo sin bloquear. El fallo es que sin operaciones atómicas nada obliga al compilador ni al procesador a que la escritura de la bandera sea visible después de la escritura del objeto; otro hilo puede ver la bandera puesta y el puntero todavía a medio construir. No es una carrera improbable que ocurra una vez al año: es comportamiento indefinido, y ninguna cantidad de volatile lo arregla, porque volatile en C nunca fue una herramienta de concurrencia.
#include <threads.h>
static once_flag bandera = ONCE_FLAG_INIT;
static Recurso *recurso; /* interno: no sale del modulo */
static void construir(void) { recurso = recurso_crear(); }
Recurso *recurso_obtener(void) {
call_once(&bandera, construir); /* exactamente una vez, con hilos */
return recurso;
}
Todo estado global plantea la pregunta simétrica: quién lo destruye y cuándo. atexit registra funciones que se ejecutan al terminar, en orden inverso al de registro, pero solo si el programa termina por la vía normal — no con _Exit, no con una señal, no con un fallo. Y en una biblioteca compartida que puede descargarse, el momento correcto ya no es el fin del proceso. Un módulo con una función de creación y una de destrucción explícitas no tiene ninguno de estos problemas, porque la vida del objeto la decide el llamante.
Qué poner en su lugar
Contexto explícito
Agrupa el estado en una struct y pásala como primer parámetro. La firma vuelve a decir la verdad y puedes tener dos instancias.
Estado de módulo
Si de verdad hay una sola instancia, que sea static de archivo y solo accesible por funciones. Global para el módulo, invisible para el programa.
Almacenamiento por hilo
thread_local da a cada hilo su copia. Elimina la carrera, no el acoplamiento.
Constantes, no variables
static const, constexpr de C23 o enum. Lo inmutable y compartido nunca fue el problema.
El ejemplo canónico de esta transformación está en la propia biblioteca estándar. errno empezó siendo una variable global de verdad, y con la llegada de los hilos se volvió indefendible: un hilo podía sobrescribir el código de error de otro entre la llamada fallida y su lectura. La solución no fue eliminarlo sino convertirlo en algo que ya no es una variable: hoy errno es una macro que se expande a un lvalue modificable por hilo. La interfaz sobrevivió intacta; lo que cambió fue el almacenamiento debajo. Es exactamente la migración que puedes aplicar a tu propio estado global cuando no puedas romper la API.
/* Antes: la firma miente y no hay dos instancias posibles */
void registrar(const char *msg);
/* Despues: el estado es un parametro, y hay tantos registros como quieras */
typedef struct Registro Registro;
Registro *registro_crear(FILE *destino, int nivel);
void registro_escribir(Registro *r, const char *msg);
void registro_destruir(Registro *r);
La migración no tiene por qué ser un salto. Cuando el código heredado es demasiado grande para reescribirlo de golpe, el camino que funciona tiene tres etapas y cada una es útil por sí sola. Primero, encapsular: la variable pasa a static de archivo y todo el acceso ocurre a través de funciones, con lo que el daño queda confinado a un módulo y aparece un punto único donde instrumentar. Segundo, parametrizar: las funciones ganan un parámetro de contexto y la variable de archivo se convierte en la instancia por defecto que se usa cuando nadie pasa otra. Tercero, retirar la instancia por defecto cuando ya no queda ningún llamante que dependa de ella. En ese orden, cada etapa deja el programa funcionando y ninguna exige tocar todo el árbol el mismo día.
Y conviene decir lo contrario también, porque el tabú mal entendido hace daño. Hay estado global legítimo: constantes inmutables, tablas precalculadas de solo lectura, y los verdaderos recursos únicos del proceso — la salida estándar es un objeto global y está bien que lo sea. El criterio no es la palabra “global”: es si el objeto es mutable, si es compartido y si su existencia está oculta en las firmas. Un objeto inmutable no tiene carrera de datos ni orden de inicialización que resolver. El veneno nunca fue la globalidad; fue el estado mutable compartido sin declarar.
Detrás de la regla hay una idea sola, y es más antigua que C. Una función es una relación entre lo que recibe y lo que devuelve, y esa relación es la única razón por la que podemos razonar sobre programas en trozos en lugar de leerlos enteros. Cada variable global que consultas añade a esa relación un argumento invisible; cada una que modificas, un valor de retorno invisible. La función sigue funcionando, pero ha dejado de ser una función en el sentido que permitía razonar: ya no basta con leer su firma y su cuerpo, hay que conocer el estado del programa entero y su historia. De ese único desplazamiento salen los cuatro daños, y no por casualidad. No puedes probar el módulo aisladamente porque el argumento invisible no se puede fijar desde fuera. No es seguro con hilos porque un argumento invisible tampoco puede sincronizarse. El compilador no puede optimizar porque no consigue demostrar quién toca ese argumento. Y el lector no puede entenderlo porque nadie escribió que existiera. Por eso la cura no es esconder la global mejor —renombrarla, envolverla en funciones, marcarla static— sino hacerla explícita: convertirla en un parámetro, agruparla en un contexto y dejar que el llamante decida cuántas instancias hay y cuánto viven. Esa es también la razón por la que static de archivo es una mejora real pero parcial: reduce el radio del daño de todo el programa a un archivo, no lo elimina. Y por eso mismo un módulo diseñado con contexto explícito casi nunca necesita extern en su cabecera. Si tu cabecera pública declara objetos, no funciones, probablemente no has diseñado un módulo: has repartido una variable.
- Coge un módulo tuyo con estado global y conviértelo a contexto explícito con funciones de creación y destrucción. Escribe una prueba que use dos instancias a la vez.
- Pon
int contador;en una cabecera incluida por dos.c, compila con-fno-commony explica el error de definición múltiple. Arréglalo conextern. - Escribe una inicialización perezosa con bandera, llámala desde varios hilos y ejecútala bajo el detector de carreras. Reescríbela con
call_oncey compárala. - Compila un bucle que compare contra una global con linkage externo y luego con
static. Localiza en el ensamblador la recarga que desaparece. - Convierte una variable global mutable en
thread_localy explica qué problema resuelve y cuál sigue exactamente igual.