牛敢当

  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

第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 这行代码不是原子操作,它实际分为两步:

  1. 读取 counter 的当前值到临时变量
  2. 将临时变量加 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 服务器)的控制台项目框架,改造实现一个飞机座位预订系统:

  1. 请求队列:维护一个预订请求队列(Queue<string>),用户输入的请求先入队。
  2. 主线程:负责读取用户控制台输入并将请求入队。
  3. 监控线程:持续检查队列,当有请求时出队并处理(预订/取消座位)。
  4. 可用座位计数器:维护一个表示剩余可用座位数的变量,这是被多个线程访问的共享资源。
  5. 互斥锁保护临界区:对访问和修改可用座位数的临界区代码应用 lock,确保线程安全。
  6. 用户指令:
    • 输入字母 B(Book):表示用户想要预订一个座位(可用座位数减 1)
    • 输入字母 C(Cancel):表示用户想要取消预订(可用座位数加 1)
  7. 边界处理:当没有可用座位时,预订请求应被拒绝并提示;取消预订不应超过初始座位总数。

基础框架(作业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 保护预订/取消的临界区。

关键设计决策

  1. 共享资源:availableTickets(可用座位数)被监控线程和多个处理线程同时访问,是核心共享资源。
  2. 临界区范围:ProcessBooking 中对 availableTickets 的"检查-修改-输出"全部放在 lock (ticketsLock) 内,确保检查和扣减之间不会被其他线程打断。
  3. 预订(B)逻辑:availableTickets > 0 时扣减并提示成功;否则输出"机票不可用"。
  4. 取消(C)逻辑:availableTickets < 10(初始座位数)时递增并提示成功;否则输出"无法取消"(防止超量取消)。
  5. 模拟处理延迟: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() 也最多唤醒与等待线程数匹配的线程,多余的信号会丢失。

基本语法三要素:

  1. 声明:using AutoResetEvent autoResetEvent = new AutoResetEvent(false); — 参数 false 表示初始状态为非信号(无产品),若启动时已有产品则传 true。推荐用 using 语句确保非托管资源释放。
  2. 消费者等待:autoResetEvent.WaitOne(); — 阻塞当前线程直到收到信号。
  3. 生产者发信号: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 秒,多余信号会丢失,实际只有部分线程能被唤醒。

易错点 / 小结

  1. AutoResetEvent 不是锁:它不保护临界区/共享资源,只负责线程间信号通知。不要用它替代 lock。
  2. 信号会自动重置:一次 Set() 只放行一个 WaitOne() 线程,放行后信号立即回到非信号状态。这与 ManualResetEvent(一次 Set 放行所有等待线程,需手动 Reset)形成对比。
  3. 信号可能丢失:如果在没有线程等待时调用 Set(),信号会被置为有信号状态,但下一个 WaitOne() 会立即消费它并重置;如果连续多次 Set() 而消费者来不及处理,多余信号不会累积,会丢失。
  4. 初始状态参数:new AutoResetEvent(false) 表示启动时无信号(消费者需等待);true 表示启动时已有信号(第一个 WaitOne() 立即通过)。
  5. using 释放资源:AutoResetEvent 封装了非托管的 Windows 事件内核对象,应使用 using 语句或在 finally 中调用 Dispose() 释放。
  6. 解决信号丢失需配合队列:要保证每个产品都被消费,应将产品放入队列,AutoResetEvent 仅作为"队列非空"的通知信号,这是作业3生产者-消费者模式的核心思路。

2.11 使用手动重置事件释放多个线程

本节要点

ManualResetEvent(手动重置事件)与 AutoResetEvent 类似,都是二进制信号机制,核心区别在于重置方式:

  • AutoResetEvent:等待线程感知到信号后,信号自动关闭(自动重置),一次 Set() 只放行一个线程。
  • ManualResetEvent:信号开启后不会自动关闭,需要开发人员手动调用 Reset() 方法才能关闭;在信号关闭之前,所有等待线程都能被放行。

适用场景——批量释放:

  1. 农场类比:农民批量生产食物,足够三头猪同时进入喂食站进食,而不是一次只放一头猪进去。
  2. 交通信号灯:绿灯亮起后所有车辆同时通行,直到再次变为红灯(Reset()),而不是一次只放行一辆车。
  3. 大文件并行处理:文件生产出来后,向多个线程发信号让它们同时分割处理文件,处理完成后再通知生产者生产下一批。

推荐使用精简版 ManualResetEventSlim: 视频中使用的是 ManualResetEventSlim(精简版),而非原始的 ManualResetEvent。精简版在等待时间较短时性能更好,且 API 略有不同(等待方法是 Wait() 而非 WaitOne())。同样推荐用 using 语句确保资源释放。

基本语法三要素:

  1. 声明:using ManualResetEventSlim mre = new ManualResetEventSlim(false); — false 表示初始非信号状态,所有线程一开始都阻塞。
  2. 开启信号(放行所有等待线程):mre.Set();
  3. 关闭信号(需手动调用):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 一次释放全部。

易错点 / 小结

  1. AutoResetEvent vs ManualResetEvent 核心区别:前者信号自动重置(一次放一个),后者需手动 Reset()(一次放所有)。选择依据是"需要逐个放行还是批量放行"。
  2. 推荐使用 ManualResetEventSlim:精简版性能更优,等待方法是 Wait() 而非 WaitOne(),声明和 Set()/Reset() 用法相同。
  3. 忘记调用 Reset():如果需要重复使用信号,必须在合适时机调用 Reset(),否则信号一直处于有信号状态,后续 Wait() 不会阻塞,程序逻辑会出错。
  4. 初始状态参数:new ManualResetEventSlim(false) 表示启动时所有线程阻塞;true 表示启动时信号已开,所有 Wait() 立即通过。
  5. ManualResetEvent 不保护临界区:与 AutoResetEvent 一样,它用于线程间信号通知/批量释放,不是锁,不能替代 lock 保护共享资源。
  6. Set() 放行所有等待线程:调用一次 Set(),当前所有在 Wait() 上阻塞的线程都会被放行;如果之后又有新线程调用 Wait(),在 Reset() 之前它们也会立即通过。

2.12 作业3:生产者-消费者场景中的双向通信

本节要点

本节是作业3的题目要求(问题P),不包含实现代码。作业要求使用 ManualResetEventSlim(手动重置事件精简版) 在生产者-消费者场景中实现双向信号传递。

场景类比——农民与猪:

  • 农民(Farmer)= 生产者线程:批量生产食物(数字),放入队列。
  • 三只小猪 = 三个消费者工作线程:从队列中取数据并消费(处理)。
  • 农民批量生产的食物足够所有猪同时进食,因此使用 ManualResetEvent(一次释放所有消费者线程),而非 AutoResetEvent(一次只放一个)。

双向信号传递的含义:

  1. 生产者 → 消费者:生产者生产完一批数据后,调用 Set() 向所有消费者线程发信号"队列里有数据了,快来消费"。
  2. 消费者 → 生产者:当所有消费者处理完队列中的数据(队列为空)后,向生产者发信号"队列空了,我们还饿,你可以再生产一批了"。

作业题目要求

请独立完成以下程序:

  1. 线程角色:

    • 使用主线程表示生产者(农民)。
    • 创建三个不同的工作线程表示消费者(三只小猪)。
  2. 共享队列:

    • 创建一个普通队列(Queue<int> 或类似),用于存放生产者生产的数字。
    • 队列中数字的数量不限(可以是 10 个、20 个、100 个)。
    • 暂不需要考虑队列的线程安全:可以假设队列是线程安全的,也可以用 lock 保护,但本作业重点是双向信号机制,队列安全将在后续并发集合章节讨论。
  3. 生产逻辑(主线程):

    • 屏幕显示提示消息"按 P 键生产"(仅当队列为空时显示此提示)。
    • 用户按下字母 P 后,生产者开始生产一批数字(如 10 个不同的数字),将它们全部入队。
    • 生产完成后,使用 ManualResetEventSlim 向所有消费者线程发送信号(Set()),通知它们队列中有数据可以消费。
  4. 消费逻辑(三个工作线程):

    • 三个工作线程在 ManualResetEvent 上等待信号。
    • 收到信号后,所有线程同时进入,从队列中取数据并处理(分而治之:分割队列,各自处理一部分)。
    • 当所有线程完成处理且队列为空时,向生产者发送信号"队列空了,可以再次生产"。
  5. 无限循环:

    • 整个过程在无限循环中运行:生产 → 发信号给消费者 → 消费者消费 → 队列为空 → 发信号给生产者 → 再次生产……
    • 用户可以不断按 P 生产新一批数据,持续运行。
  6. 信号机制要求:

    • 必须使用 ManualResetEventSlim(精简版手动重置事件)实现生产者到消费者的信号通知。
    • 消费者到生产者的"队列空"通知可以使用另一个同步原语(如 AutoResetEvent 或 ManualResetEvent)。
    • 注意在合适时机调用 Reset(),确保信号能重复使用。

易错点 / 小结

  1. 这是问题P,不是答案P:本节只给出作业要求,完整实现代码见下一节 P20(作业3答案)。
  2. 必须用 ManualResetEvent 而非 AutoResetEvent:因为生产者是批量生产,需要一次释放所有消费者线程同时处理队列数据,AutoResetEvent 一次只能放一个线程,不适合此场景。
  3. 双向信号 = 两个信号方向:生产者→消费者(有数据了)和消费者→生产者(队列为空了),需要两个同步原语分别处理。
  4. Reset() 时机:ManualResetEvent 不会自动重置,必须在消费者全部进入后、或下一轮等待前调用 Reset(),否则信号一直处于有信号状态,后续 Wait() 不会阻塞。
  5. 队列安全可暂不考虑:作业明确说明可以假设队列线程安全,或简单用 lock 保护,重点在信号机制,不要在队列安全上花过多时间。
  6. 仅队列为空时显示"按P"提示:避免在队列还有数据时重复提示,程序应根据队列状态控制提示信息的显示。

2.13 作业3(答案):生产者-消费者场景中的双向通信

本节要点

本节给出作业3的完整实现。核心是使用两个 ManualResetEventSlim 实现生产者与消费者之间的双向信号传递:

两个事件的分工:

  1. consumeEvent(初始 false):生产者 → 消费者方向。生产者生产完一批数据后调用 Set(),通知所有消费者"队列有数据了,快来消费"。消费者在 Wait() 上阻塞,收到信号后全部进入消费。
  2. produceEvent(初始 true):消费者 → 生产者方向。当所有消费者消费完毕(队列为空)后调用 Set(),通知生产者"队列空了,可以再生产了"。生产者在 Wait() 上阻塞,收到信号后允许用户输入 p 进行下一轮生产。初始设为 true 是为了让程序启动时生产者就能直接进入生产流程(否则会永远阻塞)。

关键设计细节:

  1. produceEvent.Reset():生产者收到信号后立即调用 Reset() 关闭信号,这样在当前批次消费完毕之前,生产者不会再次进入生产流程,避免过度生产。

  2. consumerCount 计数器 + lock:由于有 3 个消费者线程,每个线程消费完自己的部分后都会到达"队列为空"的位置。如果每个线程都调用 produceEvent.Set(),会导致重复信号和过度生产。因此用 consumerCount 计数,只有当 3 个线程全部完成(consumerCount == 3)时,才调用 produceEvent.Set() 通知生产者。计数器的递增和判断必须用 lock 保护,因为它是多线程共享的临界区。

  3. consumeEvent.Reset():在所有消费者完成、通知生产者之前,必须调用 consumeEvent.Reset() 关闭消费信号。否则信号一直处于有信号状态,下一轮生产者 Set() 将失去意义,消费者的 Wait() 也不会阻塞。

  4. 队列用普通 Queue:作业明确暂不要求队列线程安全,实际生产环境应使用 ConcurrentQueue<int> 或用 lock 保护入队/出队操作。

  5. 消费顺序随机: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'",等待用户下一轮输入,形成无限循环。

易错点 / 小结

  1. 两个事件的初始状态不同:consumeEvent 初始 false(消费者一开始要等生产),produceEvent 初始 true(程序启动时生产者要能直接进入,否则永远阻塞)。这是最容易出错的地方。

  2. produceEvent.Reset() 必须紧跟 Wait():生产者收到信号后立即 Reset(),否则在当前批次消费期间生产者可能再次通过 Wait(),导致重复生产。

  3. consumeEvent.Reset() 必须在通知生产者之前:所有消费者完成后,先 consumeEvent.Reset() 关闭消费信号,再 produceEvent.Set()。如果忘记 Reset,下一轮消费者的 Wait() 不会阻塞,程序逻辑混乱。

  4. consumerCount 必须用 lock 保护:3 个线程同时递增和判断 consumerCount,这是典型的临界区。不加锁会导致计数错误,可能永远达不到 3 或重复通知生产者。

  5. 只有最后一个完成的线程才通知生产者:通过 consumerCount == 3 判断,确保 3 个消费者全部消费完毕才通知,而不是每个线程完成都通知。

  6. 队列的线程安全:本作业用普通 Queue<int>,TryDequeue 在多线程下不是线程安全的。实际项目中应使用 ConcurrentQueue<int> 或对入队/出队加 lock。视频中提到如果担心可以加锁,但本作业重点在信号机制。

  7. 消费顺序不可预测:多个线程同时从队列取数据,哪个线程拿到哪个数字完全由操作系统调度决定,不要依赖特定的消费顺序。

  8. 双向信号的本质:生产者→消费者用 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 线程)上执行。
  • 本质是让两个不同的线程上下文"合二为一",在拥有资源的线程上执行操作。

两个示例场景:

  1. Windows Forms:两个按钮分别启动工作线程,延迟后更新 Label 文本。直接赋值会报跨线程异常,用 lblMessage.Invoke() 或 InvokeRequired 检查修复。
  2. 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 秒页面显示当前时间,再次点击时间正常刷新。

易错点 / 小结

  1. 线程亲和性无处不在:不仅是锁(lock/Monitor/Mutex 必须由获取线程释放),UI 控件、Blazor 渲染上下文等都有线程亲和性——在哪个线程创建的资源,只能由哪个线程访问。

  2. Semaphore 没有线程亲和性:信号量可以由一个线程获取、另一个线程释放,这是它与锁的重要区别之一。

  3. 跨线程访问 UI 控件会抛异常:WinForms 中调试模式下会立即抛出 InvalidOperationException,运行时可能被框架隐藏但仍是隐患。不要依赖"运行时不报错"。

  4. Invoke / InvokeAsync 是标准解决方案:通过将委托封送回创建资源的线程执行,同步不同线程的上下文。WinForms 用 Control.Invoke(),Blazor 用 ComponentBase.InvokeAsync()。

  5. 推荐用 InvokeRequired 检查:if (control.InvokeRequired) { control.Invoke(...); } else { 直接操作; } 这种模式适用于所有情况,无需关心调用方在哪个线程。

  6. Blazor Server 中 StateHasChanged 必须在 UI 线程:从工作线程直接调用 StateHasChanged() 会导致 SignalR 电路断开,必须用 InvokeAsync(() => StateHasChanged()) 包装。

  7. 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章详细学习。

易错点 / 小结

  1. 线程安全 = 可多线程并发使用不出错:定义的核心是"多个线程并发使用"且"不会导致竞态条件、意外行为或数据损坏"。

  2. 线程安全的代码内部已经加锁:说一个类是线程安全的,意味着它内部已经使用了 lock/Monitor/Mutex/Semaphore 等同步技术保护共享状态,调用方无需额外加锁。

  3. 普通集合(List、Queue、Dictionary)不是线程安全的:Queue<int>、List<T>、Dictionary<TKey, TValue> 等普通集合内部没有同步机制,多线程并发读写可能导致数据损坏或不可预测行为。

  4. 竞态条件的表现:非线程安全代码在多线程下的错误是不确定的——有时这样错、有时那样错,取决于线程调度顺序,难以复现和调试。

  5. 并发集合是线程安全的:.NET 提供的 System.Collections.Concurrent 命名空间下的集合(ConcurrentQueue、ConcurrentStack、ConcurrentDictionary、BlockingCollection 等)都是线程安全的,将在第8章学习。

  6. 作业3中暂不要求队列安全:作业3明确说明可以假设队列是线程安全的,或简单用 lock 保护入队/出队,重点在双向信号机制,队列安全是后续章节的内容。


2.16 嵌套锁与死锁

本节要点

死锁(Deadlock) 通常发生在具有嵌套锁的多线程场景中。当两个或多个线程互相等待对方释放锁时,就会进入死锁状态——没有人能继续执行,程序永久卡住。

经典死锁场景——两个线程、两把锁、相反的加锁顺序:

  • 场景:电子商务系统,有用户(User)和订单(Order)两个资源。
  • 线程1(主线程,ManageUser):先锁定 userLock,再锁定 orderLock(管理用户时,需要在用户内部管理订单)。
  • 线程2(工作线程,ManageOrder):先锁定 orderLock,再锁定 userLock(管理订单时,需要在订单内部管理用户)。
  • 两个线程以相反的顺序获取同一组锁,这是死锁的典型诱因。

死锁发生的过程:

  1. 线程1获取 userLock,然后 Thread.Sleep(2000) 模拟工作。
  2. 线程2获取 orderLock,然后 Thread.Sleep(1000) 模拟工作。
  3. 线程1睡眠结束后,尝试获取 orderLock——但 orderLock 被线程2持有,线程1阻塞等待。
  4. 线程2睡眠结束后,尝试获取 userLock——但 userLock 被线程1持有,线程2阻塞等待。
  5. 两个线程都在等待对方释放锁,谁也无法前进 → 死锁。

死锁的特征:

  • 程序看起来"卡住了",不报错也不继续执行。
  • 控制台只输出了每个线程获取第一把锁的消息,嵌套锁内的消息永远不会打印。
  • 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() 永远不返回。

易错点 / 小结

  1. 死锁的四个必要条件:互斥(锁不能共享)、持有并等待(持有一把锁的同时等另一把)、不可剥夺(锁不能被强制释放)、循环等待(线程间形成等待环)。本示例同时满足四个条件。

  2. 嵌套锁是死锁的温床:在一个锁的内部再获取另一个锁,就形成了嵌套锁。如果多个线程以不同顺序获取同一组锁,极易形成循环等待。

  3. 加锁顺序一致是避免死锁的关键:如果所有线程都先锁 userLock 再锁 orderLock,就不会出现循环等待——线程1持有 userLock 时,线程2连第一把锁都拿不到,只能等线程1全部完成释放。

  4. 死锁不报错:程序不会抛出异常,只是永久卡住,这使得死锁难以发现和调试。调试时可通过"调试→窗口→并行任务/线程"查看各线程的阻塞状态。

  5. Sleep 放大了死锁概率:示例中 Thread.Sleep(2000) 和 Thread.Sleep(1000) 确保两个线程都能先拿到第一把锁,从而稳定复现死锁。实际中如果没有 Sleep,死锁可能是偶发的,更难排查。

  6. Monitor.TryEnter 可作为兜底:使用 Monitor.TryEnter(lockObj, timeout) 替代 lock,在指定时间内获取不到锁就返回 false,可以避免无限等待,但需要自己处理重试逻辑和锁的释放(finally 中 Monitor.Exit)。

  7. 尽量避免嵌套锁:视频总结强调,在有层次结构和复杂资源共享的场景中,应尽力避免使用嵌套锁的策略,从设计上消除死锁的可能性。

posted on 2026-09-11 14:42  牛敢当  阅读(5)  评论(0)    收藏  举报