开发者

C#加锁防止并发的几种方法详解

目录
  • 前言
  • 什么时候需要加锁
  • C#各种加锁方式
    • Demo验证 
    • Demo验证
    • Monitor 类(显式锁)
    • SemaphoreSlim(信号量)
    • Mutex(互斥体)
    • ReaderWriterLockSlim(读写锁)
    • Concurrent 集合
    • Redis分布式锁
    • 总结
    • 加锁的注意事项:

前言

在最近的工作中,有一个抽奖的需求。涉及到利益发放,这时候就需要加锁,防止权益的重复发放,避免对客户造成经济损失。在实际的工作中我用到的是Redis分布式锁,借此机会我学习一下C#中各种加锁的方式,有不对的地方,欢迎大家指正。

什么时候需要加锁

  • 多个线程访问共享资源

当多个线程访问共享数据(例如共享的列表、字典、文件等)时,可能会导致数据竞争和不一致问题。加锁可以确保在同一时刻,只有一个线程能够修改共享资源,避免出现并发问题。

  • 并发导致数据不一致问题

多线程并发执行某个UPDATE语句,可能会导致数据不一致问题。

C#各种加锁方式

主要介绍lock、Monitor 、SemaphoreSlim、Mutex、ReaderWriterLockSlim、Concurrent、Redis分布式锁,看完之后其实不太需要考虑使用哪一种加锁方式。因为每种加锁都有特殊的适用场景。

  • lock语句(互斥锁)

lock语句用于实现互斥锁,也是我们比较常见的一种加锁方式。它是一种同步机制,用于确保多个线程在同一时间只能有一个线程进入特定的代码块,其他线程则进行等待,直到前一个线程释放该锁。lock细分的话,也有三种使用方式。

1、lock(this)

lock(this)锁定的对象是当前实例(就是该类的实例),但并不意味着该实例中的所有方法都会默认加锁,只有在该实例中的方法显式lock加锁,才能防止多个线程并发。

具体使用方法如下:

public class Test
{
    public void Get()
    {
        lock (this)  // 锁定当前实例
        {
            //执行代码
        }
    }
}

Demo验证

// demo
internal class Program
{
    static void Main(string[] args)
    {
        TestDemo testDemo = new TestDemo();
        //开启新线程,不然单线程执行,没有并发,加锁也没有意义
        Task.Run(() =>
        {
            testDemo.Get();
        });
        Thread.Sleep(2000); //等待2秒,可以让新线程先加锁
        testDemo.Set();
        Console.ReadLine();
    }
}
public class TestDemo
{
    public void Get(int index)
    {
        lock (this)
        {
            Thread.Sleep(5000);
            Console.WriteLine("执行完成,{index}");
        }
    }
    public void Set()
    {
        lock (this) //显式加锁,不然还是可以并发执行
        {
            Console.WriteLine("Set完成");
        }
    }
}

 执行结果:

C#加锁防止并发的几种方法详解

如果把Set方法中的锁去掉,执行结果肯定是反过来的,大家可以自己试一下。 

2、lock(privateObj)

lock(privateObj)锁的是一个私有的对象实例,它基于lock(this)的缺点,进一步做了改善。privateObj 作为私有对象,只能在该类的内部访问,外部类无法访问privateObj,避免了其他地方的代码可能对锁对象进行干扰,减少死锁的可能。

具体使用方法如下:

public class Test
{
    private readonly object _lockObject = new object();  // 私有锁对象
    public void Get()
    {
        lock (_lockObject)  // 锁住私有对象
        {
            //执行代码
        }
    }
}

Demo验证 

internal class Program
{
    static void Main(string[] args)
    {
        TestDemo testDemo = new TestDemo();
        Task.Run(() =>
        {
            testDemo.Get();
        });
        Thread.Sleep(2000);
        testDemo.Set();
        Console.ReadLine();
    }
}
public class TestDemo
{
    private readonly object _lockObject = new object();
    public void Get()
    {
        lock (_lockObject)
        {
            Thread.Sleep(5000);
            Console.WriteLine("Get执行完成");
        }
    }
    public void Set()
    {
        lock (_lockObject)
        {
            Console.WriteLine("Set执行完成");
        }
    }
}
 

 对比lock(this)的代码,其实没有太大的改动。唯一的区别就是,lock(this)锁住的是当前类的实例,而lock(privateObj)锁住的是该类内部一个私有对象,锁的粒度更小一点。​​​​

3、lock(staticObj)

在实际工作中,上述两种使用方式(lock(this) 和 lock(privateObj))并不常见。通过上面的代码,我们可以发现,不论是使用 lock(this) 还是 lock(privateObj),其本质上是基于锁住同一个对象实例来控制并发。如果并发时使用的对象实例不同,控制并发就会失效。例如,假设有 10 个用户同时调用抽奖接口,每次调用都会实例化一个新的对象,这样就会导致 10 个不同的对象实例,锁并不会生效,仍然会发生并发问题。因此,常见的做法是使用 lock(staticObj),即通过锁住静态对象来确保多个请求之间的并发控制。

lock(staticObj)是锁定一个静态对象。由于静态对象在类加载时只会被初始化一次,因此它是所有类实例共享的。

具体使用方法如下:

public class Test
{
    private static readonly object _lockObject = new object();  // 静态锁对象
    public void Get()
    {
        lock (_lockObject)  // 锁住静态锁对象
        {
            //执行代码
        }
    }
}

Demo验证

internal class Program
{
    static void Main(string[] args)
    {
        //开启十个线程,并行执行Get方法
        Parallel.For(0, 10, i =>
        {
            new TestDemo().Get();
        });
        Console.ReadLine();
    }
}
public class TestDemo
{
    private static int _count = 0;
    private static readonly object _lockObject = new object();
    public void Get()
    {
        lock (_lockObject)
        {
            _count += 1;
            Thread.Sleep(2000); //线程延迟,模拟处理数据
            Console.WriteLine($@"当前值:{_count}");
        }
    }
}

输出结果:

C#加锁防止并发的几种方法详解

如果把锁去掉,大家可以自行试一下,结果肯定不是这样。

lock什么时候释放锁呢,就是lock块中的代码执行完毕,会自动释放该锁

lock有个很大一个缺点,lock块中的代码,不支持异步操作。

lock本质上还是Monitor的语法糖,是在其基础上包了一层,下面我会介绍一下Monitor。

对比

特性lock(this)lock(privateObj)lock(staticObj)

锁定对象

范围

当前实例对象类内部定义的私有对象类级别的静态对象
访问范围外部代码可以访问实例对象并使用锁,可能导致同步问题仅限类内部使用,避免外部访问所有类实例共享静态对象,适用于跨实例同步
安全性较差,容易导致外部代码访问并使用锁对象较好,不容易被外部滥用跨实例同步,适合共享资源的同步
适用场景单个实例的同步,通常不推荐外部访问锁对象推荐用于类内部资源的线程同步适用于跨实例或跨线程的共享资源同步
死锁风险较高,可能因外部代码使用this 锁导致较低,避免了外部访问锁对象的问题较低,但应注意跨实例的锁顺序问题

Monitor 类(显式锁)

Monitor 也是 C# 中提供的一个用于线程同步的类,它保证在多线程程序中,只有一个线程可以同时访问某个共享资源。上面说的lock,其实是个语法糖,它在编译之后其实生成的代码就是Monitor。相比较lock,Monitor提供了相对更多一些的扩展功能。

具体使用方法如下:

public class Test
{
    //私有静态对象
    private static object lockObj = new object();
    public void Get()
    {
        Monitor.Enter(lockObj); //获取锁
        try
        {
            //具体执行逻辑
        }
        catch (Exception ex)
        {
        }
        finally
        {
            Monitor.Exit(lockObj); // 释放信号量
        }
    }
}

 转到定义,Monitor是一个静态类。

C#加锁防止并发的几种方法详解

 稍微说一下其中几个比较重要的方法。

  • Enter:进入锁定区域,阻止其他线程进入。
  • Exit:退出锁定区域,允许其他线程进入。
  • Wait:会释放当前线程的锁,并让该线程进入等待队列。当前线程会被挂起,直到其他线程通知它继续运行(使用 Pulse 或 PulseAll)
  • Pulse:会唤醒等待该对象锁的 一个线程。这个线程将会重新获得锁并继续执行。
  • PulseAll:会唤醒 所有 等待该对象锁的线程。所有被唤醒的线程会重新请求该锁,并继续执行。

其中有两个方法还是挺有意思的,可以先执行线程A,然后让线程A等待,唤醒线程B,线程B执行完毕,再唤醒线程A。

基于上面说的,简单实现一个小Demo

static async Task Main(string[] args)
{
    Task.Run(() =>
    {
        new TestDemo().Get();
    });
    Task.Run(() =>
    {
        new TestDemo().Set();
    });
    Console.ReadLine();
}
public class TestDemo
{
    private static object lockObj = new object();
    public void Get()
    {
        Monitor.Enter(lockObj);
        try
        {
            Console.WriteLine($"Get方法开始执行");
            //释放当前锁,让其他等待线程执行
            Monitor.Wait(lockObj, 1000);
            Console.WriteLine($"Get方法执行完毕");
        }
        catch (Exception ex)
        {
        }
        finally
        {
            // 确保锁被释放
            Monitor.Exit(lockObj);
        }
    }
    public void Set()
    {
        Monitor.Enter(lockObj);
        try
        {
            Console.WriteLine($"Set方法开始执行");
            Thread.Sleep(1000);
            Console.WriteLine($"Set方法执行完毕");
            //唤醒等待的线程
            Monitor.Pulse(lockObj);
        }
        catch (Exception ex)
        {
        }
        finally
        {
            // 确保锁被释放
            Monitor.Exit(lockObj);
        }
    }
}

执行效果:

C#加锁防止并发的几种方法详解

SemaphoreSlim(信号量)

SemaphoreSlim 是 .NET 提供的一种轻量级同步机制,用于限制并发线程数。它的工作原理类似于操作系统级的信号量,但相比于 Semaphore,SemaphoreSlim 更加高效且适合于高性能应用程序,因为它主要用于应用程序内部的线程同步,并且不会像操作系统信号量那样依赖操作系统内核,因此在多数情况下性能更好。

SemaphoreSlim主要的功能是,它可以控制指定多少个线程同时访问共享资源。换句话说,它可以控制并发执行的数量,并不是某个方法同一时刻只能有一个线程执行(SemaphoreSlim 也能支持,将并发数设置为1即可)。

具体使用方法如下:

public class Test
{
    //静态信号锁,同时可以3个线程同时访问
    private static readonly SemaphoreSlim semaphore = new SemaphoreSlim(3);
    public void Get()
    {
        semaphore.Wait(); //获取锁
        try
        {
            //具体执行逻辑
        }
        catch (Exception ex)
        {
        }
        finally
        {
            semaphore.Release(); // 释放信号量
        }
    }
}

具体使用方法,大家可以转到定义进去看看,其中Wait可以设置值,如果获取锁失败,可以返回,其他线程就不需要等待了。

C#加锁防止并发的几种方法详解

下面我用工作中用到的SemaphoreSlim ,作为Demo,场景就是同一时刻同一个二维码只能有一个人可以参与,防止并发,导致奖项超量发出。

public class TestDemo
{
    // 使用 SemaphoreSlim + ConcurrentDictionary 来保护共享资源
    private static readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new ConcurrentDictionary<string, SemaphoreSlim>();
    //获取锁
    private static SemaphoreSlim GetLock(stringpython key)
    {
        return _locks.GetOrAdd(key, new SemaphoreSlim(1, 1));
    }
    public async Task Get(string key)
    {
        var semaphore = GetLock(key);
        var lockAcquired = false;
        try
        {
            lockAcquired = semaphore.Wait(0); // 尝试立即获取信号量
            if (!lockAcquired) return; //获取锁失败,返回,不会等待上一个加锁的线程释放
            //获取锁成功,执行以下逻辑
        }
        catch (Exception ex)
        {
        }
        finally
        {
            if (lockAcquired)
            {
                /android/ 释放锁
                semaphore.Release();
                // 移除锁(防止内存泄漏)
                _locks.TryRemove(key, out _);
            }
        }
    }
}

上述这个例子,是没用Redis分布式锁之前,经常用的一种加锁方式。与lock相比,SemaphoreSlim这个可以使用异步,也可以限制线程并发的数量,适用的功能场景也更多。

lock不需要手动释放锁,执行完代码块里的内容,会自动释放。但SemaphoreSlim需要调用Release方法(一定要放到finally中),显式释放锁。

Mutex(互斥体)

Mutex也是一种线程同步机制,也可以控制多个线程对共享资源的访问。与之前说的lock和SemaphoreSlim有一个非常大的一个不同点,Mutex是可以跨进程使用的,之前说的两个,只能在一个进程中控制并发。

具体使用方法如下:

public class Test
{
    //创建一个 Mutex锁
    private static Mutex mutex = new Mutex();
    public void Get()
    {
        //获取 Mutex锁
        mutex.WaitOne();
        try
        {
            //具体执行逻辑
        }
        catch (Exception ex)
        {
        }
        finally
        {
            //释放 Mutex锁
            mutex.ReleaseMutex();
        }
    }
}

Mutex如果不需要跨进程防止并发,使用方法也很简单。无非就是加锁、获取锁、释放锁,这里就不再写代码演示了,下面主要写一个跨进程的Demo。

internal class Program
{
    static void Main(string[] args)
    {
        new TestDemo().Get();
        Console.WriteLine("全部执行完毕");
        Console.ReadLine();
    }
}
public class TestDemo
{
    Mutex mutex = new Mutex(false, "Global\\MutexTest");
    public void Get()
    {
        mutex.WaitOne();
        try
        {
   http://www.devze.com         Thread.Sleep(30000);
            Console.WriteLine($"Get执行完成");
        }
        catch (Exception ex)
        {
        }
        finally
        {
            mutex.ReleaseMutex();
        }
    }
}

Mutex如果需要跨进行限制并发,需要加上一个名称。命名需要以下注意事项:

  • 命名时建议使用 Global\ 或 Local\ 前缀,以明确 Mutex 的作用范围。
  • 在 Windows 系统上,Global\ 适用于所有会话,Local\ 仅限当前会话。

大家可以再创建一个控制台程序,然后实例化一个Mutex,名称保持一致,启动执行,会发现不会立马执行。需要等待另一个控制台程序中的Mutex释放锁。这里我就不把我另一个控制台的代码放出来,很简单。

将Mutex转到定义可以看出,Mutex是Threading命名空间下的,并且不支持异步。

C#加锁防止并发的几种方法详解

ReaderWriterLockSlim(读写锁)

ReaderWriterLockSlim听名字就知道,该锁有两种模式,读模式和写模式,它允许多个线程以读模式同时访问共享资源,但只有一个线程能够以写模式访问资源。ReaderWriterLockSlim 提供了比传统的 Monitor 或 lock 更细粒度的控制,特别适合高并发读操作和低频写操作的场景。

具体使用方法如下:

public class Test
{
    //创建一个 ReaderWriterLockSlim 锁
    private static readonly ReaderWriterLockSlim lockSlim = new ReaderWriterLockSlim();
    public void Get()
    {
        //获取 读锁
        lockSlim.EnterReadLock();
        //获取 写锁
        lockSlim.EnterWriteLock();
        try
        {
            //具体执行逻辑
        }
        catch (Exception ex)
        {
        }
        finally
        {
            //释放锁
            lockSlim.ExitReadLock();
            lockSlim.ExitWriteLock();
        }
    }
}
  • 读锁(Read Lock):允许多个线程并发地读取共享资源。只要没有线程持有写锁,多个线程可以同时获取读锁。
  • 写锁(Write Lock):写锁是独占的,只有一个线程能够获取写锁。如果任何线程持有读锁或写锁,其他线程就不能获得写锁。
  • 锁的升级与降级:ReaderWriterLockSlim 允许线程将锁从读锁升级到写锁(通过 EnterUpgradeableReadLock),但不能将写锁降级为读锁。

转到定义看下相关方法

C#加锁防止并发的几种方法详解

从源码中可以看出,获取锁可以定时超时时间,还有是否获取到锁的属性、等待读锁或者写锁的数量等等,在实际用到时,大家可以再看看。

下面我根据可升级读锁,做一个Demo

internal class Program
{
    static void Main(string[] args)
    {
        Task.Run(() =>
        {
            new TestDemo().WriteData();
        });
        Thread.Sleep(1000);
        Task.Run(() => {
            new TestDemo().ReadData();
        });
        Console.WriteLine("全部执行完毕");
        Console.ReadLine();
    }
}
public class TestDemo
{
    private static readonly ReaderWriterLockSlim lockSlim = new ReaderWriterLockSlim();
    public void ReadData()
    {
        lockSlim.EnterReadLock();
        try
        {
            Console.WriteLine("开始执行读锁");
            Thread.Sleep(1000);
            Console.WriteLine("执行读锁结束");
        }
        catch (Exception)
        {
        }
        finally
        {
            lockSlim.ExitReadLock();
        }
    }
    public void WriteData()
    {
        //是一个可升级的读锁
        lockSlim.EnterUpgradeableReadLock();
        try
        {
            try
            {
                Console.WriteLine("开始执行写锁");
                //升级为写锁
                lockSlim.EnterWriteLock();
                Thread.Sleep(10000);
                Console.WriteLine("写锁执行完毕");
            }
            catch (Exception)
            {
            }
            finally
            {
                lockSlim.ExitWriteLock();
            }
        }
        catch (Exception)
        {
        }
        finally
        {
            lockSlim.EnterUpgradeableReadLock();
        }
    }
}

需要注意的是,javascriptReaderWriterLockSlim中的方法都是成对出现的,必须在finally中释放锁。

Concurrent 集合

Concurrent 集合是为了在多线程环境下提供线程安全的数据结构,避免显式加锁的复杂性。System.Collections.Concurrent 命名空间提供了几种常用的线程安全集合类,如 ConcurrentDictionary、ConcurrentQueue、ConcurrentStack、blockingCollection 等。

这些集合通过内部机制确保了多线程访问时的数据一致性,并尽可能避免锁操作的使用,提升了性能。

  • ConcurrentDictionary

ConcurrentDictionary是一个线程安全的字典类型,用于存储键值对。它允许多个线程同时读写,保证在高并发环境下的数据一致性。与 Dictionary<TKey, TValue> 不同,ConcurrentDictionary 内部实现了更高效的并发访问机制。

  • ConcurrentQueue<T>

ConcurrentQueue<T> 是一个线程安全的队列,它采用先进先出(FIFO)原则,适用于多个线程同时操作队列时使用。支持多个线程进行并发的入队和出队操作。

  • ConcurrentStack<T>

ConcurrentStack<T> 是一个线程安全的栈,采用后进先出(LIFO)原则。多个线程可以并发地执行 Push 和 Pop 操作而不需要显式的锁。

  • BlockingCollection<T>

BlockingCollection<T> 是一个线程安全的集合类,基于 IProducerConsumerCollection<T> 接口实现。它允许你在多个线程之间进行生产者-消费者模式的操作,并提供阻塞和超时的机制。如果集合已满或为空,调用 Add 或 Take 方法的线程会被阻塞,直到集合中有空间或元素可用。

  • ConcurrentBag<T>

ConcurrentBag<T> 是一个线程安全的集合,适用于多个线程需要无序地向集合中添加和从集合中删除元素的场景。与其他并发集合不同,ConcurrentBag<T> 不保证元素的顺序,它更适用于那些不关心顺序的场景。

大家需要用到了,可以再详细了解一下,我用到了ConcurrentDictionary,这个比较简单,我就不做例子了。

Redis分布式锁

上面介绍的加锁方式,都是在同一个进程或多个进程下,还局限于同一个服务器。那如果程序是多个服务器分布式部署,那么以上的加锁方式肯定就失效了。解决方案就是用Redis分布式锁。

Redis 分布式锁是一种常用的分布式同步机制,适用于需要多个服务协调访问共享资源的场景。Redis 分布式锁核心是通过 Redis 提供的 原子性操作 来确保多个客户端在分布式系统中对共享资源的互斥访问。确保在多个分布式进程或节点之间,每次只有一个客户端能够获得对某个资源的访问权,防止资源冲突。

需要引用StackExchange.Redis包,下面是一个简易的Demo

static async Task Main(string[] args)
{
    Parallel.For(0, 50, async i =>
    {
        // 连接 Redis
        var redis = ConnectionMultiplexer.Connect("localhost");
        // 创建锁管理对象
        var distributedLock = new RedisDistributedLock(redis);
        // 锁标识和唯一值
        string lockKey = "CustomerRedisLock";
        string lockValue = Guid.NewGuid().ToString();
        TimeSpan lockTimeout = TimeSpan.FromSeconds(10);
        // 尝试获取锁
        bool isLockAcquired = await distributedLock.AcquireLockAsync(lockKey, lockValue, lockTimeout);
        if (isLockAcquired)
        {
            Console.WriteLine("锁已获取,执行任务中...");
            try
            {
                // 模拟任务执行
                await Task.Delay(2000);
            }
            finally
   python         {
                // 释放锁
                bool isLockReleased = await distributedLock.ReleaseLockAsync(lockKey, lockValue);
                Console.WriteLine(isLockReleased ? "锁已释放" : "释放锁失败或锁已过期");
            }
        }
        else
        {
            Console.WriteLine("未能获取锁");
        }
    });
    Console.ReadLine();
}
public class RedisDistributedLock
{
    private readonly IDatabase _redisDatabase;
    private readonly TimeSpan _defaultLockTimeout = TimeSpan.FromSeconds(10); // 默认锁超时时间
    public RedisDistributedLock(IConnectionMultiplexer redisConnection)
    {
        _redisDatabase = redisConnection.GetDatabase();
    }
    //获取锁
    public async Task<bool> AcquireLockAsync(string key, string value, TimeSpan? timeout = null)
    {
        var lockTimeout = timeout ?? _defaultLockTimeout;
        return await _redisDatabase.StringSetAsync(key, value, lockTimeout, When.NotExists);
    }
    //释放锁
    public async Task<bool> ReleaseLockAsync(string key, string value)
    {
        // 获取当前锁的值
        var currentValue = await _redisDatabase.StringGetAsync(key);
        // 如果当前锁的值和传入的值相等,则释放锁
        if (currentValue == value)
        {
            return await _redisDatabase.KeyDeleteAsync(key);
        }
        return false; // 锁值不匹配,表示锁已经被其他客户端持有
    }
}

大家自己可以动手封装一下,这只是简易的版本。

总结

  • lock

最简单的加锁方式,是一个语法糖。缺点就是代码块中不支持异步,并且语法比较单一。

Monitor:

短期内需要对共享资源进行排他访问,用于小范围的同步,性能较好。支持线程等待和唤醒。

缺点是不能控制锁的粒度(只能针对方法或代码块)

  • SemaphoreSlim

可以限制并发访问的数量,缺点是只能在同一进程中使用,不能用于跨进程同步

  • Mutex

用于跨进程同步,适合一些需要在不同进程中进行互斥操作的场景,例如文件操作、共享内存等。缺点是由于涉及到操作系统调用,性能开销较大。

  • ReaderWriterLockSlim

控制锁的粒度更细,适用读多写少的场景。如果你有一个资源,读操作比写操作多,使用 ReaderWriterLockSlim 可以显著提高并发性能。

  • Concurrent

当多个线程需要并发地访问某个集合时,使用并发集合可以避免手动管理锁。适用于高并发的环境,特别是当你需要高效地进行元素插入、删除和查询时。

  • Redis分布式锁

支持跨进程或跨服务器的分布式同步,如果你有多个服务/服务器需要同步某些操作,可以使用 Redis 分布式锁。适用于分布式系统中的任务调度、资源共享、任务独占等场景。

Redis 分布式锁需要注意处理超时、锁释放等问题,通常会加上超时机制避免死锁。

怎样加锁需要考虑具体的场景,也可能两种加锁方式一起使用。

加锁的注意事项:

  • 加锁的时间尽量缩短,所以不必要的代码,不要放到加锁的代码块中。
  • 在控制并发时,优先选择 Concurrent 集合类。
  • 避免锁的嵌套,多个锁嵌套使用时,容易导致死锁和性能问题。
  • 选择合适的锁粒度,粗粒度锁简单易用,但性能较差。细粒度锁性能高,但编码复杂度高。
  • 设置超时机制,避免让线程在加锁时长时间等待,特别是对于高并发场景,可能导致资源浪费。

到此这篇关于C#、加锁防止并发的几种方法的文章就介绍到这了,更多相关C#加锁防止并发内容请搜索编程客栈(www.devze.com)以前的文章或继续浏览下面的相关文章希望大家以后多多支持编程客栈(www.devze.com)!

0

上一篇:

下一篇:

精彩评论

暂无评论...
验证码 换一张
取 消

最新开发

开发排行榜