第2章_线程同步
第二章 线程同步
本章深入讲解多线程编程中的核心问题——线程同步。从竞态条件和临界区的基本概念出发,依次介绍互斥锁(lock)、监视器(Monitor)超时、互斥量(Mutex)进程间同步、读写锁(ReaderWriterLockSlim)、信号量(SemaphoreSlim)、自动/手动重置事件(AutoResetEvent/ManualResetEvent)等同步原语,并通过飞机座位预订和生产者-消费者两个作业加深理解。最后讨论线程亲和性、线程安全以及嵌套锁与死锁问题。
2.1 线程同步概述
本节要点
当多个线程需要访问共享资源时,如果没有适当的协调机制,就会产生冲突和数据不一致。本节通过两个生活类比和一个计数器演示,引出"线程同步"这一核心概念。
生活类比一:共享厨房
爸爸和妈妈同时在厨房做饭,灶台、切菜板、刀具、调料都是共享资源。如果没有协调,两人可能同时争抢同一工具,导致烹饪过程反而变慢。
生活类比二:银行账户
Tom 和 Jerry 共享一个账户,余额 1000 美元。两人几乎同时来取 800 美元,柜员 A 和柜员 B 分别服务他们。如果两个柜员之间没有同步:
- 柜员 A 查询余额:1000 美元 → 足够,给 Tom 800 美元
- 柜员 B 查询余额:1000 美元(还没更新)→ 也足够,给 Jerry 800 美元
- 结果:账户透支为 -600 美元,银行受损
计算机中的表现:共享计数器
两个线程同时对同一个 counter 变量执行自增操作。counter = counter + 1 这行代码不是原子操作,它实际分为两步:
- 读取 counter 的当前值到临时变量
- 将临时变量加 1 后写回 counter
当两个线程几乎同时执行时,可能都读到同一个值,各自加 1 后写回,导致两次自增只生效一次——这就是竞态条件(Race Condition)。
演示代码
using System;
using System.Threading;
int counter = 0;
Thread thread1 = new Thread(IncrementCounter);
Thread thread2 = new Thread(IncrementCounter);
// 异步并行启动两个线程
thread1.Start();
thread2.Start();
// 等待两个线程都完成
thread1.Join();
thread2.Join();
Console.WriteLine($"Final counter value is: {counter}");
void IncrementCounter()
{
for (int i = 0; i < 100000; i++)
{
counter = counter + 1;
}
}
对比:如果改为同步执行(thread1.Start() → thread1.Join() → thread2.Start() → thread2.Join()),结果恒为 200000。
运行结果 / 现象
- 同步执行:
Final counter value is: 200000(符合预期) - 异步并行执行:
Final counter value is: 149984(每次运行结果不同,但恒小于 200000)
竞态条件导致部分自增操作丢失,结果不确定且始终小于理论值。
易错点 / 小结
counter++/counter = counter + 1不是原子操作:它包含"读-改-写"三个步骤,多线程下会产生竞态条件。- 竞态条件的特征:结果不确定(每次运行不同)、且通常偏离预期值。
- 线程同步的目的:通过协调多个线程对共享资源的访问,保证数据一致性和结果可预期。
- 后续章节将依次介绍:临界区与原子操作、lock 互斥锁、Monitor 超时、Mutex 进程间同步、读写锁、信号量等同步技术。
2.2 临界区与原子操作
本节要点
本节在上一节计数器演示的基础上,引入两个核心概念:临界区(Critical Section) 和 原子操作(Atomic Operation)。
临界区
- 定义:代码中访问共享资源的那一部分区域。
- 在计数器示例中,
counter = counter + 1;这行代码就是临界区——它访问了在多个线程之间共享的counter变量。 - 对比:循环变量
i是在方法内部定义的局部变量,每个线程有自己独立的i,不涉及共享,因此不是临界区。 - 编译器会将
counter = counter + 1拆成多条指令(读 counter → 加 1 → 写回 counter),这些指令都在访问共享的 counter。
原子操作
- 定义:不可分割的操作,要么完整执行,要么完全不执行,不能被拆成多个步骤。
- 如果临界区内的操作是原子的(作为一步执行),那么即使多线程并发也不会有问题:
- 线程1 先执行:counter 从 1000 → 1001
- 线程2 再执行:看到 counter=1001,执行后 → 1002
- 结果正确,两次自增都生效。
- 问题根源:临界区存在(共享资源被多线程访问)且 临界区内的操作不是原子的(可被分割)。
- 解决方向:找到一种技术,使临界区内的代码变得不可分割——这正是后续锁、Monitor、Mutex 等同步机制的核心目标。
演示代码
本节无新增代码,沿用 P8 的计数器程序用于分析临界区:
using System;
using System.Threading;
int counter = 0;
Thread thread1 = new Thread(IncrementCounter);
Thread thread2 = new Thread(IncrementCounter);
thread1.Start();
thread2.Start();
thread1.Join();
thread2.Join();
Console.WriteLine($"Final counter value is: {counter}");
void IncrementCounter()
{
for (int i = 0; i < 100000; i++)
{
// ↓↓↓ 临界区:访问共享资源 counter ↓↓↓
counter = counter + 1;
// ↑↑↑ 临界区结束 ↑↑↑
}
}
编译器实际将 counter = counter + 1 拆解为等价的两步:
var temp = counter; // 步骤1:读取共享变量
counter = temp + 1; // 步骤2:写回共享变量
这两步之间,线程可能被切换,从而导致竞态条件。
易错点 / 小结
- 临界区 ≠ 整个方法:只有访问共享资源的代码行才是临界区,局部变量(如循环变量
i)不涉及共享。 counter++不是原子操作:即使写法上是一行,编译器/CPU 仍会拆成"读-改-写"多步。- 同步问题的两个必要条件:①存在临界区(共享资源被多线程访问);②临界区内操作非原子。两者缺一不可。
- 后续所有同步技术(lock / Monitor / Mutex / 读写锁 / 信号量)的本质都是:让临界区代码以原子方式执行。
2.3 互斥锁
本节要点
互斥锁(lock) 是 C# 中最基础、最常用的线程同步技术。它的作用是:确保被 lock 包裹的代码块(锁的主体)同一时刻最多只能由一个线程执行,从而使临界区内的操作表现得像原子操作一样。
基本语法
lock (lockObject)
{
// 临界区:最多一个线程同时执行
}
- 括号内是锁对象(必须是引用类型)
- 花括号内是锁的主体,通常就是临界区代码
- 当一个线程持有锁时,其他线程到达
lock语句会被阻塞,直到锁被释放
锁对象的声明(.NET 8 / C# 12 及之前)
object counterLock = new object();
- 可以是任意引用类型,不必一定是
object - 最佳实践:使用一个专用的锁对象,它唯一的目的就是保护临界区,不要复用其他业务对象作为锁
lock 的内部机制:try/finally
lock关键字内部由编译器展开为try { ... } finally { ... }结构- 即使锁主体内抛出异常,
finally块也会确保锁被释放 - 因此使用
lock时无需手动释放锁,也不用担心异常导致死锁
.NET 9 / C# 13 新趋势
- 推荐使用
System.Threading.Lock类型替代new object()作为锁对象 - 本节演示环境为 .NET 8 / C# 12,仍使用
object方式
演示代码
using System;
using System.Threading;
int counter = 0;
object counterLock = new object(); // 专用锁对象
Thread thread1 = new Thread(IncrementCounter);
Thread thread2 = new Thread(IncrementCounter);
thread1.Start();
thread2.Start();
thread1.Join();
thread2.Join();
Console.WriteLine($"Final counter value is: {counter}");
void IncrementCounter()
{
for (int i = 0; i < 100000; i++)
{
lock (counterLock) // 互斥锁:保证同一时刻只有一个线程执行下面的代码
{
counter = counter + 1;
}
}
}
运行结果 / 现象
- 加锁前(P8):
Final counter value is: 149984(每次不同,恒小于 200000) - 加锁后:
Final counter value is: 200000(每次运行结果一致,完全正确)
尽管 counter = counter + 1 本身不是原子操作,但 lock 保证了同一时刻只有一个线程能执行它,因此表现得如同原子操作,竞态条件被彻底消除。
易错点 / 小结
- 锁对象必须是引用类型:值类型(如 int)不能作为锁对象,会导致装箱和身份不一致问题。
- 不要用
this或字符串字面量作为锁对象:this可能被外部代码意外锁定;字符串可能被 CLR 留用(intern),导致跨实例意外共享锁。 - 锁的粒度要适中:锁太粗(包住整个循环)会降低并发度;锁太细(只包一行)在复杂逻辑中可能不够。本例中锁包住单条自增语句是正确的最小粒度。
- lock 自动释放:无需手动调用释放,异常安全。
- 专用锁对象最佳实践:为每个需要保护的共享资源声明一个独立的、只读的锁对象,职责单一。
2.4 作业2:飞机座位预订系统(问题)
作业题目
标题:Assignment 2 - Airplane Seats Booking System(飞机座位预订系统)
背景
航空公司的在线机票预订系统是典型的多线程并发场景。当仅剩一个座位时,如果有两个用户同时在线点击预订,而系统没有正确的线程同步机制,两人可能都会成功预订同一个座位——这在现实中会导致登机时座位冲突。
任务要求
基于作业1(模拟 Web 服务器)的控制台项目框架,改造实现一个飞机座位预订系统:
- 请求队列:维护一个预订请求队列(
Queue<string>),用户输入的请求先入队。 - 主线程:负责读取用户控制台输入并将请求入队。
- 监控线程:持续检查队列,当有请求时出队并处理(预订/取消座位)。
- 可用座位计数器:维护一个表示剩余可用座位数的变量,这是被多个线程访问的共享资源。
- 互斥锁保护临界区:对访问和修改可用座位数的临界区代码应用
lock,确保线程安全。 - 用户指令:
- 输入字母
B(Book):表示用户想要预订一个座位(可用座位数减 1) - 输入字母
C(Cancel):表示用户想要取消预订(可用座位数加 1)
- 输入字母
- 边界处理:当没有可用座位时,预订请求应被拒绝并提示;取消预订不应超过初始座位总数。
基础框架(作业1 Web 服务器代码,需在此基础上修改)
using System;
using System.Collections.Generic;
using System.Threading;
Queue<string> requestQueue = new Queue<string>();
// 主线程:读取用户输入并入队
while (true)
{
string? input = Console.ReadLine();
if (input != null)
{
requestQueue.Enqueue(input);
}
}
// 监控线程:检查队列并处理请求
void MonitorQueue()
{
while (true)
{
if (requestQueue.Count > 0)
{
string? input = requestQueue.Dequeue();
if (input != null)
{
Thread processingThread = new Thread(() => ProcessInput(input));
processingThread.Start();
}
}
Thread.Sleep(100);
}
}
// 处理请求
void ProcessInput(string input)
{
// 模拟处理时间
Thread.Sleep(2000);
Console.WriteLine($"Processed input: {input}");
}
提示:你需要在此框架中添加可用座位计数器、锁对象,并在
ProcessInput中根据输入是B还是C来增减座位数,同时用lock保护临界区。
易错点 / 小结
- 共享资源识别:可用座位计数器是核心共享资源,所有读写操作都必须在
lock保护下进行。 - 检查与操作的原子性:"检查是否有座位"和"扣减座位数"必须放在同一个
lock块内,否则检查通过后、扣减前可能被其他线程抢占。 - 取消操作也要加锁:不要只给预订(B)加锁而忽略取消(C),两者都修改共享计数器。
- 初始座位数:自行设定一个合理的初始值(如 10 个座位),便于测试超订场景。
- 下一节(P12)将给出完整参考答案。
2.5 作业2(答案):飞机座位预订系统
本节要点
本节给出飞机座位预订系统的完整实现。核心思路是在作业1 Web 服务器框架基础上,将 ProcessInput 改造为 ProcessBooking,引入可用座位计数器 availableTickets 和专用锁对象 ticketsLock,用 lock 保护预订/取消的临界区。
关键设计决策
- 共享资源:
availableTickets(可用座位数)被监控线程和多个处理线程同时访问,是核心共享资源。 - 临界区范围:
ProcessBooking中对availableTickets的"检查-修改-输出"全部放在lock (ticketsLock)内,确保检查和扣减之间不会被其他线程打断。 - 预订(B)逻辑:
availableTickets > 0时扣减并提示成功;否则输出"机票不可用"。 - 取消(C)逻辑:
availableTickets < 10(初始座位数)时递增并提示成功;否则输出"无法取消"(防止超量取消)。 - 模拟处理延迟:
Thread.Sleep(2000)模拟真实预订处理时间,增加并发冲突概率。
演示代码
using System;
using System.Collections.Generic;
using System.Threading;
Queue<string> requestQueue = new Queue<string>();
int availableTickets = 10; // 初始可用座位数(小型飞机)
object ticketsLock = new object(); // 专用锁对象
// 启动请求队列监控线程
Thread monitoringThread = new Thread(MonitorQueue);
monitoringThread.Start();
// 主线程:读取用户输入并入队
Console.WriteLine("Server is running.");
Console.WriteLine("Type 'B' to book a ticket.");
Console.WriteLine("Type 'C' to cancel a booking.");
Console.WriteLine();
while (true)
{
string? input = Console.ReadLine();
if (input != null)
{
if (input.ToLower() == "exit")
{
break;
}
requestQueue.Enqueue(input);
}
}
// 监控线程:持续检查队列并处理请求
void MonitorQueue()
{
while (true)
{
if (requestQueue.Count > 0)
{
string? input = requestQueue.Dequeue();
if (input != null)
{
// 每个请求启动一个独立处理线程
Thread processingThread = new Thread(() => ProcessBooking(input));
processingThread.Start();
}
}
Thread.Sleep(100);
}
}
// 处理预订/取消请求
void ProcessBooking(string input)
{
// 模拟处理时间
Thread.Sleep(2000);
// 互斥锁:保护 availableTickets 的检查与修改
lock (ticketsLock)
{
if (input == "b")
{
// 预订座位
if (availableTickets > 0)
{
availableTickets--;
Console.WriteLine();
Console.WriteLine($"Your seat is booked. {availableTickets} seats are still available.");
}
else
{
Console.WriteLine("Tickets are not available.");
}
}
else if (input == "c")
{
// 取消预订
if (availableTickets < 10)
{
availableTickets++;
Console.WriteLine();
Console.WriteLine($"Your booking is canceled. {availableTickets} seats are available.");
}
else
{
Console.WriteLine("Error. You cannot cancel a booking at this time.");
}
}
}
}
运行结果 / 现象
Server is running.
Type 'B' to book a ticket.
Type 'C' to cancel a booking.
b
Your seat is booked. 9 seats are still available.
b
Your seat is booked. 8 seats are still available.
b
Your seat is booked. 7 seats are still available.
...(连续预订直到0)
b
Tickets are not available.
c
Your booking is canceled. 1 seats are available.
c
Your booking is canceled. 2 seats are available.
...(连续取消直到10)
c
Error. You cannot cancel a booking at this time.
- 预订(B):座位数从 10 递减,到 0 后提示"Tickets are not available."
- 取消(C):座位数递增,到 10(满座)后提示"Error. You cannot cancel a booking at this time."
- 加锁后,即使多个处理线程并发运行,座位数也不会出现超卖或负数。
易错点 / 小结
- 检查与操作必须在同一锁内:
if (availableTickets > 0)和availableTickets--必须放在同一个lock块中。如果只锁扣减不锁检查,两个线程可能都通过检查后再依次扣减,导致超卖。 - 取消操作也要加锁:不要只给预订(B)加锁,取消(C)同样修改共享计数器,必须在同一把锁的保护下。
- 边界条件:取消时检查
availableTickets < 10(初始座位数),防止取消次数超过已预订数导致座位数超过容量。 - 输出也放在锁内:虽然 Console.WriteLine 本身是线程安全的,但为了保证输出内容与座位数一致(避免另一个线程在判断和输出之间修改了值),将输出也包含在临界区内。
- 锁对象专用:
ticketsLock是一个独立的object,唯一用途就是保护座位计数器,不要复用业务对象。 - 控制台输入难以复现竞态:单人控制台输入速度慢,竞态条件概率低,但在真实高并发 Web 场景下必须加锁。
2.6 使用监视器为锁添加超时功能
本节要点
监视器(Monitor) 是比 lock 更底层、更灵活的线程同步类。lock 关键字本质上由编译器展开为 Monitor.Enter + try/finally + Monitor.Exit。Monitor 提供了 lock 不具备的超时控制能力。
Monitor 的核心概念
- 监视器"监控"临界区:当一个线程进入临界区后,其他线程必须等待。
Monitor.Enter(obj):尝试获取锁,成功则生成互斥锁,其他线程被阻塞。Monitor.Exit(obj):释放锁,必须在临界区结束时调用。- 使用
try/finally确保即使抛出异常也能释放锁。
lock 与 Monitor 的关系
// lock 写法(编译器自动展开为下面的 Monitor 写法)
lock (counterLock)
{
counter = counter + 1;
}
// 等价的 Monitor 写法
Monitor.Enter(counterLock);
try
{
counter = counter + 1;
}
finally
{
Monitor.Exit(counterLock);
}
Monitor.TryEnter 超时功能
Monitor.TryEnter(obj, timeoutMilliseconds)返回bool:true:在超时时间内成功获取锁false:超时仍未获取锁,线程不再阻塞
- 这解决了
lock的一个痛点:lock会无限期阻塞,用户无法知道系统状态。 - 典型场景:航空公司订票系统高并发时,用户等待 5-10 秒却没有任何反馈,体验很差。使用
TryEnter超时后可以输出"系统繁忙,请稍后再试"。
演示代码
示例一:计数器的 Monitor 基本语法(等价于 lock)
using System;
using System.Threading;
int counter = 0;
object counterLock = new object();
Thread thread1 = new Thread(IncrementCounter);
Thread thread2 = new Thread(IncrementCounter);
thread1.Start();
thread2.Start();
thread1.Join();
thread2.Join();
Console.WriteLine($"Final counter value is: {counter}");
void IncrementCounter()
{
for (int i = 0; i < 100000; i++)
{
Monitor.Enter(counterLock); // 尝试进入临界区,获取锁
try
{
counter = counter + 1; // 临界区
}
finally
{
Monitor.Exit(counterLock); // 释放锁
}
}
}
示例二:飞机订票系统的 Monitor.TryEnter 超时版本
using System;
using System.Collections.Generic;
using System.Threading;
Queue<string> requestQueue = new Queue<string>();
int availableTickets = 10;
object ticketsLock = new object();
Thread monitoringThread = new Thread(MonitorQueue);
monitoringThread.Start();
Console.WriteLine("Server is running.");
Console.WriteLine("Type 'B' to book a ticket.");
Console.WriteLine("Type 'C' to cancel a booking.");
Console.WriteLine();
while (true)
{
string? input = Console.ReadLine();
if (input != null)
{
if (input.ToLower() == "exit") break;
requestQueue.Enqueue(input);
}
}
void MonitorQueue()
{
while (true)
{
if (requestQueue.Count > 0)
{
string? input = requestQueue.Dequeue();
if (input != null)
{
Thread processingThread = new Thread(() => ProcessBooking(input));
processingThread.Start();
}
}
Thread.Sleep(100);
}
}
void ProcessBooking(string input)
{
// TryEnter:最多等待 2000 毫秒获取锁
if (Monitor.TryEnter(ticketsLock, 2000))
{
try
{
// 模拟处理时间(放在临界区内,持锁期间其他线程无法进入)
Thread.Sleep(3000);
if (input == "b")
{
if (availableTickets > 0)
{
availableTickets--;
Console.WriteLine();
Console.WriteLine($"Your seat is booked. {availableTickets} seats are still available.");
}
else
{
Console.WriteLine("Tickets are not available.");
}
}
else if (input == "c")
{
if (availableTickets < 10)
{
availableTickets++;
Console.WriteLine();
Console.WriteLine($"Your booking is canceled. {availableTickets} seats are available.");
}
else
{
Console.WriteLine("Error. You cannot cancel a booking at this time.");
}
}
}
finally
{
Monitor.Exit(ticketsLock); // 确保释放锁
}
}
else
{
// 超时未获取锁,向用户反馈系统状态
Console.WriteLine("The system is busy. Please wait.");
}
}
运行结果 / 现象
模拟高并发(快速连续输入多个 B):
b
The system is busy. Please wait.
b
The system is busy. Please wait.
b
Your seat is booked. 9 seats are still available.
bThe system is busy. Please wait.
bThe system is busy. Please wait.
...
Your seat is booked. 8 seats are still available.
- 获取锁成功的线程:正常完成预订,输出座位信息。
- 超时(2秒内未获取锁)的线程:输出
The system is busy. Please wait.,不再无限期阻塞。 - 视频中先将处理时间设为 10 秒、超时 2 秒来触发大量超时;后改为 3 秒处理时间使部分预订能成功。
易错点 / 小结
- lock 是 Monitor 的语法糖:编译器将
lock展开为Monitor.Enter+try/finally+Monitor.Exit,两者效果完全相同。 - Monitor.Exit 必须在 finally 中调用:如果临界区抛出异常而没有 finally,锁将永远不会释放,导致死锁。
- TryEnter 的返回值必须检查:
Monitor.TryEnter不会抛出异常,超时只是返回false。如果不检查返回值就直接进入临界区,等于没有加锁。 - 超时时间要合理:太短会导致大量请求失败,太长则失去超时意义。需根据业务处理时间调整。
- 处理时间放在临界区内:视频中将
Thread.Sleep移入Monitor块内,模拟持锁时间长的场景,从而触发其他线程的超时。 - 更好的用户体验:可以用循环反复
TryEnter,而不是超时后直接放弃,但至少超时给了输出反馈的机会。 - Monitor 还支持 Wait/Pulse:本节未涉及,Monitor 还有
Wait()、Pulse()、PulseAll()方法用于线程间通信(条件变量模式)。
2.7 使用互斥量在进程间进行同步
本节要点
互斥量(Mutex) 是一种操作系统级别的同步原语,语法与 Monitor 相似,但核心优势是支持跨进程同步。lock 和 Monitor 只能在同一进程内的线程之间生效,当多个进程需要访问同一共享资源(如文件)时,必须使用命名 Mutex。
Mutex 基本语法(本地互斥量)
using (var mutex = new Mutex())
{
mutex.WaitOne(); // 获取互斥量所有权
try
{
// 临界区
}
finally
{
mutex.ReleaseMutex(); // 释放互斥量
}
}
new Mutex():创建互斥量时尚未获得所有权。WaitOne():阻塞等待,直到获得互斥量所有权。ReleaseMutex():释放互斥量,必须在 finally 中调用。using语句:确保 Mutex 对象(操作系统资源)被释放。
进程的概念
应用程序的层次结构:应用程序(App)→ 进程(Process)→ 线程(Thread)
- 一个应用程序可以包含多个进程(如浏览器每个标签页/扩展一个进程)。
- 一个进程可以包含多个线程。
- 任务管理器中应用名称后的数字(如 Microsoft Edge (16))表示该应用有 16 个进程。
为什么需要跨进程同步
当多个进程同时访问同一共享资源(如一个文件)时,会产生与多线程相同的竞态条件。但 lock/Monitor 是进程内的同步机制,无法跨进程生效——视频中实测:用 lock 保护文件读写,两个进程同时运行结果仍然远小于预期值。
命名互斥量(Named Mutex)
using (var mutex = new Mutex(false, "GlobalFileMutex"))
- 第一个参数
initiallyOwned:false表示创建时不立即获得所有权。 - 第二个参数
name:给互斥量命名后,它成为操作系统级别的全局对象,不同进程可以通过相同名称获取同一个互斥量。 - 名称应尽量唯一(可包含文件名等标识),避免与其他程序的命名互斥量冲突。
演示代码
完整示例:跨进程文件计数器(命名 Mutex 版本)
using System;
using System.IO;
using System.Threading;
string filePath = "counter.txt";
// 使用命名互斥量,名称 "GlobalFileMutex" 在操作系统全局唯一
using (var mutex = new Mutex(false, "GlobalFileMutex"))
{
for (int i = 0; i < 10000; i++)
{
mutex.WaitOne(); // 获取互斥量(跨进程互斥)
try
{
// 临界区:读取-修改-写回共享文件
int counter = ReadCounter(filePath);
counter++;
WriteCounter(filePath, counter);
}
finally
{
mutex.ReleaseMutex(); // 释放互斥量
}
}
}
Console.WriteLine("Process finished.");
Console.ReadLine();
// 从文件读取计数器值
int ReadCounter(string filePath)
{
using var stream = new FileStream(
filePath,
FileMode.OpenOrCreate,
FileAccess.Read,
FileShare.ReadWrite); // 允许其他进程同时打开
using var reader = new StreamReader(stream);
string content = reader.ReadToEnd();
return string.IsNullOrEmpty(content) ? 0 : int.Parse(content);
}
// 将计数器值写回文件
void WriteCounter(string filePath, int counter)
{
using var stream = new FileStream(
filePath,
FileMode.OpenOrCreate,
FileAccess.Write,
FileShare.ReadWrite); // 允许其他进程同时打开
using var writer = new StreamWriter(stream);
writer.Write(counter);
}
无同步版本:去掉
mutex.WaitOne()/ReleaseMutex()和using (var mutex = ...),直接在循环中读写文件,用于对比竞态条件。
运行结果 / 现象
无同步(两个进程同时运行)
- 每个进程循环 10000 次,预期结果:20000
- 实际结果:远小于 20000(如 14000+),每次运行结果不同
- 原因:两个进程同时读取文件中的旧值,各自加 1 后写回,部分增量丢失
用 lock 保护(两个进程同时运行)
- 结果仍然小于 20000
- 原因:
lock是进程内同步机制,进程 A 的锁对进程 B 完全无效
用命名 Mutex 保护(两个进程同时运行)
- 结果:20000(完全正确,每次运行一致)
- 三个进程同时运行:结果 30000
- 原因:命名 Mutex 是操作系统全局对象,跨进程互斥生效
易错点 / 小结
- Mutex 创建 ≠ 获得所有权:
new Mutex()只是创建对象,必须调用WaitOne()才能获得所有权。 ReleaseMutex只能由持有锁的线程调用:如果线程 A 调用WaitOne()获取锁,线程 B 调用ReleaseMutex()会抛出ApplicationException。- 命名 Mutex 名称要唯一:操作系统全局共享,名称冲突会导致意外同步。建议使用包含公司/产品/文件名的唯一名称。
FileShare.ReadWrite是必要的:允许多个进程同时打开文件,否则第二个进程打开文件时会抛出异常,根本无法进入竞态场景。- lock/Monitor 不能跨进程:它们是进程内用户态同步机制,不同进程的锁对象互不可见。
- Mutex 是操作系统资源:创建和管理成本高于 lock/Monitor,仅在需要跨进程同步时使用。
- 最佳实践选择:
- 进程内线程同步 → 优先用
lock/Monitor - 跨进程同步 → 必须用命名
Mutex
- 进程内线程同步 → 优先用
- using 双重释放:外层
using (var mutex = ...)释放 Mutex 对象本身,内层 try/finally 的ReleaseMutex()释放锁所有权,两者职责不同。
2.8 读写锁
本节要点
读写锁(ReaderWriterLockSlim) 是一种优化的同步原语,适用于"读多写少"的场景。普通锁(lock/Monitor/Mutex)总是互斥的——即使是只读操作也会阻塞其他所有线程。而读写锁区分读取和写入:
- 读取线程之间共享锁:多个读取线程可以同时获取读锁,并发读取共享资源,因为读取不会修改数据,不会引发竞态条件。
- 写入线程独占锁:当一个写入线程获取写锁时,其他所有线程(无论读还是写)都被阻塞,确保写入时数据一致性。
- 读锁持有期间,写锁无法获取:有读者在读时,写者必须等待所有读者释放读锁。
为什么需要读写锁
普通锁会让所有读取操作串行化,在高并发读场景下严重影响性能。典型场景:
- 数据库:SQL Server 中 SELECT 语句加共享锁(Shared Lock),UPDATE/INSERT/DELETE 加互斥锁(Exclusive Lock),本质就是读写锁机制。
- Web 服务器共享缓存:配置信息加载到内存缓存后,大量请求读取配置,偶尔有请求更新配置。读写锁允许并发读取,仅在写入时独占。
ReaderWriterLockSlim 是推荐版本
.NET 提供了 ReaderWriterLock 和 ReaderWriterLockSlim 两个类。ReaderWriterLockSlim 是轻量、高性能的改进版本,应优先使用。
演示代码
线程安全的全局配置缓存(ReaderWriterLockSlim 最终版)
using System;
using System.Collections.Generic;
using System.Threading;
public class GlobalConfigurationCache
{
// 读写锁实例
private ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
// 共享缓存:普通 Dictionary 非线程安全,需读写锁保护
private Dictionary<int, string> _cache = new Dictionary<int, string>();
// 写入操作:添加或更新缓存项
public void Add(int key, string value)
{
bool lockAcquired = false;
try
{
_lock.EnterWriteLock(); // 获取写锁(独占)
lockAcquired = true;
_cache[key] = value; // 临界区:写入共享资源
}
finally
{
if (lockAcquired)
{
_lock.ExitWriteLock(); // 释放写锁
}
}
}
// 读取操作:获取缓存项
public string? Get(int key)
{
bool lockAcquired = false;
try
{
_lock.EnterReadLock(); // 获取读锁(共享,多个读者可同时持有)
lockAcquired = true;
return _cache.TryGetValue(key, out var value) ? value : null;
}
finally
{
if (lockAcquired)
{
_lock.ExitReadLock(); // 释放读锁
}
}
}
}
无锁版本(有线程安全问题,仅作对比)
public class GlobalConfigurationCache
{
private Dictionary<int, string> _cache = new Dictionary<int, string>();
public void Add(int key, string value)
{
_cache[key] = value; // 非原子操作,多线程并发写入可能损坏 Dictionary 内部状态
}
public string? Get(int key)
{
return _cache.TryGetValue(key, out var value) ? value : null;
}
}
运行结果 / 现象
- 无锁版本:在高并发下可能出现 Dictionary 内部状态损坏,导致随机异常或数据不一致(难以稳定复现)。
- 普通 lock 版本:线程安全,但所有读取操作串行化,高并发读场景下性能瓶颈明显。
- ReaderWriterLockSlim 版本:
- 多个读取线程可并发读取缓存,吞吐量高。
- 写入线程获取写锁时独占,保证数据一致性。
lockAcquired模式确保即使EnterReadLock()/EnterWriteLock()抛出异常,也不会错误地释放未获取的锁。
易错点 / 小结
- 读写锁适用于"读多写少":如果写入频繁,读写锁的开销可能反而高于普通锁,因为读写锁需要维护读者计数等状态。
ReaderWriterLockSlim优于ReaderWriterLock:前者是轻量改进版,性能更好,应优先使用。- 读锁是共享的,写锁是独占的:多个读者可同时持有读锁;写者必须等待所有读者释放后才能获取写锁。
lockAcquired模式很重要:如果EnterReadLock()或EnterWriteLock()在获取锁之前抛出异常(如 ThreadAbortException),没有lockAcquired检查就调用Exit会抛出SynchronizationLockException。- 升级锁(UpgradeableReadLock):
ReaderWriterLockSlim还支持EnterUpgradeableReadLock(),允许先读后写的原子升级,适用于"先检查再更新"模式。本节未演示。 - 普通 Dictionary 不是线程安全的:即使看似一行的
_cache[key] = value,内部涉及哈希计算、桶查找、节点插入等多步操作,并发写入可能损坏内部数据结构。 - 替代方案:如果只是需要一个线程安全的键值集合,可以直接使用
ConcurrentDictionary<TKey, TValue>(第八章并发集合),无需手动管理锁。
2.9 使用信号量限制线程数量
本节要点
信号量(Semaphore) 是一种不同于锁的同步原语。锁(lock/Monitor/Mutex/读写锁)的核心目的是保护临界区、避免竞态条件;而信号量更常用于限制并发执行的线程或进程数量。
为什么需要限制线程数量
在 Web 服务器模拟中,监控线程从请求队列取出请求后,为每个请求创建一个处理线程。如果瞬间有数千个请求入队,就会在短时间内创建数千个线程同时运行,这对服务器来说负载过重。真实的 Web 服务器和数据库服务器都使用连接池来限制并发连接数(如 Azure SQL Server 默认约 100 个并发连接)。
信号量的工作原理
- 构造时指定初始计数(initialCount)和最大计数(maxCount),通常两者相同。
Wait():尝试进入受保护区域,当前计数减 1;如果计数为 0,则阻塞等待。Release():离开受保护区域,当前计数加 1;返回释放前的计数(previous count)。- 例如最大计数为 3:前 3 个线程调用
Wait()后计数变为 0,第 4 个线程调用Wait()时被阻塞,直到某个线程调用Release()后计数恢复,第 4 个线程才能进入。
信号量无线程亲和性(Thread Affinity)
lock/Monitor/Mutex/读写锁都有线程亲和性:哪个线程获取的锁,必须由同一个线程释放。- 信号量没有这个限制:
Wait()和Release()可以在不同线程中调用。 - 本节示例中,
semaphore.Wait()在监控线程中调用,semaphore.Release()在处理线程中调用——这正是信号量的典型用法。
SemaphoreSlim vs Semaphore
SemaphoreSlim:轻量级版本,仅用于进程内,性能更好,优先使用。Semaphore:操作系统级,支持命名(跨进程共享),但更重。需要跨进程时才使用。
演示代码
完整示例:用 SemaphoreSlim 限制 Web 服务器并发处理线程数
using System;
using System.Collections.Generic;
using System.Threading;
Queue<string> requestQueue = new Queue<string>();
object queueLock = new object(); // 保护普通 Queue 的入队/出队
// 信号量:最多允许 3 个线程并发执行受保护代码
using SemaphoreSlim semaphore = new SemaphoreSlim(initialCount: 3, maxCount: 3);
// 启动请求队列监控线程
Thread monitoringThread = new Thread(MonitorQueue);
monitoringThread.Start();
// 主线程:读取用户输入并入队
Console.WriteLine("Server is running. Type 'exit' to stop.");
while (true)
{
string? input = Console.ReadLine();
if (input != null)
{
if (input.ToLower() == "exit")
{
break;
}
lock (queueLock)
{
requestQueue.Enqueue(input);
}
}
}
// 监控线程:检查队列,为每个请求创建处理线程
void MonitorQueue()
{
while (true)
{
if (requestQueue.Count > 0)
{
string? input;
lock (queueLock)
{
input = requestQueue.Dequeue();
}
// 在监控线程中获取信号量(计数减1,为0则阻塞)
semaphore.Wait();
Thread processingThread = new Thread(() => ProcessInput(input));
processingThread.Start();
}
Thread.Sleep(100);
}
}
// 处理线程:处理请求,完成后释放信号量
void ProcessInput(string? input)
{
try
{
// 模拟处理时间
Thread.Sleep(2000);
Console.WriteLine($"Processed input: {input}");
}
finally
{
// 在处理线程中释放信号量(计数加1),返回释放前的计数
var prevCount = semaphore.Release();
Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} released the semaphore. Previous count is: {prevCount}");
}
}
跨进程命名信号量(仅作对比,非主代码)
// 命名信号量是操作系统全局对象,可跨进程同步
// 注意:跨进程必须使用 Semaphore 类,不能使用 SemaphoreSlim
using Semaphore semaphore = new Semaphore(initialCount: 3, maxCount: 3, name: "GlobalSemaphore");
运行结果 / 现象
输入单个 A:
Processed input: A
Thread 12 released the semaphore. Previous count is: 2
- 线程获取信号量后计数从 3 变为 2,释放时返回先前计数 2。
连续输入 A A(两个请求):
Processed input: A
Thread 3 released the semaphore. Previous count is: 1
Processed input: A
Thread 13 released the semaphore. Previous count is: 2
- 第一个线程获取后计数 3→2,第二个获取后 2→1;释放时分别返回 1 和 2。
连续输入 4 个请求(超过最大计数 3):
- 前 3 个线程并发进入处理,第 4 个线程在
semaphore.Wait()处阻塞。 - 当第一个处理线程调用
Release()后,计数恢复,第 4 个线程立即进入。 - 释放时返回的先前计数可能为 0(因为释放后立即被等待的线程获取)。
大量连续输入:
- 始终只有最多 3 个处理线程并发运行,其余请求排队等待。
- 处理速度变慢,但服务器不会因线程过多而过载。
易错点 / 小结
- 信号量主要用于限流,不是保护临界区:本节示例中信号量限制的是并发处理线程数,队列的入队/出队仍需单独加
lock保护。 Wait()和Release()可以跨线程调用:这是信号量与其他锁的关键区别。监控线程调用Wait(),处理线程调用Release(),完全合法。Release()返回先前计数:可用于调试和日志,了解释放时信号量的状态。- 初始计数通常等于最大计数:如果初始计数小于最大计数,意味着创建时已有部分"槽位"被占用,需要后续手动释放,适用于特殊场景。
- 优先使用
SemaphoreSlim:进程内限流用SemaphoreSlim,轻量高效;只有跨进程同步才需要命名Semaphore。 SemaphoreSlim不支持命名:命名信号量必须用Semaphore类,SemaphoreSlim只能进程内使用。- 不要忘记释放信号量:
Release()应放在 finally 中,确保即使处理过程抛出异常,信号量计数也能恢复,否则会导致"槽位泄漏",最终所有线程都被阻塞。 - 实际应用中的计数选择:视频中用 3 仅为演示,实际应用应根据服务器处理能力选择 50、100 等合适数值。
- 普通 Queue 需要额外保护:
Enqueue(主线程)和Dequeue(监控线程)属于不同线程,普通Queue<T>不是线程安全的,必须加锁;后续章节将介绍ConcurrentQueue等并发集合来替代。
2.10 使用自动重置事件进行信号通知
本节要点
AutoResetEvent(自动重置事件)用于不同线程之间的信号传递,它不是用来保护临界区的,而是用来在生产者与消费者之间传递"有产品了,可以消费了"这类信号。
核心类比——农民与猪: 农民(生产者线程)在喂食站前挂出一个标志(信号),三只小猪(消费者线程)在等待。一旦某只猪看到标志进入喂食站,标志会自动被取下(自动重置),其余猪继续等待。农民再次生产食物时重新挂出标志。
二进制信号机制:
- AutoResetEvent 维护一个二进制信号,只有两种状态:信号状态(signaled,标志挂出)和非信号状态(non-signaled,标志取下)。
- 生产者调用
Set()将信号置为"有信号"。 - 消费者调用
WaitOne()阻塞等待;一旦信号变为有信号,其中一个等待线程被放行,同时信号自动重置为非信号状态。 - 如果多个线程在
WaitOne()上等待,一次Set()只能唤醒一个线程;连续多次Set()也最多唤醒与等待线程数匹配的线程,多余的信号会丢失。
基本语法三要素:
- 声明:
using AutoResetEvent autoResetEvent = new AutoResetEvent(false);— 参数false表示初始状态为非信号(无产品),若启动时已有产品则传true。推荐用using语句确保非托管资源释放。 - 消费者等待:
autoResetEvent.WaitOne();— 阻塞当前线程直到收到信号。 - 生产者发信号:
autoResetEvent.Set();— 将信号置为有信号,放行一个等待线程。
信号丢失问题: 视频中的演示程序存在缺陷——如果生产者连续快速发送多次 go 信号,而消费者处理时间较长(Thread.Sleep(2000)),多余的信号会丢失,并非所有"产品"都能被消费。要解决这个问题,需要将产品放入队列(Queue),消费者从队列取数据,这正是后续作业3(生产者-消费者双向通信)要解决的场景。
关键定位: AutoResetEvent 用于线程交互/信号通知,而非保护共享资源;但发送信号的目的通常是为了让消费者去消费共享资源。
演示代码
以下为视频最终版本——多工作线程 + 主线程发信号的完整可运行代码(.NET 8 / top-level statements):
using System.Threading;
// 声明自动重置事件,初始状态为非信号(false = 无产品)
using AutoResetEvent autoResetEvent = new AutoResetEvent(false);
string? userInput = null;
Console.WriteLine("Server is running. Type 'go' to proceed");
// 启动 3 个工作线程(消费者),均在 WaitOne() 上等待信号
for (int i = 0; i < 3; i++)
{
Thread workerThread = new Thread(Worker);
workerThread.Name = $"Worker {i + 1}";
workerThread.Start();
}
// 主线程(生产者):接收用户输入,输入 "go" 时发送一次信号
while (true)
{
userInput = Console.ReadLine();
// Signal the worker thread if the input is "go"
if (userInput != null && userInput.ToLower() == "go")
{
autoResetEvent.Set();
}
}
void Worker()
{
while (true)
{
Console.WriteLine($"{Thread.CurrentThread.Name} is waiting for the signal");
// 阻塞等待信号;收到信号后自动重置为非信号状态
autoResetEvent.WaitOne();
Console.WriteLine($"{Thread.CurrentThread.Name} proceeds...");
// 模拟处理时间(2 秒)
Thread.Sleep(2000);
}
}
单工作线程版本(视频前半段演示):将
for循环替换为单个Thread workerThread = new Thread(Worker); workerThread.Start();,Worker方法中不使用Thread.CurrentThread.Name,直接写固定文本即可。逻辑完全一致,只是消费者数量不同。
运行结果 / 现象
控制台输出(多线程版本,连续输入两次 go):
Server is running. Type 'go' to proceed
Worker 1 is waiting for the signal
Worker 2 is waiting for the signal
Worker 3 is waiting for the signal
go
Worker 1 proceeds...
Worker 1 is waiting for the signal
go
Worker 2 proceeds...
Worker 2 is waiting for the signal
- 每次输入
go,只有一个等待中的工作线程被唤醒并继续执行,执行完 2 秒后回到等待状态。 - 哪个线程被唤醒没有特定模式,由操作系统调度决定。
- 若连续快速输入多次
go(如 10 次),由于信号自动重置且消费者处理需要 2 秒,多余信号会丢失,实际只有部分线程能被唤醒。
易错点 / 小结
- AutoResetEvent 不是锁:它不保护临界区/共享资源,只负责线程间信号通知。不要用它替代
lock。 - 信号会自动重置:一次
Set()只放行一个WaitOne()线程,放行后信号立即回到非信号状态。这与 ManualResetEvent(一次 Set 放行所有等待线程,需手动 Reset)形成对比。 - 信号可能丢失:如果在没有线程等待时调用
Set(),信号会被置为有信号状态,但下一个WaitOne()会立即消费它并重置;如果连续多次Set()而消费者来不及处理,多余信号不会累积,会丢失。 - 初始状态参数:
new AutoResetEvent(false)表示启动时无信号(消费者需等待);true表示启动时已有信号(第一个WaitOne()立即通过)。 - using 释放资源:AutoResetEvent 封装了非托管的 Windows 事件内核对象,应使用
using语句或在finally中调用Dispose()释放。 - 解决信号丢失需配合队列:要保证每个产品都被消费,应将产品放入队列,AutoResetEvent 仅作为"队列非空"的通知信号,这是作业3生产者-消费者模式的核心思路。
2.11 使用手动重置事件释放多个线程
本节要点
ManualResetEvent(手动重置事件)与 AutoResetEvent 类似,都是二进制信号机制,核心区别在于重置方式:
- AutoResetEvent:等待线程感知到信号后,信号自动关闭(自动重置),一次
Set()只放行一个线程。 - ManualResetEvent:信号开启后不会自动关闭,需要开发人员手动调用
Reset()方法才能关闭;在信号关闭之前,所有等待线程都能被放行。
适用场景——批量释放:
- 农场类比:农民批量生产食物,足够三头猪同时进入喂食站进食,而不是一次只放一头猪进去。
- 交通信号灯:绿灯亮起后所有车辆同时通行,直到再次变为红灯(
Reset()),而不是一次只放行一辆车。 - 大文件并行处理:文件生产出来后,向多个线程发信号让它们同时分割处理文件,处理完成后再通知生产者生产下一批。
推荐使用精简版 ManualResetEventSlim: 视频中使用的是 ManualResetEventSlim(精简版),而非原始的 ManualResetEvent。精简版在等待时间较短时性能更好,且 API 略有不同(等待方法是 Wait() 而非 WaitOne())。同样推荐用 using 语句确保资源释放。
基本语法三要素:
- 声明:
using ManualResetEventSlim mre = new ManualResetEventSlim(false);—false表示初始非信号状态,所有线程一开始都阻塞。 - 开启信号(放行所有等待线程):
mre.Set(); - 关闭信号(需手动调用):
mre.Reset();— 视频的简单演示中未调用Reset(),因为程序只需释放一次。
核心原理一句话: AutoResetEvent 一次只放一个线程是因为信号被自动重置了;ManualResetEvent 因为不自动重置,所以所有等待线程都有机会通过 Wait() 继续执行。
演示代码
以下为视频最终版本——3 个工作线程等待,主线程按回车后一次性释放所有线程的完整可运行代码(.NET 8 / top-level statements):
using System.Threading;
// 声明手动重置事件(精简版),初始状态为非信号(false = 所有线程阻塞等待)
using ManualResetEventSlim manualResetEvent = new ManualResetEventSlim(false);
Console.WriteLine("Press enter to release all threads...");
// 创建 3 个工作线程,均在 Wait() 上阻塞等待信号
for (int i = 0; i < 3; i++)
{
Thread thread = new Thread(Work);
thread.Name = $"Thread {i}";
thread.Start();
}
// 主线程等待用户按回车
Console.ReadLine();
// 开启信号——释放所有等待中的线程(信号不会自动关闭)
manualResetEvent.Set();
// 防止程序立即退出
Console.ReadLine();
void Work()
{
Console.WriteLine($"{Thread.CurrentThread.Name} is waiting for the signal...");
// 阻塞等待信号;ManualResetEventSlim 用 Wait() 而非 WaitOne()
manualResetEvent.Wait();
Console.WriteLine($"{Thread.CurrentThread.Name} has been released.");
// 模拟处理时间
Thread.Sleep(1000);
}
关于 Reset():本示例中信号只需开启一次,因此未调用
Reset()。如果需要重复使用该信号(如生产者-消费者循环中,一批处理完后关闭信号等待下一批),则必须在所有线程通过后调用manualResetEvent.Reset()将信号关闭,否则后续Wait()会立即通过而不会阻塞。
运行结果 / 现象
控制台输出:
Press enter to release all threads...
Thread 0 is waiting for the signal...
Thread 1 is waiting for the signal...
Thread 2 is waiting for the signal...
(用户按回车)
Thread 0 has been released.
Thread 1 has been released.
Thread 2 has been released.
- 3 个线程启动后全部输出"waiting for the signal"并阻塞在
Wait()上。 - 用户按回车后,主线程调用
Set(),所有 3 个线程几乎同时被释放,输出"has been released"。 - 这与 AutoResetEvent 形成鲜明对比:AutoResetEvent 按一次回车只能释放一个线程,而 ManualResetEvent 一次释放全部。
易错点 / 小结
- AutoResetEvent vs ManualResetEvent 核心区别:前者信号自动重置(一次放一个),后者需手动
Reset()(一次放所有)。选择依据是"需要逐个放行还是批量放行"。 - 推荐使用 ManualResetEventSlim:精简版性能更优,等待方法是
Wait()而非WaitOne(),声明和Set()/Reset()用法相同。 - 忘记调用 Reset():如果需要重复使用信号,必须在合适时机调用
Reset(),否则信号一直处于有信号状态,后续Wait()不会阻塞,程序逻辑会出错。 - 初始状态参数:
new ManualResetEventSlim(false)表示启动时所有线程阻塞;true表示启动时信号已开,所有Wait()立即通过。 - ManualResetEvent 不保护临界区:与 AutoResetEvent 一样,它用于线程间信号通知/批量释放,不是锁,不能替代
lock保护共享资源。 - Set() 放行所有等待线程:调用一次
Set(),当前所有在Wait()上阻塞的线程都会被放行;如果之后又有新线程调用Wait(),在Reset()之前它们也会立即通过。
2.12 作业3:生产者-消费者场景中的双向通信
本节要点
本节是作业3的题目要求(问题P),不包含实现代码。作业要求使用 ManualResetEventSlim(手动重置事件精简版) 在生产者-消费者场景中实现双向信号传递。
场景类比——农民与猪:
- 农民(Farmer)= 生产者线程:批量生产食物(数字),放入队列。
- 三只小猪 = 三个消费者工作线程:从队列中取数据并消费(处理)。
- 农民批量生产的食物足够所有猪同时进食,因此使用 ManualResetEvent(一次释放所有消费者线程),而非 AutoResetEvent(一次只放一个)。
双向信号传递的含义:
- 生产者 → 消费者:生产者生产完一批数据后,调用
Set()向所有消费者线程发信号"队列里有数据了,快来消费"。 - 消费者 → 生产者:当所有消费者处理完队列中的数据(队列为空)后,向生产者发信号"队列空了,我们还饿,你可以再生产一批了"。
作业题目要求
请独立完成以下程序:
-
线程角色:
- 使用主线程表示生产者(农民)。
- 创建三个不同的工作线程表示消费者(三只小猪)。
-
共享队列:
- 创建一个普通队列(
Queue<int>或类似),用于存放生产者生产的数字。 - 队列中数字的数量不限(可以是 10 个、20 个、100 个)。
- 暂不需要考虑队列的线程安全:可以假设队列是线程安全的,也可以用
lock保护,但本作业重点是双向信号机制,队列安全将在后续并发集合章节讨论。
- 创建一个普通队列(
-
生产逻辑(主线程):
- 屏幕显示提示消息"按 P 键生产"(仅当队列为空时显示此提示)。
- 用户按下字母
P后,生产者开始生产一批数字(如 10 个不同的数字),将它们全部入队。 - 生产完成后,使用 ManualResetEventSlim 向所有消费者线程发送信号(
Set()),通知它们队列中有数据可以消费。
-
消费逻辑(三个工作线程):
- 三个工作线程在 ManualResetEvent 上等待信号。
- 收到信号后,所有线程同时进入,从队列中取数据并处理(分而治之:分割队列,各自处理一部分)。
- 当所有线程完成处理且队列为空时,向生产者发送信号"队列空了,可以再次生产"。
-
无限循环:
- 整个过程在无限循环中运行:生产 → 发信号给消费者 → 消费者消费 → 队列为空 → 发信号给生产者 → 再次生产……
- 用户可以不断按
P生产新一批数据,持续运行。
-
信号机制要求:
- 必须使用 ManualResetEventSlim(精简版手动重置事件)实现生产者到消费者的信号通知。
- 消费者到生产者的"队列空"通知可以使用另一个同步原语(如 AutoResetEvent 或 ManualResetEvent)。
- 注意在合适时机调用
Reset(),确保信号能重复使用。
易错点 / 小结
- 这是问题P,不是答案P:本节只给出作业要求,完整实现代码见下一节 P20(作业3答案)。
- 必须用 ManualResetEvent 而非 AutoResetEvent:因为生产者是批量生产,需要一次释放所有消费者线程同时处理队列数据,AutoResetEvent 一次只能放一个线程,不适合此场景。
- 双向信号 = 两个信号方向:生产者→消费者(有数据了)和消费者→生产者(队列为空了),需要两个同步原语分别处理。
- Reset() 时机:ManualResetEvent 不会自动重置,必须在消费者全部进入后、或下一轮等待前调用
Reset(),否则信号一直处于有信号状态,后续Wait()不会阻塞。 - 队列安全可暂不考虑:作业明确说明可以假设队列线程安全,或简单用
lock保护,重点在信号机制,不要在队列安全上花过多时间。 - 仅队列为空时显示"按P"提示:避免在队列还有数据时重复提示,程序应根据队列状态控制提示信息的显示。
2.13 作业3(答案):生产者-消费者场景中的双向通信
本节要点
本节给出作业3的完整实现。核心是使用两个 ManualResetEventSlim 实现生产者与消费者之间的双向信号传递:
两个事件的分工:
- consumeEvent(初始 false):生产者 → 消费者方向。生产者生产完一批数据后调用
Set(),通知所有消费者"队列有数据了,快来消费"。消费者在Wait()上阻塞,收到信号后全部进入消费。 - produceEvent(初始 true):消费者 → 生产者方向。当所有消费者消费完毕(队列为空)后调用
Set(),通知生产者"队列空了,可以再生产了"。生产者在Wait()上阻塞,收到信号后允许用户输入p进行下一轮生产。初始设为true是为了让程序启动时生产者就能直接进入生产流程(否则会永远阻塞)。
关键设计细节:
-
produceEvent.Reset():生产者收到信号后立即调用
Reset()关闭信号,这样在当前批次消费完毕之前,生产者不会再次进入生产流程,避免过度生产。 -
consumerCount 计数器 + lock:由于有 3 个消费者线程,每个线程消费完自己的部分后都会到达"队列为空"的位置。如果每个线程都调用
produceEvent.Set(),会导致重复信号和过度生产。因此用consumerCount计数,只有当 3 个线程全部完成(consumerCount == 3)时,才调用produceEvent.Set()通知生产者。计数器的递增和判断必须用lock保护,因为它是多线程共享的临界区。 -
consumeEvent.Reset():在所有消费者完成、通知生产者之前,必须调用
consumeEvent.Reset()关闭消费信号。否则信号一直处于有信号状态,下一轮生产者Set()将失去意义,消费者的Wait()也不会阻塞。 -
队列用普通 Queue
:作业明确暂不要求队列线程安全,实际生产环境应使用 ConcurrentQueue<int>或用lock保护入队/出队操作。 -
消费顺序随机:3 个线程从同一个队列
TryDequeue,哪个线程拿到哪个数字是不确定的,由操作系统调度决定,没有固定模式。
演示代码
以下为视频最终完整可运行代码(.NET 8 / top-level statements):
using System.Threading;
// 共享队列——生产者生产数字入队,消费者出队消费
Queue<int> queue = new Queue<int>();
// 事件1:生产者→消费者信号(初始非信号,消费者需等待生产完成)
using ManualResetEventSlim consumeEvent = new ManualResetEventSlim(false);
// 事件2:消费者→生产者信号(初始有信号,允许程序启动时直接进入生产)
using ManualResetEventSlim produceEvent = new ManualResetEventSlim(true);
// 计数器:记录已完成消费的消费者线程数
int consumerCount = 0;
// 保护 consumerCount 的锁对象(多线程共享临界区)
object lockConsumerCount = new object();
// 创建 3 个消费者线程
Thread[] consumerThreads = new Thread[3];
for (int i = 0; i < 3; i++)
{
consumerThreads[i] = new Thread(Consume);
consumerThreads[i].Name = $"Consumer {i + 1}";
consumerThreads[i].Start();
}
// ========== 生产者(主线程)==========
while (true)
{
// 等待消费者通知"队列空了,可以生产了"
produceEvent.Wait();
// 立即关闭信号,防止本批次消费完之前重复进入生产
produceEvent.Reset();
Console.WriteLine("To produce, enter 'p'");
var input = Console.ReadLine() ?? "";
if (input.ToLower() == "p")
{
// 生产一批 10 个数字入队
for (int i = 1; i <= 10; i++)
{
queue.Enqueue(i);
Console.WriteLine($"Produced: {i}");
}
// 通知所有消费者:队列有数据了,开始消费
consumeEvent.Set();
}
}
// ========== 消费者(工作线程)==========
void Consume()
{
while (true)
{
// 等待生产者通知"有数据了"
consumeEvent.Wait();
// 不断从队列取数据,直到队列为空
while (queue.TryDequeue(out int item))
{
// 模拟处理时间
Thread.Sleep(500);
Console.WriteLine($"Consumed: {item} from thread: {Thread.CurrentThread.Name}");
}
// 队列为空——本线程消费完成
lock (lockConsumerCount)
{
consumerCount++;
// 只有当 3 个消费者全部完成时,才通知生产者
if (consumerCount == 3)
{
// 关闭消费信号,为下一轮做准备
consumeEvent.Reset();
Console.WriteLine("***** More Please! *****");
// 通知生产者:队列空了,可以再生产了
produceEvent.Set();
// 重置计数器,为下一轮做准备
consumerCount = 0;
}
}
}
}
运行结果 / 现象
控制台输出(输入 p 后生产一批,3 个消费者随机消费):
To produce, enter 'p'
p
Produced: 1
Produced: 2
Produced: 3
Produced: 4
Produced: 5
Produced: 6
Produced: 7
Produced: 8
Produced: 9
Produced: 10
Consumed: 1 from thread: Consumer 2
Consumed: 2 from thread: Consumer 1
Consumed: 3 from thread: Consumer 3
Consumed: 6 from thread: Consumer 1
Consumed: 5 from thread: Consumer 3
Consumed: 4 from thread: Consumer 2
Consumed: 7 from thread: Consumer 1
Consumed: 8 from thread: Consumer 3
Consumed: 9 from thread: Consumer 2
Consumed: 10 from thread: Consumer 3
***** More Please! *****
To produce, enter 'p'
- 生产者输入
p后,10 个数字依次入队并输出"Produced"。 consumeEvent.Set()后,3 个消费者线程同时被唤醒,从队列中随机取数消费。- 消费顺序和线程分配完全随机,每次运行结果不同。
- 当 10 个数字全部消费完毕,最后一个完成的线程检测到
consumerCount == 3,输出"***** More Please! *****"并调用produceEvent.Set()通知生产者。 - 生产者收到信号后输出"To produce, enter 'p'",等待用户下一轮输入,形成无限循环。
易错点 / 小结
-
两个事件的初始状态不同:
consumeEvent初始false(消费者一开始要等生产),produceEvent初始true(程序启动时生产者要能直接进入,否则永远阻塞)。这是最容易出错的地方。 -
produceEvent.Reset() 必须紧跟 Wait():生产者收到信号后立即
Reset(),否则在当前批次消费期间生产者可能再次通过Wait(),导致重复生产。 -
consumeEvent.Reset() 必须在通知生产者之前:所有消费者完成后,先
consumeEvent.Reset()关闭消费信号,再produceEvent.Set()。如果忘记 Reset,下一轮消费者的Wait()不会阻塞,程序逻辑混乱。 -
consumerCount 必须用 lock 保护:3 个线程同时递增和判断
consumerCount,这是典型的临界区。不加锁会导致计数错误,可能永远达不到 3 或重复通知生产者。 -
只有最后一个完成的线程才通知生产者:通过
consumerCount == 3判断,确保 3 个消费者全部消费完毕才通知,而不是每个线程完成都通知。 -
队列的线程安全:本作业用普通
Queue<int>,TryDequeue在多线程下不是线程安全的。实际项目中应使用ConcurrentQueue<int>或对入队/出队加lock。视频中提到如果担心可以加锁,但本作业重点在信号机制。 -
消费顺序不可预测:多个线程同时从队列取数据,哪个线程拿到哪个数字完全由操作系统调度决定,不要依赖特定的消费顺序。
-
双向信号的本质:生产者→消费者用
consumeEvent(批量释放所有消费者),消费者→生产者用produceEvent(所有消费者完成后通知),两个事件配合实现了完整的生产-消费循环。
2.14 线程亲和性
本节要点
线程亲和性(Thread Affinity) 的核心含义:在特定线程内创建/获取的资源,通常只能由该线程本身访问。这个概念不仅限于线程同步,而是无处不在。
线程同步中的线程亲和性:
lock/Monitor/Mutex:由哪个线程获取的锁,必须由同一个线程释放,这就是线程亲和性。Semaphore(信号量):没有线程亲和性——一个线程可以获取信号量,另一个线程可以释放它。
UI 编程中的线程亲和性:
- Windows Forms / WPF / Blazor 等 UI 框架中,控件是在 UI 线程(主线程) 中创建的。
- 如果从工作线程(后台线程)直接访问 UI 控件,会抛出"跨线程操作无效"异常(
InvalidOperationException: Cross-thread operation not valid)。 - 原因:工作线程试图访问在主线程中创建的资源,违反了线程亲和性。
解决方案——同步上下文(Invoke):
- 通过对控件调用
Invoke方法(WinForms)或InvokeAsync(Blazor),将委托封送回创建该控件的线程(UI 线程)上执行。 - 本质是让两个不同的线程上下文"合二为一",在拥有资源的线程上执行操作。
两个示例场景:
- Windows Forms:两个按钮分别启动工作线程,延迟后更新 Label 文本。直接赋值会报跨线程异常,用
lblMessage.Invoke()或InvokeRequired检查修复。 - Blazor Server:按钮点击后启动工作线程,延迟后更新时间显示并调用
StateHasChanged()重新渲染。直接调用会导致连接断开,用InvokeAsync(() => StateHasChanged())修复。
演示代码
示例一:Windows Forms 线程亲和性
有问题的代码(跨线程直接访问 UI 控件):
using System.Threading;
namespace OffloadTask
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
private void button1_Click(object sender, EventArgs e)
{
// 启动工作线程,3 秒后更新 Label
Thread thread = new Thread(() => ShowMessage("First message", 3000));
thread.Start();
}
private void button2_Click(object sender, EventArgs e)
{
// 启动工作线程,5 秒后更新 Label
Thread thread = new Thread(() => ShowMessage("Second message", 5000));
thread.Start();
}
private void ShowMessage(string message, int delay)
{
Thread.Sleep(delay);
// ⚠️ 跨线程操作无效:从创建 lblMessage 的线程以外的线程访问控件
lblMessage.Text = message;
}
}
}
修复方案——使用 Invoke 同步上下文:
private void ShowMessage(string message, int delay)
{
Thread.Sleep(delay);
// 推荐:先检查是否需要 Invoke,适用于所有情况
if (lblMessage.InvokeRequired)
{
// 在创建 lblMessage 的线程(UI 线程)上执行委托
lblMessage.Invoke(() =>
{
lblMessage.Text = message;
});
}
else
{
// 已经在 UI 线程上,直接赋值
lblMessage.Text = message;
}
}
InvokeRequired是一个布尔属性,返回true表示当前线程不是创建控件的线程,需要用Invoke封送;返回false表示已经在 UI 线程上,可以直接操作。这种写法消除了"是否需要 Invoke"的猜测,适用于所有调用场景。
示例二:Blazor Server 线程亲和性
Home.razor 组件——使用 InvokeAsync 修复线程亲和性:
@page "/"
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
<br />
<br />
@currentTime
<br />
<br />
<button class="btn btn-primary" @onclick="DisplayTime">
Display Time
</button>
@code {
private string currentTime = "";
void DisplayTime()
{
// 启动工作线程,延迟后更新时间并触发 UI 重新渲染
Thread thread = new Thread(() =>
{
Thread.Sleep(500);
currentTime = DateTime.Now.ToString();
// ⚠️ 直接调用 StateHasChanged() 会有线程亲和性问题
// 导致 Blazor Server 连接断开("Attempting to reconnect")
// StateHasChanged();
// ✅ 修复:用 InvokeAsync 将 StateHasChanged 封送到 UI 线程
InvokeAsync(() =>
{
StateHasChanged();
});
});
thread.Start();
}
}
在 Blazor Server 中,UI 渲染必须在主线程(电路线程)上执行。从工作线程直接调用
StateHasChanged()会破坏线程亲和性,导致 SignalR 连接断开并显示"Attempting to reconnect to the server"。使用InvokeAsync将渲染操作封送回正确的线程即可解决。
运行结果 / 现象
Windows Forms:
- 不调试直接运行时,跨线程赋值可能"看起来正常"(Windows Forms 框架在运行时有意隐藏了此类异常),但在调试模式下会立即抛出
InvalidOperationException: Cross-thread operation not valid: Control 'lblMessage' accessed from a thread other than the thread it was created on. - 使用
Invoke修复后,无论调试还是运行,点击按钮后延迟相应秒数,Label 正常更新为"First message"或"Second message"。
Blazor Server:
- 不使用
InvokeAsync时,点击"Display Time"按钮后页面无反应,且出现"Attempting to reconnect to the server"提示,浏览器 F12 控制台显示大量 WebSocket 连接错误。 - 使用
InvokeAsync修复后,点击按钮后约 0.5 秒页面显示当前时间,再次点击时间正常刷新。
易错点 / 小结
-
线程亲和性无处不在:不仅是锁(
lock/Monitor/Mutex必须由获取线程释放),UI 控件、Blazor 渲染上下文等都有线程亲和性——在哪个线程创建的资源,只能由哪个线程访问。 -
Semaphore 没有线程亲和性:信号量可以由一个线程获取、另一个线程释放,这是它与锁的重要区别之一。
-
跨线程访问 UI 控件会抛异常:WinForms 中调试模式下会立即抛出
InvalidOperationException,运行时可能被框架隐藏但仍是隐患。不要依赖"运行时不报错"。 -
Invoke / InvokeAsync 是标准解决方案:通过将委托封送回创建资源的线程执行,同步不同线程的上下文。WinForms 用
Control.Invoke(),Blazor 用ComponentBase.InvokeAsync()。 -
推荐用 InvokeRequired 检查:
if (control.InvokeRequired) { control.Invoke(...); } else { 直接操作; }这种模式适用于所有情况,无需关心调用方在哪个线程。 -
Blazor Server 中 StateHasChanged 必须在 UI 线程:从工作线程直接调用
StateHasChanged()会导致 SignalR 电路断开,必须用InvokeAsync(() => StateHasChanged())包装。 -
async/await 会再次涉及线程亲和性:课程后续章节讨论 async/await 时会回到线程亲和性话题(同步上下文 SynchronizationContext),本节是基础概念铺垫。
2.15 线程安全
本节要点
本节是纯概念讲解,明确"线程安全(Thread Safety)"这一术语的含义。在整个线程同步章节中,我们一直在讨论线程安全,但需要明确当文档或博客中说"某某是线程安全的"时,到底指什么。
线程安全的定义:
在多线程计算机编程中,当一个函数、数据结构或类可以被多个线程并发使用,而不会导致竞态条件(race condition)、意外行为或数据损坏时,它就是线程安全的。
线程安全的本质:
- 一个函数/数据结构/类是线程安全的,意味着它内部已经使用了适当的锁定机制(如
lock、Monitor、Mutex、Semaphore等线程同步技术),来保护其内部的共享资源。 - 线程安全的代码可以放心地在多线程环境中直接使用,无需调用方额外加锁。
非线程安全的例子——普通 Queue:
- 在作业3(生产者-消费者)中,我们使用了普通的
Queue<int>。 Queue<int>本身不是线程安全的——它的内部没有使用任何线程同步技术。- 当多个线程同时对同一个
Queue<int>实例执行Enqueue(入队)和TryDequeue(出队)时,队列中的数据可能会被破坏,行为或最终结果可能是意外的。 - 具体表现为:有时以一种方式出错,有时以另一种方式出错,取决于哪个线程先访问、哪个线程后访问——这就是典型的竞态条件。
如何判断/实现线程安全:
- 文档中明确标注"thread safe"的类(如 .NET 中的
ConcurrentQueue<T>、ConcurrentDictionary<TKey, TValue>等并发集合),内部已经做好了同步,可以直接多线程使用。 - 自己编写的共享数据结构,如果要在多线程中使用,必须在内部对共享状态的访问加锁,或者使用并发集合。
- 作业3中暂时假设队列是线程安全的(或简单用
lock保护),真正的线程安全队列将在第8章并发集合中学习(ConcurrentQueue<T>、BlockingCollection<T>)。
演示代码
本节无独立演示代码。以下用简短代码对比说明非线程安全与线程安全的区别:
非线程安全——普通 Queue(多线程并发可能出问题):
using System.Collections.Generic;
using System.Threading;
// 普通 Queue<int> 不是线程安全的
Queue<int> queue = new Queue<int>();
// 生产者线程入队
Thread producer = new Thread(() =>
{
for (int i = 0; i < 1000; i++)
{
queue.Enqueue(i); // ⚠️ 多线程并发入队/出队可能导致数据损坏
}
});
// 消费者线程出队
Thread consumer = new Thread(() =>
{
int item;
while (queue.TryDequeue(out item))
{
// 处理 item
}
});
producer.Start();
consumer.Start();
线程安全——使用 ConcurrentQueue(内部已做好同步):
using System.Collections.Concurrent;
using System.Threading;
// ConcurrentQueue<int> 是线程安全的,内部已使用同步机制
ConcurrentQueue<int> queue = new ConcurrentQueue<int>();
Thread producer = new Thread(() =>
{
for (int i = 0; i < 1000; i++)
{
queue.Enqueue(i); // ✅ 多线程并发安全
}
});
Thread consumer = new Thread(() =>
{
int item;
while (queue.TryDequeue(out item))
{
// 处理 item
}
});
producer.Start();
consumer.Start();
并发集合(
ConcurrentQueue、ConcurrentStack、BlockingCollection等)将在第8章详细学习。
易错点 / 小结
-
线程安全 = 可多线程并发使用不出错:定义的核心是"多个线程并发使用"且"不会导致竞态条件、意外行为或数据损坏"。
-
线程安全的代码内部已经加锁:说一个类是线程安全的,意味着它内部已经使用了
lock/Monitor/Mutex/Semaphore等同步技术保护共享状态,调用方无需额外加锁。 -
普通集合(List、Queue、Dictionary)不是线程安全的:
Queue<int>、List<T>、Dictionary<TKey, TValue>等普通集合内部没有同步机制,多线程并发读写可能导致数据损坏或不可预测行为。 -
竞态条件的表现:非线程安全代码在多线程下的错误是不确定的——有时这样错、有时那样错,取决于线程调度顺序,难以复现和调试。
-
并发集合是线程安全的:.NET 提供的
System.Collections.Concurrent命名空间下的集合(ConcurrentQueue、ConcurrentStack、ConcurrentDictionary、BlockingCollection等)都是线程安全的,将在第8章学习。 -
作业3中暂不要求队列安全:作业3明确说明可以假设队列是线程安全的,或简单用
lock保护入队/出队,重点在双向信号机制,队列安全是后续章节的内容。
2.16 嵌套锁与死锁
本节要点
死锁(Deadlock) 通常发生在具有嵌套锁的多线程场景中。当两个或多个线程互相等待对方释放锁时,就会进入死锁状态——没有人能继续执行,程序永久卡住。
经典死锁场景——两个线程、两把锁、相反的加锁顺序:
- 场景:电子商务系统,有用户(User)和订单(Order)两个资源。
- 线程1(主线程,ManageUser):先锁定
userLock,再锁定orderLock(管理用户时,需要在用户内部管理订单)。 - 线程2(工作线程,ManageOrder):先锁定
orderLock,再锁定userLock(管理订单时,需要在订单内部管理用户)。 - 两个线程以相反的顺序获取同一组锁,这是死锁的典型诱因。
死锁发生的过程:
- 线程1获取
userLock,然后Thread.Sleep(2000)模拟工作。 - 线程2获取
orderLock,然后Thread.Sleep(1000)模拟工作。 - 线程1睡眠结束后,尝试获取
orderLock——但orderLock被线程2持有,线程1阻塞等待。 - 线程2睡眠结束后,尝试获取
userLock——但userLock被线程1持有,线程2阻塞等待。 - 两个线程都在等待对方释放锁,谁也无法前进 → 死锁。
死锁的特征:
- 程序看起来"卡住了",不报错也不继续执行。
- 控制台只输出了每个线程获取第一把锁的消息,嵌套锁内的消息永远不会打印。
thread.Join()永远等待,"Program finished" 永远不会输出。
避免死锁的原则:
- 尽量避免嵌套锁,尤其是在有层次结构和复杂资源共享的场景中。
- 如果必须使用多把锁,所有线程必须以相同的顺序获取锁(例如都先锁 userLock 再锁 orderLock),这样就不会出现循环等待。
- 使用
Monitor.TryEnter加超时,获取不到锁时放弃并重试,避免无限等待。
演示代码
以下为视频最终完整可运行代码(.NET 8 / top-level statements)——故意制造死锁的示例:
using System.Threading;
// 电子商务场景:管理用户和订单
// 1. managing users (user -> order):先锁用户,再锁订单
// 2. managing orders (order -> user):先锁订单,再锁用户
// Thread 1 wants to lock user first then lock order
// Thread 2 wants to lock order first then lock the user
object userLock = new object();
object orderLock = new object();
// 工作线程:管理订单(先锁 orderLock,再锁 userLock)
Thread thread = new Thread(ManageOrder);
thread.Start();
// 主线程:管理用户(先锁 userLock,再锁 orderLock)
ManageUser();
// 等待工作线程完成——死锁时永远等不到
thread.Join();
Console.WriteLine("Program finished. Press any key to exit.");
Console.ReadLine();
// 管理用户:先获取 userLock,再获取 orderLock
void ManageUser()
{
lock (userLock)
{
Console.WriteLine("User Management acquired the user lock.");
Thread.Sleep(2000); // 模拟用户管理工作
lock (orderLock) // ⚠️ 尝试获取 orderLock——可能被线程2持有
{
Console.WriteLine("User Management acquired the order lock.");
}
}
}
// 管理订单:先获取 orderLock,再获取 userLock
void ManageOrder()
{
lock (orderLock)
{
Console.WriteLine("Order Management acquired the order lock.");
Thread.Sleep(1000); // 模拟订单管理工作
lock (userLock) // ⚠️ 尝试获取 userLock——可能被线程1持有
{
Console.WriteLine("Order Management acquired the user lock.");
}
}
}
运行结果 / 现象
控制台输出(程序卡住,永远不会输出 "Program finished"):
User Management acquired the user lock.
Order Management acquired the order lock.
(程序永久卡住,无后续输出)
- 主线程调用
ManageUser(),输出"User Management acquired the user lock.",睡眠 2 秒。 - 工作线程调用
ManageOrder(),输出"Order Management acquired the order lock.",睡眠 1 秒。 - 工作线程先醒,尝试获取
userLock——被主线程持有,阻塞。 - 主线程后醒,尝试获取
orderLock——被工作线程持有,阻塞。 - 两个线程互相等待,死锁发生,嵌套锁内的两条输出永远不会打印,
thread.Join()永远不返回。
易错点 / 小结
-
死锁的四个必要条件:互斥(锁不能共享)、持有并等待(持有一把锁的同时等另一把)、不可剥夺(锁不能被强制释放)、循环等待(线程间形成等待环)。本示例同时满足四个条件。
-
嵌套锁是死锁的温床:在一个锁的内部再获取另一个锁,就形成了嵌套锁。如果多个线程以不同顺序获取同一组锁,极易形成循环等待。
-
加锁顺序一致是避免死锁的关键:如果所有线程都先锁
userLock再锁orderLock,就不会出现循环等待——线程1持有 userLock 时,线程2连第一把锁都拿不到,只能等线程1全部完成释放。 -
死锁不报错:程序不会抛出异常,只是永久卡住,这使得死锁难以发现和调试。调试时可通过"调试→窗口→并行任务/线程"查看各线程的阻塞状态。
-
Sleep 放大了死锁概率:示例中
Thread.Sleep(2000)和Thread.Sleep(1000)确保两个线程都能先拿到第一把锁,从而稳定复现死锁。实际中如果没有 Sleep,死锁可能是偶发的,更难排查。 -
Monitor.TryEnter 可作为兜底:使用
Monitor.TryEnter(lockObj, timeout)替代lock,在指定时间内获取不到锁就返回 false,可以避免无限等待,但需要自己处理重试逻辑和锁的释放(finally中Monitor.Exit)。 -
尽量避免嵌套锁:视频总结强调,在有层次结构和复杂资源共享的场景中,应尽力避免使用嵌套锁的策略,从设计上消除死锁的可能性。
浙公网安备 33010602011771号