Bloquea la manipulación de la memoria a través del ensamblaje en línea
memory assembly (1)
¿No es suficiente? ¿Debería, por ejemplo, utilizar la palabra clave de registro en C?
register
es una pista sin sentido en los compiladores de optimización modernos.
Creo que un spinlock simple que no tiene ninguno de los problemas de rendimiento más importantes / obvios en x86 es algo como esto.
Por supuesto, una implementación 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 necesita notificar a los camareros con otra llamada al sistema.
Esto es importante;
no quieres girar para siempre desperdiciando tiempo de CPU (y energía / calor) sin hacer nada.
Pero
conceptualmente, esta es la parte giratoria de un spinlock antes de tomar el camino alternativo.
Es una pieza importante de cómo se implementa el
bloqueo ligero
.
(Solo intentar tomar el bloqueo una vez antes de llamar al kernel sería una opción válida, en lugar de girar en absoluto).
Implemente todo lo que desee en asm en línea, o preferiblemente utilizando la C11
stdatomic
, como esta
implementación de semáforo
.
;;; UNTESTED ;;;;;;;;
;;; TODO: **IMPORTANT** fall back to OS-supported sleep/wakeup after spinning some
; first arg in rdi, in the AMD64 SysV ABI
;;;;;void spin_lock (volatile char *lock)
global spin_unlock
spin_unlock:
;; debug: check that the old value was non-zero. double-unlocking is a nasty bug
mov byte [rdi], 0
ret
;; The store has release semantics, but not sequential-consistency (which you''d get from an xchg or something),
;; because acquire/release is enough to protect a critical section (hence the name)
;;;;;void spin_unlock(volatile char *lock)
global spin_lock
spin_lock:
cmp byte [rdi], 0 ; avoid writing to the cache line if we don''t own the lock: should speed up the other thread unlocking
jnz .spinloop
mov al, 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
.spinloop:
pause ; very old CPUs decode it as REP NOP, which is fine
cmp byte [rdi], 0 ; To get a compiler to do this in C++11, use a memory_order_acquire load
jnz .spinloop
jmp .retry
Si estaba usando un campo de bits de banderas atómicas, podría usar el
lock bts
(prueba y configuración) para el equivalente de xchg-with-1.
Puedes girar en
bt
o
test
.
Para desbloquear, necesitaría
lock btr
, no solo
btr
, porque sería una lectura-modificación-escritura no atómica del byte, o incluso el contenido de 32 bits.
Con un bloqueo de tamaño byte o palabra, ni siquiera necesita una operación de bloqueo para desbloquear;
la semántica de lanzamiento es suficiente
.
pthread_spin_unlock
de glibc hace lo mismo que mi función de desbloqueo: una tienda simple.
Esto evita escribir en la cerradura si vemos que ya está bloqueada. Esto evita invalidar la línea de caché en L1 del núcleo que ejecuta el subproceso que lo posee, por lo que puede volver a "Modificado" ( MESIF o MOESI ) con menos retraso de coherencia de caché durante el desbloqueo.
Tampoco inundamos la CPU con operaciones
lock
en un bucle.
No estoy seguro de cuánto desacelera esto en general, pero 10 hilos que esperan el mismo spinlock mantendrán el hardware de arbitraje de memoria bastante ocupado.
Esto podría ralentizar el subproceso que mantiene el bloqueo u otros subprocesos no relacionados en el sistema, mientras usan otros bloqueos o memoria en general.
PAUSE
también es esencial, para evitar especulaciones erróneas sobre el pedido de memoria por parte de la CPU.
Sale del bucle solo cuando la memoria que está leyendo
fue
modificada por otro núcleo.
Sin embargo, no queremos hacer una
pause
en el caso no disputado.
En Skylake,
PAUSE
espera mucho más tiempo, como ~ 100cycles IIRC, por lo que definitivamente debe mantener el spinloop separado de la verificación inicial de desbloqueo.
Estoy seguro de que los manuales de optimización de Intel y AMD hablan de esto, vea el wiki de la etiqueta x86 para eso y muchos otros enlaces.
Soy nuevo en las cosas de bajo nivel, así que soy completamente ajeno a qué tipo de problemas podría enfrentar allí y ni siquiera estoy seguro si entiendo el término "atómico". En este momento estoy tratando de hacer bloqueos atómicos simples alrededor de la manipulación de la memoria a través del ensamblaje extendido. ¿Por qué? Por curiosidad. Sé que estoy reinventando la rueda aquí y posiblemente simplificando demasiado el proceso completo.
¿La pregunta? ¿El código que presento aquí cumple el objetivo de hacer que la manipulación de la memoria sea segura 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 manipular la memoria, bloquear.
- Después de la manipulación de la memoria, desbloquear.
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 stackframe 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 suponer que estoy haciendo una operación atómica porque es solo una instrucción?