一、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丢任务时,线程池不会新建线程,而是直接把任务分给 "待命员工"(已有线程); - 这个线程执行完当前任务后,不会销毁,而是回到 "待命状态",等着接下一个任务;
- 只有当待命线程全忙、任务还在排队时,线程池才会 "按需新建少量线程"(但不会无限制建),任务少了还会慢慢销毁多余线程。
Thread和ThreadPool的对比:
| 操作场景 | 手动用 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 完成,代码更简洁,不阻塞线程。