Por que a insistência em testar erros contra o valor zero?

A maioria das funções da libc retornam o valor -1 quando ocorre um erro. Repare, -1, não um valor qualquer! No entanto, é prática recorrente a comparação do valor retornado contra a constante zero, assim:

if ((bytes = recv(fd, buffer, buffer_size, 0)) < 0)
{
  ... trata o erro aqui ...
}

A documentação das syscalls, por exemplo, é clara: O valor retornado para indicar erros é -1 e, portanto, o correto seria escrever:

if ((bytes = recv(fd, buffer, buffer_size, 0)) == -1)
{
  ... trata o erro aqui ...
}

A primeira forma é a preferida porque, em teoria, cria código mais simples. Comparar contra zeros é uma tarefa lógica simples, já comparar contra um valor arbitrário qualquer envolve uma operação aritmética. Eis um exemplo:

extern int f(void);

void g(void)
{
  if (f() < 0)
    puts("Error!");
}

void h(void)
{
  if (f() == -1)
    puts("Error!");
}

Eis o código equivalente, gerado para ambas as funções g() e h():

section .data

_msg:  db "Error!", 0

section .text

extern f

global g
g:
  call f
  test eax,eax
  js  .L1      ; Testa o flag de sinal.
  ret
.L1:
  mov  edi,msg
  jmp  puts
  ret

global h
h:
  call f
  cmp eax,-1
  je  .L1      ; Testa o flag de zero.
  ret
.L1:
  mov  edi,msg
  jmp  puts
  ret

O código gerado é um tanto irônico: A função que faz o teste contra zero usa o flag de sinal para decidir pelo salto, mas a função que testa pelo valor negativo usa o flag zero! :)

Repare: Ambas as funções gastam a mesma quantidade de ciclos. TEST (que é uma operação AND) entre registradores e CMP (que é uma operação de subtração) entre um registrador e uma constante gastam, ambos 1 ciclo de máquina! A única desvantagem de h() é que ela tem 1 byte a mais que g(). A constante -1, tendo todos os bits setados, pode ser “resumida” em apenas um byte na instrução com o micro-código 0x83 0xf8 0xff. O micro-código de TEST, acima, é 0x85 0xC0.

Normalmente esse byte adicional é irrelevante, já que o GCC alinha o início de uma função à fronteira de 16 bytes SEMPRE… Ele faz isso para tentar evitar cache misses no L1i. As função g() e h(), tendo praticamente o mesmo tamanho, cabem dentro de mesma quantidade de linhas de cache. É claro que, dependendo de onde caiu o alinhamento, o final das funções podem cruzar a fronteira de uma linha. Neste caso a função g() pode sofrer até de um cache miss adicional, o mesmo podendo acontecer com h().

Mas, eis um detalhe importante: O comportamento do cache L1i não é tão importante assim! O seu processador lê as instruções antes que elas sejam executadas e as coloca num buffer (que não é o cache de memória) dentro dele… Na arquitetura Haswell até 56 instruções são pré-carregadas de cada vez e colocadas num buffer de reordenamento de 192 instruções! Na prática o seu processador pré-carrega 192 instruções de cada vez (se for um Haswell). Se cada instrução pode ter até 15 bytes, o espaço do buffer de reordenamento é de 2.8 KiB ou 45 linhas do cache L1i.

Isso significa que o argumento da otimização de uso do cache não vale e não há motivos para não se ater aos padrões e testar a condição de erro contra -1!

Anúncios