一、为什么需要关心线程同步?

想象一下,你正在管理一个超市的收银台。如果所有顾客都挤在同一个收银台前,没有排队规则,那会是什么场景?这就是多线程程序中没有同步机制时的样子——混乱、错误,甚至崩溃。

在C#中,当我们使用多个线程同时操作同一个数据时,就会出现类似的资源竞争问题。比如,多个线程同时往一个银行账户里存钱,如果没有正确的同步机制,最后的余额很可能是错误的。

// 技术栈:C# .NET 6
// 一个典型的资源竞争示例
class UnsafeAccount
{
    private int balance = 1000;
    
    public void Withdraw(int amount)
    {
        // 这里存在竞态条件
        if (balance >= amount)
        {
            Thread.Sleep(10); // 模拟处理延迟
            balance -= amount;
            Console.WriteLine($"取款{amount}成功,余额:{balance}");
        }
    }
}

// 使用时可能出现问题:
var account = new UnsafeAccount();
var threads = new List<Thread>();
for (int i = 0; i < 10; i++)
{
    threads.Add(new Thread(() => account.Withdraw(100)));
}
threads.ForEach(t => t.Start());
// 可能输出多个"取款成功",导致余额变为负数

二、C#中的同步工具包

C#为我们提供了多种线程同步的工具,就像超市里的排队栏杆、叫号系统一样,每种工具适合不同的场景。

1. lock语句 - 最简单的选择

// 使用lock解决资源竞争
class SafeAccount
{
    private readonly object balanceLock = new object();
    private int balance = 1000;
    
    public void Withdraw(int amount)
    {
        lock (balanceLock) // 关键代码加锁
        {
            if (balance >= amount)
            {
                Thread.Sleep(10);
                balance -= amount;
                Console.WriteLine($"取款{amount}成功,余额:{balance}");
            }
        }
    }
}

lock是最简单直接的同步方式,它确保同一时间只有一个线程能进入被锁定的代码块。但要注意:

  • 锁对象应该是私有的,避免外部代码也能锁定它
  • 锁的范围要尽可能小,避免不必要的性能损失
  • 避免嵌套锁,容易导致死锁

2. Monitor类 - lock的底层实现

// Monitor的等效实现
class MonitorAccount
{
    private readonly object balanceLock = new object();
    private int balance = 1000;
    
    public void Withdraw(int amount)
    {
        Monitor.Enter(balanceLock); // 获取锁
        try
        {
            if (balance >= amount)
            {
                Thread.Sleep(10);
                balance -= amount;
                Console.WriteLine($"取款{amount}成功,余额:{balance}");
            }
        }
        finally
        {
            Monitor.Exit(balanceLock); // 确保锁被释放
        }
    }
}

Monitor提供了比lock更灵活的控制,比如TryEnter可以设置超时时间,避免无限等待。

3. Mutex - 跨进程的锁

当你的程序需要在多个进程间同步时,Mutex就派上用场了。

// 使用Mutex进行跨进程同步
class MutexExample
{
    private static Mutex mutex = new Mutex(false, "Global\\MyAppMutex");
    
    public void ProcessData()
    {
        if (mutex.WaitOne(1000)) // 等待最多1秒
        {
            try
            {
                // 临界区代码
                Console.WriteLine("处理数据中...");
                Thread.Sleep(2000);
            }
            finally
            {
                mutex.ReleaseMutex();
            }
        }
        else
        {
            Console.WriteLine("获取锁超时");
        }
    }
}

三、更高级的同步机制

1. Semaphore - 控制并发数量

想象一个停车场,只有有限的车位。Semaphore就是这样的机制。

// 使用Semaphore限制并发数
class ParkingLot
{
    private static Semaphore semaphore = new Semaphore(3, 3); // 3个车位
    
    public void ParkCar(string carId)
    {
        Console.WriteLine($"{carId} 等待进入停车场...");
        semaphore.WaitOne();
        try
        {
            Console.WriteLine($"{carId} 已进入停车场");
            Thread.Sleep(2000); // 模拟停车时间
        }
        finally
        {
            semaphore.Release();
            Console.WriteLine($"{carId} 已离开停车场");
        }
    }
}

// 使用示例:
var parkingLot = new ParkingLot();
for (int i = 1; i <= 5; i++)
{
    new Thread(() => parkingLot.ParkCar($"车{i}")).Start();
}

2. ReaderWriterLockSlim - 读写分离的锁

对于读多写少的场景,这种锁能显著提高性能。

// 使用ReaderWriterLockSlim优化读写操作
class CacheService
{
    private readonly ReaderWriterLockSlim cacheLock = new ReaderWriterLockSlim();
    private Dictionary<string, string> cache = new Dictionary<string, string>();
    
    public string Read(string key)
    {
        cacheLock.EnterReadLock();
        try
        {
            if (cache.TryGetValue(key, out var value))
                return value;
            return null;
        }
        finally
        {
            cacheLock.ExitReadLock();
        }
    }
    
    public void Write(string key, string value)
    {
        cacheLock.EnterWriteLock();
        try
        {
            cache[key] = value;
        }
        finally
        {
            cacheLock.ExitWriteLock();
        }
    }
}

四、异步编程中的同步问题

现代C#开发中,async/await模式非常普遍,但同步问题依然存在。

// 异步方法中的同步问题
class AsyncAccount
{
    private int balance = 1000;
    private readonly object balanceLock = new object();
    
    public async Task WithdrawAsync(int amount)
    {
        await Task.Delay(100); // 模拟异步操作
        
        lock (balanceLock) // 仍然需要同步
        {
            if (balance >= amount)
            {
                balance -= amount;
                Console.WriteLine($"取款{amount}成功,余额:{balance}");
            }
        }
    }
}

注意:在异步方法中使用lock时要小心,因为lock会阻塞线程。对于高频的异步同步场景,可以考虑使用SemaphoreSlim:

class AsyncAccountImproved
{
    private int balance = 1000;
    private readonly SemaphoreSlim semaphore = new SemaphoreSlim(1, 1);
    
    public async Task WithdrawAsync(int amount)
    {
        await semaphore.WaitAsync(); // 异步等待
        try
        {
            if (balance >= amount)
            {
                await Task.Delay(100); // 模拟异步操作
                balance -= amount;
                Console.WriteLine($"取款{amount}成功,余额:{balance}");
            }
        }
        finally
        {
            semaphore.Release();
        }
    }
}

五、常见陷阱与最佳实践

  1. 死锁:当两个或多个线程互相等待对方释放锁时发生

    • 避免方法:按固定顺序获取多个锁,或使用Monitor.TryEnter设置超时
  2. 锁粒度过大:锁住太多代码会降低并发性能

    • 最佳实践:只锁住真正需要同步的最小代码块
  3. 忘记释放锁:这会导致其他线程永远等待

    • 最佳实践:使用using语句或try-finally确保锁被释放
  4. 过度同步:不是所有共享数据都需要同步

    • 最佳实践:识别真正的竞态条件,避免不必要的同步
// 死锁示例(不要在实际代码中这样做!)
class DeadlockExample
{
    private readonly object lock1 = new object();
    private readonly object lock2 = new object();
    
    public void Method1()
    {
        lock (lock1)
        {
            Thread.Sleep(100);
            lock (lock2) // 可能在这里死锁
            {
                Console.WriteLine("Method1完成");
            }
        }
    }
    
    public void Method2()
    {
        lock (lock2)
        {
            Thread.Sleep(100);
            lock (lock1) // 可能在这里死锁
            {
                Console.WriteLine("Method2完成");
            }
        }
    }
}

六、如何选择合适的同步机制

选择同步机制时,考虑以下因素:

  1. 性能需求:读写锁在读多写少时性能更好
  2. 并发程度:Semaphore适合限制并发数量
  3. 跨进程需求:Mutex适用于进程间同步
  4. 异步环境:SemaphoreSlim更适合async/await
  5. 复杂性:简单的lock通常是最易维护的选择

记住:最简单的解决方案往往是最好的。只有在确实需要时才使用更复杂的同步机制。

七、实际应用场景分析

  1. 缓存系统:使用ReaderWriterLockSlim优化读写性能
  2. 资源池:使用Semaphore管理有限资源(如数据库连接)
  3. 全局配置:使用lock保护共享配置数据
  4. 订单处理:使用Monitor.TryEnter避免死锁
  5. 日志系统:使用简单的lock确保日志写入顺序
// 实际示例:线程安全的日志系统
public static class ThreadSafeLogger
{
    private static readonly object lockObj = new object();
    private static StreamWriter logFile;
    
    static ThreadSafeLogger()
    {
        logFile = new StreamWriter("app.log", append: true);
    }
    
    public static void Log(string message)
    {
        lock (lockObj)
        {
            logFile.WriteLine($"{DateTime.Now}: {message}");
            logFile.Flush();
        }
    }
    
    public static void Close()
    {
        lock (lockObj)
        {
            logFile?.Close();
        }
    }
}

八、总结与建议

多线程编程就像指挥一个交响乐团,每个线程就是一个乐手,而同步机制就是指挥棒。没有好的指挥,再优秀的乐手也会奏出不和谐的音符。

关键要点:

  • 识别真正的共享资源和竞态条件
  • 从最简单的lock开始,只在需要时使用更复杂的机制
  • 始终确保锁被正确释放
  • 在异步环境中考虑使用SemaphoreSlim
  • 避免常见的陷阱,如死锁和过度同步

记住,线程同步不是可有可无的装饰品,而是保证程序正确性的必需品。花时间正确实现同步机制,可以避免许多难以调试的问题。