C# 中的线程与任务 — 有什么区别?

科技   2024-11-30 06:02   上海  


在C#编程中,类(class)是一种让我们可以同时执行任务的方式,允许我们在程序的其他部分继续运行时执行代码。尽管现代C#开发人员通常使用Task来管理并发性,但Thread类提供了更多的线程行为控制,这使得它在需要进行低级别线程操作时非常有用。

虽然ThreadTask都能实现并发(同时做多件事),但它们的工作方式不同,适用于不同的场景。本文将探讨ThreadTask之间的区别,并提供何时使用每种方法的建议。

什么是Thread

线程就像是代码完成任务所遵循的单一路径。通过创建多个线程,您可以同时运行代码的不同部分。例如,如果您希望应用程序在后台加载数据的同时保持对用户交互的响应,线程可以帮助实现这一点。

using System;
using System.Threading;

class Program
{
static void Main()
{
Thread myThread = new Thread(MyMethod);
myThread.Start(); // 启动线程
}

static void MyMethod()
{
Console.WriteLine("这是在一个单独的线程上运行的!");
}
}

使用线程的场景:

  • 需要对线程执行进行低级控制。

  • 有对线程优先级或状态的特定要求。

  • 处理已使用线程的旧代码。

属性和方法

以下是Thread类的关键属性:

  • IsBackground:指示线程是否为后台线程。后台线程不会阻止进程终止。如果只有后台线程在运行,进程将退出。

  • Name:您可以为线程设置一个名称,以便在多线程应用程序中更容易调试和日志记录。

  • Priority:设置线程的优先级级别,影响线程的执行顺序。包括选项如ThreadPriority.HighestThreadPriority.BelowNormalThreadPriority.Normal等。

  • IsAlive:返回一个布尔值(true/false),指示线程是否已启动且尚未终止。可用于在执行依赖于其状态的操作之前检查线程状态。

  • ThreadState:提供线程的当前状态。以下是状态的快速概述:

    • Unstarted:线程创建但尚未启动的初始状态。

    • Running:线程正在执行其代码。已启动且系统调度器已为其分配了CPU时间。

    • WaitSleepJoin:线程暂时处于非活动状态,因为它正在等待另一个线程完成(通过Join())、处于睡眠状态(使用Sleep())、或等待另一个线程的信号(通过同步原语如Monitor.Wait())。

    • Background:线程是后台线程,后台线程不会阻止进程终止。当所有前台线程结束时,后台线程会自动停止。

    • SuspendRequested(已弃用):请求线程暂停,意味着它应暂时暂停执行。

    • Suspended(已弃用):线程已暂停且将不会执行,直到恢复执行。由于可能导致死锁和不稳定性,已在较新版本的.NET中弃用。

    • StopRequested:线程被请求停止,但尚未停止。

    • Stopped:线程已完成其执行。线程方法返回或因未处理的异常退出。

    • AbortRequested:使用Abort()方法请求线程终止,但这并不意味着线程已停止执行;仅是停止请求。

    • Aborted:线程已因中止请求成功终止,这可能导致问题,因为它可能无法正确清理资源。

线程生命周期方法概览:

  • Start():此方法启动线程的执行。一旦调用,线程从Unstarted状态过渡到Running状态。创建线程后,您应调用Start()以开始其执行。线程将执行其ThreadStartParameterizedThreadStart委托中定义的代码。

  • Join():此方法阻塞调用线程,直到调用Join()的线程完成其执行。它确保调用线程等待指定线程完成。在需要同步线程时使用。

  • Abort()(已弃用):此方法用于突然终止线程。它在目标线程中引发一个ThreadAbortException,允许其终止,但可能会导致资源泄漏和状态不一致问题。在现代应用中不推荐使用,因为其不可预测性和可能导致共享资源处于不一致状态。考虑使用协作取消模式来控制线程执行。

Thread类还有其他方法如Suspend()Resume(),但由于它们已被弃用,这里不再提及。

这些属性和生命周期方法可以显著增强您在C#应用程序中管理线程的能力。

以下是一个完整的C#代码示例,展示了每个成员(属性和方法)的用法:

using System;
using System.Threading;

class Program
{
static void Main()
{
// Unstarted状态
Thread thread = new Thread(ThreadMethod);

thread.IsBackground = true;
thread.Name = "MyThread";
thread.Priority = ThreadPriority.AboveNormal;

// 启动线程(过渡到Running状态)
thread.Start();

if (thread.IsAlive)
{
Console.WriteLine("线程仍在运行。");
}

// 等待线程完成(将其连接)
thread.Join(); // 线程进入Stopped状态
}

static void ThreadMethod()
{
Console.WriteLine("线程正在运行...");

// 一些工作
for (int i = 0; i < 3; i++)
{
Console.WriteLine($"工作中... {i + 1}");
Thread.Sleep(1000); // 线程进入WaitSleepJoin状态
}

Console.WriteLine("即将进入阻塞状态...");
// 阻塞此线程
object lockObject = new object();
lock (lockObject)
{
Console.WriteLine("线程在临界区中。");
Thread.Sleep(1000);
} // 线程退出临界区,过渡回Running状态

Console.WriteLine("工作完成。线程正在停止...");
}
}

重要提示

通常不建议使用Thread.Abort()方法,因为它可能导致不可预测的行为,并不是一种干净的线程终止方式。最好使用取消令牌或其他同步机制来控制线程执行。此示例仅为教育目的,以演示各种线程状态。

什么是Task

Task是.NET Framework 4中引入的一种更高级别的构造,提供了一种在C#中并发运行代码的更灵活的方式。作为Task Parallel Library(TPL)的一部分,任务在幕后管理线程,使得编写异步代码更为容易。在不直接管理线程的情况下运行后台操作的场景中,Task非常适用。

using System;
using System.Threading.Tasks;

class Program
{
static void Main()
{
Task myTask = Task.Run(() => MyTask());
}

static void MyTask()
{
Console.WriteLine("这是在一个单独的任务上运行的!");
}
}

请注意,我们仅声明了一个可运行的任务并将其加入线程池。同时,主线程完成了它的工作而不等待该任务完成。因此,在运行上面的示例时,命令行可能不会显示任何内容。我们需要在主线程上使用类似Console.ReadLine()的方法来等待任务完全完成。

使用任务的场景:

  • 希望简化代码并轻松管理并发性。

  • 执行多个异步操作。

  • 需要更好的错误处理和取消功能。

属性和方法

  • Id:每个任务在创建时分配的唯一ID。

  • Result:获取完成任务的结果值(对于Task<TResult>)。如果任务尚未完成或发生故障,此属性将抛出InvalidOperationException

  • Exception:获取导致任务失败的AggregateException类型的异常。如果任务成功完成,则返回null

  • Status:获取任务的当前状态,可以是多个枚举值之一:

    • Created:任务已实例化但尚未计划。

    • WaitingForActivation:任务正在等待激活和计划。

    • WaitingToRun:任务已被安排执行,但尚未开始执行。

    • Running:任务当前正在执行。

    • Completed(RanToCompletion):任务已完成执行(成功或出错)。

    • WaitingForChildrenToComplete:任务完成后,等待所有附加的子任务完成。

    • Faulted:任务因未处理的异常而终止。

    • Canceled:任务已被取消。

  • IsCompleted:指示任务是否已完成(无论是成功、故障或被取消)。

  • IsFaulted:指示任务是否因未处理的异常而完成。

  • IsCanceled:指示任务是否已被取消。

  • CreationOptions:获取用于创建任务的选项。

  • Run():启动执行指定操作的任务。

  • ContinueWith():创建一个延续任务,该任务将在当前任务完成后运行。延续任务可以基于主任务的状态进行条件执行。

  • Wait():阻塞调用线程,直到任务完成。

  • WaitAll (静态方法):阻塞调用线程,直到所有提供的任务完成。

  • WhenAll (静态方法):创建一个在所有提供的任务完成时完成的任务。

  • WhenAny (静态方法):创建一个在任意一个提供的任务完成时完成的任务。

以下是一个简单的示例,演示了如何使用这些属性和方法:

using System;
using System.Threading;
using System.Threading.Tasks;

class Program
{
static async Task Main()
{
var cts = new CancellationTokenSource();

// 创建一个任务
Task<int> task = Task.Run(() =>
{
// 执行一些操作
Thread.Sleep(2000);
return 42;
}, cts.Token);

// 如果需要,可以取消任务
// cts.Cancel();

// 等待任务完成并检查属性
try
{
int result = await task;
Console.WriteLine($"任务成功完成,结果:{result}");
}
catch (AggregateException ex)
{
Console.WriteLine("任务遇到异常:");
foreach (var innerEx in ex.InnerExceptions)
{
Console.WriteLine(innerEx.Message);
}
}
finally
{
Console.WriteLine($"任务状态:{task.Status}");
Console.WriteLine($"是否出错:{task.IsFaulted}");
Console.WriteLine($"是否取消:{task.IsCanceled}");
}
}
}

你可以通过 Wait() 或在使用异步编程时使用 await 来等待任务完成:

// 异步等待任务完成
int result = await task;

async/await 模式简化了异步编程,使代码更易读和维护。

任务可以通过 Task<TResult> 返回值:

Task<int> calculationTask = Task.Run(() => 
{
// 执行一些计算
return 10;
});

// 获取计算结果
int result = await calculationTask;

任务中的异常可以通过等待任务时捕获,或者在任务完成后检查其 Exception 属性来处理:

try 
{
await task;
}
catch (AggregateException ex)
{
// 处理异常
}

可以使用 CancellationToken 取消任务。你可以将令牌传递给任务并在任务内检查取消状态:

var cts = new CancellationTokenSource();
Task myTask = Task.Run(() =>
{
while (!cts.Token.IsCancellationRequested)
{
// 执行操作
}
}, cts.Token);

你可以创建一个在另一个任务完成后执行的延续任务,这对于将任务链式连接在一起很有用。

Task<int> initialTask = Task.Run(() => {
// 初始任务
return 10;
});

Task continuationTask = initialTask.ContinueWith(previousTask => {
// 在 initialTask 完成后运行
int result = previousTask.Result;
Console.WriteLine($"结果:{result}");
});

你可以组合多个任务并一次性处理它们的结果,这在某些场景下可以提升性能。Task.WhenAll 等待所有指定任务完成,而 Task.WhenAny 等待任意一个指定任务完成。

var task1 = Task.Run(() => {
Thread.Sleep(1000);
return 1;
});

var task2 = Task.Run(() => {
Thread.Sleep(2000);
return 2;
});

var task3 = Task.Run(() => {
Thread.Sleep(3000);
return 3;
});

// 等待所有任务完成
var allTasks = await Task.WhenAll(task1, task2, task3);
Console.WriteLine($"总和:{allTasks.Sum()}");

var firstCompleted = await Task.WhenAny(task1, task2, task3);
Console.WriteLine($"第一个完成的任务结果:{firstCompleted.Result}");

线程与任务的主要区别:

  1. 使用方便性
    线程:直接管理线程可能比较复杂,需要手动处理线程状态、优先级和同步。
    任务:任务简化了这种复杂性,自动处理线程池,无需创建或管理线程。

  2. 性能
    线程:每个线程都会消耗系统资源,创建过多线程可能导致性能问题。
    任务:任务使用线程池,可以在较少的线程上运行多个任务,对于执行许多短时间操作更有效。

  3. 错误处理
    线程:线程遇到错误可能会终止,但处理这些错误可能较为复杂。
    任务:任务提供了更好的错误处理方式,当任务失败时可以轻松使用 try-catch 块捕获。

  4. 返回值
    线程:线程在完成时不返回值,如果需要结果,需要额外管理。
    任务:任务可以轻松返回值,通过指定类型,可以直接获取结果。

  5. 取消
    线程:取消线程不太简单,通常需要在线程内检查取消请求。
    任务:任务可以更容易地通过 CancellationToken 取消,适合长时间操作。

最佳实践:

C# 中的任务并行库 (TPL) 旨在提高代码可读性、改进应用程序响应能力并确保稳健的错误处理。但要有效利用任务,需要了解最佳实践,以避免可能导致性能问题、死锁和未处理异常的常见陷阱。

  • 使用 async 和 await
    优先使用 async 和 await,而不是手动管理线程,使代码更易读和维护。

  • 避免阻塞调用
    避免在任务上调用 .Wait() 或 .Result 之类的阻塞操作,这可能导致死锁,尤其在 UI 应用中。

  • 正确处理异常
    处理可能由任务引发的异常。使用 try-catch 块捕获并检查 AggregateException 的内部异常。

  • 使用取消令牌
    在长时间任务中实现取消,允许用户取消可能耗时的操作。

  • 限制并行度
    使用 Task.WhenAll 时要考虑并发任务的数量,过多任务可能耗尽系统资源。必要时使用 SemaphoreSlim 限制并发。

在 C# 中选择 Thread 或 Task 对应用程序的性能和可维护性有很大影响。线程提供了更细粒度的控制,适用于需要低级管理的特定场景。然而,由于线程状态管理、同步复杂性及潜在的性能问题,它们并非总是最佳选择。

相比之下,Task 提供了更高的抽象,简化了异步编程。它们更易用,能够更优雅地处理错误,且易于与取消和返回值配合使用。对于大多数现代应用,尤其是涉及多个并发操作或需要响应的场景,Task 是推荐的选择。

如果你喜欢我的文章,请给我一个赞!谢谢


架构师老卢
资深软件架构师, 分享编程、软件设计经验, 教授前沿技术, 分享技术资源(每天发布电子书),每天进步一点点...
 最新文章