¿Los punteros de desreferenciación siempre causarán acceso a la memoria?

11 minutos de lectura

Avatar de usuario de YangZai
yangzai

Me pregunto si la desreferenciación de un puntero siempre se traducirá en una instrucción Cargar/Almacenar a nivel de máquina, independientemente de cuán optimizado sea el compilador.

Supongamos que tenemos dos subprocesos, uno (llamémoslo Tom) recibe la entrada del usuario y escribe un bool variable. La variable es leída por otro (y este es Jerry) para decidir si continúa un bucle. Sabemos que un compilador optimizador puede almacenar la variable en un registro al compilar el bucle. Entonces, en tiempo de ejecución, Jerry puede leer un valor obsoleto que es diferente de lo que escribe Tom. Como resultado, debemos declarar la bool variables como volatile.

Sin embargo, si hacer referencia a un puntero siempre causará acceso a la memoria, entonces los dos subprocesos pueden usar un puntero para hacer referencia a la variable. En cada escritura, Tom almacenará el nuevo valor en la memoria eliminando la referencia del puntero y escribiendo en él. En cada lectura, Jerry realmente puede leer lo que Tom escribió al eliminar la referencia a ese mismo puntero. Esto parece mejor que el dependiente de la implementación volatile

Soy nuevo en la programación de subprocesos múltiples, por lo que esta idea puede parecer trivial e innecesaria. Pero tengo mucha curiosidad al respecto.

  • “Como resultado, deberíamos declarar la variable bool como volátil”. – Eso está completamente mal. volatile no hace que una variable atómica/hilo sea segura. Necesitas std::atomic, std::mutex o similar.

    – Jesper Juhl

    16 de junio a las 10:21

  • Respuesta corta: No, el compilador es libre de optimizar siguiendo la regla como si, que normalmente es libre de asumir que no hay subprocesos múltiples. tu uso de volatile eludirlo también es obsoleto. Desde C++11 y C11, los lenguajes vienen con modelos de memoria que incluyen subprocesos múltiples. Use las primitivas apropiadas tales como std::atomic para lidiar con esto

    – Homero512

    16 de junio a las 10:23

  • Desreferenciar un puntero en dos subprocesos distintos, a menos que esas operaciones estén explícitamente sincronizadas (por ejemplo, a través de instalaciones como std::atomic, std::mutex) da un comportamiento indefinido. volatile no introduce sincronización. Si el comportamiento no está definido, un compilador es correcto, de acuerdo con el estándar, independientemente de lo que haga (omite las operaciones, de alguna manera las realiza con algún tipo de sincronización, bloquea la máquina host)

    – Pedro

    16 de junio a las 10:43

  • @JesperJuhl: La oración que cita, “Como resultado, debemos declarar el bool variables como volatile”, afirma volatile es necesario. Su declaración, “volatile no hace que una variable atómica/hilo sea segura”, afirma volatile No es suficiente. afirmando volatile no es suficiente no es una refutación a la afirmación de que es necesario.

    –Eric Postpischil

    16 de junio a las 12:37

  • @Peter: Eso no es del todo correcto. Acceder al mismo objeto (ya sea a través de punteros o no) desde múltiples subprocesos está bien si y solo si todos los accesos son de lectura. No habrá carrera de datos a menos que se produzca alguna modificación semántica. El compilador no puede introducir carreras de datos, por ejemplo, con escrituras espurias, en el comportamiento de C++. “Como si” le permite hacerlo a un nivel más bajo de abstracción, pero solo en la medida en que mantenga las garantías del modelo de memoria de C++.

    – Ben Voigt

    16 jun a las 21:00

Avatar de usuario de Jan Schultke
Jan Schultke

¿Desreferenciar un puntero siempre causará acceso a la memoria?

No, por ejemplo:

int five() {
    int x = 5;
    int *ptr = &x;
    return *ptr;
}

Cualquier compilador de optimización cuerdo no emitirá un mov de la memoria de pila aquí, pero algo así como:

five():
  mov eax, 5
  ret

Esto está permitido debido a la regla como si.

¿Cómo realizo la comunicación entre subprocesos a través de un bool* ¿entonces?

Esto es lo que std::atomic<bool> es para. No debe comunicarse entre subprocesos utilizando objetos no atómicos, porque acceder a la misma ubicación de memoria a través de dos subprocesos de manera conflictiva1) es un comportamiento indefinido en C++. std::atomic hace que sea seguro para subprocesos, volatile no. Por ejemplo:

void thread(std::atomic<bool> &stop_signal) {
    while (!stop_signal) {
        do_stuff();
    }
}

Técnicamenteesto no implica que cada carga de stop_signal realmente sucederá. El compilador puede hacer un desenrollado de bucle parcial como:

void thread(std::atomic<bool> &stop_signal) {
    // only possible if the compiler knows that do_stuff() doesn't modify stop_signal
    while (!stop_signal) {
        do_stuff();
        do_stuff();
        do_stuff();
        do_stuff();
    }
}

un atómico load() se le permite observar valores obsoletos, por lo que el compilador puede suponer que cuatro load()s todos leerían el mismo valor. Sólo algunas operaciones, como fetch_add() están obligados a observar el valor más reciente. Incluso entonces, esta optimización podría ser posible.

En la práctica, las optimizaciones como estas no se implementan para std::atomic en cualquier compilador, entonces std::atomic es cuasi-volatile. Lo mismo se aplica a C atomic_booly _Atomic tipos en general.


1) Dos accesos a la memoria en la misma ubicación entran en conflicto si al menos uno de ellos está escribiendo, es decir, dos lecturas en la misma ubicación no entran en conflicto. Ver [intro.races]

Ver también

  • Probablemente sea útil observar que no necesariamente necesita un std::atomic para cada valor compartido entre subprocesos, siempre que haya alguno forma de sincronización relacionada con él. Por ejemplo, si utiliza un std::mutex para proteger el bool valor (posiblemente junto con algunos otros), entonces la sincronización entre desbloquear el mutex en un subproceso y adquirir el mutex en el otro subproceso hará que el compilador se asegure de que se haga todo lo necesario, por ejemplo, para asegurarse de que los cachés del procesador sean coherentes, para asegurarse de que las lecturas/escrituras no se reordenen más allá de las barreras, etc.

    – Daniel Schepler

    16 de junio a las 18:33

  • “acceder a la misma memoria a través de dos subprocesos al mismo tiempo es un comportamiento indefinido”, solo si se trata de acceso de escritura, ¿verdad? Leer está bien.

    – Tomas Weller

    17 de junio a las 10:09

  • @ThomasWeller sí, lo aclaré en la respuesta ahora.

    -Jan Schultke

    17 de junio a las 10:21

  • Vale la pena mencionar que puedes usar std::memory_order_relaxed cargas y almacenes para stop_signal (¿Por qué establecer el indicador de parada usando `memory_order_seq_cst`, si lo marca con `memory_order_relaxed`?). A menudo, cuando las personas piensan que quieren evitar atomices porque piensan atomic es más lento Pero con relaxed, no lo es, es solo una carga pura en el asm incluso en ISA débilmente ordenados como ARM, sin instrucciones de barrera adicionales. (En x86, la adquisición/liberación son “gratuitas”, al igual que las cargas seq_cst; no se necesitan instrucciones de barrera adicionales).

    – Peter Cordes

    18 de junio a las 6:14

  • Además, dado que la pregunta sobre volatile – ¿Cuándo usar volátil con subprocesos múltiples? – nunca, pero funciona en la práctica en los compiladores actuales, y se usó históricamente (junto con asm en línea para ordenar la memoria) antes de que quedara obsoleto por std::atomic<>. Mi respuesta allí explica cómo/por qué funcionó y todavía funciona en la práctica en implementaciones reales, para las personas curiosas sobre qué “magia” std::atomic necesita usar. (No mucho, el hardware de la CPU hace el trabajo de mantener la coherencia del caché)

    – Peter Cordes

    18 de junio a las 6:32

Algunas formas de usar el lvalue producido al desreferenciar un puntero no darán como resultado un acceso. Por ejemplo, dado int arr[5][4]; int *p; la declaración p = *arr; no desreferenciará ningún almacenamiento asociado con arrpero simplemente hacen que el compilador reconozca que el valor l en la mitad derecha de la asignación es un int[4] que decaerá en un int*.

Fuera de tales circunstancias, el Estándar busca clasificar como Comportamiento Indefinido todas las situaciones en las que el Estándar pretende permitir que una implementación procese una operación de desreferenciación realizando un acceso o no, a su antojo, y su decisión afectaría de manera observable el comportamiento del programa.

Esta filosofía conduce a algunos casos de esquina bastante turbios en situaciones en las que un programa usa algo de almacenamiento para mantener una estructura de tipo T, y luego una estructura de tipo U, y luego una estructura de tipo T nuevamente, luego hace una copia de la T sin haber escrito todos los campos, y finalmente usa fwrite para generar la copia completa de la T. Si el compilador sabe que cierto campo en la T original se escribió con un cierto valor, podría generar un código que almacene ese mismo valor en la copia, sin importar si el almacenamiento subyacente podría haber cambiado. Si a nada en el universo le importa lo que contienen los bytes asociados con ese campo en los datos que se procesaron a través de fwrite, esto no debería representar un problema, y ​​requerir que el programador se asegure de que todo el almacenamiento asociado con T se escriba utilizando ese tipo antes de que se copie como un tipo T haría necesario que tanto el programador como la computadora que está ejecutando el programa realicen un trabajo inútil adicional. El Estándar no tiene forma de describir el comportamiento del programa que permitiría que una implementación fallara de manera observable en eliminar la referencia de todos los campos de la T al copiarlo, sin caracterizar el programa como invocando un comportamiento indefinido.

Otro caso en el que *p no daría como resultado el acceso a la memoria es con el operador sizeof: sizeof(*p) simplemente determinará el tamaño del tipo estático al que apunta p. Tenga en cuenta que en C, con argumentos de longitud variable, sizeof(*p) en realidad puede requerir un acceso a la memoria, pero los VLA son una extensión del compilador en C++.

Cuando se trabaja con subprocesos múltiples, la claridad es buena. Así que voy a desglosar cada pieza.

“La desreferenciación de los punteros siempre provocará el acceso a la memoria”.

No. Considere la declaración de expresión (void)*p. El *p realiza indirección. De [expr.unary.op]:

El operador unario * realiza el direccionamiento indirecto: la expresión a la que se aplica será un puntero a un tipo de objeto o un puntero a un tipo de función y el resultado es un lvalue que se refiere al objeto o función al que apunta la expresión.

Así que el resultado es una referencia de lvalue. Eso, por sí solo, no es suficiente para causar una “lectura” de los datos señalados por la p. En el ejemplo anterior, descarto explícitamente el resultado, por lo que no hay razón para leer la memoria.

Por supuesto, uno podría argumentar que la memoria de p es leído. Solo para ser pedante, señalaría que esa es una interpretación de la palabra. Sin embargo, un compilador optimizador puede ver que el lvalue apuntado por p no es necesario aquí, por lo que en realidad no necesita leer/escribir el puntero en absoluto.

Ahora, ¿qué pasa en un entorno multihilo? La clave de esto es la relación “sucede antes” en [intro.multithread]. Es un lenguaje formal increíblemente seco, pero la idea básica es que el evento A ocurre antes que el evento B si A se secuencia antes que B (en un solo hilo), o si A entre subprocesos ocurre antes que B. Este último es el lenguaje elegante que los abogados hablan para una herramienta utilizada para capturar el comportamiento de las primitivas de sincronización, como mutexes y atómicos.

Si A no sucede antes de B y B no sucede antes de A, entonces los dos eventos no están ordenados entre sí. Esto es lo que sucede en dos subprocesos cuando no tiene ningún mutex para forzar un pedido. Si un evento escribe en una ubicación de memoria y el otro lee o escribe en esa dirección, el resultado es una carrera de datos. Y una carrera de datos es un comportamiento indefinido: obtienes lo que obtienes. La especificación no tiene cualquier cosa para decir acerca de lo que sucede cuando eso ocurre. No dice nada sobre si desencadena un acceso a la memoria o no… no dice absolutamente nada al respecto.

Como efecto de las normas codificadas en [intro.multithread]el compilador puede efectivamente optimizar su código como si un subproceso estuviera operando en completo aislamiento a menos que una primitiva de enhebrado (como un mutex o atómico) fuerza lo contrario. Esto incluye todas las elisiones habituales, como no leer de la memoria si no es necesario.

¿Ha sido útil esta solución?