.NET 进阶 —— 深入理解线程(3)ThreadPool 与 Task 入门:从手动线程到池化任务的升级

一、ThreadPool线程池

ThreadPool线程池是.NET Framework 2.0时代的产物,.NET CORE时代下用的很少,但是我们还是要理解ThreadPool线程池,因为有助于理解后续的Task任务。

1.1 为什么要用到ThreadPool线程池?

如果我们在高并发场景下使用Thread创建线程,就会出现一个致命的问题,可以看下面的代码:

cs 复制代码
            // 使用Thread手动创建线程
            {
                Thread thread1 = new Thread(() =>
                {
                    Console.WriteLine($"创建了一个线程,线程ID为:{Thread.CurrentThread.ManagedThreadId}");
                });
                thread1.Start();
                Thread thread2 = new Thread(() =>
                {
                    Console.WriteLine($"创建了一个线程,线程ID为:{Thread.CurrentThread.ManagedThreadId}");
                });
                thread2.Start();
                // 高并发场景下:
                for (int i = 0; i < 100; i++)
                {
                    new Thread(() =>
                    {
                        Console.WriteLine($"创建了一个线程,线程ID为:{Thread.CurrentThread.ManagedThreadId}");
                    }).Start();
                }
            }

运行结果:

结果非常牛逼克拉斯啊,直接创建了一百多个线程,几乎是没有重复的。并且这里只是模拟100个并发,假如有一万个呢?要知道我们普通的电脑总共的线程才只有几千个。所以用Thread手动创建线程是非常不可取的。那咋办呢?

为了不让我们自己手动创建线程,.NET非常贴心的帮我们创建好了线程。它把这些创建好的线程放在一个池子里,如果我们需要用到其他线程,那就从池子里拿就行,不需要我们自己创建。

.NET将这样装有很多线程的池子命名为线程池ThreadPool ,我们来看看线程池的强大之处(现在不需要能理解下面的代码,只需要关注执行结果!!!):

cs 复制代码
            // 使用线程池的线程
            {
                for (int i = 0; i < 100; i++)
                {
                    ThreadPool.QueueUserWorkItem(_ =>
                    {
                        Console.WriteLine($"创建了一个线程,线程ID为:{Thread.CurrentThread.ManagedThreadId}");
                    });
                }
                Thread.Sleep(2000); // 等任务完成
            }

运行结果:

可以看到,效果非常明显,原本要创建出100多个线程,现在,只需要线程池给我们创建好的那几个线程就行了,线程复用率大大提高!

1.2 ThreadPool线程池的工作原理

看完 1.1 的对比,是不是好奇:为啥线程池能这么牛?100 个任务只复用几个线程就搞定?

首先,用了线程池之后,我们就不需要自己去管理线程的生命周期了:

Thread时,你得手动new Thread()Start()启动、甚至手动等线程完成;但线程池帮你包办了所有琐事:

  • 你只需要调用ThreadPool.QueueUserWorkItem把任务 "丢进池子",剩下的启动、执行、复用、回收全由线程池自动处理;
  • 就像你点外卖,只需要下单,不用管骑手是谁、怎么取餐、送完这单去哪 ------ 骑手(线程)的调度全由平台(线程池)管。

其次,线程池就是一个 "预创建 + 可复用" 的线程仓库,妈妈再也不用担心我滥用线程了:

线程池在程序启动后,会提前创建少量 "待命线程" (比如默认先创建 2-4 个),就像公司提前招好的 "待命员工"------

  • 当你用ThreadPool.QueueUserWorkItem丢任务时,线程池不会新建线程,而是直接把任务分给 "待命员工"(已有线程);
  • 这个线程执行完当前任务后,不会销毁,而是回到 "待命状态",等着接下一个任务;
  • 只有当待命线程全忙、任务还在排队时,线程池才会 "按需新建少量线程"(但不会无限制建),任务少了还会慢慢销毁多余线程。

ThreadThreadPool的对比:

操作场景 手动用 Thread 用 ThreadPool
100 个任务来了 建 100 个新线程(100 个新骑手) 复用几个线程(几个骑手跑 100 单)
任务执行完 100 个线程全销毁(骑手全离职) 线程回到待命状态(骑手等下一单)
资源开销 内存 / CPU 直接飙高 资源消耗只有 Thread 的几十分之一

1.3 ThreadPool的核心用法(如何把任务交给线程池)

基于前面的原理,我们知道线程池的核心价值是 "复用线程、自动调度"------ 我们不用关心线程的创建、销毁,并且我们无法创建线程池,线程池是由系统创建好的,我们只需要把要执行的 "任务" 交给线程池就行, 。而把任务传给线程池的核心方法,就是 ThreadPool.QueueUserWorkItem(...)

在讲具体用法前,先搞懂一个基础约定:线程池要求 "交给它的任务必须符合固定格式" ,这个格式由WaitCallback委托定义,是线程池和我们的 "沟通规则"。

前置:线程池的任务格式 ------WaitCallback

线程池规定:要扔给它执行的任务,必须是 "无返回值、接收一个object类型参数" 的方法。这个规则被封装成了WaitCallback委托,源码核心定义如下(不用记,理解规则就行):

cs 复制代码
// 线程池的"任务格式约定":无返回值,参数为object(可传null)
public delegate void WaitCallback(object? state);

我们后续调用QueueUserWorkItem时,传的任务本质都是符合这个格式的方法(包括匿名方法)。


1.3.1 基础用法:无参数的任务

这个静态方法的签名: public static bool QueueUserWorkItem(WaitCallback callBack),接收WaitCallback参数,返回bool类型,返回的Bool类型如果用不到可以不接收。

这里的WaitCallback委托的签名我们再前面介绍过了,这里就直接创建这样的一个委托,然后把它传递到QueueUserWorkItem里就行:

cs 复制代码
            // 只有一个WaitCallback的用法:
            {
                WaitCallback waitCallback = obj =>
                {
                    Console.WriteLine($"我被分配给了线程池里的线程,这个线程的id是:{Thread.CurrentThread.ManagedThreadId}");
                };

                ThreadPool.QueueUserWorkItem(waitCallback); 

                //Thread.Sleep(2000);

            }

重点注意: 可以看到我这里特地写了这样一行代码://Thread.Sleep(2000);,我注释了这个等待代码,于是执行结果是这样的:

什么都没有执行! 这可不是因为代码写错了,而是因为线程池里创建好的线程,默认都是后台线程 。而进程不会等待后台线程,只会等待前台线程,所以这里主线程执行完了后,就直接杀死进程了,程序退出,线程池里的线程任务不会被执行,于是就什么也没有打印!!!!

当我们把//Thread.Sleep(2000);放开注释后,主线程会在这里卡2秒,2秒后,线程池里的线程大概率是做完了这个任务的,所以就可以正常打印了:

Lambda作为WaitCallback任务传递给ThreadPool.QueueUserWorkItem方法:

cs 复制代码
            {
                for (int i = 0; i < 100; i++)
                {
                    ThreadPool.QueueUserWorkItem(obj =>
                    {
                        Console.WriteLine($"我被分配给了线程池里的线程,这个线程的id是:{Thread.CurrentThread.ManagedThreadId}");
                    });
                }
                Thread.Sleep(5000);
            }

执行结果:


1.3.2 进阶用法:带参数的任务

这个静态方法的签名: public static bool QueueUserWorkItem(WaitCallback callBack, object? state),接收一个WaitCallback参数和一个object类型的可空参数,返回bool类型,返回的Bool类型如果用不到可以不接收。

cs 复制代码
            {
                WaitCallback waitCallback = obj =>
                {
                    Console.WriteLine($"我是{obj},我被线程{Thread.CurrentThread.ManagedThreadId}执行");
                };
                ThreadPool.QueueUserWorkItem(waitCallback, "小王");
                Thread.Sleep(2000);
            }

运行结果

一个更贴近实战的例子:

cs 复制代码
            public class OrderInfo
            {
               public int OrderId { get; set; }    // 订单编号
               public string UserName { get; set; } // 用户名
               public decimal Amount { get; set; }  // 订单金额
            }
           {
               List<OrderInfo> orders = new List<OrderInfo>()
               {
                   new OrderInfo() { OrderId = 1, UserName = "小王", Amount = 11.5m },
                   new OrderInfo() { OrderId = 2, UserName = "小明", Amount = 12.5m },
                   new OrderInfo() { OrderId = 3, UserName = "小张", Amount = 13.5m },
                   new OrderInfo() { OrderId = 4, UserName = "小李", Amount = 14.5m }
               };
               ThreadPool.QueueUserWorkItem(obj =>
               {

                   var orders = obj as List<OrderInfo>;
                   foreach (var orderInfo in orders)
                   {
                       Console.WriteLine($"id:{orderInfo.OrderId},username = {orderInfo.UserName},Amount = {orderInfo.Amount}");
                   }
               },orders);

               Thread.Sleep(2000);
               Console.WriteLine("所有订单处理完毕");
           }

运行结果:

1.4 ThreadPool的致命缺点

前面学了 ThreadPool 的用法,但它有个实际开发完全绕不开 的致命缺点 ------我们在主线程并不知道子线程什么时候执行完成,只能无脑使用Thread.Sleep()来猜子线程执行完了没

举个例子:需求:计算 10+20 的和,在主线程中拿到结果并打印。

用 ThreadPool 实现:如果主线程等待的事件太短了,结果就拿不到!

cs 复制代码
            {

                int a = 10, b = 20;
                int sum = 0; // 想存计算结果

                // 用ThreadPool执行计算任务
                ThreadPool.QueueUserWorkItem(obj =>
                {
                    Thread.Sleep(1000);
                    sum = a + b; // 线程内计算完成,赋值给sum
                    Console.WriteLine($"线程池内计算:{a}+{b}={sum}");
                });

                Thread.Sleep(500);
                Console.WriteLine($"主线程想拿结果:{a}+{b}={sum}");

            }

运行结果

ini 复制代码
主线程想拿结果:10+20=0

问题 1:等待时间不匹配,主线程拿不到正确结果

  • 线程池任务里加了Thread.Sleep(1000):意味着任务要等 1 秒后,才会给sum赋值为 30;
  • 主线程只加了Thread.Sleep(500):只等 0.5 秒就去打印sum------ 此时线程池任务还在 "睡觉",sum还是初始值 0,所以主线程打印10+20=0

问题 2:主线程退出导致线程池任务直接中断

主线程打印完sum=0后,整个代码块执行完毕,进程直接退出 (控制台显示 "已退出,代码为 0")------ 而线程池任务还没等到 1 秒的睡眠结束,连Console.WriteLine($"线程池内计算:{a}+{b}={sum}")这行都没机会执行,直接被杀死了。

这就是ThreadPool最坑的地方:你永远没法精准控制 "任务是否执行完",也没法让主线程 "等任务真的完成再退出"


对比 Task:一行代码解决所有问题(可靠又优雅)

同样的需求,用Task改写后,不管是等待还是拿结果,都 100% 可靠,还不会让进程提前退出:

cs 复制代码
{
    int a = 10, b = 20;

    // Task声明:执行一个返回int的任务,哪怕任务要sleep1000ms
    Task<int> sumTask = Task.Run(() =>
    {
        Thread.Sleep(1000); // 模拟任务耗时
        int sum = a + b;
        Console.WriteLine($"Task内计算:{a}+{b}={sum}");
        return sum; // 直接返回结果,不用共享变量
    });

    // 核心:精准等待Task完成(不用猜Sleep时间,到底是睡1秒还是睡半秒),拿到结果
    int sum = sumTask.Result; 
    Console.WriteLine($"主线程拿到结果:{a}+{b}={sum}"); // 永远是30!
}

运行结果(100% 可靠):

ini 复制代码
Task内计算:10+20=30
主线程拿到结果:10+20=30

二、Task 任务 ------ ThreadPool 的全能升级版

既然ThreadPool有 "拿不到可靠结果、等待靠瞎猜、进程提前退出" 这些致命缺点,那.NET Core 时代我们该用什么?答案就是Task------ 它完全继承了ThreadPool"线程复用、控数量" 的优点,又把所有痛点全解决了,是实际开发中多线程编程的首选方案

2.1 Task 的核心定位:为啥能替代 ThreadPool?

先一句话说清关系:Task 是 ThreadPool 的 "高级封装" ------Task 底层还是用 ThreadPool 的线程执行任务,但给我们提供了更友好、更可靠的 API,完美解决 ThreadPool 的 4 大痛点:

痛点(ThreadPool) 解决方案(Task) 新手直观感受
任务无返回值 Task 直接返回强类型结果,不用共享变量 不用再靠全局变量传值,拿结果更靠谱
等待靠 Thread.Sleep Result/Wait/await 精准等待任务完成 不用瞎猜 Sleep 多久,任务完了才继续
进程提前终止任务 主线程会等 Task 执行完,不会中途杀死任务 任务不会被莫名中断,逻辑能完整执行
异常 "悄悄吞掉" 可捕获 Task 的异常,主线程能感知任务失败 哪里错了能直接看到,不用猜原因

2.2 Task 核心语法

Task 的用法分两类:「手动创建(new Task ()+Start ())」和「快捷创建(Task.Run ())」,先学手动创建(理解更透),再学快捷方式(实际开发常用)。

2.2.1 基础款:new Task() + Start()(手动创建+延时启动)

① 构造函数 1:无参、无返回值(最简单的任务)

适用场景:只干活不拿结果(比如打印、清理文件、写日志)。

cs 复制代码
            // 用Task定义个任务
            // Task构造函数的参数是一个Action委托,我们直接用Lambda表达式传递
            Task task = new Task(() =>
            {
                Console.WriteLine("子线程开始执行任务");
                Console.WriteLine($"线程Id:{Thread.CurrentThread.ManagedThreadId}");
                Console.WriteLine("子线程结束执行任务");
            });

            Console.WriteLine("主线程不等待,直接执行Start()");
            // 启动任务
            task.Start();
            Console.WriteLine("遇到了Wait(),主线程开始等待子线程执行结束,并且不需要猜子线程什么时候结束完毕");
            task.Wait();
            Console.WriteLine("Wait()结束,主线程结束等待子线程执行结束");

执行结果:

scss 复制代码
主线程不等待,直接执行Start()
遇到了Wait(),主线程开始等待子线程执行结束,并且不需要猜子线程什么时候结束完毕
子线程开始执行任务
线程Id:6
子线程结束执行任务
Wait()结束,主线程结束等待子线程执行结束
  • new Task(...) 只是 "定义任务",不是 "执行任务";
  • Start() 是 "启动任务" 的唯一方式(漏写就白定义);
  • Wait() 是 "精准等待",替代 ThreadPool 的 Thread.Sleep 瞎猜。
② 构造函数 2:带参数(给任务传递数据)

适用场景:任务需要外部数据(比如给指定用户发消息、处理指定订单)。

cs 复制代码
                // 此时的构造函数是:Task.Task(Action<object?> action, object? state)
                // 所以我们要传递一个带object类型的委托,还要传递一个object类型的参数
                Task task = new Task(obj =>
                {
                    string nameof = obj as string;
                    Console.WriteLine($"欢迎{obj}");
                },"张三");

                // 从线程池中找个线程执行这个task任务
                task.Start();
                // 主线程阻塞等待子线程
                task.Wait();

运行结果

cs 复制代码
欢迎张三

要点

  • 参数是 object 类型,任务内要手动转换为目标类型(用 is/as 最安全);
  • 可传任意复杂参数(比如 new OrderInfo { OrderId = 1001 }),只需在任务内转换为 OrderInfo
③ 构造函数 3:可以取消

适用场景:任务可能需要中途终止(比如用户取消操作、超时、条件不满足)。

核心工具:CancellationTokenSource(简称 CTS,"叫停哨子")+ CancellationToken("令牌",传给任务让它能听到叫停)。

cs 复制代码
                using CancellationTokenSource cts = new CancellationTokenSource();

                // 此时构造函数是:Task(Action, CancellationToken)
                // 意味着我们要传递一个Action 和一个CancellationToken
                Task task = new Task(() =>
                {
                    for (int i = 0; i < 10; i++)
                    {
                        if (cts.Token.IsCancellationRequested)
                        {
                            Console.WriteLine($"子线程{Thread.CurrentThread.ManagedThreadId} 任务被取消,不执行任务了......");
                            return;
                        }
                        Console.WriteLine($"子线程{Thread.CurrentThread.ManagedThreadId} 执行任务中......");
                        Thread.Sleep(500);
                    }
                }, cts.Token);

                // 找了个空闲的子线程执行任务
                Console.WriteLine("遇到了Start(),找了个空闲的子线程执行task任务");
                task.Start();

                Thread.Sleep(2000);
                Console.WriteLine("遇到了Cancel,子线程的任务被取消了");
                cts.Cancel();

                Console.WriteLine("遇到了Wait(),如果子线程没执行完,主线程阻塞等待,如果执行完了直接走");
                task.Wait();

运行结果

erlang 复制代码
遇到了Start(),找了个空闲的子线程执行task任务
子线程6 执行任务中......
子线程6 执行任务中......
子线程6 执行任务中......
子线程6 执行任务中......
遇到了Cancel,子线程的任务被取消了
遇到了Wait(),如果子线程没执行完,主线程阻塞等待,如果执行完了直接走
子线程6 任务被取消,不执行任务了......

要点

  • 取消任务的核心是「任务内主动用if检测令牌」,只喊 Cancel() 不检测,任务会继续干;
  • CTS 用完要用using 自动释放,不然会占内存。

3.1.4 构造函数 4:有返回值(Task,拿任务结果)

适用场景:任务干完要拿结果(比如计算求和、查询数据、调用接口返回值)------ 这是替代 ThreadPool 的核心优势!

cs 复制代码
                // 使用有返回值的构造函数Task<T>(Func<int> function)
                Task<int> task = new Task<int>(() =>
                {
                    Console.WriteLine("任务开始,我负责计算20+10");
                    return 20+10;
                });

                Console.WriteLine("遇到Start:找了个线程开始执行task任务");
                task.Start();

                Console.WriteLine("遇到Result:主线程阻塞了,等待子线程执行完毕");
                int sum = task.Result;

                Console.WriteLine($"子任务执行完毕了,结果是{sum}");

运行结果

cs 复制代码
遇到Start:找了个线程开始执行task任务
遇到Result:主线程阻塞了,等待子线程执行完毕
任务开始,我负责计算20+10
子任务执行完毕了,结果是30

要点

  • 有返回值的任务要用 Task<T>(T 是结果类型,比如 Task<string> 返回字符串,Task<OrderInfo> 返回自定义对象);
  • Result 是 "阻塞式获取结果":主线程会等任务干完,再拿到结果(不用像 ThreadPool 那样靠共享变量传值)。

3.2 快捷款:Task.Run ()(创建 + 立即启动,实际开发首选)

Task.Run()new Task()+Start() 的快捷写法 ------ 少写一行 Start(),直接创建并启动任务,90% 的业务场景用这个就够。

3.2.1 无返回值(替代 new Task (()=>{...}).Start ())

cs 复制代码
// 一行搞定:创建+启动无返回值任务
            Task task = Task.Run(() =>
            {
                Console.WriteLine("开始执行任务啦~~");
            });
            Console.WriteLine("主线程开始等待");
            task.Wait();
            Console.WriteLine("主线程等待结束");

执行结果:

复制代码
主线程开始等待
开始执行任务啦~~
主线程等待结束    

3.2.2 有返回值(替代 new Task(()=>{...}).Start())

cs 复制代码
            Task<int> task = Task.Run(() =>
            {
                return DateTime.Now.Year;
            });

            int sum = task.Result;
            Console.WriteLine($"今年是{sum}年");

3.2.3 带参数(小技巧:用闭包传参,比构造函数更简洁)

cs 复制代码
string userName = "李四"; // 要传的参数
Task paramTask = Task.Run(() =>
{
    // 直接用外部变量(闭包),不用转obj,更简洁
    Console.WriteLine($"✅ 欢迎 {userName}!");
});
paramTask.Wait();

四、Task 的等待方式(精准等待,不用瞎猜)

除了单个任务的 Wait(),Task 还支持「批量等待」,满足复杂场景:

4.1 等待单个任务:Task.Wait ()

cs 复制代码
Task task = Task.Run(() => Thread.Sleep(1000));
task.Wait(); // 等这个任务干完

4.2 等待所有任务:Task.WaitAll ()

适用场景:批量任务都干完才继续(比如批量查询数据,汇总所有结果)。

cs 复制代码
// 创建3个任务
Task t1 = Task.Run(() => { Thread.Sleep(500); Console.WriteLine(" t1干完"); });
Task t2 = Task.Run(() => { Thread.Sleep(1000); Console.WriteLine(" t2干完"); });
Task t3 = Task.Run(() => { Thread.Sleep(1500); Console.WriteLine(" t3干完"); });

// 等所有任务干完(等最慢的t3,耗时1.5秒)
Task.WaitAll(t1, t2, t3);
Console.WriteLine(" 所有任务都干完了,汇总结果");

4.3 等待任意一个任务:Task.WaitAny ()

适用场景:多个任务抢跑,拿到第一个结果就继续(比如多源查询,缓存比数据库快,拿缓存结果就返回)。

cs 复制代码
// 创建2个任务(缓存查询快,数据库查询慢)
Task cacheTask = Task.Run(() => { Thread.Sleep(500); Console.WriteLine(" 缓存查询完成"); });
Task dbTask = Task.Run(() => { Thread.Sleep(2000); Console.WriteLine(" 数据库查询完成"); });

// 等第一个任务干完(cacheTask先完成,耗时0.5秒)
Task.WaitAny(cacheTask, dbTask);
Console.WriteLine(" 拿到第一个结果,直接返回");

五、Task 的异常处理(不吞异常,新手能定位问题)

ThreadPool 的异常会 "悄悄消失",Task 会把异常包装在 AggregateException 里,主线程能精准捕获。

5.1 单个任务的异常捕获

cs 复制代码
Task errorTask = Task.Run(() =>
{
    // 模拟任务出错:除以0
    int num = 0;
    int result = 10 / num;
});

try
{
    errorTask.Wait(); // 调用Wait/Result时,异常会抛到主线程
}
catch (AggregateException ex)
{
    // 遍历InnerExceptions,拿到真实异常
    foreach (var innerEx in ex.InnerExceptions)
    {
        Console.WriteLine($"捕获异常:{innerEx.GetType().Name} - {innerEx.Message}");
    }
}

运行结果

复制代码
捕获异常:DivideByZeroException - 尝试除以零。

5.2 批量任务的异常捕获

cs 复制代码
Task t1 = Task.Run(() => { throw new ArgumentNullException("参数为空"); });
Task t2 = Task.Run(() => { throw new IndexOutOfRangeException("索引越界"); });

try
{
    Task.WaitAll(t1, t2);
}
catch (AggregateException ex)
{
    foreach (var innerEx in ex.InnerExceptions)
    {
        Console.WriteLine($"🚫 异常:{innerEx.Message}");
    }
}

六、Task 的进阶:async/await(语法糖,简化异步代码)

async/await 是 Task 的 "语法糖"------ 不用写 Wait()/Result,让异步代码看起来像同步代码,新手学完基础后必学。

6.1 基础用法(异步计算求和)

cs 复制代码
// 步骤1:定义异步方法(标记async,返回Task/Task<T>)
async Task<int> CalculateSumAsync()
{
    // 步骤2:await 替代 Wait()/Result,非阻塞等待
    int sum = await Task.Run(() =>
    {
        Thread.Sleep(1000);
        return 10 + 20;
    });
    return sum;
}

// 步骤3:调用异步方法(控制台程序需Wait,WinForm/Web不用)
async Task Main()
{
    int sum = await CalculateSumAsync();
    Console.WriteLine($"🏁 结果:{sum}"); // 输出30
}

// 执行入口
Main().Wait();

核心规则

  • 方法必须标记 async
  • 返回值是 Task(无返回值)或 Task<T>(有返回值);
  • await 等待 Task 完成,代码更简洁,不阻塞线程。
相关推荐
CreasyChan2 小时前
unity四元数 - “处理旋转的大师”
unity·c#·游戏引擎
wuguan_2 小时前
C#索引器
c#·索引器
聪明努力的积极向上2 小时前
【设计】分批查询数据通用方法(基于接口 + 泛型 + 定点复制)
开发语言·设计模式·c#
张人玉3 小时前
C# WPF 折线图制作(可以连接数据库)
数据库·c#·wpf·sugar
kylezhao20193 小时前
C# 中的委托(Delegate)与事件(Event)
c#·c#上位机
步步为营DotNet4 小时前
深度解析.NET中属性(Property)的幕后机制:优化数据访问与封装
java·算法·.net
lzhdim4 小时前
C#应用程序取得当前目录和退出
开发语言·数据库·microsoft·c#
wuguan_4 小时前
C#之接口
c#·接口
bugcome_com4 小时前
深入解析 C# 中 int? 与 int 的核心区别:可空值类型的本质与最佳实践
开发语言·c#