Trampas de la memoria compartida y buenas prácticas para producción

· Actualizado el: · · Memoria compartida, IPC, Concurrencia, C++, C#, Desarrollo en Windows

Fotogramas de imagen, resultados de inspección, registros temporales, datos de profundidad de mercado, buffers enormes. Cuando se quiere intercambiar datos grandes con baja latencia dentro de la misma máquina, la memoria compartida resulta bastante atractiva.

Sin embargo, aquí hay algo un poco peligroso: la memoria compartida se presenta con la cara de «IPC rápido». En realidad, la memoria compartida es «un IPC que reduce las copias, pero a cambio devuelve a la aplicación la responsabilidad de mantener la coherencia».

  • Rápida
  • Flexible
  • Pero el protocolo lo diseña usted
  • Cuando falla, los síntomas son aparatosos

En general, este es el paquete de cuatro puntos.

En este artículo, teniendo en mente el file mapping de Windows y shm_open / mmap de POSIX, organizamos los puntos donde se atasca el uso de memoria compartida en la práctica y el diseño que reduce la tasa de incidentes. Tanto en C/C++ como en MemoryMappedFile de C#, la esencia es prácticamente la misma.1

Público objetivo y contexto

Este artículo está dirigido a desarrolladores que están a punto de decidir un diseño para pasar datos grandes entre procesos dentro de la misma máquina. Pensamos sobre todo en quienes manejan directamente el file mapping de Windows o el shm_open de POSIX en C / C++, pero quienes llegan desde MemoryMappedFile de C# caen en las mismas trampas. Los capítulos de trampas y pautas de diseño (capítulos 5 y 6) son independientes del lenguaje.

El ejemplo funcional está en el apartado 6.9, con tanto C (Windows / MSVC) como C#. Para el lado POSIX solo mostramos la correspondencia de nombres de API en la tabla del capítulo 7.

Terminología que conviene tener clara desde el principio

En el cuerpo del artículo aparecen varios términos que dejamos en inglés. Para que no resulten un obstáculo la primera vez que aparecen, los resumimos aquí de antemano.

Término Significado
IPC (Inter-Process Communication) Comunicación entre procesos. El conjunto de mecanismos para intercambiar datos o señales con otro proceso. Incluye pipe, socket, named pipe, memoria compartida, etc.
coherent Que varias views que apuntan a la misma entidad se vean con el mismo contenido en un instante dado. No significa que «el lector siempre pueda leer un registro actualizado y consistente»
ABI (Application Binary Interface) El contrato a nivel binario que deben respetar los ejecutables entre sí, no el código fuente. Incluye el tamaño de los tipos, el alignment, el padding y el orden de los campos de las estructuras
SPSC / MPSC / SPMC / MPMC Abreviaturas que indican el número de producers y consumers. S es single, M es multi, P es producer, C es consumer. En SPSC hay 1 writer y 1 reader. Se desarrolla en el apartado 4.2
lock-free Un diseño que avanza solo con operaciones atomic, sin tomar locks. Es un término sobre la garantía de progreso («al menos uno de los threads siempre puede avanzar»), una propiedad distinta de «ser rápido»
sentinel Un valor especial reservado para representar «inválido» o «fin». Por ejemplo, en un offset se puede decidir que «UINT64_MAX significa inválido»
NUMA (Non-Uniform Memory Access) Una configuración en la que la distancia de la memoria, vista desde la CPU, no es uniforme. Acceder a memoria de un nodo lejano hace que el mismo código se vuelva notablemente más lento

1. La conclusión primero (en pocas palabras)

Dicho de forma bastante directa, pero útil en la práctica, es lo siguiente.

  • La memoria compartida es un mecanismo que muestra la misma secuencia de bytes a varios procesos, no es la sincronización en sí misma23
  • Es rápida cuando se intercambian datos grandes dentro de la misma máquina. Si solo se trata de pequeños mensajes de control, suele ser mucho más cómodo usar pipe / socket / named pipe / queue
  • En memoria compartida, que algo sea visible y que se pueda leer con seguridad son problemas distintos
  • Es mejor no usar volatile como base del diseño. La atomicidad, el orden y la espera se piensan por separado45
  • Colocar tal cual punteros crudos, HANDLE, file descriptors, std::string, std::vector, std::mutex suele traer disgustos más adelante
  • Es más seguro que los datos en memoria compartida se ciñan a enteros de ancho fijo + layout explícito + cabecera con versión
  • Con solo colocar magic / version / size / state / generation / heartbeat en la cabecera inicial, la facilidad para investigar incidentes cambia bastante
  • El punto difícil de la memoria compartida no es la velocidad, sino la inicialización, el ciclo de vida, la recuperación, los permisos y el ABI
  • En Windows, el esqueleto son CreateFileMapping / OpenFileMapping / MapViewOfFile; en POSIX, shm_open / ftruncate / mmap63
  • Lo que menos accidentes provoca es empezar con un ring buffer SPSC (single-producer single-consumer) o con double buffering

En resumen, la memoria compartida es rápida, pero si se usa sin cuidado se cae en la «enfermedad de creer que se sincroniza sola». Evitar esto es la primera batalla.

2. Qué comparte la memoria compartida y qué no comparte

La memoria compartida es, en términos generales, un mecanismo que mapea la misma página física en el espacio de direcciones virtuales de varios procesos. En Windows se usa un file mapping object y una view; en POSIX se hace mmap de un shared memory object.273

Aquí hay dos puntos importantes.

  1. Lo que se comparte es la secuencia de bytes del contenido, no la dirección virtual en sí
  2. Ser coherent y estar sincronizado son cosas distintas

Incluso la documentación de Windows indica que las views creadas a partir del mismo file mapping object son coherent en un instante dado. Pero eso no significa que el lector siempre pueda leer un registro actualizado y consistente.8

Por ejemplo, aunque el writer tenga la intención de escribir

  • length
  • a continuación payload
  • y a continuación ready flag

en ese orden, si el reader lee sin ninguna sincronización puede llegar a ver una combinación de un length nuevo con un payload antiguo. La memoria compartida no corrige esto de forma automática.

Es decir, lo que comparte la memoria compartida son bytes. Lo que no comparte es el significado, el orden, la notificación de finalización y la política de recuperación. Todo esto hay que diseñarlo por nuestra cuenta.

3. Situaciones en las que la memoria compartida encaja o no encaja

Situación Adecuado / no adecuado Motivo
Pasar fotogramas o buffers grandes dentro de la misma máquina Adecuado Facilita reducir el número de copias
Valores de sensores de alta frecuencia, imagen, audio, datos de profundidad de mercado, etc. Adecuado Facilita alcanzar baja latencia y alto throughput
Intercambiar solo comandos o respuestas pequeñas Poco adecuado El coste de sincronización para el control es relativamente pesado
Comunicarse con otra máquina No adecuado La memoria compartida asume básicamente el mismo host
Coexistencia prolongada de lenguajes o versiones distintas Difícil Requiere diseño de ABI y versionado
También se necesita persistencia Depende del objetivo Un file-backed mapping es una opción sólida, pero las responsabilidades de persistencia e IPC tienden a mezclarse

En la práctica, la separación de «el control va por mensajes, el cuerpo de los datos va en memoria compartida» es bastante sólida. Por ejemplo, una estructura como esta:

  • El proceso de UI notifica al proceso worker «usa el siguiente fotograma» mediante event / pipe / socket
  • El fotograma en sí va en memoria compartida

resulta bastante tranquila.

4. Las cuatro cosas que hay que decidir primero

Al diseñar memoria compartida, lo primero que hay que decidir son estas cuatro cosas.

4.1 Separar el control plane del data plane

Primero se decide qué se coloca en la shared memory.

  • data plane: imágenes, audio, secuencias de registros, datos masivos
  • control plane: inicio, parada, errores, reconexión, reinicialización, notificaciones

Con solo separar estas dos cosas, el diseño del lado de shared memory se simplifica bastante.

4.2 Limitar el modelo de concurrencia

  • SPSC: 1 producer / 1 consumer
  • MPSC: varios writers / 1 consumer
  • SPMC: 1 writer / varios readers
  • MPMC: varios writers / varios readers

La dificultad aumenta, en general, en este orden. No es recomendable ir directamente a MPMC desde el principio. Hay que lidiar a la vez con la exclusión mutua entre writers y con el orden de memoria, y más adelante aparecen fallos difíciles de reproducir en pruebas.

4.3 Decidir el propietario y el ciclo de vida

  • Quién lo crea
  • Quién lo inicializa
  • Quién lo elimina
  • Quién se encarga de la recuperación cuando un participante cae a mitad de camino

Si esto queda ambiguo, el comportamiento cambia cada vez según el orden de arranque o el reinicio, y se vuelve difícil aislar la causa de los problemas.

4.4 Decidir el ABI y la versión

  • Layout
  • Tamaño de los tipos
  • Alignment
  • Zonas reserved
  • Version / feature flags
  • Existencia o no de compatibilidad

La shared memory no es una cuestión de API, sino de ABI (binary interface). Si esto se trata con descuido, se produce el desagradable accidente de tener compatibilidad a nivel de fuente pero romperse solo en tiempo de ejecución.

5. Trampas habituales

5.1 No sincronizar

Es la trampa más frecuente.

«Como estamos viendo la misma memoria, si se escribe se podrá leer.»

Es cierto que a veces se puede leer. Pero eso no significa que se pueda leer en el momento correcto, en la unidad correcta y en el orden correcto.

Tanto en Windows como en POSIX, se da por hecho que el acceso a memoria compartida se combina con otro mecanismo de sincronización. La propia documentación de Windows indica que el acceso a una view compartida debe coordinarse con mutex / semaphore / event, entre otros.2 La documentación de POSIX también indica que el acceso a shared memory requiere sincronización.9

5.2 Intentar resolverlo con volatile

volatile no es una solución mágica que salve el diseño de memoria compartida. Al menos la atomicity y la mutual exclusion son problemas distintos.45

Por ejemplo, un diseño que coloca volatile bool ready; y hace busy loop sobre ella suele traer solo problemas:

  • Desperdicia CPU
  • Deja ambigua la garantía de orden entre payload y ready
  • No es portable
  • Tiende a capturar estados intermedios

Además, WaitOnAddress de Windows está pensado para threads dentro del mismo proceso. Es más seguro no considerarlo un mecanismo de espera cross-process.10

5.3 Dejar que se lea un estado intermedio

El aspecto que tiene un accidente en memoria compartida es bastante común.

  • Solo la cabecera es nueva
  • Solo el payload es antiguo
  • Solo la longitud está actualizada
  • La combinación de dos campos está corrompida

Al representarlo en un diagrama, la forma en que ocurre el accidente es sencilla: el reader simplemente se cuela en el hueco antes de que el writer termine de escribir length y payload.

Lectura de un estado intermedio en memoria compartidaDiagrama de secuencia que muestra cómo el reader lee un length nuevo junto con un payload todavía antiguo porque entra en el hueco antes de que el writer termine de escribir length y payloadproceso readermemoria compartidaproceso writerproceso readermemoria compartidaproceso writerlength es nuevopayload sigue siendo antiguo«solo la cabecera es nueva»captura un estado a mediasescribe 1024 en lengthlee length1024lee 1024 bytes de payloadcontenido de la generación anteriorescribe payloadactiva ready flag

Figura 1: secuencia de una lectura de estado intermedio en memoria compartida, donde el reader lee un length nuevo junto con un payload todavía antiguo.

Este hueco existe siempre, porque escribir length y payload no es «una única operación indivisible». Si solo se actualiza un scalar único de forma atómica el asunto es relativamente simple, pero si se publica un registro compuesto por varios campos, hace falta un procedimiento de commit.

Típicamente se recurre a alguna de estas opciones:

  • Proteger todo con un mutex
  • Usar double buffering y, al final, cambiar el «número de buffer actualmente válido»
  • Usar un ring buffer con state / sequence por slot
  • Con 1 writer / varios readers, tomar snapshots con un sequence counter

Incluso con «activar el ready flag al final», si no se decide con qué orden de memoria se escribe y se lee ese flag, el diseño sigue siendo insuficiente. En memoria compartida, el momento mismo de la publicación es el protocolo.

5.4 Colocar punteros u objetos complejos tal cual

Este es también un patrón frecuente.

  • Punteros crudos
  • HANDLE
  • File descriptors
  • std::string
  • std::vector
  • std::unordered_map
  • std::mutex
  • CRITICAL_SECTION

Este es el patrón de colocar tal cual estos elementos en shared memory e intentar usarlos desde otro proceso. En el proceso lector, casi con toda seguridad se producirá una violación de acceso o se obtendrá un valor sin sentido.

El motivo es simple: las direcciones virtuales y los recursos process-local solo tienen sentido en el contexto de ese proceso. Incluso con las views de Windows, aunque se mapee el mismo mapping en otro proceso, no hay garantía de que la dirección virtual coincida.711

Por eso, si se necesita una referencia, lo básico es mantenerla como un offset desde la dirección base.

typedef struct ShmRef {
    uint64_t offset;   // posición relativa desde el inicio del segmento
    uint32_t length;
    uint32_t kind;
} ShmRef;

Así, cada proceso puede convertirlo a su propia dirección con base + offset.

5.5 Cuando se rompe el ABI

La shared memory no es un contrato de código fuente, sino un contrato binario. Es decir, todas las diferencias siguientes tienen efecto:

  • El tamaño de int / long
  • La representación de bool
  • El underlying type de un enum
  • El tamaño de wchar_t
  • La diferencia entre 32 bits y 64 bits
  • #pragma pack
  • Diferencias de compilador o de lenguaje
  • Alignment / padding
  • Little-endian / big-endian

Dentro del mismo host, la endianness suele coincidir, pero basta con que entre en juego soporte para ARM64 o un mixed toolchain para que se desalinee con bastante normalidad.

Por eso, para las estructuras que se colocan en shared memory recomendamos encarecidamente:

  • Enteros de ancho fijo como uint32_t / uint64_t
  • Padding / reserved explícitos
  • version, header_size, record_size, total_size en la cabecera
  • Si hace falta, static_assert(sizeof(...))
  • No colocar objetos no triviales

5.6 Carreras de inicialización

La shared memory se rompe fácilmente por asumir que «quien la creó ya la debe haber inicializado».

En Windows, cuando CreateFileMapping coincide con un nombre existente, devuelve el objeto existente, y se puede saberlo mediante ERROR_ALREADY_EXISTS en GetLastError(). Las páginas iniciales de un mapping pagefile-backed empiezan en 0.8 En POSIX, un shared memory object nuevo empieza con longitud 0, y se le da tamaño con ftruncate. Los bytes recién reservados se inicializan a 0. La creación con O_CREAT | O_EXCL es atómica.3

Si se desconoce esta diferencia y se hace algo como

  • usarlo justo después de abrirlo
  • no tener un flag de inicialización completada
  • que varios participantes inicialicen al mismo tiempo
  • no comprobar el version mismatch

el resultado se rompe según el orden de arranque.

Como mínimo, conviene colocar en la cabecera inicial los siguientes estados:

  • INITIALIZING
  • READY
  • BROKEN

Y que solo el creator inicialice, mientras el joiner espera a READY. Con esta sola disciplina, el panorama se vuelve bastante más tranquilo.

5.7 No pensar en la recuperación ante caídas

¿Qué pasa si el writer cae mientras actualiza los datos compartidos? Si se saca esto a producción sin definirlo, el aspecto de un fallo se vuelve de repente mucho más grave.

Un mutex de Windows se vuelve abandoned cuando el thread propietario termina sin hacer release, y el lado que espera puede recibir WAIT_ABANDONED. Esto significa que el recurso compartido podría estar en un estado indeterminado.12 Con un robust mutex de POSIX ocurre algo parecido: cuando el owner muere se devuelve EOWNERDEAD, y después de reparar el estado hay que llamar a pthread_mutex_consistent().1314

Lo importante es no «seguir adelante sin más» en este punto. La recuperación necesita al menos uno de los siguientes elementos:

  • Un número de generation
  • La última sequence confirmada (commit)
  • Heartbeat
  • Un flag dirty / clean
  • Un commit en dos fases al estilo journal
  • Un procedimiento de reinicialización completa en caso de corrupción

5.8 False sharing y contención de la línea de caché

Se suele decir que la shared memory es rápida. Pero si los contadores hot están apretados en la misma cache line, la línea va y viene entre CPUs, y se vuelve lenta hasta un punto que no se puede ignorar.

El ejemplo típico es:

  • El producer actualiza write_index
  • El consumer actualiza read_index
  • Ambos están en la misma cache line

En este caso, con solo:

  • Separar los hot fields en cache lines distintas
  • Separar los fields de actualización frecuente de los de actualización poco frecuente
  • Tener en mente la idea de 1 writer por cache line

la cosa cambia bastante. Es habitual que se hable de alinear a 64 bytes, pero conviene verlo con la idea de que 64 bytes es un valor común en muchas CPUs, no una ley absoluta.

5.9 Subestimar nombres, permisos y seguridad

El named shared memory es cómodo, pero si se tratan el nombre y los permisos con descuido, se producen accidentes.

En Windows hay estas particularidades:

  • Existen los namespaces Global\ y Local\
  • Para crear un file mapping en Global\ desde algo que no sea la session 0 hace falta SeCreateGlobalPrivilege
  • El object name comparte namespace con event / semaphore / mutex / waitable timer / job1582

Es decir, aparece un lío muy propio de Windows del estilo:

  • Se piensa que con "Global\\MyApp" se podrá compartir entre un service y una desktop app
  • Pero falla por permisos
  • Y además, como ya existía un mutex con el mismo nombre, se obtiene ERROR_INVALID_HANDLE

En el lado POSIX, si se toman a la ligera el mode o el umask de shm_open, se termina viendo con permisos innecesariamente amplios o, al contrario, sin poder abrirlo.3

La shared memory no es segura solo por ser memoria. Desde cualquier proceso con permiso de lectura, se ve con bastante facilidad. Si se va a colocar información sensible, hay que pensarlo en el mismo contexto de paging / swap / dump / permisos que la memoria normal.

5.10 Cambiar de tamaño o actualizar sin cuidado

Querer «ampliar un poco la memoria compartida más adelante» es, en realidad, una petición bastante peligrosa.

  • El mapping object de Windows tiene un tamaño fijado en el momento de creación8
  • En POSIX, si no se cuida la coherencia entre ftruncate y mmap, deja de coincidir con la longitud de map del lado de los participantes316

En la práctica, es más seguro que el tamaño sea invariable dentro de esa generación. Si hace falta ampliar, es preferible:

  1. Crear un segment con nueva version / nombre / generation
  2. Cambiar a los participantes hacia ese segment
  3. Cerrar el segment antiguo

Con esto la tasa de incidentes baja.

5.11 Meter también las notificaciones dentro de shared memory

Un caso frecuente es:

  • Poner ready = 1 en memoria compartida
  • Que el otro lado haga while (!ready) Sleep(1);

Esto funciona al principio. Pero más adelante se paga en forma de:

  • Desperdicio de CPU
  • Latencia que oscila por culpa de Sleep(1)
  • Dificultad para notar cuando algo se pierde
  • Dificultad para escribir de forma limpia los timeouts o las notificaciones de finalización

Es mejor concentrar la memoria compartida en el plano de datos y desviar las notificaciones hacia primitivas que permitan esperar.

  • Windows: event / semaphore / mutex / named pipe, entre otros217
  • POSIX: semaphore / process-shared mutex + condvar, entre otros1819

5.12 Pensar «con esto también podré compartir con otra máquina»

Hay momentos en los que uno se ve tentado a pensar que, usando file-backed mapping para mapear un archivo compartido a través de la red, se podría llegar a algo parecido a shared memory entre máquinas distintas.

Esto es peligroso.

La propia documentación de CreateFileMapping de Windows indica que no se garantiza la coherence para un remote file. Si dos máquinas mapean la misma página como writable, cada una solo ve sus propias escrituras, y tampoco se hace merge al actualizar el disco.8

La memoria compartida es, básicamente, un mecanismo dentro del mismo host. Si hay que cruzar máquinas, es más sensato optar directamente por socket / RPC / message broker.

6. Buenas prácticas

6.1 Separar el control plane del data plane

Si se lleva la separación decidida en 4.1 hasta la asignación a nivel de implementación, queda así (para la forma de fallo, ver 5.11).

  • shared memory: frame, sample, batch, snapshot
  • event / semaphore / pipe / socket: ready, consumed, stop, error, reconnect

Esta separación mejora, antes que el rendimiento, la claridad del diseño.

6.2 Colocar una cabecera fija al principio

Como mínimo, recomendamos encarecidamente colocar una cabecera así al principio.

typedef struct SharedHeader {
    uint32_t magic;
    uint16_t abi_version;
    uint16_t header_size;

    uint32_t state;          // 0=initializing, 1=ready, 2=broken
    uint32_t flags;

    uint64_t total_size;
    uint64_t generation;
    uint64_t heartbeat_ns;

    uint64_t payload_offset;
    uint64_t payload_size;

    uint64_t write_seq;
    uint64_t read_seq;

    uint8_t  reserved[64];
} SharedHeader;

Los puntos clave son:

  • magic descarta objetos distintos o sin inicializar
  • abi_version y header_size descartan diferencias de layout
  • state descarta una inicialización a medias
  • generation detecta una recreación
  • heartbeat vigila si sigue vivo
  • reserved deja una vía de escape para futuras ampliaciones

Lo difícil de la shared memory es que «resulta difícil ver qué está pasando». Precisamente por eso, se le añaden metadatos de observación desde el principio.

6.3 Usar referencias por offset

Las referencias se mantienen como offset, no como pointer.

  • Se resuelven con base + offset
  • Se incluye una comprobación de rango de offset + length
  • Se decide un sentinel para valores inválidos

Con solo esto, los accidentes del tipo address mismatch se reducen bastante.

6.4 Limitar el modelo de concurrencia

De los cuatro modelos del apartado 4.2, lo que conviene elegir primero es una de estas dos opciones:

  • SPSC ring buffer
  • Snapshot de 1 writer / varios readers

El ring buffer SPSC es una estructura sencilla: sobre un array de slots de longitud fija, el producer escribe en la posición write_seq y el consumer lee desde la posición read_seq. Como solo hay un writer y un reader, el sentido de avance es unidireccional.

Ring buffer SPSC de 8 slotsDiagrama que muestra un ring buffer de 8 slots donde el consumer avanza read_seq tras terminar de leer, el producer avanza write_seq tras terminar de escribir, y al llegar al último slot se vuelve al slot 0ring buffer de 8 slotsal llegar al final, vuelve al slot 0slot 0lectura terminadaslot 1lectura terminadaslot 2sin leerslot 3sin leerslot 4escribiendoslot 5libreslot 6libreslot 7libreconsumerread_seq = 2avanza tras terminar de leerproducerwrite_seq = 4avanza tras terminar de escribir

Figura 2: ring buffer SPSC de 8 slots, con el consumer avanzando read_seq y el producer avanzando write_seq tras terminar de leer o escribir respectivamente.

El punto clave es no alterar el orden de «avanzar el index después de terminar de escribir» y «avanzar el index después de terminar de leer». Y, tal como se indicó en 5.8, write_seq y read_seq se colocan en cache lines distintas.

Si hace falta más de un writer, suele funcionar bien reducir los puntos de responsabilidad sobre la coherencia, por ejemplo así:

  • Que solo el enqueue sea lock-free / atomic
  • Concentrar la actualización real de los datos en un único consumer

6.5 Hacer explícito el commit protocol

Un diseño en el que no se puede explicar con palabras «a partir de qué momento se puede leer» es peligroso.

Por ejemplo, en double buffering:

  1. Se escribe en el buffer no publicado
  2. Se fija el checksum y la longitud
  3. Se cambia el active buffer index con release
  4. El reader lee el active index con acquire
  5. Al terminar de leer, se comprueba si el index ha cambiado

De esta forma se define el ritual de publicación. Al representarlo en un diagrama, queda claro que el momento del cambio ocurre en un único punto.

Protocolo de commit con double bufferingDiagrama de secuencia que muestra cómo el reader lee el active index con acquire, el writer escribe en el buffer no publicado y cambia el active index con release, y el reader vuelve a comprobar el index al terminar de leer para detectar si cambió durante la lecturareaderbuffer Bactive indexbuffer Awriterreaderbuffer Bactive indexbuffer Awriterel active index es Ael active index es Bdescarta lo leídoy vuelve a leer desde Blee con acquireAlee el buffer Aescribe en el B no publicadofija la longitud y el checksumcambia a B con releaseal terminar de leer, vuelve a comprobar el indexhabía cambiado a B

Figura 3: protocolo de commit con double buffering, donde el reader vuelve a comprobar el active index al terminar de leer para detectar un cambio ocurrido durante la lectura.

Si se omite este paso de «volver a comprobar el index después de terminar de leer», el writer puede reutilizar el mismo buffer para la siguiente escritura mientras el reader todavía lo está leyendo, y se llega al mismo estado a medias que en 5.3. Además, con solo dos buffers puede volver a cambiar mientras se está releyendo, así que si las actualizaciones son rápidas conviene aumentar el número de buffers o inclinarse por el esquema de sequence counter mencionado en 5.3.

6.6 Fijar el tamaño por generación

Antes que un resize in place, es más fácil de mantener cortar generaciones así:

  • name = MyShm.v3
  • abi_version = 3
  • generation = 42

La memoria compartida no hace, como una API, una «comprobación de tipos en el momento de la llamada». Por eso es importante no romper el ABI una vez decidido.

6.7 Incorporar observabilidad

Como mínimo, ayuda tener lo siguiente:

  • Hora de la última actualización
  • Última sequence exitosa
  • Número de drops / overwrites
  • Número de version mismatch
  • Número de attach / detach
  • Last error code
  • Heartbeat

Cuando la shared memory se rompe, los logs suelen ser escasos. Colocar contadores propios facilita bastante la respuesta ante incidentes.

6.8 Crear primero las pruebas de casos anómalos

No basta con los casos normales. Como mínimo conviene comprobar lo siguiente:

  • Terminación forzada mientras el writer está actualizando
  • Desbordamiento del ring por retraso del reader
  • Conexión con version mismatch
  • Mezcla de 32 bits y 64 bits
  • Open a través de distintas sessions
  • Falta de permisos
  • Reinicio de un proceso previo que se quedó con una generación antigua
  • Impacto de cache miss / NUMA durante la transferencia continua de huge data

En shared memory, las pruebas de cómo se rompe tienen más valor que las de los casos normales.

6.9 Ejemplo mínimo de ida y vuelta

Llevamos las prácticas vistas hasta aquí a una configuración mínima funcional. Es una configuración simple: se coloca un único bloque de layout fijo en un file mapping pagefile-backed de Windows, y las notificaciones se intercambian con dos auto-reset events.

Los acuerdos comunes son estos cuatro:

  • El bloque contiene solo enteros de ancho fijo y arrays de longitud fija. No se colocan ni punteros ni HANDLE
  • Al principio se colocan magic / abi_version / block_size / state
  • La longitud se fija después de terminar de escribir el cuerpo, y solo entonces se activa el event
  • El nombre del event es distinto del nombre del file mapping (porque en Windows event / semaphore / mutex / waitable timer / job / file mapping comparten namespace)815

Primero, el lado que envía.

/* shm_writer.c : lado que envía. Inicie este primero.
 *   cl /W4 /nologo shm_writer.c        (kernel32.lib se enlaza por defecto) */
#include <windows.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>

#define SHM_NAME  L"Local\\KsShmDemo.v1.Block"
#define EVT_REQ   L"Local\\KsShmDemo.v1.Request"
#define EVT_REP   L"Local\\KsShmDemo.v1.Reply"
#define SHM_MAGIC 0x314D4853u   /* Valor de 'S','H','M','1' colocados en little-endian */
#define SHM_ABI   1u
#define STATE_INITIALIZING 0u
#define STATE_READY        1u

#pragma pack(push, 8)
typedef struct DemoBlock {
    uint32_t magic;
    uint32_t abi_version;
    uint32_t block_size;
    uint32_t state;
    uint32_t request_len;
    uint32_t reply_len;
    char     request[256];
    char     reply[256];
} DemoBlock;          /* 24 + 256 + 256 = 536 bytes */
#pragma pack(pop)

int main(void)
{
    HANDLE hMap = NULL, hReq = NULL, hRep = NULL;
    DemoBlock *blk = NULL;
    uint32_t len = 0;
    DWORD waited = 0;
    int rc = 1;

    /* 1. Crea un mapping pagefile-backed. La pagina inicial empieza en 0.
     *    Si el nombre ya existe, CreateFileMappingW "tiene exito y devuelve
     *    el existente", asi que comprobar solo NULL no basta para rechazar
     *    un segundo lado que envia. Si se continua con el memset de abajo,
     *    se borra el bloque compartido del lado que ya esta funcionando.
     *    GetLastError() se establece tambien en caso de exito, asi que hay
     *    que leerlo justo despues. */
    hMap = CreateFileMappingW(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE,
                              0, (DWORD)sizeof(DemoBlock), SHM_NAME);
    if (hMap == NULL) {
        printf("CreateFileMapping failed: %lu\n", GetLastError());
        goto cleanup;
    }
    if (GetLastError() == ERROR_ALREADY_EXISTS) {
        printf("%ls ya esta en uso. Solo puede haber un lado que envia a la vez\n", SHM_NAME);
        goto cleanup;
    }

    blk = (DemoBlock *)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(DemoBlock));
    if (blk == NULL) {
        printf("MapViewOfFile failed: %lu\n", GetLastError());
        goto cleanup;
    }

    /* 2. Event de notificacion. Usa un nombre distinto al del mapping */
    hReq = CreateEventW(NULL, FALSE, FALSE, EVT_REQ);   /* auto-reset / no senalizado */
    hRep = CreateEventW(NULL, FALSE, FALSE, EVT_REP);
    if (hReq == NULL || hRep == NULL) {
        printf("CreateEvent failed: %lu\n", GetLastError());
        goto cleanup;
    }

    /* 3. Solo quien lo crea inicializa. El state se activa al final */
    memset(blk, 0, sizeof(*blk));
    blk->magic       = SHM_MAGIC;
    blk->abi_version = SHM_ABI;
    blk->block_size  = (uint32_t)sizeof(DemoBlock);
    blk->state       = STATE_INITIALIZING;
    MemoryBarrier();
    blk->state = STATE_READY;

    /* 4. No alterar el orden: cuerpo -> barrera -> longitud -> notificacion */
    strcpy_s(blk->request, sizeof(blk->request), "ping from writer");
    MemoryBarrier();
    blk->request_len = (uint32_t)strlen(blk->request);
    if (!SetEvent(hReq)) {
        printf("SetEvent failed: %lu\n", GetLastError());
        goto cleanup;
    }

    /* 5. Espera la respuesta. No usar una espera infinita */
    waited = WaitForSingleObject(hRep, 5000);
    if (waited == WAIT_TIMEOUT) {
        printf("no hay respuesta del reader\n");
        goto cleanup;
    }
    if (waited != WAIT_OBJECT_0) {
        printf("WaitForSingleObject failed: %lu\n", GetLastError());
        goto cleanup;
    }

    /* 6. Comprobar el rango de la longitud antes de leer */
    len = blk->reply_len;
    if (len > sizeof(blk->reply)) {
        printf("reply_len fuera de rango: %u\n", len);
        goto cleanup;
    }
    printf("reply: %.*s\n", (int)len, blk->reply);
    rc = 0;

cleanup:
    /* Al cerrar todas las views y handles, el nombre tambien desaparece. No termine antes que el reader */
    if (hRep != NULL) CloseHandle(hRep);
    if (hReq != NULL) CloseHandle(hReq);
    if (blk  != NULL) UnmapViewOfFile(blk);
    if (hMap != NULL) CloseHandle(hMap);
    return rc;
}

El receptor, con las definiciones desde SHM_NAME hasta DemoBlock idénticas a las del emisor, y solo se sustituye main. En la práctica, esta parte común se extraería a un archivo de cabecera.

/* shm_reader.c : lado que recibe. Las constantes y la definicion de
 * DemoBlock son identicas a shm_writer.c.
 *   cl /W4 /nologo shm_reader.c */
int main(void)
{
    HANDLE hMap = NULL, hReq = NULL, hRep = NULL;
    DemoBlock *blk = NULL;
    uint32_t len = 0;
    int rc = 1;

    hMap = OpenFileMappingW(FILE_MAP_ALL_ACCESS, FALSE, SHM_NAME);
    if (hMap == NULL) {
        printf("OpenFileMapping failed: %lu / esta en ejecucion el writer\n", GetLastError());
        goto cleanup;
    }

    blk = (DemoBlock *)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(DemoBlock));
    if (blk == NULL) {
        printf("MapViewOfFile failed: %lu\n", GetLastError());
        goto cleanup;
    }

    hReq = CreateEventW(NULL, FALSE, FALSE, EVT_REQ);
    hRep = CreateEventW(NULL, FALSE, FALSE, EVT_REP);
    if (hReq == NULL || hRep == NULL) {
        printf("CreateEvent failed: %lu\n", GetLastError());
        goto cleanup;
    }

    /* 1. Espera primero la notificacion. El writer solo la activa despues de terminar de inicializar */
    if (WaitForSingleObject(hReq, 5000) != WAIT_OBJECT_0) {
        printf("no llego ninguna request\n");
        goto cleanup;
    }

    /* 2. Comprobar el ABI y el state antes de tocar nada */
    if (blk->magic != SHM_MAGIC || blk->abi_version != SHM_ABI ||
        blk->block_size != (uint32_t)sizeof(DemoBlock)) {
        printf("el ABI no coincide: magic=%08X abi=%u size=%u\n",
               blk->magic, blk->abi_version, blk->block_size);
        goto cleanup;
    }
    if (blk->state != STATE_READY) {
        printf("la inicializacion todavia no ha terminado: state=%u\n", blk->state);
        goto cleanup;
    }

    /* 3. Comprobar el rango de la longitud antes de leer */
    len = blk->request_len;
    if (len > sizeof(blk->request)) {
        printf("request_len fuera de rango: %u\n", len);
        goto cleanup;
    }
    printf("request: %.*s\n", (int)len, blk->request);

    /* 4. cuerpo -> barrera -> longitud -> notificacion, mismo orden que en el writer */
    strcpy_s(blk->reply, sizeof(blk->reply), "pong from reader");
    MemoryBarrier();
    blk->reply_len = (uint32_t)strlen(blk->reply);
    if (!SetEvent(hRep)) {
        printf("SetEvent failed: %lu\n", GetLastError());
        goto cleanup;
    }
    rc = 0;

cleanup:
    if (hRep != NULL) CloseHandle(hRep);
    if (hReq != NULL) CloseHandle(hReq);
    if (blk  != NULL) UnmapViewOfFile(blk);
    if (hMap != NULL) CloseHandle(hMap);
    return rc;
}

MemoryBarrier está para evitar que un reordenamiento haga visibles antes de tiempo solo la longitud o el flag, sin que el cuerpo haya terminado de escribirse.5 Si se quiere detectar en tiempo de compilación un desajuste de layout, se añade /std:c11 a MSVC y se fija sizeof(DemoBlock) con el static_assert de <assert.h>.

Si se maneja el mismo bloque desde el lado de C#, queda así. El punto clave es que los offsets se dejan explícitos como constantes, escritos de forma que no se desvíen ni un byte respecto de la struct del lado de C. El MemoryMappedFile y el EventWaitHandle con nombre son exclusivos de Windows.1

// .NET 8 / Windows. El lado que envia usa dotnet run -- write, el que recibe usa dotnet run -- read
using System.IO.MemoryMappedFiles;
using System.Text;

const string MapName = "Local\\KsShmDemo.v1.Block";
const string ReqName = "Local\\KsShmDemo.v1.Request";
const string RepName = "Local\\KsShmDemo.v1.Reply";
const uint Magic = 0x314D4853;   // 'S','H','M','1'
const uint Abi = 1;
const int BlockSize = 536;
const int MaxBody = 256;

// Fija, mediante constantes de offset, el mismo layout que el DemoBlock del lado de C
const int OffMagic = 0, OffAbi = 4, OffBlockSize = 8, OffState = 12;
const int OffRequestLen = 16, OffReplyLen = 20, OffRequest = 24, OffReply = 280;

bool isWriter = args.Length > 0 && args[0] == "write";

// El lado que envia usa CreateNew. Con CreateOrOpen, un segundo lado que
// envia abriria tal cual el bloque que ya esta funcionando y romperia el
// intercambio del otro lado con la inicializacion de abajo.
// Con CreateNew, si ya existe el mismo nombre se produce IOException, con
// lo que se puede detectar ahi
// (el mismo espiritu que la comprobacion de ERROR_ALREADY_EXISTS del lado C)
using var mmf = isWriter
    ? MemoryMappedFile.CreateNew(MapName, BlockSize)
    : MemoryMappedFile.OpenExisting(MapName);
using var view = mmf.CreateViewAccessor(0, BlockSize);
using var reqEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ReqName);
using var repEvent = new EventWaitHandle(false, EventResetMode.AutoReset, RepName);

if (isWriter)
{
    view.Write(OffMagic, Magic);
    view.Write(OffAbi, Abi);
    view.Write(OffBlockSize, (uint)BlockSize);
    Thread.MemoryBarrier();
    view.Write(OffState, 1u);              // READY

    byte[] request = Encoding.UTF8.GetBytes("ping from C#");
    view.WriteArray(OffRequest, request, 0, request.Length);
    Thread.MemoryBarrier();
    view.Write(OffRequestLen, (uint)request.Length);
    reqEvent.Set();

    if (!repEvent.WaitOne(TimeSpan.FromSeconds(5)))
    {
        Console.WriteLine("no hay respuesta del reader");
        return 1;
    }
    return PrintBody(OffReplyLen, OffReply, "reply");
}

if (!reqEvent.WaitOne(TimeSpan.FromSeconds(5)))
{
    Console.WriteLine("no llego ninguna request");
    return 1;
}
if (view.ReadUInt32(OffMagic) != Magic || view.ReadUInt32(OffAbi) != Abi
    || view.ReadUInt32(OffBlockSize) != BlockSize || view.ReadUInt32(OffState) != 1u)
{
    Console.WriteLine("el ABI no coincide o la inicializacion todavia no ha terminado");
    return 1;
}
if (PrintBody(OffRequestLen, OffRequest, "request") != 0)
{
    return 1;
}

byte[] reply = Encoding.UTF8.GetBytes("pong from C#");
view.WriteArray(OffReply, reply, 0, reply.Length);
Thread.MemoryBarrier();
view.Write(OffReplyLen, (uint)reply.Length);
repEvent.Set();
return 0;

int PrintBody(int lenOffset, int bodyOffset, string label)
{
    uint length = view.ReadUInt32(lenOffset);
    if (length > MaxBody)
    {
        Console.WriteLine($"la longitud de {label} esta fuera de rango: {length}");
        return 1;
    }
    byte[] body = new byte[length];
    view.ReadArray(bodyOffset, body, 0, body.Length);
    Console.WriteLine($"{label}: {Encoding.UTF8.GetString(body)}");
    return 0;
}

Las versiones en C y en C# usan el mismo nombre y el mismo layout, así que aunque una actúe de emisor y la otra de receptor, el intercambio funciona igual. Fijar el ABI significa esto.

Este ejemplo es intencionadamente de una sola ida y vuelta. Si se quiere una transferencia continua, avance hacia el ring buffer de 6.4; si se quiere resistir la terminación anómala del writer, avance hacia el generation y el heartbeat de 5.7.

7. Puntos a revisar en Windows y en POSIX

Aspecto Windows POSIX
Creación / open CreateFileMapping / OpenFileMapping / MapViewOfFile6 shm_open / ftruncate / mmap3
Compartir sin respaldo en disco Mapping pagefile-backed indicando INVALID_HANDLE_VALUE68 POSIX shared memory object + mmap3
Valor inicial Las páginas pagefile-backed se inicializan a 08 Un object nuevo tiene longitud 0. Los bytes recién reservados se inicializan a 03
Sincronización mutex / semaphore / event / interlocked, entre otros25 process-shared mutex / condvar / semaphore2018
Lo que no debe usarse cross-process CRITICAL_SECTION, WaitOnAddress2110 mutex / condvar que se dejan como PTHREAD_PROCESS_PRIVATE2019
Muerte del owner WAIT_ABANDONED12 robust mutex + EOWNERDEAD / pthread_mutex_consistent()1314
Eliminación del nombre Desaparece al liberar el último handle / view28 El nombre se elimina con shm_unlink. Si quedan referencias, el objeto sigue existiendo hasta el final2223
Namespace / permisos Global\ / Local\, ACL, SeCreateGlobalPrivilege1524 mode, umask, namespace, O_CREAT\|O_EXCL3

El MemoryMappedFile de C# también es, en esencia, un wrapper del file mapping de Windows. Por eso los fundamentos no cambian:

  • Se abre con el mismo nombre
  • Se usa por separado un mutex / event
  • Se lee sobre la view con un layout explícito
  • No se coloca tal cual una referencia a objeto

1

8. Lista de verificación para revisar primero

  • ¿De verdad hace falta memoria compartida? ¿Se trata de datos grandes en el mismo host?
  • ¿Se separaron el control plane y el data plane?
  • ¿Se puede reducir el modelo de concurrencia hasta SPSC / 1 writer con varios readers?
  • ¿Tiene la cabecera inicial magic / version / size / state / generation / heartbeat?
  • ¿Se evitó colocar pointer / HANDLE / fd / objetos STL / std::mutex?
  • ¿Existe un commit protocol que evita que el reader vea un estado intermedio?
  • ¿Está fijado en una única persona quién inicializa?
  • ¿Hay un procedimiento de recuperación para la terminación anómala?
  • ¿Se especificaron claramente el nombre y los permisos?
  • ¿Realmente hace falta Global\?
  • ¿Se evitó asumir un resize in place?
  • ¿Se probaron writer kill / reader stall / version mismatch / falta de permisos?

9. Resumen

Bien utilizada, la memoria compartida es realmente potente. En particular, resulta muy eficaz con datos grandes dentro de la misma máquina, como:

  • Imágenes
  • Audio
  • Secuencias de sensores
  • Lotes grandes
  • Snapshots de alta frecuencia

Ahora bien, la esencia de la memoria compartida no es la «velocidad», sino el traslado de responsabilidad. A cambio de reducir las copias y el paso de mensajes a través del kernel, uno asume por su cuenta:

  • La sincronización
  • La visibilidad
  • La inicialización
  • El ABI
  • La recuperación
  • Los permisos
  • La observabilidad

Por eso, para el primer proyecto es más seguro hacer lo siguiente:

  • Un SPSC ring buffer o double buffering
  • Cabecera fija al principio
  • Referencias por offset
  • Notificación por un canal separado
  • Con version / generation / heartbeat
  • Con pruebas de casos anómalos

Empezando desde esta forma, la shared memory se convierte en una herramienta bastante manejable. Al contrario, si se trata desde el principio como «una memoria común rápida donde se puede poner cualquier cosa», poco a poco deja de ser una aplicación y se convierte en arqueología.

10. Referencias

  • Windows: fundamentos de file mapping y named shared memory682
  • Windows: namespace / security / synchronization1524512
  • POSIX: shm_open, shm_unlink, mmap, process-shared / robust synchronization322162013
  • .NET: introducción a MemoryMappedFile1
  1. Microsoft Learn, “Archivos asignados en memoria” / Microsoft Learn, “Clase MemoryMappedFile”  2 3 4

  2. Microsoft Learn, “Sharing Files and Memory”  2 3 4 5 6 7 8

  3. man7.org, “shm_open(3)”  2 3 4 5 6 7 8 9 10 11

  4. Microsoft Learn, “/volatile (volatile Keyword Interpretation)” / Microsoft Learn, “volatile (C++)”  2

  5. Microsoft Learn, “Interlocked Variable Access” / Microsoft Learn, “MemoryBarrier function”  2 3 4 5

  6. Microsoft Learn, “Creating Named Shared Memory” / Microsoft Learn, “Creación de memoria compartida con nombre”  2 3 4

  7. Microsoft Learn, “Scope of Allocated Memory”  2

  8. Microsoft Learn, “CreateFileMappingA function”  2 3 4 5 6 7 8 9 10

  9. man7.org, “POSIX Shared Memory” diapositivas de formación 

  10. Microsoft Learn, “WaitOnAddress function”  2

  11. Microsoft Learn, “MapViewOfFileEx function” / Microsoft Learn, “MapViewOfFile function” 

  12. Microsoft Learn, “Mutex Objects”  2 3

  13. man7.org, “pthread_mutex_lock(3p)” / man7.org, “pthread_mutexattr_setrobust(3)”  2 3

  14. man7.org, “pthread_mutex_consistent(3)” / man7.org, “pthread_mutex_consistent(3p)”  2

  15. Microsoft Learn, “Kernel object namespaces”  2 3 4

  16. man7.org, “mmap(2)”  2

  17. Microsoft Learn, “Using Mutex Objects” 

  18. man7.org, “sem_init(3)” / man7.org, “sem_init(3p)”  2

  19. man7.org, “pthread_condattr_setpshared(3p)” / man7.org, “pthread_condattr_getpshared(3p)”  2

  20. man7.org, “pthread_mutexattr_getpshared(3)” / man7.org, “pthread_mutexattr_getpshared(3p)”  2 3

  21. Microsoft Learn, “Critical Section Objects” 

  22. Microsoft Learn, “Seguridad y derechos de acceso de la asignación de archivos” / Microsoft Learn, “File Mapping Security and Access Rights”  2

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿Un valor escrito en memoria compartida se puede leer correctamente de inmediato desde otro proceso?
Que el valor sea visible y que se pueda leer de forma segura son cosas distintas. La memoria compartida es un mecanismo para mostrar la misma secuencia de bytes a varios procesos, no un mecanismo de sincronización en sí mismo. Aunque el writer tenga intención de escribir length, payload y ready flag en ese orden, si el reader lee sin ninguna sincronización puede llegar a ver una combinación de un length nuevo con un payload antiguo. Tanto en Windows como en POSIX, el acceso a memoria compartida se da por hecho que se combina con mecanismos de sincronización como mutex, semaphore o event.
¿Se pueden colocar punteros, std::string o HANDLE en memoria compartida?
Es mejor no hacerlo. Las direcciones virtuales y los recursos process-local solo tienen sentido en el contexto de ese proceso concreto, y aunque se mapee el mismo mapping en otro proceso, no hay garantía de que la dirección virtual coincida. Lo mismo ocurre con std::vector, std::mutex o CRITICAL_SECTION. Si se necesita una referencia, es más seguro mantenerla como un offset desde la dirección base, y conviene que los datos colocados en memoria compartida se limiten a enteros de ancho fijo, un layout explícito y una cabecera con versión.
¿Usar volatile hace innecesaria la sincronización en memoria compartida?
No. volatile no es una solución mágica para el diseño de memoria compartida, y como mínimo la atomicidad y la exclusión mutua son problemas distintos. Un diseño que vigila un volatile bool mediante busy loop desperdicia CPU, deja ambigua la garantía de orden entre payload y ready flag, y tiende a capturar estados intermedios. Además, WaitOnAddress de Windows está pensado para threads dentro del mismo proceso, así que es más seguro no considerarlo un mecanismo de espera cross-process. Las notificaciones conviene delegarlas a primitivas que permitan esperar, como event o semaphore.
¿Qué se debe decidir primero al diseñar memoria compartida?
Hay cuatro cosas. Primero, la separación entre control plane y data plane: el control (inicio, parada, notificaciones) va por un sistema de mensajes y el cuerpo de los datos va en memoria compartida. Segundo, limitar el modelo de concurrencia (al principio, un ring buffer SPSC o un double buffering son los que menos accidentes generan). Tercero, quién crea, inicializa, elimina y recupera el recurso, es decir, propietario y ciclo de vida. Y cuarto, el diseño del ABI, incluyendo el layout y la versión. Solo con colocar magic, version, size, state, generation y heartbeat en la cabecera inicial, la facilidad para investigar incidentes cambia bastante.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog