Participe da Maratona Behind the Code! A competição de programação que mais te desafia! Inscreva-se aqui

Transferência de Dados Eficiente através de Cópia Zero

Muitos aplicativos da Web servem uma quantidade significativa de conteúdo estático, que soma dados de leitura fora de um disco e grava novamente os mesmos dados para o soquete de resposta. Essa atividade pode parecer exigir relativamente pouca atividade da CPU, mas ela é, de certa forma, ineficaz: o kernel lê os dados fora do disco e os coloca no limite do usuário do kernel para o aplicativo e depois o aplicativo os coloca de volta ao limite do usuário do kernel para gravação no soquete. De fato, o aplicativo atua como um intermediário ineficaz que obtém os dados do arquivo de disco para o soquete.

Sempre que o dado passa do limite de kernel do usuário, ele deve ser copiado, o que consome ciclos de CPU e largura de banda da memória. Felizmente, é possível eliminar essas cópias através de uma técnica chamada, — adequadamente, de — cópia zero. Os aplicativos que usam a cópia zero solicitam que o kernel copie os dados diretamente do arquivo de disco para o soquete, sem passar pelo aplicativo. A cópia zero aumenta muito o desempenho do aplicativo e reduz o número de comutadores de contexto entre o modo do kernel e do usuário.

As bibliotecas de classe Java oferecem suporte à cópia zero nos sistemas Linux e UNIX através do método transferTo() em java.nio.channels.FileChannel. É possível utilizar o método transferTo() para transferir bytes diretamente do canal no qual ele foi chamado para outro canal de byte gravável, sem exigir que os dados passem pelo aplicativo. Este artigo primeiro demonstra a sobrecarga embutida na transferência de arquivo simples, feita através de semânticas de cópias tradicionais, e depois mostra como a técnica de cópia zero utiliza transferTo() para obter melhor desempenho.

Transferência de Dados: A Abordagem Tradicional

Considere o cenário de leitura de um arquivo e transferência de dados para outro programa através da rede. (Este cenário descreve o comportamento de muitos aplicativos do servidor, inclusive os aplicativos da Web que servem o conteúdo estático, os servidores FTP, os servidores de correio, etc). O núcleo da operação está nas duas chamadas da Lista 1 (consulte Faça Download de para obter um link para um código de amostra completo):

Lista 1. Copiando Bytes de um Arquivo para um Soquete

File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);

Embora a Lista 1 seja conceitualmente simples, internamente, a operação de cópia requer quatro comutadores de contexto entre o modo do usuário e do kernel e os dados são copiados quatro vezes antes da conclusão da operação. A Figura 1 mostra como os dados são movidos internamente do arquivo para o soquete:

Figura 1. Abordagem de Cópia de Dados Tradicional

Abordagem de Cópia de Dados Tradicional

A Figura 2 mostra a comutação de contexto:

Figura 2. Comutadores de Contexto Tradicionais

Comutadores de Contexto Tradicionais

As etapas envolvidas são:

  1. A chamada read() causa uma comutação de contexto (consulte a Figura 2. Comutadores de Contexto Tradicionais) do modo do usuário para o modo do kernel. Internamente, sys_read() (ou equivalente) é emitido para a leitura dos dados a partir do arquivo. A primeira cópia (consulte a Figura 1. Abordagem de Cópia de Dados Tradicional) é executada pelo mecanismo de acesso direto à memória (DMA), que lê o conteúdo do arquivo do disco e o armazena em um buffer de espaço de endereço do kernel.
  2. A quantidade de dados solicitada é copiada do buffer de leitura no buffer de usuário e a chamada read() retorna. O retorno da chamada faz com que outro comutador de contexto do kernel volte para o modo do usuário. Agora os dados são armazenados no buffer de espaço de endereço do usuário.
  3. A chamada do soquete send() causa a comutação do contexto do modo do usuário para o modo do kernel. Uma terceira cópia é executada para colocar os dados novamente em um buffer de espaço de endereço do kernel. De qualquer forma, neste momento, os dados são colocados em um buffer diferente, um que seja associado ao soquete de destino.
  4. A chamada do sistema send() retorna, criando o quarto comutador de contexto. De forma independente e assíncrona, ocorre uma quarta cópia, à medida que o mecanismo DMA transmite os dados do buffer de kernel para o mecanismo de protocolo.

O uso do buffer de kernel intermediário (em vez de uma transferência direta dos dados no buffer do usuário) pode parecer ineficaz. Mas os buffers de kernel intermediários foram introduzidos no processo para melhorar o desempenho. O uso do buffer intermediário na leitura permite que o buffer de kernel atue como um “cache readahead”, quando o aplicativo não solicita tantos dados quantos o buffer de kernel mantém. Isso melhora significativamente o desempenho, quando a quantidade de dados solicitada é menor que o tamanho do buffer de kernel. O buffer intermediário na gravação permite que a gravação seja concluída de forma assíncrona.

Infelizmente, essa abordagem pode tornar, por si só, um problema para o desempenho se o tamanho dos dados solicitados for consideravelmente maior que o tamanho de buffer do kernel. Os dados são copiados várias vezes entre o disco, o buffer de kernel e o buffer de usuário antes de finalmente serem entregues ao aplicativo.

A cópia zero melhora o desempenho, eliminando essas cópias de dados redundantes.

Transferência de Dados: A Abordagem de Cópia Zero

Se você examinar novamente o Transferência de Dados: A Abordagem Tradicional, observará que a segunda e terceira cópias de dados não são realmente necessárias. O aplicativo não faz nada mais que armazenar os dados em cache e transferi-los de volta ao buffer de soquete. Em vez disso, os dados podem ser transferidos diretamente do buffer de leitura para o buffer de soquete. O método transferTo() permite que você faça exatamente isso. A Lista 2 mostra a assinatura do método de transferTo():

Lista 2. O Método transferTo()

public void transferTo(long position, long count, WritableByteChannel target);

O método transferTo() transfere dados do canal de arquivo para o canal de byte gravável fornecido. Internamente, isso depende do suporte do sistema operacional subjacente para a cópia zero; no UNIX e em vários recursos do Linux, essa chamada é roteada para a chamada do sistema sendfile(), mostrada na Lista 3, que transfere dados de um descritor de arquivos para outro:

Lista 3. A Chamada do Sistema sendfile()

#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

A ação das chamadas file.read() e socket.send() na Lista 1. Copiando Bytes de um Arquivo para um Soquete pode ser substituída por uma única chamada transferTo(), como mostrado na Lista 4:

Lista 4. Utilizando transferTo() para Copiar Dados de um Arquivo de Disco para um Soquete

transferTo(position, count, writableChannel);

A Figura 3 mostra o caminho dos dados quando o método transferTo() é utilizado:

Figura 3. Cópia de Dados com transferTo()

Cópia de Dados com transferTo()

A Figura 4 mostra os comutadores de contexto quando o método transferTo() é utilizado:

Figura 4. Comutação de Contexto com transferTo()

Comutação de Contexto usando o transferTo()

As etapas feitas quando se utiliza transferTo() como na Lista 4. Utilizando transferTo() para Copiar Dados de um Arquivo de Disco para um Soquete são:

  1. O método transferTo() faz com que os conteúdos do arquivo sejam copiados em um buffer de leitura pelo mecanismo DMA. Depois os dados são copiados pelo kernel no buffer de kernel associado ao soquete de saída.
  2. A terceira cópia ocorre quando o mecanismo DMA transmite os dados dos buffers de soquete do kernel para o mecanismo de protocolo.

Este é um aperfeiçoamento: reduzimos o número de comutadores de contexto de quatro para dois e reduzimos o número de cópias de dados de quatro para três (apenas envolvendo a CPU). Mas isso ainda não nos leva a nossa meta de cópia zero. Podemos reduzir ainda mais a duplicação de dados feita pelo kernel se a placa da interface de rede subjacente oferecer suporte a operações de coleta. Nos kernels Linux 2.4 e posteriores, o descritor de buffer de soquete foi modificado para acomodar esse requisito. Essa abordagem não apenas reduz os diversos comutadores de contexto, mas também elimina as cópias de dados duplicadas que exigem envolvimento da CPU. O uso do usuário ainda permanece igual, mas a essência mudou:

  1. O método transferTo() faz com que o conteúdo do arquivo seja copiado em um buffer de kernel pelo mecanismo DMA.
  2. Nenhum dado é copiado no buffer do soquete. Ao contrário, somente descritores com informações sobre o local e o comprimento dos dados são anexados ao buffer de soquete. O mecanismo DMA transmite dados diretamente do buffer de kernel para o mecanismo de protocolo, eliminando assim a cópia final da CPU restante.

A Figura 5 mostra as cópias de dados usando transferTo() com a operação de coleta:

Figura 5. Cópias de Dados quando transferTo() e as Operações de Coleta São Utilizadas

Cópias de Dados quando transferTo() e as Operações de Coleta São Utilizadas

Criando um Servidor de Arquivo

Agora colocaremos a cópia zero em prática, utilizando o mesmo exemplo de transferência de um arquivo entre um cliente e um servidor (consulte Faça Download de para o código de amostra). TraditionalClient.java e TraditionalServer.java são baseados nas semânticas de cópias tradicionais, que utilizam File.read() e Socket.send(). TraditionalServer.java é um programa de servidor que atende em uma porta específica para a conexão do cliente e depois lê 4K bytes de dados de uma vez do soquete. TraditionalClient.java se conecta ao servidor, lê (utilizando File.read()) 4K bytes de dados de um arquivo e envia (utilizando socket.send()) o conteúdo para o servidor via soquete.

De modo semelhante, TransferToServer.java e TransferToClient.java executam a mesma função, mas, ao contrário, utilizam o método transferTo() (e, por sua vez, a chamada do sistema sendfile()) para transferir o arquivo do servidor para o cliente.

Comparação de Desempenho

Executamos os programas de amostra em um sistema Linux executando o kernel 2.6 e medimos o tempo de execução em milissegundos tanto para a abordagem tradicional quanto para a abordagem transferTo() para vários tamanhos. A Tabela 1 mostra os resultados:

Tabela 1. Comparação de Desempenho: Abordagem Tradicional vs. Cópia Zero

Tamanho do Arquivo Transferência de Arquivo Normal (ms) transferTo (ms)
7MB 156 45
21MB 337 128
63MB 843 387
98MB 1320 617
200MB 2124 1150
350MB 3631 1762
700MB 13498 4422
1GB 18399 8537

Como é possível ver, a API transferTo() reduz o tempo em aproximadamente 65% quando comparada à abordagem tradicional. Isso pode aumentar significativamente o desempenho de aplicativos que fazem muitas cópias de dados de um canal de E/S para outro, como servidores da Web.

Resumo

Demonstramos as vantagens do desempenho utilizando transferTo(), comparado à leitura de um canal e à gravação dos mesmos dados em outro. Cópias de buffers intermediários, — mesmo aqueles ocultos no kernel, — podem ter um custo moderado. Em aplicativos que fazem muitas cópias de dados entre canais, a técnica de cópia zero pode oferecer uma melhoria significativa no desempenho.

Download

j-zerocopy.zip: Sample programs for this article

Aviso

O conteúdo aqui presente foi traduzido da página IBM Developer US. Caso haja qualquer divergência de texto e/ou versões, consulte o conteúdo original.