[C#] Como tasks e threads trabalham juntos
Threads são pequenas unidades de trabalho que executam partes de um processo. Um processo pode usar apenas uma ou múltiplas threads. Utilizar múltiplas threads pode ser vantajoso em computadores com múltiplos núcleos, pois o trabalho é distribuído e processado em paralelo pelos múltiplos núcleos do processador, tornando a execução mais rápida.
Task é uma classe que representa uma operação assíncrona - ou seja, uma operação que pode ser executada independente do fluxo de execução atual. Task pode retornar um valor, e é um recurso fundamental na programação assíncrona do .NET.
Tasks têm um status baseado em qual etapa da execução ela está. Executar await em uma task incompleta irá parar a execução do algoritmo, a thread será desalocada da execução atual e se tornará livre para executar outras atividades.
Assim que a task for concluída, outra thread será alocada para continuar a execução de onde parou.
Executar await em uma task concluída não irá rodar a task novamente, e a thread não será liberada. Se a task tiver algum valor de resultado, ativar o await retornará o resultado da task de forma síncrona.
Em caso de exception dentro de uma task, executar await irá lançar a exception pra dentro do fluxo principal.
Relacionamento com ThreadPool
Aplicações .NET, incluindo projetos web, possuem um ThreadPool com threads pré-criadas disponíveis para serem utilizados quando o sistema precisar de alocação rápida de threads.
Tais threads são usadas para diversos recursos internos do .NET, e como a quantidade de threads internas (pool) é limitada, não é recomendado usar em operações que levam muito tempo pra acabar - caso contrário o ThreadPool poderá eventualmente ficar vazio e o sistema sofrerá com lentidão ou erros.
Não há uma regra clara sobre qual o tempo de uma operação rápida ou lenta, mas é comumente aceito que uma operação que demora menos que 500ms é considerado rápida o suficiente para ser executado por uma thread do ThreadPool.
Na maioria dos casos, tasks utilizam as threads do ThreadPool para continuar com as atividades. Tenha cuidado quais tipos de operações são executadas por elas para evitar operações de longa duração.
Note que uma task pode executar várias operações assíncronas internamente. Nesses casos, existe alocação de thread quando uma operação assíncrona é concluída e liberação dela assim que precisar aguardar uma operação assíncrona.
Mesmo que uma task possa demorar para ser concluída, cada alocação de thread pode ser curta o suficiente para usar as threads do ThreadPool com segurança.
public async Task RunOperationAsync()
{
// Handled by Thread 1
var firstResult = await FirstFunctionAsync();
// Handled by Thread 2
var secondResult = await SecondFunctionAsync();
// Handled by Thread 3
var thirdResult = await ThirdFunctionAsync();
}
A forma que as tasks utilizam threads muda dependendo de como são criadas, conforme especificado abaixo.
Instanciação via construtor
Criar tasks pelo construtor não é recomendado. Dê preferência ao uso de funções assíncronas ou utilizando os métodos estáticos disponíveis na classe Task, como demonstrado abaixo.
Task.FromResult
Métodos estáticos como Task.FromResult() e propriedades como Task.CompletedTask retornam instâncias de Tasks sem relacionar com nenhum código assíncrono.
Pode ser útil em situações aonde é necessário implementar um método que retorna Task, mas que não possui nenhum tipo de execução assíncrona, ou que o dado já foi fornecido de forma síncrona.
Quando a task é criada desta forma, ela já nasce no estado RanToCompletion. Executar await em tasks concluídas fará com que o resultado seja retornado de forma síncrona.
public async Task<int> GetValueAsync()
{
return await Task.FromResult(1);
}
public async Task ExecuteAsync()
{
// Run synchronously
var value = await GetValueAsync();
}
Task.Run()
Este método aloca uma thread da ThreadPool para executar a operação e não é recomendado para operações de longa duração.
Task<int> task = Task.Run(() => 1 + 1);
var value = await task;
Tome cuidado quando passar um método assíncrono ou um lambda assíncrono no parâmetro, pois ele pode ativar o overload síncrono e gerar uma nova task que não está relacionada com a execução do parâmetro enviado.
Para esses casos, é mais seguro chamar diretamente o método assíncrono ou o lambda assíncrono ao invés de chamar via Task.Run().
Func<Task<int>> asyncFunction = async () => Task.FromResult(1+1);
var value = await asyncFunction();
Task.Factory.StartNew()
Permite a customização de uma task através do TaskCreationOptions.
Quando configurar a task com TaskCreationOptions.LongRunning, ao invés de utilizar uma thread do ThreadPool, será criada uma nova thread independente para rodar a task. Essa configuração é recomendada para operações de longa duração, pois evita a alocação excessiva de threads do ThreadPool.
Criar uma nova thread não é recomedado para operações rápidas, pois o esforço de criar a thread pode custar mais recursos do que executar a própria operação. Nesses casos, as threads do ThreadPool podem ser uma opção melhor.
var task = Task.Factory.StartNew
(
() => LongRunningMethod(),
TaskCreationOptions.LongRunning
);
var value = await task;
Funções assíncronas
Quando uma função assíncrona é chamada, a thread atualmente em uso continuará executando a função de forma síncrona até que seja forçado a aguardar uma operação assíncrona que ainda não está concluída, por exemplo uma operação de I/O ou uma task em execução.
Quando a thread precisa aguardar essa operação assíncrona, a execução é parada e a thread é liberada. Assim que a operação assíncrona é concluída, uma thread do ThreadPool é alocada para continuar a execução de onde ela havia parado.
Note que funções assíncronas nunca criam threads novas, e sempre utilizam threads do ThreadPool.
public async Task ExampleAsync()
{
// Run synchronously by the main thread
var value = 1 + 1;
// Release the thread until the task is completed
await File.WriteLineAsync("path", value.ToString());
// Executed by a thread from the ThreadPool
Console.WriteLine(value.ToString());
}
Também é possível invocar funções assíncronas sem imediatamente aguardar a resposta. Nesse caso, ao invés de liberar a thread imediatamente, a thread atual executará a função invocada até encontrar uma operação assíncrona, e ao encontrar essa operação, ela sairá da função assíncrona e continuará executando o código depois da chamada da função.
Enquanto isso, quando uma das operações assíncronas forem concluídas em background, uma thread do ThreadPool será alocada para continuar a execução da função.
Se o processo atual estava em situação de aguardar o resultado, a thread que foi alocada após a conclusão da operação se tornará a nova thread principal para continuar a execução do processo. Caso contrário, a thread alocada será desalocada e retornará para o ThreadPool.
public async Task<int> ExampleAsync()
{
/*
Since the task was not awaited yet,
the main thread exits this method
to continue running the
next lines of code
*/
await Task.Sleep(1_000);
/*
After the asynchronous operation
completes, the task will be handled
by a thread from the ThreadPool
*/
return value;
/*
At this point, is the program is
halted awaiting this result,
the thread allocated before becomes
the main thread. Otherwise, the
thread returns to the pool.
*/
}
Quando chamar múltiplas funções assíncronas sem await, os códigos escritos depois das operações assíncronas serão executados por threads do ThreadPool, e múltiplas threads poderão ser alocadas simultaneamente.
public async Task ExecuteAsync()
{
var task1 = ExampleAsync();
var task2 = ExampleAsync();
var task3 = ExampleAsync();
await Task.WhenAll(task1, task2, task3);
}
public async Task<int> ExampleAsync()
{
await Task.Sleep(1_000);
// Each task will receive an available task from the ThreadPool
Console.WriteLine("Completed");
}