Guia para java.util.concurrent.BlockingQueue

Guia para java.util.concurrent.BlockingQueue

1. Visão geral

Neste artigo, veremos uma das construções mais úteisjava.util.concurrent para resolver o problema produtor-consumidor concorrente. Veremos uma API da interfaceBlockingQueue e como os métodos dessa interface tornam a escrita de programas simultâneos mais fácil.

Mais adiante neste artigo, mostraremos um exemplo de um programa simples que possui vários encadeamentos de produtor e vários encadeamentos de consumidor.

2. TiposBlockingQueue

Podemos distinguir dois tipos deBlockingQueue:

  • fila ilimitada - pode crescer quase indefinidamente

  • fila limitada - com capacidade máxima definida

2.1. Fila sem limites

Criar filas ilimitadas é simples:

BlockingQueue blockingQueue = new LinkedBlockingDeque<>();

A capacidade deblockingQueue será definida comoInteger.MAX_VALUE. Todas as operações que adicionam um elemento à fila ilimitada nunca serão bloqueadas, portanto, ela pode crescer para um tamanho muito grande.

A coisa mais importante ao projetar um programa produtor-consumidor usando o BlockingQueue ilimitado é que os consumidores possam consumir mensagens tão rapidamente quanto os produtores estão adicionando mensagens à fila. Caso contrário, a memória poderia ficar cheia e obteríamos uma exceçãoOutOfMemory.

2.2. Fila Limitada

O segundo tipo de filas é a fila limitada. Podemos criar essas filas passando a capacidade como argumento para um construtor:

BlockingQueue blockingQueue = new LinkedBlockingDeque<>(10);

Aqui temos umblockingQueue que tem uma capacidade igual a 10. Isso significa que quando um consumidor tenta adicionar um elemento a uma fila já cheia, dependendo de um método que foi usado para adicioná-lo (offer(),add() ouput()), ele irá bloquear até que haja espaço para inserir o objeto. Caso contrário, as operações falharão.

Usar a fila limitada é uma boa maneira de projetar programas simultâneos porque, quando inserimos um elemento em uma fila já cheia, essas operações precisam esperar até que os consumidores os alcancem e disponibilizem algum espaço na fila. Isso nos dá controle sem esforço da nossa parte.

3. APIBlockingQueue

Existem dois tipos de métodos na interfaceBlockingQueue métodos responsáveis ​​por adicionar elementos a uma fila e métodos que recuperam esses elementos. Cada método desses dois grupos se comporta de maneira diferente caso a fila esteja cheia / vazia.

3.1. Adicionando elementos

  • add() – retornatrue se a inserção foi bem-sucedida, caso contrário, gera umIllegalStateException

  • put() – insere o elemento especificado em uma fila, esperando por um slot livre se necessário

  • offer() – retornatrue se a inserção foi bem-sucedida, caso contrário,false

  • offer(E e, long timeout, TimeUnit unit) – tenta inserir o elemento em uma fila e espera por um slot disponível dentro de um tempo limite especificado

3.2. Recuperando Elementos

  • take() - espera por um elemento principal de uma fila e o remove. Se a fila estiver vazia, ela bloqueia e aguarda a disponibilidade de um elemento

  • poll(long timeout, TimeUnit unit) – recupera e remove o início da fila, aguardando até o tempo de espera especificado, se necessário, para que um elemento fique disponível. Retornanull após um tempo limite __

Esses métodos são os blocos de construção mais importantes da interfaceBlockingQueue ao construir programas produtor-consumidor.

4. Exemplo de produtor-consumidor multithread

Vamos criar um programa que consiste em duas partes - um produtor e um consumidor.

O produtor estará produzindo um número aleatório de 0 a 100 e colocará esse número emBlockingQueue. Teremos 4 threads produtores e usaremos o métodoput() para bloquear até que haja espaço disponível na fila.

O importante é lembrar que precisamos impedir que nossos threads de consumidor esperem que um elemento apareça em uma fila indefinidamente.

Uma boa técnica para sinalizar do produtor para o consumidor que não há mais mensagens para processar é enviar uma mensagem especial chamada pílula de veneno. Precisamos enviar tantas pílulas de veneno quanto temos consumidores. Então, quando um consumidor receber essa mensagem especial de pílula envenenada de uma fila, concluirá a execução normalmente.

Vejamos uma classe de produtor:

public class NumbersProducer implements Runnable {
    private BlockingQueue numbersQueue;
    private final int poisonPill;
    private final int poisonPillPerProducer;

    public NumbersProducer(BlockingQueue numbersQueue, int poisonPill, int poisonPillPerProducer) {
        this.numbersQueue = numbersQueue;
        this.poisonPill = poisonPill;
        this.poisonPillPerProducer = poisonPillPerProducer;
    }
    public void run() {
        try {
            generateNumbers();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    private void generateNumbers() throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            numbersQueue.put(ThreadLocalRandom.current().nextInt(100));
        }
        for (int j = 0; j < poisonPillPerProducer; j++) {
            numbersQueue.put(poisonPill);
        }
     }
}

Nosso construtor produtor usa como argumentoBlockingQueue que é usado para coordenar o processamento entre o produtor e o consumidor. Vemos que o métodogenerateNumbers() colocará 100 elementos em uma fila. É preciso também uma mensagem de pílula envenenada, para saber que tipo de mensagem será colocada em uma fila quando a execução será concluída. Essa mensagem precisa ser colocadapoisonPillPerProducer vezes em uma fila.

Cada consumidor pegará um elemento de umBlockingQueue usando o métodotake(), então ele será bloqueado até que haja um elemento na fila. Depois de pegar umInteger de uma fila, ele verifica se a mensagem é uma pílula de veneno, se sim, a execução de uma thread está concluída. Caso contrário, ele imprimirá o resultado na saída padrão junto com o nome do segmento atual.

Isso nos dará informações sobre o funcionamento interno de nossos consumidores:

public class NumbersConsumer implements Runnable {
    private BlockingQueue queue;
    private final int poisonPill;

    public NumbersConsumer(BlockingQueue queue, int poisonPill) {
        this.queue = queue;
        this.poisonPill = poisonPill;
    }
    public void run() {
        try {
            while (true) {
                Integer number = queue.take();
                if (number.equals(poisonPill)) {
                    return;
                }
                System.out.println(Thread.currentThread().getName() + " result: " + number);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

O importante a ser observado é o uso de uma fila. Assim como no construtor produtor, uma fila é passada como argumento. Podemos fazer isso porqueBlockingQueue pode ser compartilhado entre threads sem qualquer sincronização explícita.

Agora que temos nosso produtor e consumidor, podemos iniciar nosso programa. Precisamos definir a capacidade da fila e configuramos para 100 elementos.

Queremos ter 4 threads de produtor e um número de threads de consumidores será igual ao número de processadores disponíveis:

int BOUND = 10;
int N_PRODUCERS = 4;
int N_CONSUMERS = Runtime.getRuntime().availableProcessors();
int poisonPill = Integer.MAX_VALUE;
int poisonPillPerProducer = N_CONSUMERS / N_PRODUCERS;
int mod = N_CONSUMERS % N_PRODUCERS;

BlockingQueue queue = new LinkedBlockingQueue<>(BOUND);

for (int i = 1; i < N_PRODUCERS; i++) {
    new Thread(new NumbersProducer(queue, poisonPill, poisonPillPerProducer)).start();
}

for (int j = 0; j < N_CONSUMERS; j++) {
    new Thread(new NumbersConsumer(queue, poisonPill)).start();
}

new Thread(new NumbersProducer(queue, poisonPill, poisonPillPerProducer + mod)).start();

BlockingQueue é criado usando construção com uma capacidade. Estamos criando 4 produtores e N consumidores. Especificamos nossa mensagem de poison pill comoInteger.MAX_VALUE porque tal valor nunca será enviado por nosso produtor sob condições normais de trabalho. A coisa mais importante a notar aqui é queBlockingQueue é usado para coordenar o trabalho entre eles.

Quando executamos o programa, 4 encadeamentos produtores colocarãoIntegers aleatórios emBlockingQueuee os consumidores retirarão esses elementos da fila. Cada linha imprime na saída padrão o nome da linha junto com um resultado.

5. Conclusão

Este artigo mostra um uso prático deBlockingQueuee explica os métodos que são usados ​​para adicionar e recuperar elementos dele. Além disso, mostramos como construir um programa produtor-consumidor multithread usandoBlockingQueue para coordenar o trabalho entre produtores e consumidores.

A implementação de todos esses exemplos e trechos de código pode ser encontrada emGitHub project - este é um projeto baseado em Maven, portanto, deve ser fácil de importar e executar como está.