wandres.dev
RCU · Read-Copy-Update

El lado escritor: publicar, synchronize_rcu, call_rcu y listas RCU

El lado caro de RCU: el patrón copy-update con rcu_assign_pointer, esperar el grace period con synchronize_rcu o diferir con call_rcu, y las primitivas de lista RCU que hacen todo esto seguro.

⏱ 16 min

Si el lector es un fantasma, el escritor es un contable meticuloso: copia, publica de forma atómica y luego espera —o difiere la espera— hasta que sea seguro liberar la versión vieja. Aquí está el patrón que da nombre a RCU: Read, Copy, Update.

🎯 Al terminar esta lección sabrás
  • Publicar con rcu_assign_pointer y el patrón copy-update completo.
  • synchronize_rcu (bloqueante) frente a call_rcu (asíncrono).
  • Recorrer y mutar listas RCU sin romper a los lectores.
  • Elegir entre esperar el grace period y diferir la liberación.

El patrón copy-update

El nombre lo dice todo. Para modificar una estructura protegida por RCU, el escritor no la edita in situ (eso rompería a los lectores en vuelo). En su lugar: lee el objeto actual (Read), lo copia en uno nuevo (Copy), actualiza la copia (Update), publica la copia y difiere la liberación del viejo.

Un detalle crucial: RCU coordina lectores-contra-escritores, no escritores-contra-escritores. Si hay varios escritores, siguen necesitando exclusión mutua entre ellos, típicamente un spinlock o un mutex.

static DEFINE_SPINLOCK(cfg_lock);        /* serializa escritores entre si */
static struct config __rcu *cfg;

static int actualizar(int nuevo_valor)
{
	struct config *viejo, *nuevo;

	nuevo = kmalloc(sizeof(*nuevo), GFP_KERNEL);
	if (!nuevo)
		return -ENOMEM;

	spin_lock(&cfg_lock);
	viejo = rcu_dereference_protected(cfg, lockdep_is_held(&cfg_lock));
	*nuevo = *viejo;                 /* Copy */
	nuevo->valor = nuevo_valor;      /* Update */
	rcu_assign_pointer(cfg, nuevo);  /* publica de forma atomica */
	spin_unlock(&cfg_lock);

	synchronize_rcu();               /* espera a los lectores del viejo */
	kfree(viejo);                    /* seguro: nadie lo mira ya */
	return 0;
}

Los lectores en vuelo cuando se ejecutó rcu_assign_pointer siguen usando viejo; los que llegan después ven nuevo. Nunca hay un estado intermedio visible.

synchronize_rcu vs call_rcu

Hay dos formas de esperar el grace period, y elegir bien define el rendimiento.

synchronize_rcu() bloquea al llamante hasta que transcurre un grace period completo —que puede durar milisegundos. Es simple y correcto, pero exige un contexto donde puedas dormir: nada de spinlocks tomados, nada de contexto de interrupción.

call_rcu() no bloquea: registra un callback que se ejecutará tras el próximo grace period y devuelve el control de inmediato. El objeto debe embeber un struct rcu_head. El callback corre más tarde, en softirq. Úsalo cuando no puedes dormir o cuando el throughput importa.

struct nodo {
	u32 clave;
	int valor;
	struct list_head lista;
	struct rcu_head rcu;         /* necesario para call_rcu/kfree_rcu */
};

static void liberar_nodo(struct rcu_head *head)
{
	struct nodo *n = container_of(head, struct nodo, rcu);

	kfree(n);
}

/* ...tras desenlazar n bajo el lock de escritura... */
call_rcu(&n->rcu, liberar_nodo);   /* difiere la liberacion, no bloquea */

/* Si el callback solo hace kfree, hay un atajo sin escribir funcion: */
kfree_rcu(n, rcu);                 /* equivalente idiomatico */

Hay más azúcar en esta familia. kvfree_rcu() cubre memoria reservada con kvmalloc; y existe una forma de un solo argumento, kfree_rcu(ptr), que espera el grace period sin necesidad de un struct rcu_head embebido, a costa de poder bloquear brevemente. Como regla: usa synchronize_rcu() cuando actualizas rara vez y puedes permitirte esperar, y call_rcu/kfree_rcu cuando el contexto es atómico o el throughput manda.

💡
RCU_INIT_POINTER para el primer publish

Cuando publicas un puntero cuyo destino aún no es visible para ningún lector —típico en la inicialización, o al asignar NULL— no necesitas la barrera de rcu_assign_pointer. RCU_INIT_POINTER() hace la asignación sin barrera y ahorra ese coste. Úsalo solo cuando de verdad no hay lectores concurrentes o el valor es NULL; en cualquier otro caso, rcu_assign_pointer es lo correcto.

⚠️
No bloquees en el camino caliente, y limpia al descargar

Nunca llames a synchronize_rcu() sosteniendo el lock de escritura: alargas la sección crítica milisegundos y estrangulas a los demás escritores. En rutas calientes, prefiere call_rcu/kfree_rcu. Y cuidado con dos peligros: una avalancha de call_rcu puede acumular callbacks más rápido de lo que se drenan (usa rcu_barrier() para forzar el drenado). Y al descargar un módulo que registró callbacks, llama a rcu_barrier() antes de liberar el código: si un callback pendiente ejecuta una función de tu módulo ya descargado, es un crash inmediato.

Listas RCU

Casi nunca proteges un solo puntero: proteges colecciones. linux/rculist.h ofrece variantes RCU de las listas del nivel 10, y su diseño esconde una sutileza preciosa.

  • Lado lector: list_for_each_entry_rcu(), siempre dentro de rcu_read_lock().
  • Insertar: list_add_rcu(), list_add_tail_rcu() — publican el nodo con rcu_assign_pointer por debajo.
  • Borrar: list_del_rcu() — desenlaza para que ningún lector nuevo alcance el nodo, pero conserva su puntero next válido.
  • Reemplazar: list_replace_rcu().

La sutileza: list_del_rcu() no envenena el puntero next como hace list_del(). ¿Por qué? Porque un lector concurrente puede estar parado sobre ese nodo justo ahora, y necesita seguir next para continuar el recorrido. Envenenarlo lo mandaría a LIST_POISON y provocaría un crash. El nodo sigue apuntando hacia adelante hasta que el grace period garantiza que nadie lo pisa.

static LIST_HEAD(lista);
static DEFINE_SPINLOCK(lista_lock);

/* Lector: sin bloqueo */
rcu_read_lock();
list_for_each_entry_rcu(n, &lista, lista) {
	if (n->clave == k)
		usar(n->valor);
}
rcu_read_unlock();

/* Escritor: insertar */
spin_lock(&lista_lock);
list_add_rcu(&nuevo->lista, &lista);
spin_unlock(&lista_lock);

/* Escritor: borrar y diferir liberacion */
spin_lock(&lista_lock);
list_del_rcu(&n->lista);
spin_unlock(&lista_lock);
kfree_rcu(n, rcu);
flowchart LR
W[Escritor copia y actualiza] --> P[rcu_assign_pointer publica el nuevo]
P --> O[Lectores en vuelo siguen en la version vieja]
P --> N[Lectores nuevos ven la version nueva]
O --> G[Grace period]
G --> F[call_rcu o kfree libera la version vieja]
style P fill:#f9e2af,color:#11111b
style G fill:#cba6f7,color:#11111b
style F fill:#89b4fa,color:#11111b
La actualización atómica de una versión entera

La modificación in situ de una estructura compartida es, bajo concurrencia, una fuente inagotable de carreras: un lector puede observar el punto medio entre dos escrituras y ver un estado que nunca fue válido. RCU disuelve el problema cambiando la granularidad de la atomicidad. En lugar de intentar que N campos se actualicen “a la vez” —imposible sin un candado que serialice a los lectores—, RCU actualiza un solo puntero, y esa escritura de puntero es naturalmente atómica en el hardware. El truco es que ese puntero representa la versión entera del objeto: al cambiarlo, el mundo salta de la versión vieja a la nueva de golpe, sin estados intermedios observables. Copy-update es, en el fondo, control de versiones aplicado a la memoria: nunca editas la copia que otros están leyendo; publicas una revisión nueva y dejas que las lecturas viejas terminen contra la revisión vieja antes de reciclarla. Esta es la idea que permite mutar el enrutado, las reglas de firewall o el dcache de un kernel en producción sin detener ni un solo lector.

⚔️ Escribe como un escritor RCU
  1. Implementa actualizar con el patrón copy-update completo, incluyendo el spinlock que serializa escritores.
  2. Convierte una liberación con synchronize_rcu() + kfree() a kfree_rcu() y explica cuándo prefieres cada una.
  3. Explica por qué list_del_rcu no envenena next mientras list_del sí lo hace.
  4. Añade rcu_barrier() en la salida de un módulo que usa call_rcu y justifica por qué evita un crash.
  5. Diagrama la línea temporal copy-update marcando dónde exactamente ocurre la atomicidad.