C# - Programação Paralela : Criando e gerenciando threads


Hoje vamos recordar conceitos da programação paralela com C# e o uso da classe Thread e como gerenciar a criação de threads com ThreadPool e BackgroundWorker.

Imagine que temos um aplicativo com tarefas independentes. Que tal aumentar o desempenho do aplicativo usando a classe Thread ?

Essa abordagem também funciona bem para grandes tarefas. Isso nos permite dividir o processamento em tarefas menores e executá-los de forma simultânea.

A maneira mais simples e fácil de criar uma thread é usando a classe Thread do namespace System.Threading.

Basicamente, precisamos fornecer o nome do método para nossa instância Thread por meio do delegate ThreadStart.

Vamos mostrar a seguir um exemplo de código em um projeto Console (.NET Core) no ambiente do NET 6.0 :

 Console.WriteLine("### - Usando Thread ### \n");

 Console.WriteLine("Iniciar");
 CriarThreadSemParametro();
 Console.WriteLine("Terminar");

 Console.ReadLine();

 static void CriarThreadSemParametro()
 {
    Console.WriteLine("Iniciando a thread sem parâmetro.");

    var thread = new Thread(new ThreadStart(CriarValoresAleatorios));
    thread.Start();
 }

 static void CriarValoresAleatorios()
 {
    Random random = new Random();
    for (int i = 0; i < 10; i++)
    {
        Console.Write($"{random.Next(1, 10)}\t");
    }
 }

Resultado:



Além disso, podemos fornecer um parâmetro para o método com o delegate ParameterizedThreadStart que representa o método que será executado em uma Thread.

Abaixo vemos um exemplo de código :

Console.WriteLine("### - Usando Thread com parâmetros ### \n");

Console.WriteLine("Inicio");

CriaThreadComParametro();

Console.WriteLine("Termino");
Console.ReadLine();

static void CriaThreadComParametro()
{
    Console.WriteLine("Iniciando a thread com parâmetro.");
    int limite = 10;
    var thread = new Thread(new ParameterizedThreadStart(CriaValoresAleatorios));
    thread.Start(limite);
}

static void CriaValoresAleatorios(object limite)
{
    Random random = new Random();
    var fim = int.Parse(limite.ToString());
    for (int i = 0; i < fim; i++)
    {
        Console.Write($"{random.Next(10, 20)}\t");
    }

}

Quando uma thread gerenciada é criada, o método executado na thread é representado por:

Assim,  thread não começa a ser executada até que o Thread.Start método seja chamado, e, o delegate ThreadStart. ParameterizedThreadStart ou é invocado na thread, e a execução começa na primeira linha do método representado pelo delegate. No caso do delegate ParameterizedThreadStart , o objeto que é passado para o método Start(Object) é passado para o delegate.

Dessa forma, o parâmetro do método deve ser um tipo de objeto, porque precisamos respeitar a assinatura do delegate.

Além disso o delegate ParameterizedThreadStart oferece suporte a apenas um único parâmetro. Podemos passar vários itens de dados para o ParameterizedThreadStart fazendo com que esse parâmetro seja um dos seguintes:

Infelizmente, existem algumas preocupações a serem consideradas quando estamos implementando threads:

Gerenciando a criação de Threads

Sabemos que um grande número de threads pode diminuir o desempenho do nosso aplicativo. Então, como podemos gerenciar a criação de threads ?

O Common Language Runtime (CLR) tem um algoritmo para determinar o número ideal de threads com base na carga da CPU, conhecido como ThreadPool.

Assim podemos usar ThreadPool para gerenciar a criação e execução de threads em nosso aplicativo. O código a seguir apresenta como podemos usar a classe ThreadPool do namespace System.Threading.

Console.WriteLine("### - Criando Threads usando ThreadPool ### \n");
Console.WriteLine("Início");
CriarThreadUsandoThreadPool(); 
Console.WriteLine("Término");
Console.ReadLine();
static void CriarThreadUsandoThreadPool()
{
    Console.WriteLine("------\n");
    int limite = 20;
    ThreadPool.QueueUserWorkItem(new WaitCallback(CriaValoresSequenciais), limite);
}
static void CriaValoresSequenciais(object limite)
{
    var end = int.Parse(limite.ToString());
    for (int i = 1; i <= end; i++)
    {
        Console.Write($"{i.ToString("000")}\t");
    }
}

Resultado :

O método QueueUserWorkItem enfileira um método para execução em uma thread do pool de threads. Em nosso caso, estamos enfileirando o método CriaValoresSequenciais.

Observe que usamos um delegado WaitCallback, que representa um ponteiro para o método que será executado em uma thread do pool de threads. Ele será executado quando houver uma thread disponível para isso.

Podemos ainda usar a classe BackgroundWorker que executa uma operação em outra thread e permite gerenciar as threads de um ThreadPool facilmente. Esta classe fornece alguns eventos úteis para lidar com a execução de nossas threads, e, podemos gerenciar as threads com eventos ativando as propriedades:

Depois de habilitar essas propriedades, precisamos nos inscrever em três eventos:

  1. DoWork: Irá executar o trabalho principal quando o método RunWorkerAsync for invocado.
  2. ProgressChanged: Gerado pelo método ReportProgress. Com este evento, podemos controlar o andamento de nossa tarefa.
  3. RunWorkerCompleted: gerado quando a execução da tarefa é concluída.

Além disso esta classe apresenta também as propriedades:

O código a seguir apresenta um exemplo de como implementar esses eventos:

using System.ComponentModel;

Console.WriteLine("### - Usando BackgroundWorker ### \n");

Console.WriteLine("Início");

var backgroundWorker = new BackgroundWorker
{
    WorkerReportsProgress = true,
    WorkerSupportsCancellation = true
};

backgroundWorker.DoWork += BackgroundWorker_DoWork;
backgroundWorker.ProgressChanged += BackgroundWorker_ProgressChanged;
backgroundWorker.RunWorkerCompleted += BackgroundWorker_RunWorkerCompleted;

int valorLimite = 50;
backgroundWorker.RunWorkerAsync(valorLimite);

Thread.Sleep(5000);
backgroundWorker.CancelAsync();

Console.WriteLine("Término");

static void BackgroundWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
    if (e.Error != null)
    {
        Console.WriteLine($"Erro : {e.Error.Message}");
    }
    else
    {
        Console.WriteLine($"Resultado {e.Result}");
    }
}

static void BackgroundWorker_ProgressChanged(object sender, ProgressChangedEventArgs e)
{
    Console.WriteLine($"{e.ProgressPercentage}% concluído...");
}

static void BackgroundWorker_DoWork(object sender, DoWorkEventArgs e)
{
    var worker = sender as BackgroundWorker;
    int limite = int.Parse(e.Argument.ToString());

    int soma = 0;

    for (int i = 1; i <= limite; i++)
    {
        if (worker.CancellationPending)
        {
            worker.CancelAsync();
            break;
        }

        soma += i;

        var percentual = ((double)i / limite) * 100;
        worker.ReportProgress((int)percentual);

        Thread.Sleep(200);
    }

    e.Result = soma;
}

Resultado:

Cabe destacar neste código o método CancelAsync.

Quando habilitamos a propriedade WorkerSupportsCancellation, podemos chamar esse método para enviar um sinal de cancelamento para nossa thread.

Quando enviamos esse sinal, a propriedade CancelamentoPending torna-se verdadeira, então podemos fazer o método CancelAsync da thread atual sair normalmente.

A desvantagem em usar o BackgroundWorker é quanto mais threads temos, fica mais difícil de depurar e manter o código.

Pegue o código do exemplo: UsandoThread

"O homem bom, do bom tesouro do seu coração tira o bem, e o homem mau, do mau tesouro do seu coração tira o mal, porque da abundância do seu coração fala a boca."
Lucas 6:45

Referências:


José Carlos Macoratti