CodeGym /课程 /C# SELF /比较 Task 和 Thread

比较 Task 和 Thread

C# SELF
第 60 级, 课程 1
可用

1. 介绍

现在是时候弄清楚——Task 和 Thread 到底有啥根本区别?为什么多年来 C# 都建议用 Task 而不是直接管理线程?在哪些场景还能继续手动用线程,什么时候又应该用任务的风格?

如果你觉得“线程”和“任务”这些词在你脑子里开始有点搅在一起、心跳加速——别担心,你不是一个人。就算是有经验的程序员,有时候在并行性和异步性话题上也会糊涂。

我们来把东西分门别类。开始吧!

Task 出现的简短历史

在过去的好日子(在 .span class="code text-user">.NET 4.0 之前),执行并行或“后台”代码的明显方式就是创建新线程。比如,new Thread(() => { ... }).Start(); 线程的好处是直观简单。但它们也很糟糕,因为很多事情都得你自己操心。资源分配、生命周期、异常处理、同步、监控、可扩展性——这些都得开发者操心。编程里我们都想要更多的懒惰嘛!

一切都因任务的到来而改变——Task,来自命名空间 System.Threading.Tasks.Task。任务不是线程。这是更抽象、更灵活的概念。它描述的是未来某个时候要做的工作,可能会并行运行。

2. Thread — “裸线程”

线程 是底层的执行单元,表示操作系统分配的一块资源(自己的栈、执行上下文等)。如果你手动创建线程,你要负责它的启动、结束和生命周期的所有细节。

using System;
using System.Threading;

class Program
{
    static void Main()
    {
        Thread thread = new Thread(() => {
            Console.WriteLine("来自线程的问候!");
        });

        thread.Start();
        thread.Join(); // 等待线程结束
    }
}
  • 这里我们创建了一个线程,在它自己的栈上执行 lambda。
  • 线程启动后我们调用了 Join(),来等待它结束。

问题在哪里?

  • 每个线程都占用内存(栈,约 1MB 左右)。
  • 在 .NET 中不建议手动创建成千上万的线程——系统会吃不消。
  • 如果忘了调用 Join(),主线程可能先结束,程序就“断了”。
  • 线程内部的异常不会自动冒泡到外面——需要专门捕获!
  • 启动一个线程后,没办法“优雅地”取消它(没有 Stop()() 这样的标准方法)。

3. Task — “新一代的任务”

Task 是更聪明的抽象,表示“将来会完成的工作”。在内部,任务通常在线程池 ThreadPool 上执行,这比不断创建新线程高效得多。你不需要手动管理线程的创建,线程池会根据负载自动扩展线程数量。

using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        Task task = Task.Run(() =>
        {
            Console.WriteLine("来自 Task 的问候!");
        });

        await task; // 等待任务完成
    }
}
  • 此处任务并不保证一定在独立线程上运行,但通常会在线程池的线程上执行。
  • 你可以用熟悉的方式等待任务完成(在异步方法里用 await,在同步里用 task.Wait())。

4. Task 和 Thread 的区别是什么?

我们把它们的差异列出来,说明各自适用场景和一些不太明显的陷阱。

Thread Task
抽象 操作系统线程 工作/任务(抽象,可能使用线程)
启动 通过 new Thread(...).Start() 通过 Task.Run(...)、Task.Factory.StartNew(...)、async 方法
直接控制 有(启动、Join、优先级等) 没有,.NET 接管管理
线程池 没有,线程总是新建的 有,通常使用 ThreadPool
资源管理 分配独立栈 资源由池复用
可扩展性 差:对 1000+ 线程不友好 好:成千上万的任务没问题
交互 在操作系统层面是独立线程 可能是当前线程的延续,也可能在 ThreadPool 上
异常 需要显式捕获,否则可能“丢失” 异常保存在 Task 中;可在 await 或 .Wait() 时捕获
取消 没有标准方式 支持通过 CancellationToken
等待结果 通过 Join() 等待 await、.Wait()、.Result
适用场景 特殊场景——UI 线程、长寿命线程 几乎所有后台/并行任务

5. 什么时候用哪个?

什么时候用 Thread?

说实话,在现代 .NET 代码中手动创建线程的情况非常少。下面是一些合理的例子:

  • 需要创建一个长期运行的线程(例如,广播信号的序列化,或从硬件读取数据),并且这个线程“特别”:需要低优先级、独立的区域设置、独立的名字。
  • 有时需要与需要手动管理线程的底层 API 集成。
  • 在极其特殊的场景,比如自定义任务调度器。

在其他大多数情况下,Task 会是更正确、更现代的选择。

什么时候用 Task?

几乎所有需要“后台”或“并行”运行的工作:

  • 任何可以在线程池上运行的后台计算(例如,服务器请求处理、文件解析、邮件发送)。
  • 启动异步操作(async/await)——这些机制返回 Task 或 Task<T>。
  • 组合任务、处理继续(continuations)、链式工作流。
  • 方便的取消、等待和结果收集:Task 支持 CancellationToken,容易和现代 API 集成。
  • 异步 I/O 操作:网络请求、文件操作、数据库访问。

对比

场景 Thread Task
长寿命线程(比如自定义服务) 是 否
大量短时间任务 否 是
异步 I/O 操作(await) 否 是
任务组合、取消、链式 否 是
精细设置优先级和区域设置 是(但少见) 否,只适用于默认任务
简单地把工作分到多个 CPU 核心 有时 是

6. 有用的细节

Task 不一定等于线程!

最强的魔法是:如果你用 Task 去做异步 I/O 操作,是不会创建新线程的!一切“神奇地”转到外部(IO Completion Ports 或其他平台原语)。当你的任务在等待外部资源(文件、网络、数据库)时,线程是空闲的:实际上等待期间没有线程被占用。

Task 和异步(I/O-bound)——await 的魔力

using System;
using System.Net.Http;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        // 异步下载网站内容(I/O-bound)
        HttpClient client = new HttpClient();
        string data = await client.GetStringAsync("https://www.dotnetfoundation.org");
        Console.WriteLine($"收到的字符数: {data.Length}");
    }
}
  • 这里任务(Task<string>)封装了异步 I/O 操作。
  • 线程不会被阻塞——它可以继续做别的事,等到下载完成后方法继续执行。
  • 为这种场景手动创建线程是完全多余且低效的。

Task 和 ThreadPool

当你调用 Task.Run(...) 或使用异步 API(await),.NET 通常会使用线程池 ThreadPool。线程池是一组预先创建好的线程,它们“坐在替补席上”,可以快速接手任何任务。如果工作少,线程可能空闲;工作多时线程会按需增加,但很有节制!这让你的应用能以任务数量扩展,而不会给系统带来过多负担。

通过 new Thread 创建的线程通常是系统中的独立“居民”——它们完成后不会回到池里复用,而是直接结束。这也是为什么 Task 在大规模并行时更高效的原因。

7. 常见错误和陷阱

如果你突然想复古,用纯线程写一切,那你会遇到有趣的冒险:内存泄漏、复杂的同步、无法取消的任务、“挂起”的线程(僵尸进程)、用特殊 API 捕获和处理错误的麻烦。

最重要的是记住:"Task" 很方便、更安全、更现代。在绝大多数 C# 开发场景下,今天没有理由回到手动管理线程。

2
任务
C# SELF, 第 60 级, 课程 1
已锁定
使用 Task 启动任务
使用 Task 启动任务
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION