C# Monitor类 使用详解
总目录
前言
在 C# 中,Monitor 类是一个用于实现线程同步的重要工具,它提供了一种机制来确保同一时间只有一个线程可以访问特定的代码块或资源,从而避免多线程环境下的数据竞争和不一致问题。下面将对 Monitor 类进行详细介绍。
一、Monitor 类概述
Monitor 类位于 System.Threading 命名空间中,它允许你对对象进行锁定(即获取互斥锁),从而确保在同一时刻只有一个线程可以执行被锁定保护的代码块。
1. 基本锁机制
Monitor.Enter 和 Monitor.Exit 用于保护临界区,确保线程互斥访问共享资源。
object lockObj = new object();
// 进入临界区
Monitor.Enter(lockObj);
try
{
// 操作共享资源
}
finally
{
Monitor.Exit(lockObj); // 确保释放锁
}
- 与 lock 的关系:lock 关键字是 Monitor 的语法糖,编译后等价于 try-finally 块包裹的 Enter 和 Exit。
- 何时用 Monitor:需要超时控制或非阻塞尝试获取锁时(通过 TryEnter)。
2. 常用方法
- Enter:尝试进入临界区并获取锁。
- Exit:释放锁并退出临界区。
- TryEnter:尝试进入临界区并在指定时间内等待锁。
- Wait:释放当前线程持有的锁,并将该线程放入等待队列。
- Pulse/PulseAll:通知等待队列中的一个或所有线程锁已释放,它们可以重新竞争锁。
3. 使用场景
- 保护共享资源:确保多个线程不会同时修改同一数据结构。
- 生产者-消费者模式:协调生产者和消费者之间的操作。
- 条件变量:实现基于条件的同步。
二、常用方法详解
1. Enter 和 Exit 方法
public static void Enter(object obj);
public static void Exit(object obj);
- Enter 和 Exit 是最常用的方法,分别用于获取和释放锁。
- Enter 作用:用于获取指定对象的锁。如果锁当前未被其他线程持有,则当前线程会立即获取该锁并继续执行后续代码;如果锁已被其他线程持有,则当前线程会被阻塞,直到锁被释放。
- Exit 作用:释放指定对象的锁。当一个线程完成对受保护资源的操作后,需要调用 Exit 方法来释放锁,以便其他线程可以获取该锁并访问资源。
- 注意事项:Exit 方法必须在 try-finally 块中调用,以确保无论代码是否发生异常,锁都会被释放。
- 使用示例
using System;
using System.Threading;
class Program
{
private static readonly object _lockObject = new object();
private static int _counter = 0;
static void Main()
{
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"Counter value: {_counter}");
}
static void IncrementCounter()
{
Monitor.Enter(_lockObject);
try
{
for (int i = 0; i < 100_0000; i++)
{
_counter++;
}
}
finally
{
Monitor.Exit(_lockObject);
}
}
static void IncrementCounter2()
{
for (int i = 0; i < 100_0000; i++)
{
try
{
Monitor.Enter(_lockObject);
_counter++;
}
finally
{
Monitor.Exit(_lockObject);
}
}
}
}
代码说明:
-
上例中创建了两个线程,这两个线程会同时尝试对一个静态的整数计数器 _counter 进行递增操作,并且利用 Monitor 类实现互斥锁机制,避免多线程竞争导致的数据不一致问题,最后输出计数器的最终值。
-
创建两个线程 t1 和 t2,它们都以 IncrementCounter 方法作为执行体。
-
调用 Start 方法启动这两个线程,让它们开始执行 IncrementCounter 方法。
-
Join 方法的作用是让主线程等待 t1 和 t2 线程执行完毕后再继续执行后续代码。这样能保证在输出计数器值时,两个线程的递增操作已经全部完成。
-
最后使用 Console.WriteLine 输出计数器 _counter 的最终值。
-
IncrementCounter 方法:
- Monitor.Enter(_lockObject):尝试获取 _lockObject 对象的锁。若锁当前未被其他线程持有,当前线程会获取该锁并继续执行后续代码;若锁已被其他线程持有,当前线程会被阻塞,直到锁被释放。
- try 块:包含需要进行线程安全保护的代码,这里是对 _counter 进行 100 万次递增操作。
- finally 块:无论 try 块中的代码是否抛出异常,finally 块中的代码都会执行。Monitor.Exit(_lockObject) 用于释放 _lockObject 对象的锁,确保其他线程可以获取该锁并继续执行,防止死锁情况发生。
-
IncrementCounter2 方法
- 该方法同样是对 _counter 进行 100 万次递增操作,但与 IncrementCounter 方法不同的是,它在每次递增操作前后都获取和释放锁。而 IncrementCounter 方法是在进入循环前获取锁,循环结束后再释放锁。
-
性能问题:
- IncrementCounter2 方法在每次递增操作前后都获取和释放锁,会带来较大的性能开销,因为频繁的锁竞争和上下文切换会消耗大量的系统资源。
- IncrementCounter 方法虽然减少了锁的获取和释放次数,但在高并发场景下,长时间持有锁也可能导致其他线程等待时间过长,影响性能。可以根据实际情况,进一步优化锁的粒度。
-
替代方案:
可以使用 lock 关键字替代 Monitor.Enter 和 Monitor.Exit,使代码更简洁易读。lock 关键字是 Monitor 类的语法糖,以下是使用 lock 关键字重写 IncrementCounter 方法的示例:
static void IncrementCounter2()
{
for (int i = 0; i < 100_0000; i++)
{
lock (_lockObject)
{
_counter++;
}
}
}
2. Enter 重载方法
Monitor.Enter(object obj, ref bool lockTaken)
-
作用:是Monitor.Enter 的一个重载方法,用于尝试获取指定对象的锁。该方法会将是否成功获取锁的结果存储在 lockTaken 这个布尔型变量中,这样可以确保即使在获取锁的过程中出现异常,也能正确处理锁的状态。
-
使用示例:
模拟一个多线程环境下对共享资源(一个简单的计数器)进行操作的场景,使用Monitor.Enter(object obj, ref bool lockTaken)
方法来保证线程安全。
using System;
using System.Threading;
class Program
{
// 共享资源,计数器
private static int counter = 0;
// 锁对象
private static readonly object lockObject = new object();
static void Main()
{
// 创建多个线程
Thread[] threads = new Thread[5];
for (int i = 0; i < 5; i++)
{
threads[i] = new Thread(IncrementCounter);
threads[i].Start();
}
// 等待所有线程执行完毕
foreach (Thread thread in threads)
{
thread.Join();
}
// 输出最终的计数器值
Console.WriteLine($"Final counter value: {counter}");
}
static void IncrementCounter()
{
bool lockTaken = false;
try
{
// 尝试获取锁
Monitor.Enter(lockObject, ref lockTaken);
for (int i = 0; i < 1000; i++)
{
// 对共享资源进行操作
counter++;
}
}
finally
{
// 如果成功获取了锁,释放锁
if (lockTaken)
{
Monitor.Exit(lockObject);
}
}
}
}
代码说明:
-
共享资源和锁对象的定义:
- counter:作为共享资源,多个线程会对其进行递增操作。
- lockObject:作为锁对象,用于控制对 counter 的访问。
-
Main 方法:
- 创建 5 个线程,每个线程都会执行 IncrementCounter 方法。
- 启动所有线程后,使用 Join 方法等待所有线程执行完毕。
- 最后输出计数器的最终值。
-
IncrementCounter 方法:
- 定义一个布尔型变量 lockTaken,用于记录是否成功获取锁。
- 使用 Monitor.Enter(lockObject, ref lockTaken) 尝试获取锁,并将结果存储在 lockTaken 中。
- 在 try 块中,对 counter 进行 1000 次递增操作。
- 在 finally 块中,检查 lockTaken 的值,如果为 true,说明成功获取了锁,使用 Monitor.Exit(lockObject) 释放锁。
-
lockTaken 的作用:
- lockTaken 变量用于记录锁的获取状态,确保只有在成功获取锁的情况下才会释放锁,避免因异常导致的锁未释放问题。
3. TryEnter 方法
bool result = Monitor.TryEnter(object obj);
- 作用:尝试获取指定对象的锁。与 Enter 方法不同的是,TryEnter 方法不会阻塞线程,如果锁当前未被其他线程持有,则当前线程会获取该锁并返回 true;如果锁已被其他线程持有,则方法会立即返回 false。
- 使用示例:
using System;
using System.Threading;
class Program
{
private static readonly object _lockObject = new object();
static void Main()
{
if (Monitor.TryEnter(_lockObject))
{
try
{
Console.WriteLine("Lock acquired.");
}
finally
{
Monitor.Exit(_lockObject);
}
}
else
{
Console.WriteLine("Failed to acquire lock.");
}
}
}
4. TryEnter 重载方法
// 还可以指定超时时间
bool result = Monitor.TryEnter(object obj, TimeSpan timeout);
bool result = Monitor.TryEnter(object obj, int millisecondsTimeout);
- 作用:TryEnter 方法允许你在尝试获取锁时设置一个超时时间,如果在指定时间内无法获得锁,则返回 false,避免无限期等待。
- 使用示例:
public void IncrementWithTimeout(TimeSpan timeout)
{
// 设置超时时间
if (Monitor.TryEnter(_lock, timeout))
{
try
{
// 操作共享资源
_count++;
Console.WriteLine($"Incremented count to {_count} with timeout");
}
finally
{
Monitor.Exit(_lock);
}
}
else
{
// 处理获取锁失败的情况
Console.WriteLine("Failed to acquire the lock within the specified timeout.");
}
}
4. Wait 和 Pulse/PulseAll 方法
Monitor.Wait(object obj);
// 还可以指定超时时间
Monitor.Wait(object obj, TimeSpan timeout);
Monitor.Wait(object obj, int millisecondsTimeout);
- Wait 作用:使当前线程释放对象的锁,并进入等待状态,直到其他线程调用 Pulse 或 PulseAll 方法唤醒它。
Monitor.Pulse(object obj);
- Pulse 作用:唤醒在指定对象的锁上等待的一个线程。如果有多个线程在等待,则只会唤醒其中一个线程。
Monitor.PulseAll(object obj);
-
PulseAll 作用:唤醒在指定对象的锁上等待的所有线程。
-
使用示例:生产者-消费者模式
using System;
using System.Collections.Generic;
using System.Threading;
public class ProducerConsumerQueue
{
private readonly Queue<int> _queue = new Queue<int>();
private readonly object _lock = new object();
public void Enqueue(int item)
{
Monitor.Enter(_lock);
try
{
_queue.Enqueue(item);
Console.WriteLine($"Produced: {item}");
Monitor.Pulse(_lock); // 通知消费者有新项目
}
finally
{
Monitor.Exit(_lock);
}
}
public int Dequeue()
{
Monitor.Enter(_lock);
try
{
// 使用while循环来检查条件是因为可能存在虚假唤醒,
// 或者在被唤醒后条件又被其他线程改变的情况,所以需要循环检查。
while (_queue.Count == 0)
{
Monitor.Wait(_lock); // 等待直到有项目可用
}
int item = _queue.Dequeue();
Console.WriteLine($"Consumed: {item}");
return item;
}
finally
{
Monitor.Exit(_lock);
}
}
}
class Program
{
static void Main(string[] args)
{
var queue = new ProducerConsumerQueue();
// 启动生产者线程
Task.Run(() =>
{
for (int i = 0; i < 5; i++)
{
queue.Enqueue(i);
Thread.Sleep(100); // 模拟生产延迟
}
});
// 启动消费者线程
Task.Run(() =>
{
for (int i = 0; i < 5; i++)
{
queue.Dequeue();
Thread.Sleep(150); // 模拟消费延迟
}
});
Console.ReadLine();
}
}
在这个例子中,生产者线程通过 Enqueue 方法向队列中添加项目,并调用 Pulse 通知消费者。消费者线程通过 Dequeue 方法从队列中移除项目,并在队列为空时调用 Wait 进入等待状态,直到生产者调用 Pulse 通知有新项目可用。
三、与 lock 语句的关系
lock 语句是 C# 中用于实现线程同步的语法糖,它内部实际上是使用 Monitor 类来实现的。以下两种代码是等价的:
// 使用 lock 语句
private static readonly object _lockObject = new object();
lock (_lockObject)
{
// 受保护的代码块
}
// 使用 Monitor 类
Monitor.Enter(_lockObject);
try
{
// 受保护的代码块
}
finally
{
Monitor.Exit(_lockObject);
}
四、注意事项
- 锁对象的选择:应该使用专门为同步目的创建的对象作为锁对象,通常使用
private static readonly object
类型的对象。避免使用 this 引用或类型对象作为锁对象,因为这可能会导致意外的锁竞争。 - 异常处理:在使用 Monitor.Enter 方法时,必须在 try-finally 块中调用 Monitor.Exit 方法,以确保锁在任何情况下都会被释放。
- 死锁问题:在使用多个锁时,要注意避免死锁的发生。死锁是指两个或多个线程相互等待对方释放锁,从而导致程序陷入无限等待的状态。
- 调用约束:调用 Wait、Pulse 前必须持有锁,否则抛出 SynchronizationLockException。
- 性能:Monitor 是轻量级的进程内同步机制,优于 Mutex,但需避免长时间持锁。
五、最佳实践
1. 使用 lock 关键字简化代码
对于大多数简单的同步需求,推荐使用 lock 关键字,因为它更简洁且不易出错。实际上,lock 是 Monitor 的一种语法糖。
private readonly object _lock = new object();
public void Increment()
{
lock (_lock)
{
_count++;
Console.WriteLine($"Incremented count to {_count}");
}
}
2. 使用 Monitor.TryEnter 避免阻塞
当需要避免长时间等待时,可以使用 Monitor.TryEnter 并设置超时时间。
if (Monitor.TryEnter(_lock, TimeSpan.FromSeconds(5)))
{
try
{
// Critical section code here
}
finally
{
Monitor.Exit(_lock);
}
}
else
{
Console.WriteLine("Failed to acquire the lock within the timeout period.");
}
3. 使用 Wait 和 Pulse 实现条件变量
在需要等待某个条件满足的情况下,可以使用 Wait 和 Pulse 来实现条件变量。
while (!conditionMet)
{
Monitor.Wait(_lock);
}
// 条件满足后继续执行
Monitor.Pulse(_lock); // 通知其他等待的线程
在这个示例中,我们将创建一个简单的队列,并确保生产者和消费者之间的同步:
- 生产者:当队列未满时,生产者向队列中添加项目。
- 消费者:当队列不为空时,消费者从队列中移除项目。
- 同步机制:使用 Monitor.Wait 和 Monitor.Pulse 来协调生产者和消费者之间的操作。
using System;
using System.Collections.Generic;
using System.Threading;
public class ProducerConsumerQueue
{
private readonly Queue<int> _queue = new Queue<int>();
private const int Capacity = 5; // 队列的最大容量
private readonly object _lock = new object();
public void Enqueue(int item)
{
Monitor.Enter(_lock);
try
{
// 等待直到队列中有空间
while (_queue.Count >= Capacity)
{
Console.WriteLine("Queue is full, waiting for consumer to dequeue...");
Monitor.Wait(_lock); // 释放锁并进入等待状态
}
_queue.Enqueue(item);
Console.WriteLine($"Produced: {item}, Queue count: {_queue.Count}");
// 通知消费者有新项目
Monitor.PulseAll(_lock);
}
finally
{
Monitor.Exit(_lock);
}
}
public int Dequeue()
{
Monitor.Enter(_lock);
try
{
// 等待直到队列中有项目
while (_queue.Count == 0)
{
Console.WriteLine("Queue is empty, waiting for producer to enqueue...");
Monitor.Wait(_lock); // 释放锁并进入等待状态
}
int item = _queue.Dequeue();
Console.WriteLine($"Consumed: {item}, Queue count: {_queue.Count}");
// 通知生产者队列中有空位
Monitor.PulseAll(_lock);
return item;
}
finally
{
Monitor.Exit(_lock);
}
}
}
class Program
{
static void Main(string[] args)
{
var queue = new ProducerConsumerQueue();
// 启动生产者线程
Task.Run(() =>
{
for (int i = 0; i < 10; i++)
{
queue.Enqueue(i);
Thread.Sleep(100); // 模拟生产延迟
}
});
// 启动消费者线程
Task.Run(() =>
{
for (int i = 0; i < 10; i++)
{
int item = queue.Dequeue();
Thread.Sleep(150); // 模拟消费延迟
}
});
Console.ReadLine(); // 等待用户输入以结束程序
}
}
代码解释
- ProducerConsumerQueue 类
- 成员变量:
- _queue: 存储项目的队列。
- _lock: 用于同步的锁对象。
- Capacity: 队列的最大容量(防止队列溢出)。
- Enqueue 方法:
- 使用 Monitor.Enter 获取锁。
- 如果队列已满,则调用 Monitor.Wait 释放锁并进入等待状态,直到其他线程调用 PulseAll 唤醒它。
- 将项目添加到队列中,并调用 Monitor.PulseAll 通知所有等待的线程(特别是消费者)队列中有新项目。
- 最后,使用 Monitor.Exit 释放锁。
- Dequeue 方法:
- 使用 Monitor.Enter 获取锁。
- 如果队列为空,则调用 Monitor.Wait 释放锁并进入等待状态,直到其他线程调用 PulseAll 唤醒它。
- 从队列中移除项目,并调用 Monitor.PulseAll 通知所有等待的线程(特别是生产者)队列中有空位。
- 最后,使用 Monitor.Exit 释放锁。
- Program 类
- 创建 ProducerConsumerQueue 的实例。
- 启动两个任务:一个作为生产者,另一个作为消费者。
- 生产者每100毫秒生成一个项目并将其添加到队列中。
- 消费者每150毫秒从队列中取出一个项目并处理它。
- 使用 Console.ReadLine() 等待用户输入以保持程序运行。
运行结果示例
假设我们运行上述程序,可能会看到如下输出:
Produced: 0, Queue count: 1
Consumed: 0, Queue count: 0
Produced: 1, Queue count: 1
Produced: 2, Queue count: 2
Consumed: 1, Queue count: 1
Produced: 3, Queue count: 2
Produced: 4, Queue count: 3
Consumed: 2, Queue count: 2
Produced: 5, Queue count: 3
Produced: 6, Queue count: 4
Consumed: 3, Queue count: 3
Produced: 7, Queue count: 4
Produced: 8, Queue count: 5
Queue is full, waiting for consumer to dequeue...
Consumed: 4, Queue count: 4
Produced: 9, Queue count: 5
Queue is full, waiting for consumer to dequeue...
Consumed: 5, Queue count: 4
Consumed: 6, Queue count: 3
Consumed: 7, Queue count: 2
Consumed: 8, Queue count: 1
Consumed: 9, Queue count: 0
Queue is empty, waiting for producer to enqueue...
关键点解释
- Monitor.Wait:在生产者和消费者方法中,当条件不满足时(例如队列已满或队列为空),当前线程会调用 Monitor.Wait 释放锁并进入等待状态,直到其他线程调用 PulseAll 唤醒它。
- Monitor.PulseAll:当生产者向队列中添加项目或消费者从队列中移除项目后,调用 Monitor.PulseAll 通知所有等待的线程重新竞争锁。这样可以确保即使只有一个线程被唤醒也能继续执行。
- 锁的作用范围:尽量缩小锁的作用范围,只锁定必要的最小代码片段。这有助于减少阻塞时间,提高并发性能。
结语
回到目录页:C#/.NET 知识汇总
希望以上内容可以帮助到大家,如文中有不对之处,还请批评指正。