Introdução ao multithreading

Impasses

Um deadlock ocorre quando cada membro de algum grupo de dois ou mais threads deve esperar que um dos outros membros faça algo (por exemplo, liberar um bloqueio) antes de prosseguir. Sem intervenção, os threads vão esperar para sempre.

Um exemplo de pseudocódigo de um projeto propenso a deadlock é:

thread_1 {
    acquire(A)
    ...
    acquire(B)
    ...
    release(A, B)
}

thread_2 {
    acquire(B)
    ...
    acquire(A)
    ...
    release(A, B)
}

Um deadlock pode ocorrer quando thread_1 adquiriu A, mas ainda não B, e thread_2 adquiriu B, mas não A. Conforme mostrado no diagrama a seguir, ambos os encadeamentos aguardarão para sempre. Deadlock Diagram

Como evitar impasses

Como regra geral, minimize o uso de bloqueios e minimize o código entre bloqueio e desbloqueio.

Adquira Bloqueios na Mesma Ordem

Um redesenho de thread_2 resolve o problema:

thread_2 {
    acquire(A)
    ...
    acquire(B)
    ...
    release(A, B)
}

Ambas as threads adquirem os recursos na mesma ordem, evitando assim deadlocks.

Essa solução é conhecida como “solução de hierarquia de recursos”. Foi proposto por Dijkstra como uma solução para o “problema dos filósofos gastronômicos”.

Às vezes, mesmo se você especificar uma ordem estrita para aquisição de bloqueio, essa ordem estática de aquisição de bloqueio pode se tornar dinâmica em tempo de execução.

Considere o seguinte código:

void doCriticalTask(Object A, Object B){
     acquire(A){
        acquire(B){
            
        }
    }
}

Aqui, mesmo que a ordem de aquisição de bloqueio pareça segura, ela pode causar um impasse quando thread_1 acessar esse método com, digamos, Object_1 como parâmetro A e Object_2 como parâmetro B e thread_2 na ordem oposta, ou seja, Object_2 como parâmetro A e Object_1 como parâmetro B.

Em tal situação, é melhor ter alguma condição única derivada usando Object_1 e Object_2 com algum tipo de cálculo, por exemplo. usando hashcode de ambos os objetos, então sempre que um thread diferente entrar nesse método em qualquer ordem paramétrica, toda vez que essa condição única derivará a ordem de aquisição de bloqueio.

por exemplo. Say Object tem alguma chave exclusiva, por exemplo accountNumber no caso de objeto Account.

void doCriticalTask(Object A, Object B){
    int uniqueA = A.getAccntNumber();
    int uniqueB = B.getAccntNumber();
    if(uniqueA > uniqueB){
         acquire(B){
            acquire(A){
                
            }
        }
    }else {
         acquire(A){
            acquire(B){
                
            }
        }
    }
}

Condições da corrida

Uma corrida de dados ou condição de corrida é um problema que pode ocorrer quando um programa multithread não está sincronizado corretamente. Se dois ou mais threads acessarem a mesma memória sem sincronização e pelo menos um dos acessos for uma operação de ‘gravação’, ocorrerá uma corrida de dados. Isso leva a um comportamento do programa dependente da plataforma e possivelmente inconsistente. Por exemplo, o resultado de um cálculo pode depender do agendamento do encadeamento.

Problema dos leitores-escritores:

writer_thread {
    write_to(buffer)
}

reader_thread {
    read_from(buffer)
}

Uma solução simples:

writer_thread {
    lock(buffer)
    write_to(buffer)
    unlock(buffer)
}

reader_thread {
    lock(buffer)
    read_from(buffer)
    unlock(buffer)
}

Essa solução simples funciona bem se houver apenas um thread de leitura, mas se houver mais de um, ela retarda a execução desnecessariamente, pois os threads de leitura podem ler simultaneamente.

Uma solução que evita esse problema pode ser:

writer_thread {
    lock(reader_count)
    if(reader_count == 0) {
        write_to(buffer)
    }
    unlock(reader_count)
}

reader_thread {
    lock(reader_count)
    reader_count = reader_count + 1
    unlock(reader_count)

    read_from(buffer)

    lock(reader_count)
    reader_count = reader_count - 1
    unlock(reader_count)
}

Observe que o reader_count está bloqueado durante toda a operação de escrita, de modo que nenhum leitor pode começar a ler enquanto a escrita não tiver terminado.

Agora muitos leitores podem ler simultaneamente, mas um novo problema pode surgir: O reader_count pode nunca chegar a 0, de modo que a thread do escritor nunca pode escrever no buffer. Isso é chamado de inanição, existem diferentes soluções para evitá-lo.


Mesmo programas que podem parecer corretos podem ser problemáticos:

boolean_variable = false 

writer_thread {
    boolean_variable = true
}

reader_thread {
    while_not(boolean_variable)
    {
       do_something()
    }         
}

O programa de exemplo pode nunca terminar, pois o thread de leitura pode nunca ver a atualização do thread de gravação. Se, por exemplo, o hardware usa caches de CPU, os valores podem ser armazenados em cache. E como uma gravação ou leitura em um campo normal não leva a uma atualização do cache, o valor alterado pode nunca ser visto pelo thread de leitura.

C++ e Java definem no chamado modelo de memória, o que significa sincronizado corretamente: Modelo de Memória C++ , Modelo de memória Java.

Em Java uma solução seria declarar o campo como volátil:

volatile boolean boolean_field;

Em C++ uma solução seria declarar o campo como atômico:

std::atomic<bool> data_ready(false)

Uma corrida de dados é um tipo de condição de corrida. Mas nem todas as condições de corrida são corridas de dados. O seguinte chamado por mais de um thread leva a uma condição de corrida, mas não a uma corrida de dados:

class Counter {
    private volatile int count = 0;

    public void addOne() {
     i++;
    }
}

Ele está corretamente sincronizado de acordo com a especificação do Java Memory Model, portanto, não é uma corrida de dados. Mas ainda leva a condições de corrida, por exemplo. o resultado depende do entrelaçamento dos fios.

Nem todas as corridas de dados são bugs. Um exemplo de uma chamada condição de corrida benigna é o sun.reflect.NativeMethodAccessorImpl:

class  NativeMethodAccessorImpl extends MethodAccessorImpl {
    private Method method;
    private DelegatingMethodAccessorImpl parent;
    private int numInvocations;
    
    NativeMethodAccessorImpl(Method method) {
        this.method = method;
    }

    public Object invoke(Object obj, Object[] args)
        throws IllegalArgumentException, InvocationTargetException
    {
        if (++numInvocations > ReflectionFactory.inflationThreshold()) {
              MethodAccessorImpl acc = (MethodAccessorImpl)
            new MethodAccessorGenerator().
            generateMethod(method.getDeclaringClass(),
                             method.getName(),
                             method.getParameterTypes(),
                             method.getReturnType(),
                             method.getExceptionTypes(),
                             method.getModifiers());
                             parent.setDelegate(acc);
          }
          return invoke0(method, obj, args);
    }
    ...
}

Aqui o desempenho do código é mais importante do que a exatidão da contagem de numInvocation.

Hello Multithreading - Criando novos threads

Este exemplo simples mostra como iniciar vários threads em Java. Observe que não é garantido que os encadeamentos sejam executados em ordem e a ordem de execução pode variar para cada execução.

public class HelloMultithreading {

    public static void main(String[] args) {

        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(new MyRunnable(i));
            t.start();
        }
    }

    public static class MyRunnable implements Runnable {

        private int mThreadId;

        public MyRunnable(int pThreadId) {
            super();
            mThreadId = pThreadId;
        }

        @Override
        public void run() {
            System.out.println("Hello multithreading: thread " + mThreadId);
        }

    }

}

O mesmo thread pode ser executado duas vezes?

Foi a pergunta mais frequente que pode um mesmo thread pode ser executado duas vezes.

A resposta para isso é saber que um thread pode ser executado apenas uma vez.

se você tentar executar o mesmo thread duas vezes, ele será executado pela primeira vez, mas dará erro pela segunda vez e o erro será IllegalThreadStateException .

exemplo:

public class TestThreadTwice1 extends Thread{  
 public void run(){  
   System.out.println("running...");  
 }  
 public static void main(String args[]){  
  TestThreadTwice1 t1=new TestThreadTwice1();  
  t1.start();  
  t1.start();  
 }  
}  

resultado:

running
       Exception in thread "main" java.lang.IllegalThreadStateException

Propósito

Threads são as partes de baixo nível de um sistema de computação onde ocorre o processamento de comandos. É suportado/fornecido pelo hardware CPU/MCU. Existem também métodos de software. O objetivo do multi-threading é fazer cálculos em paralelo, se possível. Assim, o resultado desejado pode ser obtido em uma fatia de tempo menor.