1. 为什么需要多线程?
我第一次接触多线程是在开发一个数据采集系统时。当时程序需要同时从多个传感器读取数据,还要实时显示波形并保存到数据库。单线程模式下,界面会频繁卡顿,数据丢失率高达15%。改用多线程后,这些问题迎刃而解。
多线程的核心价值在于并发执行能力。现代CPU通常有多个核心,但单线程程序只能利用其中一个。通过创建多个线程,我们可以:
- 提升CPU利用率(实测i7-11800H运行多线程时利用率从12%提升到78%)
- 避免阻塞主线程(特别是UI线程)
- 简化复杂任务的组织结构
注意:多线程不是银弹。线程切换本身有开销,当线程数超过CPU核心数时,性能反而可能下降。我的经验法则是:计算密集型任务线程数=核心数,IO密集型可适当增加。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. C#多线程基础实现
2.1 Thread类基础用法
最直接的线程创建方式:
csharp复制using System.Threading;
void WorkerMethod()
{
Console.WriteLine($"线程ID:{Thread.CurrentThread.ManagedThreadId}");
}
Thread worker = new Thread(WorkerMethod);
worker.Start();
关键参数说明:
- IsBackground:设为true时,线程会在主线程退出时自动终止(默认false)
- Priority:从Lowest到Highest共5个级别,影响线程调度权重
- ThreadState:监控线程状态(Running, WaitSleepJoin等)
实测案例:在我的i7-11800H上,同时运行8个计算线程时:
- 默认优先级完成时间:23.6秒
- 设为Highest后:19.8秒(提升16%)
2.2 线程传参的三种方式
- 通过Start参数传递(类型安全):
csharp复制void Worker(object data)
{
if(data is int value) {...}
}
new Thread(Worker).Start(42);
- 使用Lambda捕获:
csharp复制int param = 42;
new Thread(() => {
Console.WriteLine(param);
}).Start();
- 自定义类封装(推荐复杂场景):
csharp复制class TaskParams {
public int Id {get; set;}
public string Name {get; set;}
}
void Worker(TaskParams p) {...}
踩坑记录:我曾遇到Lambda捕获的变量在线程启动前被修改的问题。解决方案是先在局部变量中保存副本:
csharp复制int temp = param; new Thread(() => UseValue(temp));
3. 线程同步与资源竞争
3.1 lock关键字实战
当多个线程访问共享资源时,必须同步:
csharp复制private static readonly object _lockObj = new object();
private static int _counter = 0;
void Increment()
{
lock(_lockObj) {
_counter++;
}
}
重要原则:
- 锁对象应该是private readonly的引用类型
- 尽量缩小锁的作用范围
- 绝对不要锁定this或Type实例
性能对比测试(100万次累加):
- 无锁:结果错误,耗时12ms
- 有锁:结果正确,耗时48ms
- Interlocked:结果正确,耗时15ms
3.2 更高效的Interlocked
对于简单数值操作:
csharp复制Interlocked.Increment(ref _counter);
// 相当于原子操作的_counter++
支持的操作包括:
- Add/Increment/Decrement
- CompareExchange(原子条件替换)
- Exchange(原子赋值)
3.3 信号量Semaphore实战
控制同时访问资源的线程数:
csharp复制SemaphoreSlim semaphore = new SemaphoreSlim(3); // 允许3个并发
async Task AccessResource()
{
await semaphore.WaitAsync();
try {
// 受保护的代码
} finally {
semaphore.Release();
}
}
我在数据库连接池中应用时,将最大连接数从30降到10(通过信号量控制),连接异常率反而从5%降至0.3%。
4. 线程池与Task进阶
4.1 ThreadPool的合理使用
手动创建线程开销较大(实测约300μs/个),推荐使用线程池:
csharp复制ThreadPool.QueueUserWorkItem(state => {
Console.WriteLine($"线程池线程ID:{Thread.CurrentThread.ManagedThreadId}");
});
配置建议:
- 通过ThreadPool.SetMinThreads()设置初始线程数
- IO密集型应用可适当增大线程数
- 默认最大线程数=处理器核心数*250
4.2 Task的完整生命周期
现代C#推荐使用Task替代直接操作线程:
csharp复制Task.Run(() => {
// 后台工作
}).ContinueWith(t => {
// 完成后执行
}, TaskScheduler.FromCurrentSynchronizationContext());
状态转换图:
Created → WaitingToRun → Running →
(RanToCompletion | Faulted | Canceled)
异常处理最佳实践:
csharp复制try {
await Task.Run(() => {...});
} catch(AggregateException ae) {
foreach(var e in ae.InnerExceptions) {
Logger.Error(e);
}
}
5. 实战中的高级技巧
5.1 取消令牌的应用模式
csharp复制CancellationTokenSource cts = new CancellationTokenSource();
async Task LongRunningTask()
{
while(!cts.Token.IsCancellationRequested) {
await Task.Delay(1000);
cts.Token.ThrowIfCancellationRequested();
}
}
// 在UI中:
cancelButton.Click += (s,e) => cts.Cancel();
5.2 线程安全的集合选择
- ConcurrentBag:无序集合
- ConcurrentQueue:先进先出
- ConcurrentDictionary:键值对
- BlockingCollection:有界集合
性能测试(100万次操作):
- List加锁:1.2秒
- ConcurrentBag:0.4秒
5.3 async/await底层原理
编译器会将async方法转换为状态机。关键点:
- await会检查操作是否已完成
- 未完成时注册续延(continuation)
- 使用SynchronizationContext决定恢复上下文
反编译后的代码结构示例:
csharp复制[AsyncStateMachine(typeof(<Method>d__1))]
public Task AsyncMethod() {...}
struct <Method>d__1 : IAsyncStateMachine
{
public int <>1__state;
public AsyncTaskMethodBuilder <>t__builder;
// 局部变量会变成字段
void MoveNext() {...}
void SetStateMachine(...) {...}
}
6. 性能优化与诊断
6.1 线程数调优公式
最优线程数 ≈ [CPU核心数] * [1 + (等待时间/计算时间)]
例如:
- 4核CPU
- 任务50%时间在等待
- 最优线程数 = 4 * (1 + 0.5/0.5) = 8
6.2 诊断工具链
- Visual Studio并行堆栈视图
- PerfView分析线程切换
- dotnet-counters监控线程数
- BenchmarkDotNet量化对比
典型问题诊断案例:
- 现象:CPU利用率低但吞吐量上不去
- 诊断:PerfView显示90%时间在锁等待
- 解决:改用ConcurrentQueue后吞吐量提升3倍
6.3 内存模型与volatile
理解以下代码的输出:
csharp复制bool _flag = true;
void Thread1()
{
_flag = false;
}
void Thread2()
{
while(_flag) {}
Console.WriteLine("退出");
}
可能永远不退出!解决方案:
- 加锁
- 使用volatile
- 使用Volatile.Read/Write
7. 真实项目经验分享
7.1 数据采集系统案例
需求:
- 16通道模拟量采集(1kHz/通道)
- 实时波形显示
- 数据持久化
最终架构:
code复制UI线程(WPF) ←[绑定]→ 共享内存缓冲区 ←[生产者]→
采集线程(高优先级)
处理线程(计算FFT)
存储线程(批量写入SQLite)
关键技巧:
- 使用MemoryMappedFile共享数据
- 双缓冲策略避免读写冲突
- 通过CancellationTokenSource统一取消
7.2 多线程调试心得
- 给线程命名:
Thread.CurrentThread.Name = "采集线程" - 使用断点条件:
Thread.CurrentThread.ManagedThreadId == xx - 记录线程ID的日志格式:
text复制
[2023-08-20 14:00:00][Thread#12] 开始处理批次38 - 使用Thread.Sleep(1)强制线程切换重现竞态条件
7.3 避免死锁的编码规范
- 锁的获取顺序要全局一致
- 使用Monitor.TryEnter设置超时
- 不要在锁内调用外部代码
- 使用
lock而不是直接调用Monitor - 静态锁和实例锁要严格区分
我曾遇到的一个经典死锁:
- 线程A:先获取锁X,再申请锁Y
- 线程B:先获取锁Y,再申请锁X
解决方案:统一按字母顺序获取锁。
