C# 温故而知新:线程篇(一)

在.NET开发的世界里,线程始终是一个绕不开的核心话题。无论是构建高并发的Web服务,还是开发需要响应式UI的桌面应用,理解线程的底层原理和使用方式都至关重要。本文将从基础出发,结合原理剖析与代码实践,带你重新审视C#中的线程模型。### 一、线程的本质:从操作系统到CLR在深入代码之前,我们先厘清一个关键概念:线程是操作系统调度的最小执行单元。每个线程拥有独立的栈、寄存器上下文,但共享进程的堆内存。C#中的System.Threading.Thread类是对操作系统线程的封装,而CLR(公共语言运行时)在此基础上提供了更深层的托管线程模型。托管线程与OS线程的关系:CLR维护了一个线程池(ThreadPool),默认情况下,每个托管线程映射到一个OS线程。但值得注意的是,CLR在创建线程时会进行额外的初始化工作,比如分配ThreadStatic内存、初始化文化信息等。这意味着托管线程的创建成本远高于原生OS线程,这也是为什么.NET社区强调使用线程池而非手动创建线程的原因。### 二、线程的创建与生命周期控制让我们从最基础的线程创建开始。下面的示例演示了如何通过Thread类启动一个新线程,并正确管理其生命周期:csharpusing System;using System.Threading;class ThreadDemo{ static void Main() { // 创建一个新线程,指定其执行的入口方法 Thread worker = new Thread(DoWork); worker.Name = "WorkerThread"; // 设置线程名称,便于调试 // 设置为后台线程:主线程结束时,后台线程自动终止 worker.IsBackground = true; // 启动线程 worker.Start(); // 主线程执行其他操作 for (int i = 0; i < 5; i++) { Console.WriteLine($"主线程: {i}"); Thread.Sleep(100); // 模拟耗时操作 } // 等待工作线程完成(Join会阻塞当前线程直到目标线程结束) worker.Join(); Console.WriteLine("所有线程执行完毕"); } static void DoWork() { for (int i = 0; i < 10; i++) { Console.WriteLine($"工作线程: {i}"); Thread.Sleep(50); } }}代码解析:- Thread.Sleep是阻塞当前线程的简单方式,单位是毫秒- IsBackground属性决定线程是否为后台线程。前台线程会阻止进程退出,而后台线程不会- Join()方法用于同步等待,它会阻塞调用线程直到目标线程终止### 三、线程同步:为什么需要,以及如何正确使用多个线程访问共享数据时,会出现竞态条件(Race Condition)。看下面的经典例子:csharpusing System;using System.Threading;class RaceConditionDemo{ private static int counter = 0; private static readonly object lockObject = new object(); static void Main() { // 创建10个线程,每个线程对counter进行1000次自增 Thread[] threads = new Thread[10]; for (int i = 0; i < threads.Length; i++) { threads[i] = new Thread(Increment); threads[i].Start(); } // 等待所有线程完成 foreach (Thread t in threads) { t.Join(); } Console.WriteLine($"最终counter值: {counter}"); // 预期10000,但可能小于该值 } static void Increment() { for (int i = 0; i < 1000; i++) { // 未加锁版本:此处的++操作并非原子性 // counter++; // 如果使用这行,结果可能不正确 // 正确做法:使用lock关键字保证原子性 lock (lockObject) { counter++; } } }}原理剖析counter++在IL层面实际上是三条指令:读取值、加1、写回值。当多个线程同时执行时,可能出现:线程A读取counter=10,线程B也读取counter=10,然后A写入11,B也写入11,最终counter只增加1而不是2。这就是典型的竞态条件lock关键字在编译时会转换为Monitor.EnterMonitor.Exit调用,确保同一时刻只有一个线程能够进入临界区。但需要注意:- lock的对象必须是引用类型,且最好使用私有静态对象避免外部干扰- 过度使用lock会导致性能下降,因为线程需要等待锁释放### 四、线程池与任务并行库(TPL)现代C#开发中,我们更推荐使用Taskasync/await,它们基于线程池构建,提供了更高效的资源利用和更简洁的编程模型。csharpusing System;using System.Threading.Tasks;class TplDemo{ static void Main() { // 使用Task.Run在线程池中执行异步操作 Task<int> task = Task.Run(() => { // 模拟耗时计算 Task.Delay(500).Wait(); int sum = 0; for (int i = 1; i <= 100; i++) { sum += i; } return sum; }); // 使用async/await模式(需要async Main) Task<int> task2 = ComputeSumAsync(100); // 同步等待两个任务完成 Task.WaitAll(task, task2); Console.WriteLine($"Task1结果: {task.Result}"); Console.WriteLine($"Task2结果: {task2.Result}"); // 并行循环示例 long total = 0; Parallel.For(1, 1001, i => { Interlocked.Add(ref total, i); // 原子操作避免锁 }); Console.WriteLine($"并行求和结果: {total}"); } static async Task<int> ComputeSumAsync(int n) { await Task.Delay(200); // 模拟异步IO操作 return n * (n + 1) / 2; }}关键差异:- Task默认运行在线程池线程上,避免了频繁创建销毁线程的开销- async/await不会阻塞线程,而是挂起状态机,释放线程回线程池- Parallel.For自动在多个线程间分配工作,但需要谨慎使用Interlocked或锁来保护共享状态### 五、线程安全的最佳实践1. 优先使用不可变数据:对于共享数据,尽量设计为不可变(如readonly字段、string)2. 使用并发集合System.Collections.Concurrent命名空间下的ConcurrentDictionaryConcurrentQueue等专门为并发设计3. 避免使用Thread.Sleep做同步:应使用Monitor.WaitSemaphoreSlim等同步原语4. 注意死锁:始终以相同顺序锁定多个资源,或使用Monitor.TryEnter带超时### 六、总结本文从线程的底层原理出发,逐步深入到C#中的线程使用实践。我们认识到:- 线程是操作系统级的资源,其创建和切换都有成本- 托管线程在OS线程之上增加了额外抽象,使用线程池能显著提升效率- 多线程编程的核心挑战在于同步与互斥lockMonitor等机制是基础保障- 现代C#推荐使用Taskasync/await,它们提供了更安全、高效的并发模型理解这些基础概念后,后续我们将进一步探讨更高级的主题,如async/await的底层状态机、线程同步原语(SemaphoreSlim、Mutex)的适用场景,以及Span<T>等高性能解决方案在并发中的应用。线程的世界深邃而美妙,掌握了其本质,你就能够在高并发开发中游刃有余。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐