Medir o tempo é algo que a gente faz corriqueiramente, seja andando na rua ou esperando por uma reunião importante e, como o tempo é uma parte importante das nossas vidas, é de se esperar que quando a gente está codando alguma coisa, ele também seja.

A ideia desse artigo veio quando percebi algumas inconsistências na medição do tempo usando nosso tão amado Date.now, a forma mais padrão possível para medir o tempo em uma aplicação JavaScript.

Enquanto eu estava buscando algumas alternativas para medição de tempo usando Node.js, me deparei com esse artigo sensacional do Deepal sobre como esse método pode ser bastante problemático. Embora você provavelmente não vai ver alguns desses casos muitas vezes na sua vida, vale a pena entender o que rola por trás de uma ação tão simples quanto medir o tempo.

Medindo o tempo

Historicamente, o método padrão de medição de tempo em sistemas eletrônicos é a contagem de segundos desde 1º de Janeiro de 1970, o chamado Unix timestamp.

Enquanto hoje a Unix Epoch, como é chamada, é largamente utilizada pela maioria das linguagens de programação e SOs ao redor do mundo, há pelo menos 12 outras formas de se contar o tempo que não são nem de longe pequenas para serem ignoradas, mas eu não vou contar toda essa história aqui (pelo menos não nesse artigo).

A questão é que a representação pela contagem de segundos precisa de algum tipo de sincronia por conta que existem pequenas irregularidades na contagem do tempo dentro de processadores.

Computadores comuns não tem um processador dedicado para contar o tempo, por isso o mesmo núcleo que está processando sua série da Netflix, está sendo usado para contar o tempo na sua máquina, isso é conhecido como time sharing. Originalmente feito para compartilhar o tempo de CPU entre usuários distintos de um sistema, mas depois implementado direto dentro dos Sistemas Operacionais com o nome de context switching.

Toda a ideia é que seu processador está dividindo o tempo de processamento com todos os processos rodando dentro do seu sistema, por isso ele não pode dar total atenção só para o seu relógio, e assim a gente sempre tem um problema que é chamado de clock drifting ou (tentando traduzir no mais poético português) deriva temporal.

Clock Drifting

A deriva temporal é um problema antigo que acontece em qualquer sistema que precisa de uma determinada precisão pra rodar, isso vai desde relógios até pêndulos.

Em computadores e em relógios especificamente, a deriva temporal é causada pela falta de precisão de equipamentos como relógios de pulso, de parede e etc – quantas vezes você já teve que ajustar o seu relógio de parede porque ele estava diferente do relógio do celular?

E isso vale até mesmo para computadores não só por conta dessa diferença de tempos de CPU, mas também porque computadores usam relógios de quartzo para medir o tempo localmente. E um relógio de quartzo tem uma deriva temporal de aproximadamente 1 segundo a cada alguns dias.

Inclusive, a deriva temporal em computadores é utilizada largamente para criar geradores de números aleatórios, porque a própria deriva do relógio é naturalmente aleatória.

Então onde é que a programação entra nisso tudo? Imagina que a gente tem um código comum como esse aqui:

const inicio = Date.now()
// alguma operação aqui
const fim = Date.now()
console.log(fim - inicio)

A ideia é que ele funcione normalmente, eu já usei muito esse tipo de código e também outros como o console.time, por exemplo:

console.time('contador')
// Fazemos alguma coisa
console.time('contador')
// mais alguma coisa
console.timeEnd('contador')

O problema é justamente a deriva temporal dentro de computadores, se você precisa sincronizar algum tipo de tempo com outro computador em outro lugar do mundo ou então com outro relógio que está fora da sua própria máquina, você pode ter um resultado, digamos, curioso.

Em um exemplo, vamos imaginar que a gente tem um relógio que sofreu uma deriva temporal:

const { setTimeout } = require('timers/promises')

const inicio = Date.now()

adiantarTempo() // Adiantando o relógio 1 minuto para a frente
await setTimeout(2000) // Simulando uma operação de 2s

const fim = Date.now()
console.log(`Duração ${fim - inicio}ms`)

Se você rodar esse código, vai receber uma saída parecida com: Duração 7244758ms, ou seja, 7 segundos, para uma operação que deveria ter levado 2...

Eu vou colocar o código para esse teste completo (com o código para adiantar o relógio) no final do artigo, assim você também pode replicar o experimento.

Se a gente inverter as duas linhas de tempo

import { setTimeout } from 'node:timers/promises'

adiantarTempo() // Adiantando o relógio 1 minuto para a frente

const inicio = Date.now()
await setTimeout(2000) // Simulando uma operação de 2s
const fim = Date.now()
console.log(`Duração ${fim - inicio}ms`)

Vamos ter a saída esperada de Duração 2002ms. Então aqui a gente aprendeu que o Date.now pega o horário como está agora no sistema.

Agora você vai me perguntar: "Mas quando que isso vai acontecer sem eu forçar?". E a resposta é: O tempo todo.

NTP - Network Time Protocol

Para corrigir o problema da deriva temporal em computadores, existe o NTP, que é um protocolo de transmissão de tempo universal. Basicamente é um servidor que ouve requisições e responde essas requisições com o horário atual, ajustado através de um relógio atômico, que é muito mais preciso.

O problema é que a gente não tem controle do NTP, ele é implementado pelo SO para sincronizar o relógio local com um relógio central sempre que existe uma deriva temporal aparente, ou seja, o SO vai corrigir o relógio automaticamente diversas vezes durante o dia mesmo sem você perceber.

Então agora vamos fazer o exemplo inverso;

import { setTimeout } from 'node:timers/promises'

adiantarTempo() // Adiantando o relógio 1 minuto para a frente
const inicio = Date.now()
setImmediate(() => corrigeNTP()) // Corrige o tempo pelo NTP
await setTimeout(2000) // Simulando uma operação de 2s
const fim = Date.now()
console.log(`Duração ${fim - inicio}ms`)

E agora a gente tem um resultado NEGATIVO e isso sem a gente precisar fazer nada. Já viu onde o problema pode acontecer, certo?

Se estivermos medindo o tempo enquanto o computador faz uma correção do NTP, vamos ter um problemão justamente porque nossas medidas vão ser completamente incongruentes.

Uma observação interessante é que o tempo que a gente observa na saída é exatamente o tempo da correção do NTP

Relógios monotônicos

A solução para esse problema é um monotonic clock, que é simplesmente um contador que começa em um ponto qualquer to tempo (no passado) e se move em direção ao futuro na mesma velocidade do relógio do sistema. Em outras palavras, um contador.

Como ele é só um contador, obviamente não temos nenhum uso para esse tipo de funcionalidade que não seja contar a diferença entre dois intervalos, mas a parte importante é que, justamente por não ter uso como medidor de tempo, ele não é afetado pelo NTP. Portanto, qualquer diferença entre dois pontos de um relógio monotônico vai sempre ser um inteiro positivo menor que o final e maior que o início.

A maioria das linguagens possui funções para lidar com relógios normais e contadores como esses, no NodeJS não é diferente, a gente pode usar require('perf_hooks').performance.now() e process.hrtime.bigint() (ou process.hrtime() nas versões mais antigas).

Vamos usar o mesmo código, só que ao invés de usar o Date.now, vamos modificar para usar o contador do perf_hooks:

import { setTimeout } from 'node:timers/promises'
import { performance } from 'node:perf_hooks'

adiantarTempo() // Adiantando o relógio 1 minuto para a frente
const inicio = performance.now()
setImmediate(() => corrigeNTP()) // Corrige o tempo pelo NTP
await setTimeout(2000) // Simulando uma operação de 2s
const fim = performance.now()
console.log(`Duração ${fim - inicio}ms`)

E teremos a saída que a gente espera, 2000 milissegundos:

Lembrando que o próprio setTimeout e setImmediate está sujeito a alguns pequenos atrasos por conta do que acontece no Event Loop do Node.js, por isso a diferença.

Conclusão

Agora, sabendo que podemos ter problemas usando o Date.now, você já sabe que existe uma outra solução para poder contar durações entre scripts! Use o perf_hooks para evitar os problemas de NTP e todos os outros que eu comentei por aqui.

Lembrando que no artigo do Deepal existe também um terceiro experimento super legal de fazer onde podemos comparar o resultado dos outros dois experimentos juntos, vale a pena dar uma olhada!

Outro recurso sensacional é essa talk do Dr. Martin Kleppmann sobre deriva temporal em sistemas distribuídos que vale super a pena.

Eu vou ficando por aqui, se quiser saber mais sobre o código que eu usei para poder gerar esses exemplos e replicar o que eu fiz aqui na sua máquina, continue para o apêndice do artigo!

Até mais!

Apêndices

Antes de compartilhar os códigos, existem algumas notas:

  • Esse código só funciona no MacOS, mas você pode modificar livremente para rodar no Linux
  • Você provavelmente vai precisar usar o sudo
  • Você precisa ter uma versão do Node compatível com ESModules (>=12)
  • Essa é uma versão mais atualizada do código presente no artigo que comentei
import { execSync } from 'node:child_process'
import { setTimeout } from 'node:timers/promises'
import { performance } from 'node:perf_hooks'

function adiantarTempo () {
  const toTwoDigits = (num) => num.toString().padStart(2, "0")
  const now = new Date()
  const month = toTwoDigits(now.getMonth() + 1)
  const date = toTwoDigits(now.getDate())
  const hours = toTwoDigits(now.getHours())
  const fakeMinutes = toTwoDigits(now.getMinutes() + 1)
  const year = now.getFullYear().toString().substring(2, 4)

  // executa o comando do OS
  execSync(`date -u ${month}${date}${hours}${fakeMinutes}${year}`)
}

function correcaoNTP () {
  const output = execSync(`sntp -sS time.apple.com`)
  console.log(`Tempo corrigido: ${output}`)
}

const esperar2Segundos = () => setTimeout(2000)

// ------- Experimento 1: Relógios normais
{
  adiantarTempo()
  const timeNow = Date.now()

  setImmediate(() => correcaoNTP())

  await esperar2Segundos()

  const endTime = Date.now()
  const duration = endTime - timeNow
  console.log(`Duração\t: ${duration}ms`)
}

// ------- Experimento 2: Relógios monotonicos
{
  adiantarTempo()
  const timeNow = performance.now()

  setImmediate(() => correcaoNTP())

  await esperar2Segundos()

  const endTime = performance.now()
  const duration = endTime - timeNow
  console.log(`Duração\t: ${duration}ms`)
}