Ir al contenido

12. Memoria compartida

La memoria compartida como mecanismo de comunicación entre procesos y su uso en sistemas POSIX y Windows.

7 min de lectura

La memoria compartida es una estrategia para comunicar procesos donde uno de ellos gana acceso a regiones de la memoria del otro; algo que por lo general el sistema operativo siempre intenta evitar. Por eso, para que pueda haber memoria compartida es necesario que los dos procesos estén de acuerdo en eliminar dicha restricción.

Dos procesos que comparten una región de la memoria pueden intercambiar información simplemente leyendo y escribiendo datos en la misma. Sin embargo, debemos tener en cuenta que:

  • La estructura de los datos y su localización dentro de la región compartida la determinan los procesos en comunicación y no el sistema operativo, a diferencia de lo que ocurre en los sistemas de paso de mensajes.

  • Los procesos son responsables de sincronizarse para no escribir y leer en el mismo sitio de la memoria al mismo tiempo, pues esto puede generar inconsistencias (ver el capítulo «Sincronización»).

Las principales ventajas de la memoria compartida frente a otros mecanismos de comunicación son:

  • Eficiencia. Puesto que la comunicación tiene lugar a la velocidad de la memoria principal, se trata de un mecanismo tremendamente rápido.

  • Conveniencia. Puesto que el mecanismo de comunicación solo requiere leer y escribir de la memoria, se trata de un sistema muy sencillo y fácil de utilizar.

Al igual que ocurre con las tuberías la memoria compartida puede ser anónima o con nombre.

La memoria compartida anónima solo existe para el proceso que la crea y para los procesos con los que decide compartirla —normalmente sus hijos—. Una vez que dos procesos tienen mapeada la misma región, cualquier escritura que haga uno es visible para el otro sin necesidad de copiar datos entre ellos, lo que la convierte en una forma muy eficiente de comunicarlos.

En los sistemas POSIX, la memoria compartida anónima se reserva con la llamada al sistema mmap(), el indicador MAP_SHARED. La función devuelve un puntero a la región de memoria reservada, que puede ser utilizado para leer y escribir en ella.

void* p = mmap(
NULL,
length,
PROT_READ | PROT_WRITE,
MAP_ANONYMOUS | MAP_SHARED,
-1,
0
);

La opción MAP_ANONYMOUS indica que la memoria no está respaldada por ningún archivo, por lo que su contenido es inicializado a cero. Mientras que MAP_SHARED establece que la región de memoria es compartida.

En los sistemas POSIX, la forma habitual de compartir esta región es crear un hijo con fork(). Este heredará una copia de toda la memoria de su padre, excepto las regiones de memoria compartida que estarán respaldadas por la misma memoria física en ambos procesos.

En anom-shared-memory.cpp se puede ver un ejemplo muy simple del uso de la memoria compartida en sistemas POSIX. Se trata de un caso similar al ejemplificado en fork-pipe.cpp pero comunicando ambos procesos mediante memoria compartida. Como se puede apreciar, la versión que usa memoria compartida es bastante más sencilla que la que utiliza tuberías.

La memoria compartida con nombre es pública para el resto del sistema, por lo que teóricamente cualquier proceso con permisos puede acceder a ella para comunicarse con otros procesos, sin necesidad de que exista relación de parentesco entre ellos —como ocurre con las tuberías con nombre—.

  1. Crear o abrir el objeto de memoria compartida, indicando su nombre.

    En los sistemas POSIX se usa la función shm_open(), indicando el nombre con el que otros procesos podrán acceder al mismo objeto.

    int shmfd = shm_open(
    "/foo-shm",
    O_RDWR | O_CREAT,
    0666
    );

    Resultado shmfd contiene el descriptor del objeto de memoria compartida, que se usará en el siguiente paso para mapearlo.

    Un objeto de memoria compartida recién creado tiene tamaño 0. Para redimensionarlo se utiliza ftruncate(), que lo que espera es el descriptor del objeto shmfd y el nuevo tamaño.

  2. Mapear el objeto en el espacio de direcciones del proceso, para obtener un puntero con el que leer y escribir en él.

    El descriptor devuelto por shm_open() se utiliza con mmap():

    void* p = mmap(
    NULL,
    length,
    PROT_READ | PROT_WRITE,
    MAP_SHARED,
    shmfd,
    0
    );

    A diferencia de la memoria compartida anónima, aquí se pasa el descriptor shmfd del objeto en lugar de -1, por lo que ya no se usa el indicador MAP_ANONYMOUS. El último argumento —el desplazamiento— permite, junto con length, hacer visible para el proceso solo una parte del objeto en lugar de su totalidad; ya que permiten indicar la posición inicial y extensión de la memoria del objeto que se quiere mapear en la memoria del proceso.

    Resultado p apunta a la región de memoria compartida, lista para leer y escribir en ella.

En shared-memory.cpp se puede ver el ejemplo de un programa POSIX que muestra periódicamente la hora del sistema. El programa es controlado por shared-memory-control.cpp mediante memoria compartida.

Ejemplo Comunicación con memoria compartida

Sección titulada « Comunicación con memoria compartida»

Vamos a ilustrar el uso de la memoria compartida en sistemas POSIX con un ejemplo sencillo: un proceso padre escribe un número en la memoria compartida y crea un proceso hijo que lo dobla varias veces seguidas. Antes de leer cada resultado, el padre tiene que esperar a que el hijo termine de calcularlo, así que ambos procesos se tienen que sincronizar para no leer datos antes de que estén listos.

En este ejemplo utilizaremos un semáforo, que es un tipo de objeto de sincronización que permite a un proceso llamar a sem_wait() para esperar a que otro procesos llame a sem_post(). Este mecanismo de sincronización es muy sencillo, pero no es el único que se puede utilizar para sincronizar procesos. Estudiaremos este y otros mecanismos de sincronización en el capítulo «Sincronización».

  1. Definir la estructura para interpretar el contenido de la memoria compartida.

    struct memory_content
    {
    sem_t ready;
    int value;
    };

    Resultado En la estructura memory_content se define un semáforo ready y un entero value que contendrá el número que el hijo va a doblar, para que sean accesibles tanto para el proceso padre como para el hijo.

  2. Reservar la memoria compartida anónima e inicializar el contenido.

    constexpr int num_steps = 5;
    // Reserva de memoria compartida anónima del tamaño de memory_content
    void* shared_mem = mmap( nullptr, sizeof(memory_content),
    PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_SHARED, -1, 0 );
    memory_content* memory_region = static_cast<memory_content*>(shared_mem);
    // Inicializar el semáforo y el valor a través de un puntero `memory_region*`
    sem_init( &memory_region->ready, 1, 0 );
    memory_region->value = 1;

    Resultado El semáforo ready queda listo para usarse entre procesos y se asigna 1 —el valor inicial— a value.

  3. Crear el proceso hijo con fork(), para que doble el valor num_steps veces.

    pid_t child = fork();
    if (child == 0)
    {
    for (int i = 0; i < num_steps; i++)
    {
    sleep(1); // Simula que el hijo tarda un poco en cada paso
    memory_region->value *= 2;
    sem_post( &memory_region->ready );
    }
    _exit(EXIT_SUCCESS);
    }

    Resultado El proceso hijo dobla el valor y notifica al padre utilizando sem_post(), num_steps veces seguidas.

  4. En el padre, esperar la notificación del hijo con sem_wait() antes de leer cada resultado.

    if (child > 0)
    {
    for (int i = 0; i < num_steps; i++)
    {
    sem_wait( &memory_region->ready );
    std::println( "[PADRE] El valor ahora es {}", memory_region->value );
    }
    // ...
    }

    Resultado Cada vuelta del bucle, el proceso padre queda bloqueado en sem_wait() hasta que el hijo llama a sem_post(), así que ambos avanzan al mismo ritmo aunque el padre se ejecutara más rápido. De esta forma, el padre nunca lee antes de que el hijo haya escrito un nuevo valor.

  5. Esperar la terminación del hijo con wait(), liberar el semáforo con sem_destroy() y terminar.

    if (child > 0)
    {
    wait( nullptr );
    sem_destroy( &memory_region->ready );
    return EXIT_SUCCESS;
    }

    Resultado wait() evita que el proceso hijo, que ya ha terminado, se quede como proceso zombi. En este caso no nos interesa recuperar su valor de estado, así que pasamos nullptr como argumento. sem_destroy() libera los recursos del semáforo, que ya no hace falta. Y, finalmente, terminamos el proceso padre con EXIT_SUCCESS.

Lenguaje C

malloc«función»malloc

Asigna un bloque de memoria en el heap.

void* malloc(size_t size);

Lenguaje C++

new«función»new

Operador para asignar memoria dinámicamente en el heap.

T* ptr = new T;
T* arr = new T[n];

POSIX

fork«función»fork

Crea un proceso hijo como copia exacta del proceso actual.

pid_t fork(void);
ftruncate«función»ftruncate

Trunca o extiende un archivo a un tamaño específico.

int ftruncate(int fd, off_t length);
mmap«función»mmap

Proyecta un archivo o dispositivo en la memoria del proceso.

void* mmap(void* addr, size_t length, int prot, int flags, int fd,
off_t offset);
sem_destroy«función»sem_destroy

Destruye un semáforo POSIX anónimo.

int sem_destroy(sem_t* sem);
sem_post«función»sem_post

Incrementa (señaliza) un semáforo POSIX.

int sem_post(sem_t* sem);
sem_wait«función»sem_wait

Decrementa (espera) un semáforo POSIX, bloqueando si es necesario.

int sem_wait(sem_t* sem);
shm_open«función»shm_open

Abre o crea un objeto de memoria compartida POSIX.

int shm_open(const char* name, int oflag, mode_t mode);
wait«función»wait

Espera a que un proceso hijo termine.

pid_t wait(int* wstatus);

Windows API

CreateFileMapping«función»CreateFileMapping

Crea o abre un objeto de asignación de archivo en memoria.

HANDLE CreateFileMapping(HANDLE hFile,
LPSECURITY_ATTRIBUTES lpFileMappingAttributes,
DWORD flProtect,
DWORD dwMaximumSizeHigh,
DWORD dwMaximumSizeLow,
LPCTSTR lpName);
MapViewOfFile«función»MapViewOfFile

Proyecta una vista de un objeto de asignación de archivo en la memoria del proceso.

LPVOID MapViewOfFile(HANDLE hFileMappingObject,
DWORD dwDesiredAccess,
DWORD dwFileOffsetHigh,
DWORD dwFileOffsetLow,
SIZE_T dwNumberOfBytesToMap);