Introdução ao Java NIO Selector

Introdução ao Java NIO Selector

1. Visão geral

Neste artigo, exploraremos as partes introdutórias do componenteSelector do Java NIO.

Um seletor fornece um mecanismo para monitorar um ou mais canais NIO e reconhecer quando um ou mais estão disponíveis para transferência de dados.

Dessa forma,a single thread can be used for managing multiple channels e, portanto, várias conexões de rede.

2. Por que usar um seletor?

Com um seletor, podemos usar um segmento em vez de vários para gerenciar vários canais. Context-switching between threads is expensive for the operating system, e adicionalmente,each thread takes up memory.

Portanto, quanto menos threads usarmos, melhor. No entanto, é importante lembrar quemodern operating systems and CPU’s keep getting better at multitasking, de modo que os overheads de multithreading continuam diminuindo ao longo do tempo.

Vamos tratar aqui de como podemos lidar com vários canais com um único thread usando um seletor.

Observe também que os seletores não ajudam apenas a ler os dados; eles também podem ouvir conexões de rede de entrada e gravar dados em canais lentos.

3. Configuração

Para usar o seletor, não precisamos de nenhuma configuração especial. Todas as classes de que precisamos são o pacote principaljava.nio e só temos que importar o que precisamos.

Depois disso, podemos registrar vários canais com um objeto seletor. Quando a atividade de E / S acontece em qualquer um dos canais, o seletor nos notifica. É assim que podemos ler de um grande número de fontes de dados de um único thread.

Qualquer canal que registramos com um seletor deve ser uma subclasse deSelectableChannel. Esse é um tipo especial de canal que pode ser colocado no modo sem bloqueio.

4. Criando um Seletor

Um seletor pode ser criado invocando o métodoopen estático da classeSelector, que usará o provedor de seletor padrão do sistema para criar um novo seletor:

Selector selector = Selector.open();

5. Registrando Canais Selecionáveis

Para que um seletor monitore qualquer canal, devemos registrar esses canais no seletor. Fazemos isso invocando o métodoregister do canal selecionável.

Porém, antes de um canal ser registrado com um seletor, ele deve estar no modo sem bloqueio:

channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

Isso significa que não podemos usarFileChannels com um seletor, pois eles não podem ser alternados para o modo sem bloqueio da maneira que fazemos com canais de soquete.

O primeiro parâmetro é o objetoSelector que criamos anteriormente, o segundo parâmetro define um conjunto de interesse, que significa quais eventos estamos interessados ​​em escutar no canal monitorado, através do seletor.

Existem quatro eventos diferentes que podemos ouvir, cada um é representado por uma constante na classeSelectionKey:

  • Connect quando um cliente tenta se conectar ao servidor. Representado porSelectionKey.OP_CONNECT

  • Accept quando o servidor aceita uma conexão de um cliente. Representado porSelectionKey.OP_ACCEPT

  • Read quando o servidor está pronto para ler do canal. Representado porSelectionKey.OP_READ

  • Write quando o servidor está pronto para gravar no canal. Representado porSelectionKey.OP_WRITE

O objeto retornadoSelectionKey representa o registro do canal selecionável com o seletor. Veremos isso mais detalhadamente na seção seguinte.

6. O objetoSelectionKey

Como vimos na seção anterior, quando registramos um canal com um seletor, obtemos um objetoSelectionKey. Este objeto contém dados que representam o registro do canal.

Ele contém algumas propriedades importantes que devemos entender bem para podermos usar o seletor no canal. Veremos essas propriedades nas subseções a seguir.

6.1. O Conjunto de Interesses

Um conjunto de interesses define o conjunto de eventos que queremos que o seletor observe neste canal. É um valor inteiro; podemos obter essas informações da seguinte maneira.

Primeiro, temos o conjunto de juros retornado pelo métodoSelectionKey'sinterestOps. Então temos a constante de evento emSelectionKey que vimos anteriormente.

Quando AND AND esses dois valores, obtemos um valor booleano que informa se o evento está sendo monitorado ou não:

int interestSet = selectionKey.interestOps();

boolean isInterestedInAccept  = interestSet & SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;
boolean isInterestedInRead    = interestSet & SelectionKey.OP_READ;
boolean isInterestedInWrite   = interestSet & SelectionKey.OP_WRITE;

6.2. The Ready Set

O conjunto pronto define o conjunto de eventos para os quais o canal está pronto. Também é um valor inteiro; podemos obter essas informações da seguinte maneira.

Temos o conjunto pronto retornado pelo métodoSelectionKey'sreadyOps. Quando AND e esse valor com as constantes de eventos, como fizemos no caso do conjunto de interesses, obtemos um booleano representando se o canal está pronto para um valor específico ou não.

Outra alternativa e maneira mais curta de fazer isso é usar métodos de conveniênciaSelectionKey's para o mesmo propósito:

selectionKey.isAcceptable();
selectionKey.isConnectable();
selectionKey.isReadable();
selectionKey.isWriteable();

6.3. O canal

Acessar o canal que está sendo assistido a partir do objetoSelectionKey é muito simples. Nós apenas chamamos o métodochannel:

Channel channel = key.channel();

6.4. O seletor

Assim como obter um canal, é muito fácil obter o objetoSelector do objetoSelectionKey:

Selector selector = key.selector();

6.5. Anexando objetos

Podemos anexar um objeto a umSelectionKey. Às vezes, podemos querer dar a um canal um ID personalizado ou anexar qualquer tipo de objeto Java que possamos rastrear.

Anexar objetos é uma maneira prática de fazer isso. Aqui está como você anexa e obtém objetos de umSelectionKey:

key.attach(Object);

Object object = key.attachment();

Como alternativa, podemos optar por anexar um objeto durante o registro do canal. Nós o adicionamos como um terceiro parâmetro ao métodoregister do canal, assim:

SelectionKey key = channel.register(
  selector, SelectionKey.OP_ACCEPT, object);

7. Seleção de chave de canal

Até agora, vimos como criar um seletor, registrar canais para ele e inspecionar as propriedades do objetoSelectionKey que representa o registro de um canal para um seletor.

Isso é apenas metade do processo, agora temos que executar um processo contínuo de seleção do conjunto pronto que analisamos anteriormente. Fazemos a seleção usando o métodoselect do seletor, assim:

int channels = selector.select();

Este método bloqueia até que pelo menos um canal esteja pronto para uma operação. O número inteiro retornado representa o número de chaves cujos canais estão prontos para uma operação.

Em seguida, geralmente recuperamos o conjunto de chaves selecionadas para processamento:

Set selectedKeys = selector.selectedKeys();

O conjunto que obtivemos é de objetosSelectionKey, cada chave representa um canal registrado que está pronto para uma operação.

Depois disso, geralmente iteramos sobre esse conjunto e, para cada chave, obtemos o canal e executamos qualquer uma das operações que aparecem em nosso interesse nele.

Durante a vida útil de um canal, ele pode ser selecionado várias vezes, pois sua tecla aparece no conjunto pronto para diferentes eventos. É por isso que precisamos ter um loop contínuo para capturar e processar eventos de canal como e quando eles ocorrem.

8. Exemplo completo

Para cimentar o conhecimento que adquirimos nas seções anteriores, vamos construir um exemplo cliente-servidor completo.

Para facilitar o teste de nosso código, construiremos um servidor de eco e um cliente de eco. Nesse tipo de configuração, o cliente se conecta ao servidor e começa a enviar mensagens para ele. O servidor faz eco das mensagens enviadas por cada cliente.

Quando o servidor encontra uma mensagem específica, comoend, ele a interpreta como o fim da comunicação e fecha a conexão com o cliente.

8.1. O servidor

Aqui está nosso código paraEchoServer.java:

public class EchoServer {

    private static final String POISON_PILL = "POISON_PILL";

    public static void main(String[] args) throws IOException {
        Selector selector = Selector.open();
        ServerSocketChannel serverSocket = ServerSocketChannel.open();
        serverSocket.bind(new InetSocketAddress("localhost", 5454));
        serverSocket.configureBlocking(false);
        serverSocket.register(selector, SelectionKey.OP_ACCEPT);
        ByteBuffer buffer = ByteBuffer.allocate(256);

        while (true) {
            selector.select();
            Set selectedKeys = selector.selectedKeys();
            Iterator iter = selectedKeys.iterator();
            while (iter.hasNext()) {

                SelectionKey key = iter.next();

                if (key.isAcceptable()) {
                    register(selector, serverSocket);
                }

                if (key.isReadable()) {
                    answerWithEcho(buffer, key);
                }
                iter.remove();
            }
        }
    }

    private static void answerWithEcho(ByteBuffer buffer, SelectionKey key)
      throws IOException {

        SocketChannel client = (SocketChannel) key.channel();
        client.read(buffer);
        if (new String(buffer.array()).trim().equals(POISON_PILL)) {
            client.close();
            System.out.println("Not accepting client messages anymore");
        }

        buffer.flip();
        client.write(buffer);
        buffer.clear();
    }

    private static void register(Selector selector, ServerSocketChannel serverSocket)
      throws IOException {

        SocketChannel client = serverSocket.accept();
        client.configureBlocking(false);
        client.register(selector, SelectionKey.OP_READ);
    }

    public static Process start() throws IOException, InterruptedException {
        String javaHome = System.getProperty("java.home");
        String javaBin = javaHome + File.separator + "bin" + File.separator + "java";
        String classpath = System.getProperty("java.class.path");
        String className = EchoServer.class.getCanonicalName();

        ProcessBuilder builder = new ProcessBuilder(javaBin, "-cp", classpath, className);

        return builder.start();
    }
}

Isso é o que está acontecendo; criamos um objetoSelector chamando o métodoopen estático. Em seguida, criamos um canal também chamando seu métodoopen estático, especificamente uma instânciaServerSocketChannel.

Isso ocorre porqueServerSocketChannel is selectable and good for a stream-oriented listening socket.

Em seguida, vinculamos a uma porta de nossa escolha. Lembre-se de que dissemos anteriormente que, antes de registrar um canal selecionável em um seletor, devemos primeiro configurá-lo para o modo sem bloqueio. Então, em seguida, fazemos isso e registramos o canal no seletor.

Não precisamos da instânciaSelectionKey deste canal neste estágio, então não vamos lembrar dela.

O Java NIO usa um modelo orientado a buffer diferente de um modelo orientado a fluxo. Portanto, a comunicação do soquete geralmente ocorre escrevendo e lendo a partir de um buffer.

Portanto, criamos um novoByteBuffer no qual o servidor estará escrevendo e lendo. Inicializamos para 256 bytes, é apenas um valor arbitrário, dependendo da quantidade de dados que planejamos transferir para lá e para cá.

Por fim, realizamos o processo de seleção. Selecionamos os canais prontos, recuperamos suas teclas de seleção, iteramos sobre as teclas e executamos as operações para as quais cada canal está pronto.

Fazemos isso em um loop infinito, pois os servidores geralmente precisam continuar em execução, independentemente de haver uma atividade ou não.

A única operação que umServerSocketChannel pode realizar é uma operaçãoACCEPT. Quando aceitamos a conexão de um cliente, obtemos um objetoSocketChannel no qual podemos ler e escrever. Nós o configuramos no modo sem bloqueio e o registramos para uma operação READ no seletor.

Durante uma das seleções subsequentes, esse novo canal ficará pronto para leitura. Nós o recuperamos e lemos o conteúdo no buffer. Como é um servidor de eco, devemos escrever esse conteúdo de volta para o cliente.

When we desire to write to a buffer from which we have been reading, we must call the flip() method.

Finalmente configuramos o buffer para o modo de gravação chamando o métodoflipe simplesmente gravamos nele.

O métodostart() é definido para que o servidor de eco possa ser iniciado como um processo separado durante o teste de unidade.

8.2. O cliente

Aqui está nosso código paraEchoClient.java:

public class EchoClient {
    private static SocketChannel client;
    private static ByteBuffer buffer;
    private static EchoClient instance;

    public static EchoClient start() {
        if (instance == null)
            instance = new EchoClient();

        return instance;
    }

    public static void stop() throws IOException {
        client.close();
        buffer = null;
    }

    private EchoClient() {
        try {
            client = SocketChannel.open(new InetSocketAddress("localhost", 5454));
            buffer = ByteBuffer.allocate(256);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public String sendMessage(String msg) {
        buffer = ByteBuffer.wrap(msg.getBytes());
        String response = null;
        try {
            client.write(buffer);
            buffer.clear();
            client.read(buffer);
            response = new String(buffer.array()).trim();
            System.out.println("response=" + response);
            buffer.clear();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return response;

    }
}

O cliente é mais simples que o servidor.

Usamos um padrão singleton para instanciá-lo dentro do método estáticostart. Chamamos o construtor privado desse método.

No construtor privado, abrimos uma conexão na mesma porta na qual o canal do servidor estava ligado e ainda no mesmo host.

Em seguida, criamos um buffer no qual podemos escrever e do qual podemos ler.

Finalmente, temos um métodosendMessage que lê quebra qualquer string que passamos a ele em um buffer de bytes que é transmitido pelo canal para o servidor.

Em seguida, lemos do canal do cliente para obter a mensagem enviada pelo servidor. Retornamos isso como o eco da nossa mensagem.

8.3. Teste

Dentro de uma classe chamadaEchoTest.java, vamos criar um caso de teste que inicia o servidor, envia mensagens para o servidor e só passa quando as mesmas mensagens são recebidas de volta do servidor. Como etapa final, o caso de teste para o servidor antes da conclusão.

Agora podemos executar o teste:

public class EchoTest {

    Process server;
    EchoClient client;

    @Before
    public void setup() throws IOException, InterruptedException {
        server = EchoServer.start();
        client = EchoClient.start();
    }

    @Test
    public void givenServerClient_whenServerEchosMessage_thenCorrect() {
        String resp1 = client.sendMessage("hello");
        String resp2 = client.sendMessage("world");
        assertEquals("hello", resp1);
        assertEquals("world", resp2);
    }

    @After
    public void teardown() throws IOException {
        server.destroy();
        EchoClient.stop();
    }
}

9. Conclusão

Neste artigo, abordamos o uso básico do componente Java NIO Selector.

O código-fonte completo e todos os trechos de código para este artigo estão disponíveis em meuGitHub project.