C#的同步原语

61 阅读6分钟

做.net开发久了,面试问这些问的少,就没有怎么准备。有一次面试博赛信息时,面试官提到了,在此记录一下。话说平时在webapi里很少用到锁相关的,也尽量用线程并发安全的数据结构。实在要用锁,就简单的用了一下lock就不管其它的的了。 C# 中用于线程同步的原语确实不少,每种都有其特定的适用场景。下面我为你梳理一下主要的同步原语、它们的核心作用以及代码示例,希望能帮你更好地理解和运用它们。

同步原语核心特点适用场景注意事项
​**lock关键字**​语法简单,自动释放锁,​可重入​(同一线程可多次获取)进程内多线程同步,保护共享资源,如计数器、状态标志等锁对象需为 private readonly object类型,临界区代码应尽量精简
​**Monitor类**​​**lock的底层实现**,提供更灵活的控制(如超时)需要手动控制锁或设置获取超时的场景需配合 try-finally确保锁释放,避免死锁
​**Mutex(互斥体)​**​内核级对象,支持跨进程同步,重量级保护跨进程共享的资源,如文件、共享内存等性能开销较大,通常进程内同步不首选
​**SemaphoreSlim**​轻量级信号量,限制同时访问资源的线程数量资源池(如数据库连接池)、限流、控制最大并发数适用于进程内同步,WaitAsync支持异步等待
​**ReaderWriterLockSlim**​读写分离,允许多个读线程并发,写线程独占读多写少的场景,如缓存、配置管理注意避免写线程饥饿问题
​**AutoResetEvent**​事件通知,​自动重置,一次只释放一个等待线程线程间信号通知,如任务完成后通知另一线程需注意信号可能被遗漏
​**ManualResetEvent**​事件通知,​手动重置,释放后所有等待线程可继续,直到手动重置线程间广播通知,如初始化完成后通知所有工作线程需手动调用 Reset方法才能重新阻塞线程
​**CountdownEvent**​等待多个操作完成需要等待一组任务全部完成后再继续的场景计数减至零时释放所有等待线程
​**SpinLock**​忙等待​(自旋),避免线程切换开销,适用于极短的临界区临界区代码执行非常快​(纳秒或微秒级)的场景适合耗时操作或I/O操作,否则浪费CPU
​**Interlocked**​提供基本的原子操作​(如递增、递减、交换、比较交换),无需显式锁简单的整数、变量的原子操作,性能极高仅支持简单数据类型,复杂操作仍需其他同步机制

下面是这些同步原语的简要说明和代码示例:

🔒 1. lock关键字

这是最常用、最简单的同步机制,它确保了同一时刻只有一个线程能进入临界区代码块。

private readonly object _lockObj = new object();
private int _counter = 0;

public void Increment()
{
    lock (_lockObj) // 进入锁
    {
        _counter++; // 临界区
    } // 自动释放锁
}

🔍 2. Monitor

lock语句的底层就是通过 Monitor类实现的。 它提供了更灵活的控制,比如设置超时时间。

private readonly object _monitorObj = new object();

public void TryDoSomething()
{
    bool lockAcquired = false;
    try
    {
        // 尝试在100毫秒内获取锁
        Monitor.TryEnter(_monitorObj, 100, ref lockAcquired);
        if (lockAcquired)
        {
            // 临界区
        }
        else
        {
            // 获取锁超时的处理
        }
    }
    finally
    {
        if (lockAcquired)
        {
            Monitor.Exit(_monitorObj); // 必须手动释放
        }
    }
}

🌐 3. Mutex(互斥体)

Mutex是内核级别的锁,可以用于跨进程的同步。

// 命名的 Mutex 可以被系统内其他进程识别
private static Mutex _mutex = new Mutex(false, "Global\MyAppMutex");

public void CrossProcessSafeMethod()
{
    _mutex.WaitOne(); // 请求拥有互斥体
    try
    {
        // 操作跨进程共享的资源,如一个文件
    }
    finally
    {
        _mutex.ReleaseMutex(); // 释放
    }
}

🚦 4. SemaphoreSlim/ Semaphore

信号量用于控制同时访问某个资源的线程数量SemaphoreSlim是轻量版,推荐在进程内使用。

// 允许最多3个线程同时访问资源池
private SemaphoreSlim _semaphore = new SemaphoreSlim(3, 3);

public async Task AccessResourceAsync()
{
    await _semaphore.WaitAsync(); // 等待进入
    try
    {
        // 使用资源,最多3个线程同时在这里
    }
    finally
    {
        _semaphore.Release(); // 释放,允许其他线程进入
    }
}

📖 5. ReaderWriterLockSlim

此锁允许多个线程同时读取,但写入时独占,非常适合读多写少的场景。

private ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim();
private Dictionary<int, string> _cache = new Dictionary<int, string>();

public string Read(int key)
{
    _rwLock.EnterReadLock(); // 获取读锁
    try
    {
        return _cache[key];
    }
    finally
    {
        _rwLock.ExitReadLock();
    }
}

public void Write(int key, string value)
{
    _rwLock.EnterWriteLock(); // 获取写锁
    try
    {
        _cache[key] = value;
    }
    finally
    {
        _rwLock.ExitWriteLock();
    }
}

🚏 6. AutoResetEventManualResetEvent

用于线程间的信号通知AutoResetEvent在信号被设置后自动重置,只释放一个等待线程;ManualResetEvent则需要手动重置,释放后所有等待线程都可继续。

// AutoResetEvent 示例
private AutoResetEvent _autoEvent = new AutoResetEvent(false);

void Thread1()
{
    // 做一些工作...
    _autoEvent.Set(); // 发送信号,释放一个等待线程
}

void Thread2()
{
    _autoEvent.WaitOne(); // 等待信号
    // 收到信号后继续执行...
}

// ManualResetEvent 示例
private ManualResetEvent _manualEvent = new ManualResetEvent(false);

void Initialize()
{
    // 执行初始化...
    _manualEvent.Set(); // 初始化完成,通知所有等待线程
}

void WorkerThread()
{
    _manualEvent.WaitOne(); // 等待初始化完成
    // 初始化完成后所有线程同时继续...
}

🔢 7. Interlocked

提供了一系列静态方法用于执行原子操作,这些操作是天生线程安全的,无需使用锁,性能极高。

private int _atomicCounter = 0;

public void IncrementAtomically()
{
    // 原子递增,返回递增后的值
    Interlocked.Increment(ref _atomicCounter);
}

public int CompareExchangeExample(int newValue, int compareTo)
{
    // 如果当前值等于compareTo,则替换为newValue。总是返回原始值。
    return Interlocked.CompareExchange(ref _atomicCounter, newValue, compareTo);
}

⚡ 8. SpinLock(自旋锁)

当线程无法获取锁时,它会进行“忙等待”(自旋),而不是立刻被阻塞。这避免了线程上下文切换的开销,但会持续占用CPU。 ​仅适用于临界区代码执行速度极快(例如,只是几条简单指令)的场景

private SpinLock _spinLock = new SpinLock();

public void VeryFastOperation()
{
    bool lockTaken = false;
    try
    {
        _spinLock.Enter(ref lockTaken); // 获取自旋锁
        // 执行非常快速的操作,例如修改一个标志位
    }
    finally
    {
        if (lockTaken)
        {
            _spinLock.Exit(); // 释放自旋锁
        }
    }
}

🎯 如何选择同步原语?

选择哪种同步机制,主要考虑以下几点:

  1. 同步范围​:是进程内 (lock, Monitor, SemaphoreSlim, ReaderWriterLockSlim) 还是需要跨进程 (Mutex, Semaphore)?
  2. 操作粒度​:是简单的变量操作 (Interlocked)?还是短暂的代码块 (lock, SpinLock)?或是需要控制并发数量 (SemaphoreSlim)?
  3. 读写模式​:是纯写入 (lock) 还是读多写少 (ReaderWriterLockSlim)?
  4. 性能需求​:临界区执行时间极短,想避免上下文切换?(考虑 SpinLock,但要谨慎)。
  5. 协作方式​:是否需要线程间发送信号、等待事件?(使用 AutoResetEvent, ManualResetEvent)。

通用建议​:

  • ​**首选 lock**​:对于大多数进程内的同步需求,lock语句因其简单性和可靠性通常是首选。
  • 避免过度同步​:只在必要的时候保护共享资源,锁的粒度要尽可能小,以减少对并发性能的影响。
  • 警惕死锁​:确保多个锁的获取顺序一致,并考虑使用 Monitor.TryEnter来提供超时机制。