os y math: tiempo, sistema y azar
Tiempo civil frente a tiempo de proceso, la diferencia exacta entre os.clock y os.time al medir, ejecutar comandos del sistema y recoger su resultado, y el generador de números aleatorios de Lua 5.4 con el problema de la semilla que casi todo el mundo resuelve mal.
os y math parecen bibliotecas de conveniencia y son en realidad las dos zonas donde Lua toca directamente el hardware y el sistema operativo, con todas las consecuencias que eso acarrea. En os conviven dos nociones de tiempo que se miden en la misma unidad y significan cosas distintas, y confundirlas es el error de medición más extendido del ecosistema. En math vive un generador de números pseudoaleatorios que en 5.4 dejó de ser el de C para convertirse en un algoritmo propio de calidad seria, y cuya semilla sigue estando mal inicializada en una fracción alarmante del código publicado. Esta lección se ocupa de ambas cosas con el detalle que merecen.
- Convertir entre tiempo numérico, tiempo civil y texto con
os.timeyos.datesin ambigüedad de zona horaria. - Elegir entre
os.clockyos.timesegún se mida consumo de procesador o tiempo transcurrido. - Ejecutar comandos del sistema, interpretar su código de salida y capturar su salida estándar.
- Explicar el generador de Lua 5.4 y sembrarlo de forma correcta y reproducible.
El tiempo civil: time, date y difftime
os.time sin argumentos devuelve un número que representa el instante actual. El manual no promete que ese número sean segundos desde 1970, aunque en toda plataforma realista lo sea; lo único garantizado es que se puede pasar a os.date y a os.difftime. Con una tabla como argumento, hace el camino inverso: interpreta los campos de año, mes, día, hora, minuto y segundo como tiempo civil local y devuelve el número correspondiente, normalizando de paso los valores fuera de rango.
os.date es la función más densa de la biblioteca. Recibe una cadena de formato con especificadores heredados de C y un instante opcional. Dos prefijos cambian su comportamiento por completo: la exclamación fuerza tiempo universal en lugar de local, y el asterisco seguido de la letra t hace que devuelva una tabla en vez de una cadena.
local ahora = os.time()
print(os.date("%Y-%m-%d %H:%M:%S", ahora)) -- hora local
print(os.date("!%Y-%m-%dT%H:%M:%SZ", ahora)) -- hora universal
local t = os.date("*t", ahora)
print(t.year, t.month, t.day, t.wday, t.yday, t.isdst)
local cumple = os.time{ year = 1993, month = 7, day = 28, hour = 12 }
print(os.difftime(ahora, cumple) / 86400, "dias transcurridos")
Pasar el día treinta y dos de un mes no es un error: os.time lo normaliza al día correcto del mes siguiente. Eso convierte la función en la forma más simple de hacer aritmética de calendario sin implementar años bisiestos. Lo que sí es un error es olvidar que interpreta la tabla en hora local, con horario de verano incluido, de modo que la misma tabla puede producir números distintos en máquinas con zonas distintas.
Medir: os.clock frente a os.time
Aquí está el error más repetido. os.time mide tiempo civil con resolución de un segundo, y os.clock mide tiempo de procesador consumido por el proceso, con resolución típicamente de microsegundos. No son versiones precisa e imprecisa de lo mismo: miden magnitudes diferentes.
local t0 = os.clock()
local suma = 0
for i = 1, 20000000 do suma = suma + i end
print(string.format("procesador: %.3f s", os.clock() - t0))
Ese fragmento mide correctamente el coste de un bucle de cálculo puro. Ahora considera un programa que espera una respuesta de red durante tres segundos: os.clock apenas avanzará, porque el proceso no consume procesador mientras espera, mientras que el tiempo real transcurrido sí fueron tres segundos. Y a la inversa, en un programa con varias hebras del sistema anfitrión, os.clock puede devolver más segundos de los que marca el reloj de pared, porque suma el consumo de todas ellas.
flowchart TD A[Quiero medir algo] --> B[Mido trabajo de calculo puro] B -->|si| C[Usar os.clock] B -->|no| D[Hay espera de disco o de red] D -->|si| E[Necesito reloj de pared] D -->|no| C E --> F[os.time tiene resolucion de un segundo] F --> G[Insuficiente para medidas finas] G --> H[Recurrir a una biblioteca externa de alta resolucion]
La conclusión incómoda es que la biblioteca estándar de Lua no ofrece un reloj de pared de alta resolución. os.time da segundos enteros y os.clock no mide lo que se quiere medir en cuanto hay espera. Para medir latencia real hay que salir del lenguaje: la función de tiempo de alta resolución de un intérprete anfitrión, una biblioteca POSIX o un módulo de eventos. Es un ejemplo perfecto de la ausencia deliberada que estudia la lección siguiente.
Al medir con os.clock el ruido del sistema solo puede sumar tiempo, nunca restarlo. Por eso la estadística correcta para comparar dos implementaciones no es la media de varias pasadas sino el mínimo: es la estimación menos contaminada del coste real. Y conviene ejecutar antes una pasada de calentamiento que deje las tablas dimensionadas y las cadenas internadas.
El resto de os: entorno, ficheros y procesos
Lo que queda de la biblioteca son siete funciones que envuelven llamadas de C sin adornos.
getenv y setlocale
os.getenv consulta el entorno y devuelve nada si la variable no existe. os.setlocale cambia la configuración regional del proceso, con el efecto lateral de alterar el separador decimal que usan las conversiones numéricas.
remove y rename
Borran y renombran rutas. Devuelven un valor verdadero o bien nada más un mensaje de error, nunca lanzan. Son las dos únicas operaciones de sistema de ficheros del lenguaje que no exigen abrir nada.
tmpname y exit
os.tmpname devuelve una ruta libre pero no crea el fichero, lo que abre una condición de carrera clásica. os.exit termina el proceso y admite un segundo argumento para pedir el cierre ordenado del estado de Lua.
execute
Lanza un intérprete de órdenes del sistema. Sin argumentos informa de si existe tal intérprete, lo que permite programar de forma defensiva antes de depender de él.
Desde Lua 5.2, os.execute devuelve tres valores: si el proceso terminó normalmente, una cadena que indica si acabó por salida o por señal, y el código correspondiente. Esa terna es la única forma de saber qué ocurrió, porque la salida del comando va al terminal, no al programa. Para capturarla hay que usar io.popen, que devuelve un manejador de fichero legible como cualquier otro.
local ok, causa, codigo = os.execute("ls /noexiste")
print(ok, causa, codigo) --> nil exit 2
local p = assert(io.popen("uname -sr", "r"))
local salida = p:read("a")
local ok2, causa2, codigo2 = p:close()
print(salida, ok2, causa2, codigo2)
Lua no tiene ninguna función para escapar argumentos de intérprete de órdenes. Construir la cadena con datos que vengan de fuera es exactamente el patrón de inyección de órdenes que lleva décadas explotándose. Si los datos no son literales del programa, o los validas contra una lista de valores permitidos, o no usas os.execute en absoluto.
math y el generador de números aleatorios
math reúne la aritmética que el operador no cubre: redondeos, trigonometría, logaritmos, los extremos del rango entero y las dos funciones que hacen falta desde el cisma de los enteros, math.type para distinguir entero de flotante y math.tointeger para convertir cuando la conversión es exacta.
Lo verdaderamente interesante es el generador. Hasta Lua 5.3 incluido, math.random delegaba en la función de C, cuya calidad estadística depende de la biblioteca del sistema y en algunas plataformas es francamente mala. Lua 5.4 cortó esa dependencia e incorporó un generador propio de la familia xoshiro, con estado interno de doscientos cincuenta y seis bits y un periodo astronómico. Eso significa que desde 5.4 el mismo programa con la misma semilla produce la misma secuencia en cualquier plataforma, algo que antes no estaba garantizado.
print(math.random()) -- flotante en el intervalo cero incluido, uno excluido
print(math.random(6)) -- entero de uno a seis, ambos incluidos
print(math.random(10, 20)) -- entero en el rango indicado
print(math.random(0)) -- entero con los sesenta y cuatro bits aleatorios
Un detalle del que conviene ser consciente: math.random con dos argumentos no calcula un resto, sino que descarta y vuelve a tirar hasta caer dentro del rango. Ese procedimiento evita el sesgo que introduce el módulo cuando el tamaño del intervalo no divide al espacio del generador, y es una de esas correcciones silenciosas que separan un generador serio de uno improvisado.
La semilla es donde casi todo el mundo se equivoca. El idioma más copiado del ecosistema siembra con el tiempo civil, y ese tiempo tiene resolución de un segundo: dos ejecuciones lanzadas dentro del mismo segundo producen secuencias idénticas, lo que en un banco de pruebas o en un despliegue paralelo es un fallo silencioso y difícil de diagnosticar.
-- Mal: dos procesos en el mismo segundo comparten secuencia
math.randomseed(os.time())
-- Bien en 5.4: sin argumentos usa una fuente razonablemente impredecible
-- y devuelve los dos componentes usados, para poder reproducir despues
local a, b = math.randomseed()
print("semilla registrada:", a, b)
-- Bien cuando se necesita reproducibilidad exacta
math.randomseed(20260804, 7)
La decisión de Lua 5.4 de sembrar el generador automáticamente al arrancar el estado, y a la vez ofrecer una siembra explícita de dos componentes, resuelve con elegancia una tensión que muchos lenguajes dejan sin resolver. Un programa que nunca menciona la semilla obtiene secuencias distintas en cada ejecución, que es lo que espera quien escribe un juego o un simulador. Un programa que llama a math.randomseed con argumentos obtiene una secuencia exactamente reproducible en cualquier máquina y cualquier sistema operativo, que es lo que necesita quien escribe una prueba o publica un experimento. Y un programa que llama a math.randomseed sin argumentos obtiene lo primero pero además recibe de vuelta los números empleados, de modo que puede registrarlos y reproducir a posteriori una ejecución que falló. Esas tres necesidades son distintas, se confunden constantemente y aquí quedan separadas por la firma de una única función. Hay una cuarta necesidad que Lua deliberadamente no atiende, y es importante saberlo: este generador no es criptográficamente seguro. Observando suficientes salidas se puede reconstruir su estado interno y predecir todas las siguientes. Para claves, credenciales, identificadores de sesión o cualquier cosa que un adversario tenga interés en adivinar, math.random es la herramienta equivocada, y la correcta está fuera del lenguaje: leer del dispositivo de aleatoriedad del sistema o usar una biblioteca criptográfica. Que Lua no la incluya y tampoco pretenda que su generador sirva para eso es, una vez más, la misma disciplina de frontera que recorre toda la biblioteca estándar.
- Mide con
os.clocky con diferencias deos.timeun bucle de cálculo puro y otro que espere un segundo con una llamada al sistema; explica las cuatro cifras. - Escribe una función que reciba dos tablas de fecha civil y devuelva los días naturales entre ambas, apoyándote en la normalización de
os.time. - Lanza dos procesos Lua en el mismo segundo sembrando con el tiempo civil y compara sus primeras diez tiradas.
- Siembra con dos componentes fijos, registra cien tiradas, vuelve a sembrar igual y verifica que la secuencia se repite byte a byte.
- Captura con
io.popenla salida de un comando inexistente y examina los tres valores que devuelve el cierre del manejador.