聊到C#多线程,很多人第一反应就是Thread
和lock
。没错,它们是基石,但如果你只停留在它们,那就像只会用菜刀切菜,永远做不出满汉全席。现代C#多线程的核心思想是 “高效地利用计算资源,并安全地处理并发”。下面我跟你捋几个最核心的点,保证接地气。
1. 为什么要用多线程?—— “别让CPU看戏”
想象一下,你的程序需要从网上下载10个文件。如果用单线程,它就是傻乎乎地一个一个下,期间CPU大部分时间都在那“空转”(等待网络响应)。多线程的核心目的就是充分利用多核CPU的计算能力,把这种“等待”的时间利用起来,让其他线程去干活,或者把一个大任务拆成多个小任务同时处理,极大提升程序性能和响应速度。
- CPU密集型:计算圆周率、图像处理、加密解密。开多个线程,让每个CPU核心都忙起来。
- I/O密集型:读写文件、网络请求、数据库访问。开多个线程,在等待一个I/O操作时,让CPU去处理别的线程的任务。
2. 线程安全:最大的“坑”——“你的变量,大家的变量”
这是多线程最核心、最易出错的概念。多个线程同时访问同一个资源(变量、集合、文件等),如果不做任何保护,结果将是不可预知的。
// 一个经典的错误示例
private static int _counter = 0;void Main()
{for (int i = 0; i < 10; i++){// 启动10个线程,每个都对 _counter 加1000次new Thread(() => {for (int j = 0; j < 1000; j++)_counter++; // 这行不是原子操作!}).Start();}Thread.Sleep(2000);Console.WriteLine(_counter); // 你几乎永远得不到 10000!
}
为什么?因为 _counter++
在底层其实是三步:读 -> 改 -> 写
。线程A读完值(比如100)后,可能还没来得及写回,线程B也读了(也是100),然后两个线程都计算完写回,结果就成了101,而不是预期的102。
怎么办?加“锁”(Synchronization)
最常用的工具就是lock
关键字(Monitor的语法糖)。
private static readonly object _lockObj = new object(); // 必须是一个私有、只读的引用对象
private static int _counter = 0;void Main()
{for (int i = 0; i < 10; i++){new Thread(() => {for (int j = 0; j < 1000; j++)lock(_lockObj) // 只有一个线程能进入这块代码{_counter++;}}).Start();}Thread.Sleep(2000);Console.WriteLine(_counter); // 现在稳稳的是 10000
}
记住:锁的对象应该是一个私有的、只读的引用类型对象,千万别用lock(this)
、lock(“string”)
这种。
3. 现代多线程的利器:Task 和 async/await
别再一上来就new Thread()
了!Thread
是“底层工人”,创建和销毁成本高,不好管理。.NET 4.0 引入的 TPL(Task Parallel Library) 才是我们现在的主力。
-
Task
:代表一个异步操作。它比Thread
更轻量,底层用的是线程池,能高效地管理和复用线程,避免了频繁创建销毁线程的开销。// 用 Task 来执行后台计算 Task.Run(() => {// 这里会在线程池线程中执行DoSomeHeavyCalculations(); });
-
async/await
(C# 5.0):这是异步编程的语法糖,它的主要目的是解放UI线程,保持界面响应,而不是直接创建新线程。// 在UI按钮点击事件中 private async void btnDownload_Click(object sender, EventArgs e) {btnDownload.Enabled = false;// await 不会阻塞UI线程!// 它告诉编译器:等这个耗时的Task完成后再回来执行后面的代码,期间UI线程是自由的。string data = await HttpClient.GetStringAsync("http://example.com");// 这里会自动回到UI线程上下文,所以可以直接更新UItxtResult.Text = data;btnDownload.Enabled = true; }
关键理解:
async/await
本身不创建新线程。HttpClient.GetStringAsync
这类I/O操作,大部分时间是在等待网络硬件,根本不需要占用任何CPU线程。它用了一种叫“IO完成端口”的高效机制。只有在遇到CPU密集型任务时,你才应该用Task.Run
把它推到后台线程。
4. 并发集合:让你“锁”得更少一点
List<T>
, Dictionary<TKey, TValue>
这些集合都不是线程安全的。如果你每次都靠lock
来保护它们,代码会很难写且容易死锁。
.NET 在 System.Collections.Concurrent
命名空间下提供了一堆现成的线程安全集合:
ConcurrentBag<T>
: 一个无序的包,适合生产者-消费者场景。ConcurrentDictionary<TKey, TValue>
: 线程安全的字典,它的GetOrAdd
,AddOrUpdate
等方法非常强大且原子性。BlockingCollection<T>
: 一个带阻塞功能的集合,是实现生产者-消费者模式的绝佳工具。
用它们可以大大减少你手动lock
的次数。
5. 取消操作:让线程“优雅地”退出
你不能直接粗暴地Abort()
一个线程,这会导致资源泄露和状态不一致。正确的做法是使用协作式取消。
.NET 提供了 CancellationTokenSource
和 CancellationToken
来实现这个模式。
void Main()
{var cts = new CancellationTokenSource();// 启动一个可取消的任务var task = Task.Run(() => DoWork(cts.Token), cts.Token);// 2秒后发出取消信号Thread.Sleep(2000);cts.Cancel();try { task.Wait(); } catch (AggregateException ex) { /* 处理取消异常 */ }
}void DoWork(CancellationToken token)
{while (true){token.ThrowIfCancellationRequested(); // 如果已取消,就抛出OperationCanceledException// ... 或者也可以这样检查if (token.IsCancellationRequested)break; // 优雅地清理并退出循环// 做一点工作Thread.Sleep(500);}
}
总结一下核心思想:
- 目的:榨干CPU性能,提升响应能力。
- 基石:理解线程安全,熟练使用
lock
。 - 现代工具:抛弃原始的
Thread
,拥抱Task
和async/await
。分清 CPU密集型(用Task.Run
)和 I/O密集型(用async/await
)。 - 基础设施:使用并发集合减少锁的烦恼。
- 良好习惯:使用取消令牌实现优雅停止。
把这些点吃透,你就能解决95%的日常多线程问题了。剩下的就是一些高级主题,比如内存模型、信号量(SemaphoreSlim
)、读写锁(ReaderWriterLockSlim
)等,等遇到具体场景再深入研究也不迟。