1. 介绍
多线程就像办公室里几个人并行工作:一个打印文件,另一个给客户打电话,第三个煮咖啡(当然,他们都是程序员)。如果只有一个人,那他得按顺序做所有事,办公室会被无聊和排队的咖啡淹没。在编程里也是类似:单线程程序一次只能做一件事。
假设我们的应用在执行一个很耗时的操作,比如从网络下载文件或计算一个超大的表格。其他所有东西在那一刻都会“冻结”——按钮点不了,动画不动,弹窗会显示 "没有响应"。
多线程让应用能同时做多件事:界面保持响应,操作并行运行,我们不用再对着电脑说“电脑,你又卡了?!”。
基本概念和术语
在深入之前,弄清楚什么是线程 (thread),它和进程的区别。
- 进程 (Process): 独立运行的程序,有自己的地址空间、变量和资源。例如,在 Windows 中每个启动的应用就是一个进程。
- 线程 (Thread): 进程内的执行单元。一个进程可以包含一个或多个线程,它们共享同样的资源(内存、变量)。
为什么多线程会引出这么多问题?
因为线程是爱冒险的家伙:它们可以在任何时刻开始执行、打乱数据、打断彼此,如果不按顺序处理,会在内存里制造混乱。如果感觉这像没有管理员的幼儿园——完全正确!纪律和对线程的照顾,是写出可靠多线程程序的基础。
2. C# 和 .NET 中多线程的历史和角色
很久以前 C# 是单线程的,程序很简单。随着对性能的需求增长、多核处理器的出现以及需要构建不冻结界面的响应式应用,.NET 引入了多线程支持。最初是经典的 System.Threading.Thread,后来出现了任务 (Task)、异步方法 (async/await), 并行数据处理 (PLINQ) 以及更高级的同步原语。
C# 已成长为一个强大的平台,多线程不是稀罕事而是日常。
直观:进程和线程
下面是一个简单示意图:
+--------------------------------------------------+
| 进程(你的程序) |
| +-------------+ +-------------+ |
| | 线程 1 | | 线程 2 | |
| +-------------+ +-------------+ |
| ... |
| +-------------+ |
| | 线程 N | |
| +-------------+ |
+--------------------------------------------------+
进程内的所有线程能看到共同的变量和资源。
3. 在 C# 中如何创建线程?
从最基础的开始:来自命名空间 System.Threading 的 Thread 类。
示例:启动第二个线程
假设我们有个耗时任务——比如计算从 1 到 10_000_000 的和。计算进行时,主线程向用户打招呼。
using System;
using System.Threading;
class Program
{
// 用于第二个任务的方法
static void CalculateSum()
{
long sum = 0;
for (int i = 1; i <= 10_000_000; i++)
sum += i;
Console.WriteLine($"[线程 2] 和: {sum}");
}
static void Main()
{
// 创建线程,指定方法的委托
Thread thread = new Thread(CalculateSum);
thread.Start(); // 启动第二个线程
// 主线程继续工作
Console.WriteLine("[线程 1] 你好!我们并行工作...");
// 在退出前等待第二个线程完成
thread.Join();
Console.WriteLine("[线程 1] 全部完成!");
}
}
会发生什么?
屏幕上会出现 "[线程 1] 你好!我们并行工作...",然后当计算线程结束时,会输出计算结果。
典型问题:谁先输出
多次运行这段代码你会看到输出顺序可能不同!有时和先出现,有时问候先出现。这就是多线程——程序变得不那么可预测,像周一猫的心情一样。
4. 内存区域:线程能看到什么?
进程内的所有线程都可以访问相同的变量(如果它们不是方法的局部变量)。在一个线程中改变变量,其他线程也会看到!
示例:共享变量
using System;
using System.Threading;
class Program
{
static int counter = 0;
static void Increment()
{
for (int i = 0; i < 1000; i++)
counter++;
}
static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"counter = {counter}");
}
}
我们期望在 counter 中看到多少?逻辑上应该是 2000,因为每个线程增加 1000 次。
但并不是!多次运行会得到不同的值:1782、1935、1999……
为什么?这是经典的 Race Condition(竞态条件)——线程在“读 -> 增加 -> 写”这个序列中互相抢占,部分自增丢失了。
5. 有用的细节
线程如何与界面交互?
在现代桌面应用(WinForms/WPF/MAUI)中,主线程负责图形界面。所有用户操作(点击、输入)都在这个线程上。后台任务应该在其他线程执行,但通常不允许直接从后台线程“碰”界面。这是为了避免混乱。
在控制台程序里没有这种限制,可以从任何线程写 Console.WriteLine。不过在真实应用中,如果没有正确同步,界面还是可能出问题。
顺序性和并行性
用表格来巩固区别。
| 单线程代码 | 多线程代码 |
|---|---|
| 按顺序执行任务 | 任务可以同时执行 |
| 长任务会让 UI “卡住” | UI 保持响应 |
| 读写变量很简单 | 需要控制对数据的访问 |
| 容易调试 | 调试可能很复杂 |
与线程工作时的重要点
- 共享变量 = 共享风险。 如上所示,多个线程用同一个变量时,如果不做同步会出错!(更详细内容在后面的讲座。)
- 可以用 Join() 等待线程。 Join() 方法可以暂停主线程直到后台线程完成。需要等待结果时就用它。
- 线程不能启动两次。 线程执行完后不能再次 Start() —— 需要创建新的 Thread 对象。
- 线程的结束。 线程在其方法执行完时结束。强行“杀死”线程通常是坏主意(像 Abort() 这样的做法早已被认为有害且过时)。
多线程在实践中的用途
- UI 应用:当后台加载或计算时不让界面冻结。
- 服务器和服务:同时处理多个客户端请求。
- 高性能计算:把大任务(比如处理上百万条记录)分成多份并行执行。
- 游戏、模拟、数据处理:模拟复杂系统同时保持性能。
多线程的问题
多线程带来力量,但也增加复杂性:
- Race Condition(竞态条件): 当多个线程同时修改相同数据时,结果依赖于指令执行顺序,这是不可预测的。
- Deadlock(死锁): 线程相互等待对方,谁也无法继续。
- Starvation(饥饿): 某个线程长期得不到资源访问。
在这节课我们只是提到多线程的主要问题,接下来的课会学会识别和避免它们。先记住一句话:如果一切都“卡住”了,不一定只是bug,也可能是线程在开派对。
GO TO FULL VERSION