Bloquea la manipulación de la memoria mediante ensamblaje en línea

Resuelto user1235831 asked hace 8 años • 1 respuestas

Soy nuevo en cosas de bajo nivel, por lo que no tengo ni idea de qué tipo de problemas podrías enfrentar allí y ni siquiera estoy seguro de entender bien el término "atómico". En este momento estoy intentando crear bloqueos atómicos simples en torno a la manipulación de la memoria mediante un ensamblaje extendido. ¿Por qué? Por curiosidad. Sé que estoy reinventando la rueda y posiblemente simplificando demasiado todo el proceso.

¿La pregunta? ¿El código que presento aquí logra el objetivo de hacer que la manipulación de la memoria sea segura para subprocesos y reentrante?

  • Si funciona, ¿por qué?
  • Si no funciona, ¿por qué?
  • ¿No es suficiente? ¿Debería, por ejemplo, utilizar la palabra clave de registro en C?

Lo que simplemente quiero hacer...

  • Antes de la manipulación de la memoria, bloquee.
  • Después de la manipulación de la memoria, desbloquee.

El código:

volatile int atomic_gate_memory = 0;

static inline void atomic_open(volatile int *gate)
{
    asm volatile (
        "wait:\n"
        "cmp %[lock], %[gate]\n"
        "je wait\n"
        "mov %[lock], %[gate]\n"
        : [gate] "=m" (*gate)
        : [lock] "r" (1)
    );
}

static inline void atomic_close(volatile int *gate)
{
    asm volatile (
        "mov %[lock], %[gate]\n"
        : [gate] "=m" (*gate)
        : [lock] "r" (0)
    );
}

Entonces algo como:

void *_malloc(size_t size)
{
        atomic_open(&atomic_gate_memory);
        void *mem = malloc(size);
        atomic_close(&atomic_gate_memory);
        return mem;
}
#define malloc(size) _malloc(size)

.. lo mismo para calloc, realloc, free y fork (para Linux).

#ifdef _UNISTD_H
int _fork()
{
        pid_t pid;
        atomic_open(&atomic_gate_memory);
        pid = fork();
        atomic_close(&atomic_gate_memory);
        return pid;
}
#define fork() _fork()
#endif

Después de cargar el marco de pila para atomic_open, objdump genera:

00000000004009a7 <wait>:
4009a7: 39 10                   cmp    %edx,(%rax)
4009a9: 74 fc                   je     4009a7 <wait>
4009ab: 89 10                   mov    %edx,(%rax)

Además, dado el desmontaje anterior; ¿Puedo asumir que estoy realizando una operación atómica porque es solo una instrucción?

user1235831 avatar May 16 '16 00:05 user1235831
Aceptado

Creo que un spinlock simple que no tiene ninguno de los problemas de rendimiento realmente importantes/obvios en x86 es algo como esto. Por supuesto, una implementación mutex real usaría una llamada al sistema (como Linux futex) después de girar por un tiempo, y el desbloqueo tendría que verificar si es necesario notificar a algún camarero con otra llamada al sistema. Esto es importante; no querrás girar eternamente perdiendo tiempo de CPU (y energía/calor) sin hacer nada. Pero conceptualmente esta es la parte de giro de un mutex antes de tomar el camino alternativo. Es una parte importante de cómo se implementa el bloqueo liviano . (Sólo intentar tomar el bloqueo una vez antes de llamar al kernel sería una opción válida, en lugar de girar).

Implemente todo esto como desee en un conjunto en línea, o preferiblemente usando C11 stdatomic, como esta implementación de semáforo . Esta es la sintaxis NASM. Si utiliza un conjunto en línea de GNU C, asegúrese de utilizar un "memory"clobber para detener la reordenación del acceso a la memoria en tiempo de compilación . Pero no utilice ensamblaje en línea; use C _Atomic uint8_to C++ std::atomic<uint8_t>con .exchange(1, std::memory_order_acquire) y .store(0, std::memory_order_release)y _mm_pause()desde immintrin.h.

;;; UNTESTED ;;;;;;;;
;;; TODO: **IMPORTANT** fall back to OS-supported sleep/wakeup after spinning some
;;; e.g. Linux futex
    ; first arg in rdi as per AMD64 SysV ABI (Linux / Mac / etc)

;;;;;void spin_lock  (volatile char *lock)
global spin_unlock
spin_unlock:
       ; movzx  eax, byte [rdi]  ; debug check for double-unlocking.  Expect 1
    mov   byte [rdi], 0        ; lock.store(0, std::memory_order_release)
    ret

align 16
;;;;;void spin_unlock(volatile char *lock)
global spin_lock
spin_lock:
    mov   eax, 1                 ; only need to do this the first time, otherwise we know al is non-zero
.retry:
    xchg  al, [rdi]

    test  al,al                  ; check if we actually got the lock
    jnz   .spinloop
    ret                          ; no taken branches on the fast-path

align 8
.spinloop:                    ; do {
    pause
    cmp   byte [rdi], al      ; C++11
    jne   .retry              ; if (lock.load(std::memory_order_acquire) != 1)
    jmp   .spinloop

; if not translating this to inline asm, you could put the spin loop *before* the function entry point, saving the last jmp
; but since this is probably too simplistic for real use, I'm going to leave it as-is.

Una tienda simple tiene semántica de lanzamiento, pero no consistencia secuencial (que obtendría de un xchg o algo así). Adquirir/liberar es suficiente para proteger una sección crítica (de ahí el nombre).


Si estuviera usando un campo de bits de indicadores atómicos, podría usar lock bts(probar y configurar) para el equivalente de xchg-with-1. Puedes girar bto test. Para desbloquear, necesitaría lock btr, no solo btr, porque sería una lectura, modificación y escritura no atómica del byte, o incluso los 32 bits que lo contienen.

Con un bloqueo de tamaño byte o int como el que normalmente debería usar, ni siquiera necesita una lockoperación ed para desbloquear; la semántica de lanzamiento es suficiente . La de glibc pthread_spin_unlockhace lo mismo que mi función de desbloqueo: una simple tienda.

( lock btsno es necesario; xchgo lock cmpxchgson igual de buenos si se trata de una cerradura normal).


El primer acceso debería ser un RMW atómico.

Consulte la discusión sobre ¿cmpxchg escribe la línea de caché de destino en caso de falla? Si no, ¿es mejor que xchg para spinlock? - si el primer acceso es de solo lectura, la CPU podría enviar solo una solicitud para compartir esa línea de caché. Luego, si ve la línea desbloqueada (el caso de baja contención, con suerte, común), tendría que enviar una RFO (lectura de propiedad) para poder escribir la línea de caché. Entonces eso es el doble de transacciones extracentrales.

La desventaja es que esto tomará la propiedad exclusiva de MESI de esa línea de caché, pero lo que realmente importa es que el hilo que posee el bloqueo puede almacenar de manera eficiente un archivo 0para que podamos verlo desbloqueado. De cualquier manera, de solo lectura o RMW, ese núcleo perderá la propiedad exclusiva de la línea y tendrá que realizar una RFO antes de poder comprometer esa tienda de desbloqueo.

Creo que un primer acceso de solo lectura simplemente optimizaría un poco menos de tráfico entre núcleos cuando varios subprocesos se ponen en cola para esperar un bloqueo que ya está realizado. Sería una tontería optimizar eso.

( El spinlock de ensamblaje en línea más rápido también probó la idea de un spinlock masivamente disputado con múltiples subprocesos que no hacen nada más que intentar tomar el bloqueo, con malos resultados. Esa respuesta vinculada hace algunas afirmaciones incorrectas sobre el xchgbloqueo global de un bus: los s alineados lockno funcionan eso, solo un bloqueo de caché ( ¿incrementar un int es efectivamente atómico en casos específicos? ), y cada núcleo puede estar haciendo un RMW atómico separado en una línea de caché diferente al mismo tiempo ).


Sin embargo, si ese intento inicial encuentra que está bloqueado, no queremos seguir golpeando la línea de caché con RMW atómicos . Ahí es cuando volvemos al modo de solo lectura. 10 subprocesos, todos spam xchgpara el mismo spinlock, mantendrían el hardware de arbitraje de memoria bastante ocupado. Probablemente retrasaría la visibilidad de la tienda que se desbloquea (porque ese hilo tiene que competir por la propiedad exclusiva de la línea), por lo que es directamente contraproducente. También puede tener memoria en general para otros núcleos.

PAUSETambién es esencial para evitar especulaciones erróneas sobre el orden de la memoria por parte de la CPU. Sales del ciclo solo cuando la memoria que estás leyendo fue modificada por otro núcleo. Sin embargo, no queremos hacerlo pauseen el caso no controvertido. En Skylake, PAUSElas esperas son mucho más largas, como ~100 ciclos en comparación con ~5, por lo que definitivamente debes mantener el ciclo de giro separado de la verificación inicial para desbloquearlo.

Estoy seguro de que los manuales de optimización de Intel y AMD hablan de esto, consulte elx86etiqueta wiki para eso y muchos otros enlaces.


¿No es suficiente? ¿Debería, por ejemplo, utilizar la palabra clave de registro en C?

registeres una sugerencia sin sentido en los compiladores de optimización modernos, excepto en las compilaciones de depuración ( gcc -O0).

Peter Cordes avatar May 16 '2016 03:05 Peter Cordes