C# 线程(Thread)详解
一、什么是线程?
1.1 生活比喻
进程 = 一家餐厅(有独立的厨房、餐桌、收银台)
线程 = 餐厅里的一个员工(服务员、厨师、收银员)
一个餐厅(进程)里可以有多个员工(线程)同时工作——厨师在炒菜、服务员在端盘子、收银员在结账。
他们都是在这个餐厅里工作,共用同一个厨房、同一些餐具(共享进程的内存)。
主线程相当于老板——程序启动就有,负责主要工作。
子线程相当于雇来的员工——老板分派任务,让员工去干。
1.2 为什么需要多线程?
// 没有多线程——程序卡住了!
static void Main()
{
DownloadFile("https://example.com/bigfile.zip"); // 下载要 10 秒
ShowProgressBar(); // 等下载完才能显示进度条
// 用户感觉程序"死了"——界面卡住不动
}
// 有多线程——同时进行!
static void Main()
{
Thread downloadThread = new Thread(() => DownloadFile(...));
downloadThread.Start(); // 让子线程去下载
// 主线程继续跑——显示进度条,响应用户操作
ShowProgressBar();
}
1.3 核心概念速览
| 概念 | 说明 |
|---|---|
| 进程 | 运行中的程序,有自己的内存空间 |
| 线程 | 进程中的执行单元,共享进程内存 |
| 主线程 | 程序启动时自动创建的线程 |
| 子线程 | 代码中手动创建的线程 |
| 多线程 | 多个线程并发执行 |
| 线程安全 | 多线程同时访问数据不会出错 |
二、创建和启动线程
2.1 最基础的方式 —— Thread 类
using System;
using System.Threading;
class Program
{
// 这个方法将在线程中执行
static void PrintNumbers()
{
for (int i = 1; i <= 5; i++)
{
Console.WriteLine($"子线程: {i}");
Thread.Sleep(500); // 暂停 500 毫秒
}
}
static void Main()
{
Console.WriteLine("主线程开始");
// 创建线程——告诉它要执行哪个方法
Thread thread = new Thread(PrintNumbers);
// 启动线程
thread.Start();
// 主线程继续执行
for (int i = 1; i <= 3; i++)
{
Console.WriteLine($"主线程: {i}");
Thread.Sleep(300);
}
Console.WriteLine("主线程结束");
}
}
输出(每次运行顺序可能不同):
主线程开始
主线程: 1
子线程: 1
主线程: 2
子线程: 2
主线程: 3
主线程结束
子线程: 3
子线程: 4
子线程: 5
注意:主线程做完了子线程还可能继续跑!它们各跑各的。
2.2 传参数给线程 —— ParameterizedThreadStart
// 方式一:ParameterizedThreadStart(参数是 object 类型)
static void PrintMessage(object msg)
{
string message = (string)msg; // 需要强转
Console.WriteLine($"收到消息: {message}");
}
Thread t1 = new Thread(PrintMessage);
t1.Start("Hello from Main!");
// 方式二:Lambda 表达式(推荐!更灵活)
Thread t2 = new Thread(() =>
{
string name = "张三";
int age = 18;
Console.WriteLine($"{name} 今年 {age} 岁");
});
t2.Start();
// 方式三:Lambda 捕获外部变量
string city = "北京";
int score = 95;
Thread t3 = new Thread(() =>
{
Console.WriteLine($"{city} 的学生考了 {score} 分");
});
t3.Start();
2.3 五种创建方式汇总
// 1. ThreadStart 委托(无参方法)
static void DoWork() { }
Thread t1 = new Thread(DoWork);
// 2. ThreadStart + Lambda
Thread t2 = new Thread(() => { });
// 3. ParameterizedThreadStart(object 参数)
static void DoWork2(object obj) { }
Thread t3 = new Thread(DoWork2);
// 4. ParameterizedThreadStart + Lambda
Thread t4 = new Thread(obj => { });
// 5. 直接 new Thread 传 Lambda(最常用)
Thread t5 = new Thread(() =>
{
Console.WriteLine("子线程工作!");
});
三、线程的关键操作
3.1 等待线程结束 —— Join
static void Main()
{
Console.WriteLine("主线程: 开始");
Thread worker = new Thread(() =>
{
Console.WriteLine("子线程: 工作开始...");
Thread.Sleep(2000); // 模拟耗时工作
Console.WriteLine("子线程: 工作完成!");
});
worker.Start();
// 等子线程干完再继续
worker.Join(); // 主线程停在这里,等 worker 结束
Console.WriteLine("主线程: 子线程已完成,我继续了");
}
输出:
主线程: 开始
子线程: 工作开始...
子线程: 工作完成! ← 等了 2 秒
主线程: 子线程已完成,我继续了
Join 带超时:
// 最多等 3 秒,超时就继续
if (worker.Join(3000))
{
Console.WriteLine("子线程在 3 秒内完成了");
}
else
{
Console.WriteLine("超时!不等了,继续执行");
}
3.2 暂停线程 —— Sleep
Thread.Sleep(1000); // 当前线程暂停 1000 毫秒(1 秒)
Thread.Sleep(0); // 让出 CPU 时间片给其他线程
Thread.Sleep(Timeout.Infinite); // 永久休眠(几乎不用)
注意:
Sleep只能让当前线程休眠,不能用来让别的线程休眠。
3.3 线程的前后台属性 —— IsBackground
Thread t = new Thread(() =>
{
while (true)
{
Console.WriteLine("工作中...");
Thread.Sleep(1000);
}
});
// t.IsBackground = false; // 默认——前台线程
// 程序会一直运行,死循环不会退出
t.IsBackground = true; // 后台线程
// 主线程结束后,后台线程自动终止,程序退出
| 属性 | 前台线程(默认) | 后台线程(IsBackground = true) |
|---|---|---|
| 主线程结束时 | 子线程继续跑 | 子线程被强制终止 |
| 适用场景 | 重要工作,必须完成 | 辅助工作,跟着主线程走 |
3.4 获取当前线程信息
Thread current = Thread.CurrentThread;
Console.WriteLine($"线程 ID: {current.ManagedThreadId}");
Console.WriteLine($"线程名称: {current.Name}");
Console.WriteLine($"是否后台: {current.IsBackground}");
Console.WriteLine($"是否存活: {current.IsAlive}");
Console.WriteLine($"线程池线程: {current.IsThreadPoolThread}");
3.5 给线程起名字
Thread t1 = new Thread(DoWork);
t1.Name = "下载线程"; // 给线程起个名,调试时好辨认
Thread t2 = new Thread(DoWork);
t2.Name = "上传线程";
四、线程安全问题——多线程的"雷区"
4.1 什么是线程安全问题?
多个线程同时修改同一个数据,导致数据错乱。
static int counter = 0;
static void Main()
{
for (int i = 0; i < 10; i++)
{
new Thread(() =>
{
for (int j = 0; j < 1000; j++)
{
counter++; // ⚠️ 不是原子操作!线程不安全!
}
}).Start();
}
Thread.Sleep(2000); // 等所有线程跑完
Console.WriteLine($"预期: 10000, 实际: {counter}");
// 实际可能输出: 9823, 9876, 10000... 每次不一样!
}
为什么 counter++ 不安全? 它其实分三步:
1. 读取 counter 的值到 CPU 寄存器
2. 把值 +1
3. 把新值写回 counter
线程 A 线程 B
读取 counter = 10
读取 counter = 10(也是 10!)
+1 → 11
+1 → 11
写回 counter = 11
写回 counter = 11(覆盖了!)
结果:两次 ++,counter 只加了 1!
4.2 解决方案——lock 锁
static int counter = 0;
static object lockObj = new object(); // 锁对象
static 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($"预期: 10000, 实际: {counter}");
// 实际输出: 10000 ✅ 正确!
}
lock 的工作原理:
lock (lockObj) 相当于:
┌─────────────────────────────┐
│ 检查 lockObj 有没有人锁着 │
│ ├─ 没人 → 锁上,进去执行 │
│ └─ 有人 → 排队等 │
│ 执行完 → 开锁,下一个进去 │
└─────────────────────────────┘
4.3 lock 的使用规则
// ✅ 锁对象必须是引用类型
static object locker = new object();
// ❌ 不能锁值类型
// lock (123) { } // 编译错误!
// ❌ 不能锁 null
// object obj = null; lock (obj) { } // 运行时错误!
// ❌ 不要锁 this 或 typeof 或字符串字面量(可能被其他代码锁)
// lock (this) { } // 不推荐
// lock (typeof(Program)) { } // 不推荐
// lock ("myLock") { } // 不推荐(字符串会被驻留)
// ✅ 用 private static readonly object
private static readonly object _lock = new object();
五、线程安全的其他方式
5.1 Interlocked —— 原子操作(轻量级)
static int counter = 0;
// Interlocked 提供硬件级别的原子操作,比 lock 快
Interlocked.Increment(ref counter); // 原子地 +1
Interlocked.Decrement(ref counter); // 原子地 -1
Interlocked.Add(ref counter, 5); // 原子地 +5
Interlocked.Exchange(ref counter, 100); // 原子地设值,返回旧值
int oldValue = Interlocked.CompareExchange(ref counter, 100, 50);
// 如果 counter == 50,就改成 100,无论是否改了都返回旧值
5.2 Monitor —— lock 的底层实现
// lock 语句编译后就是 Monitor
lock (lockObj)
{
// 临界区
}
// 等价于
Monitor.Enter(lockObj);
try
{
// 临界区
}
finally
{
Monitor.Exit(lockObj);
}
// Monitor 比 lock 多了 TryEnter(尝试获取锁,不阻塞)
if (Monitor.TryEnter(lockObj, 1000)) // 1 秒超时
{
try
{
// 拿到锁了
}
finally
{
Monitor.Exit(lockObj);
}
}
else
{
Console.WriteLine("没拿到锁,不等了");
}
5.3 ManualResetEvent —— 线程间发信号
// 场景:子线程干完活通知主线程
ManualResetEvent signal = new ManualResetEvent(false);
Thread worker = new Thread(() =>
{
Console.WriteLine("子线程:工作中...");
Thread.Sleep(2000);
Console.WriteLine("子线程:干完了!");
signal.Set(); // 发信号——我搞定了!
});
worker.Start();
Console.WriteLine("主线程:等子线程的信号...");
signal.WaitOne(); // 阻塞,等信号
Console.WriteLine("主线程:收到信号,继续执行");
输出:
主线程:等子线程的信号...
子线程:工作中...
子线程:干完了!
主线程:收到信号,继续执行
六、线程池(ThreadPool)—— 不用自己 new 线程
6.1 什么是线程池?
线程池就是预先创建好的一群线程,你要用直接取,用完还回去——省去了反复创建/销毁线程的开销。
手动创建线程: 雇一个临时工,干完活开除,下次再雇(开销大)
线程池: 有一个固定团队,来活了分配,干完回收(开销小)
6.2 基本用法
// 用线程池执行任务——不需要 new Thread
ThreadPool.QueueUserWorkItem(state =>
{
Console.WriteLine($"线程池线程 ID: {Thread.CurrentThread.ManagedThreadId}");
Console.WriteLine($"参数: {state}");
}, "Hello ThreadPool");
6.3 手动 vs 线程池
| 特性 | 手动 new Thread | 线程池 |
|---|---|---|
| 创建开销 | 大(每次都创建新线程) | 小(复用线程) |
| 线程数量 | 可控 | 系统自动管理 |
| 线程名称 | 可以设置 | 不能设置 |
| 前台/后台 | 默认前台 | 总是后台 |
| 适用场景 | 长期运行的任务 | 短小频繁的任务 |
| 返回值 | 不方便 | 不方便(用 Task 更方便) |
七、Task —— 现代 C# 推荐的方式
7.1 Task 比 Thread 好在哪?
// Thread 方式
Thread t = new Thread(() =>
{
int result = Calculate();
Console.WriteLine(result);
});
t.Start();
// 问题:返回值不好拿,异常处理麻烦
// Task 方式
Task<int> task = Task.Run(() => Calculate());
int result = task.Result; // 直接拿到返回值!
// Task 还支持 await(异步)、异常捕获更友好
建议:新代码用
Task代替Thread。Thread 是底层 API,Task 是现代高层的 API,更好用。
7.2 Task 基本用法
using System.Threading.Tasks;
// 创建并启动任务
Task task1 = Task.Run(() =>
{
Console.WriteLine("Task 工作中...");
});
// 带返回值
Task<int> task2 = Task.Run(() =>
{
Thread.Sleep(1000);
return 42;
});
int result = task2.Result; // 获取结果(会等待任务完成)
Console.WriteLine($"结果: {result}");
// 等待完成
task2.Wait(); // 等效于 Thread.Join
// 批量启动
Task[] tasks = new Task[3];
for (int i = 0; i < 3; i++)
{
int taskId = i;
tasks[i] = Task.Run(() =>
{
Console.WriteLine($"任务 {taskId} 完成");
});
}
Task.WaitAll(tasks); // 等全部完成
Console.WriteLine("所有任务完成");
八、实战示例——多线程下载模拟
using System;
using System.Collections.Generic;
using System.Threading;
class Program
{
// 线程安全的结果收集器
private static List<string> _results = new List<string>();
private static readonly object _lock = new object();
private static int _completedCount = 0;
static void DownloadFile(string url)
{
Console.WriteLine($"[线程 {Thread.CurrentThread.ManagedThreadId}] 开始下载: {url}");
// 模拟下载耗时(随机 1~3 秒)
Random rand = new Random();
int downloadTime = rand.Next(1000, 3000);
Thread.Sleep(downloadTime);
// 线程安全地保存结果
lock (_lock)
{
_results.Add($"{url} - 下载完成 ({downloadTime / 1000.0:F1}秒)");
_completedCount++;
}
Console.WriteLine($"[线程 {Thread.CurrentThread.ManagedThreadId}] 完成: {url}");
}
static void Main()
{
Console.WriteLine("===== 多线程下载管理器 =====\n");
string[] urls =
{
"文件1.zip", "文件2.zip", "文件3.zip",
"文件4.zip", "文件5.zip"
};
// 创建多个下载线程
List<Thread> threads = new List<Thread>();
foreach (string url in urls)
{
Thread t = new Thread(() => DownloadFile(url));
t.Name = $"下载-{url}";
t.Start();
threads.Add(t);
}
// 实时显示进度(主线程)
while (_completedCount < urls.Length)
{
lock (_lock)
{
Console.WriteLine($"\r进度: {_completedCount}/{urls.Length}");
}
Thread.Sleep(500);
}
// 等待全部完成
foreach (Thread t in threads)
{
t.Join();
}
// 显示结果
Console.WriteLine("\n===== 下载完成 =====\n");
foreach (string result in _results)
{
Console.WriteLine($" ✅ {result}");
}
}
}
输出示例:
===== 多线程下载管理器 =====
[线程 3] 开始下载: 文件1.zip
[线程 4] 开始下载: 文件2.zip
[线程 5] 开始下载: 文件3.zip
[线程 6] 开始下载: 文件4.zip
[线程 7] 开始下载: 文件5.zip
进度: 2/5
[线程 5] 完成: 文件3.zip
进度: 3/5
[线程 4] 完成: 文件2.zip
[线程 6] 完成: 文件4.zip
[线程 7] 完成: 文件5.zip
[线程 3] 完成: 文件1.zip
===== 下载完成 =====
✅ 文件3.zip - 下载完成 (1.2秒)
✅ 文件2.zip - 下载完成 (1.5秒)
✅ 文件4.zip - 下载完成 (2.1秒)
✅ 文件5.zip - 下载完成 (2.3秒)
✅ 文件1.zip - 下载完成 (2.8秒)
九、常见易错点(避坑指南)
坑1:忘记线程正在跑,程序提前退出
// ❌ 子线程是前台线程,循环内跑不完程序不会退 → 那倒没问题
// 但 Join 能保证顺序
// ✅ 用 Join 等待
Thread t = new Thread(() => { Thread.Sleep(3000); });
t.IsBackground = true; // 或用后台线程
t.Start(); // 不 Join 也不 wait,程序直接退出,子线程被 kill
坑2:lock 锁的对象不能用 public
// ❌ public 的对象可能被外部代码锁
public object LockObj = new object();
// ✅ 用 private 的
private static readonly object _lock = new object();
坑3:在多线程中更新 UI 控件
// ❌ 直接在子线程更新 UI(WinForms/WPF)
// textBox1.Text = "从子线程更新"; // 抛异常!
// ✅ 用 Invoke 回到 UI 线程
textBox1.Invoke(new Action(() =>
{
textBox1.Text = "从子线程更新";
}));
坑4:Lambda 捕获循环变量
// ❌ 经典闭包陷阱
for (int i = 0; i < 5; i++)
{
new Thread(() => Console.WriteLine(i)).Start();
}
// 输出可能是: 5 5 5 5 5 ← 全是 5!因为 i 是同一个变量
// ✅ 用局部变量捕获
for (int i = 0; i < 5; i++)
{
int copy = i; // 每次循环创建新变量
new Thread(() => Console.WriteLine(copy)).Start();
}
// 输出: 1 4 3 0 2(顺序不定,但数字对)
坑5:Abort 已经过时
// ❌ Thread.Abort() 在 .NET Core/5+ 中会抛 PlatformNotSupportedException
// thread.Abort(); // 已过时!
// ✅ 用 CancellationToken 优雅取消
CancellationTokenSource cts = new CancellationTokenSource();
Task.Run(() =>
{
while (!cts.Token.IsCancellationRequested)
{
// 工作中...
}
}, cts.Token);
cts.Cancel(); // 请求取消
坑6:死锁——两个线程互相等对方释放锁
static object lockA = new object();
static object lockB = new object();
// ❌ 死锁示例
Thread t1 = new Thread(() =>
{
lock (lockA)
{
Thread.Sleep(100);
lock (lockB) { } // t1 等 lockB
}
});
Thread t2 = new Thread(() =>
{
lock (lockB)
{
Thread.Sleep(100);
lock (lockA) { } // t2 等 lockA
}
});
// t1 握着 lockA 等 lockB,t2 握着 lockB 等 lockA → 死锁!
// ✅ 避免死锁:始终按相同顺序获取锁
十、总结
线程操作速查
创建线程: Thread t = new Thread(() => { });
启动: t.Start();
等待完成: t.Join(); 或 t.Join(超时毫秒);
休眠: Thread.Sleep(毫秒);
前台/后台: t.IsBackground = true;
线程名: t.Name = "名字";
当前线程: Thread.CurrentThread
线程安全速查
| 场景 | 方案 | 性能 |
|---|---|---|
| 简单计数 | Interlocked.Increment |
高 |
| 保护代码块 | lock (obj) { } |
中 |
| 需要超时 | Monitor.TryEnter |
中 |
| 线程间通知 | ManualResetEvent |
— |
| 现代异步 | Task + async/await |
— |
记忆口诀
线程就像多窗口,同时运行不停留
Start 启动各跑各,Join 等待一起走
Sleep 暂停一小会儿,IsBackground 后台溜
多线程里要小心,共享数据会打架
lock 锁住关键区,一次一人稳当当
新代码用 Task 好,async await 最时尚
一句话总结:线程让程序能同时做多件事——用
new Thread创建、Start启动、Join等待。多线程最大的坑是共享数据并发修改,用lock或Interlocked保护关键区域。现代 C# 推荐用Task代替Thread,用async/await写异步代码。
评论区