菜鸟科技网

NET多线程如何同步?

下面我将从核心理念常用工具最佳实践一个完整的示例四个方面,详细讲解如何在 .NET 中进行多线程同步。

NET多线程如何同步?-图1
(图片来源网络,侵删)

核心理念:为什么需要同步?

想象一下,一个银行账户(共享资源)有两个窗口(两个线程),两个人分别要从两个窗口存入100元。

  • 没有同步的情况(线程不安全)

    1. 线程A读取账户余额:1000元。
    2. 线程B读取账户余额:1000元。
    3. 线程A计算新余额:1000 + 100 = 1100元。
    4. 线程B计算新余额:1000 + 100 = 1100元。
    5. 线程A将新余额1100写回账户。
    6. 线程B将新余额1100写回账户。

    结果:账户余额变成了1100元,而不是正确的1200元,这就是数据竞争,因为多个线程同时读写了同一个数据,导致结果不可预测。

  • 有同步的情况(线程安全): 我们需要一种机制,确保在任何一个线程修改余额时,其他线程必须等待,这个过程就像一个“锁”。

    NET多线程如何同步?-图2
    (图片来源网络,侵删)
    1. 线程A请求“锁”,成功获得。
    2. 线程A读取账户余额:1000元。
    3. 线程A计算新余额:1100元。
    4. 线程A将新余额1100写回账户。
    5. 线程A释放“锁”。
    6. 线程B现在才能请求“锁”,成功获得。
    7. 线程B读取账户余额(此时已经是1100元)。
    8. 线程B计算新余额:1200元。
    9. 线程B将新余额1200写回账户。
    10. 线程B释放“锁”。

    结果:账户余额正确地变成了1200元。


常用的同步工具(从简单到强大)

.NET 提供了丰富的同步工具,适用于不同的场景。

lock 语句 - 最常用、最基础的锁

lock 是 C# 提供的一个语法糖,它基于 Monitor 类实现,是最简单、最常用的线程同步方式。

工作原理

NET多线程如何同步?-图3
(图片来源网络,侵删)
  • 它确保只有一个线程可以执行被 lock 保护的代码块。
  • 当一个线程进入 lock 代码块时,它会获取一个“互斥锁”,其他试图进入该 lock 的线程将被阻塞,直到第一个线程释放锁。
  • 锁的释放是自动的,即使代码块内发生异常。

语法

private readonly object _lockObject = new object();
// ...
lock (_lockObject)
{
    // 只有一个线程能同时执行这里的代码
    // 访问或修改共享资源
}

重要提示

  • 锁对象必须是 privatereadonly 的,以防止被外部代码意外修改或锁定,这可能导致死锁。
  • 永远不要锁定 this、字符串类型或任何可被公开访问的对象,因为它们可能在代码的其他地方被锁定,增加死锁风险。

Monitor 类 - lock 的底层实现

lock 语句实际上是对 Monitor 类的简化封装。Monitor 提供了更灵活的功能,但使用起来也更复杂。

常用方法

  • Monitor.Enter(obj): 尝试获取指定对象上的锁,如果已被锁定,则阻塞。
  • Monitor.Exit(obj): 释放锁。
  • Monitor.TryEnter(obj, millisecondsTimeout): 尝试获取锁,但可以设置超时时间,避免无限等待。

示例(不推荐直接使用,除非有特殊需求)

private readonly object _lockObject = new object();
// ...
try
{
    Monitor.Enter(_lockObject);
    // 受保护的代码区域
}
finally
{
    // 确保锁一定会被释放
    Monitor.Exit(_lockObject);
}

lock 语句会自动生成这个 try-finally 结构,所以更安全、更推荐。

Mutex (互斥量) - 跨进程同步

Mutexlock 非常相似,但它是一个内核对象,因此可以用于跨进程同步,这意味着不同的应用程序实例可以通过 Mutex 来协调它们的行为。

由于涉及到操作系统内核,Mutex 的开销比 lock 大得多,仅在需要跨进程同步时才使用。

示例

// 全局命名互斥量,确保同一时间只有一个实例在运行
using (var mutex = new Mutex(false, "Global\\MyAppSingleInstance"))
{
    if (!mutex.WaitOne(0, false)) // 尝试立即获取锁
    {
        Console.WriteLine("另一个实例已在运行。");
        return;
    }
    // ... 程序主逻辑 ...
    // 当 `using` 块结束时,mutex 会被自动释放
}

Semaphore (信号量) - 限制资源并发数

Semaphore 允许指定数量的线程同时访问一个资源,它像一个夜总会的保镖,只允许固定数量的人进入。

  • SemaphoreSlim 是轻量级的,适用于进程内同步。
  • System.Threading.Semaphore 是内核对象,可用于跨进程同步。

示例 (SemaphoreSlim): 假设我们有一个只能处理3个并发请求的服务。

private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(3, 3); // 初始3个,最大3个
public async Task ProcessRequestAsync()
{
    await _semaphore.WaitAsync(); // 尝试获取一个“入场券”
    try
    {
        Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 开始处理,当前并发数: {_semaphore.CurrentCount}");
        // 模拟耗时操作
        await Task.Delay(1000);
        Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 处理完成。");
    }
    finally
    {
        _semaphore.Release(); // 释放“入场券”
    }
}

ReaderWriterLockSlim - 读写分离优化

当共享资源的读操作远多于写操作时,使用 lock 会很低效,因为它会把所有读操作都串行化。ReaderWriterLockSlim 允许多个线程同时读取资源,但在写入时会独占访问。

  • 进入读锁 (EnterReadLock):多个线程可以同时进入。
  • 进入写锁 (EnterWriteLock):只有一个线程可以进入,此时所有其他读线程和写线程都会被阻塞。

示例

private readonly ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim();
private int _sharedValue = 0;
public void ReadValue()
{
    _rwLock.EnterReadLock();
    try
    {
        Console.WriteLine($"读取值: {_sharedValue}");
    }
    finally
    {
        _rwLock.ExitReadLock();
    }
}
public void WriteValue(int newValue)
{
    _rwLock.EnterWriteLock();
    try
    {
        _sharedValue = newValue;
        Console.WriteLine($"写入值: {_sharedValue}");
    }
    finally
    {
        _rwLock.ExitWriteLock();
    }
}

Interlocked 类 - 原子操作

对于简单的单一变量的原子操作(如递增、递减、交换),Interlocked 类提供了最高性能的解决方案,它不使用锁,而是通过CPU的原子指令来保证操作的不可分割性。

常用方法

  • Interlocked.Increment(ref value): 原子递增。
  • Interlocked.Decrement(ref value): 原子递减。
  • Interlocked.Exchange(ref location, value): 原子交换。
  • Interlocked.CompareExchange(ref location, value, comparand): 原子比较并交换,是实现无锁算法的关键。

示例

private int _counter = 0;
// 在多个线程中调用
Interlocked.Increment(ref _counter); // 比 lock 更快

同步的最佳实践和注意事项

  1. 锁定范围要尽可能小:只锁定访问共享资源所必需的最小代码块,锁得越久,其他线程等待的时间就越长,性能越差。

  2. 避免死锁:死锁是指两个或多个线程互相等待对方释放锁,导致所有线程都卡住。

    • 场景:线程A锁住了对象1并等待对象2;线程B锁住了对象2并等待对象1。
    • 预防
      • 按固定顺序获取锁:如果需要锁住多个对象,总是以相同的顺序获取它们。
      • 使用 try-finally:确保锁一定会被释放。
      • 避免在持有锁时调用外部代码:因为你不知道外部代码是否会尝试获取同一个锁。
  3. 优先使用 asyncawait

    • 当一个线程在等待锁时,它会被阻塞,无法做任何事。
    • 对于 lockMonitor,这是无法避免的。
    • 但对于 SemaphoreSlim.WaitAsync(),线程在等待时不会阻塞,而是会“让出”CPU,释放线程池资源,当锁可用时,它会自动恢复执行,这能极大地提高高并发场景下的应用程序吞吐量。
  4. 考虑无锁编程:对于高性能场景,如果可能,尽量使用 Interlocked 或更高级的无锁数据结构(如 ConcurrentQueue<T>, ConcurrentDictionary<TKey, TValue>),它们通过原子操作避免了锁的开销,但编程模型更复杂。


完整示例:生产者-消费者模型

这个例子综合了 lockMonitorwhile 循环和线程协作,是多线程同步的经典案例。

场景:一个线程(生产者)不断向队列中添加产品,另一个线程(消费者)不断从队列中取出产品,队列是共享资源,需要同步。

using System;
using System.Collections.Generic;
using System.Threading;
public class Program
{
    // 共享队列
    private static readonly Queue<int> _productQueue = new Queue<int>();
    // 用于同步队列访问的锁对象
    private static readonly object _queueLock = new object();
    // 用于在生产者停止时通知消费者的信号量
    private static readonly SemaphoreSlim _consumerSignal = new SemaphoreSlim(0);
    public static void Main()
    {
        var producer = new Thread(ProducerThread);
        var consumer = new Thread(ConsumerThread);
        producer.Start();
        consumer.Start();
        // 让生产者运行5秒
        Thread.Sleep(5000);
        producer.Interrupt(); // 中断生产者线程(或设置一个停止标志)
        producer.Join();
        consumer.Join(); // 等待消费者处理完所有剩余产品后退出
        Console.WriteLine("程序结束。");
    }
    // 生产者线程
    public static void ProducerThread()
    {
        int productId = 0;
        try
        {
            while (true)
            {
                productId++;
                // 模拟生产耗时
                Thread.Sleep(100); 
                lock (_queueLock)
                {
                    _productQueue.Enqueue(productId);
                    Console.WriteLine($"生产了产品 {productId},队列长度: {_productQueue.Count}");
                }
                // 通知消费者有新产品了
                _consumerSignal.Release();
            }
        }
        catch (ThreadInterruptedException)
        {
            Console.WriteLine("生产者线程被中断,停止生产。");
        }
    }
    // 消费者线程
    public static void ConsumerThread()
    {
        while (true)
        {
            // 等待生产者发出信号
            _consumerSignal.Wait();
            int product = -1;
            lock (_queueLock)
            {
                if (_productQueue.Count > 0)
                {
                    product = _productQueue.Dequeue();
                }
            }
            if (product != -1)
            {
                // 模拟消费耗时
                Thread.Sleep(200);
                Console.WriteLine($"消费了产品 {product},队列长度: {_productQueue.Count}");
            }
        }
    }
}
工具 类型 用途 特点
lock 语法糖 基本互斥锁 最简单、最常用,封装了 Monitor
Monitor 互斥锁 lock 的底层实现,功能更灵活
Mutex 跨进程互斥锁 内核对象,开销大,可用于进程间同步
Semaphore 限制并发数 允许N个线程同时访问资源
ReaderWriterLockSlim 读写锁 读多写少场景的性能优化
Interlocked 原子操作 对单一变量的无锁、高性能操作

选择哪种同步工具,完全取决于你的具体场景:是简单的互斥,还是复杂的跨进程协作,或是读写性能的优化,理解它们的工作原理和适用场景,是编写健壮、高效多线程程序的关键。

分享:
扫描分享到社交APP
上一篇
下一篇