マルチスレッド同時プログラミングは物事を「ロック」します

序文

マルチスレッドテクノロジは、システムの同時実行性を向上させるための重要なテクノロジです。マルチスレッドテクノロジを適用する場合、スレッドの終了、CPUとメモリのリソース使用率、スレッドの安全性など、多くの問題に注意する必要があります。この記事では、主にスレッドの安全性とその使用方法について説明します。スレッドの安全性の問題を解決するための「ロック」。


1.関連する概念

ロックを解除する前に、まずスレッドの安全性の問題に関連する概念を説明してください。

1.スレッドの安全性

コードが同時に実行されている複数のスレッドがあるプロセスにある場合、これらのスレッドはこのコードを同時に実行する可能性があります。各実行の結果がシングルスレッド実行の結果と同じであり、他の変数の値が期待どおりである場合、それはスレッドセーフです。スレッドの安全性の問題は、共有リソース(グローバル変数、ファイル、またはデータベーステーブル内の特定のデータ)が原因で発生します。複数のスレッドがそのようなリソースに同時にアクセスすると、スレッドの安全性の問題が発生する可能性があります。

2.重要なリソース

重要なリソースは、一度に1つのプロセス(スレッド)でのみ使用が許可され、他のプロセス(スレッド)が共有リソースにアクセスするまで待機する必要がある共有リソースです。

3.クリティカルゾーン

重要なセクションは、共有リソースにアクセスするコードセグメントを指します。

4.スレッドの同期

スレッドの安全性の問題を解決するために、通常、「重要なリソースへのシリアルアクセス」または「重要なリソースへのシリアルアクセス」のソリューションが採用されます。つまり、同時に、1つのスレッドのみが重要なリソースにアクセスできることが保証されます。これはスレッド同期相互除外とも呼ばれます。アクセス。

5.ロック

ロックは、スレッドの同期を実現するための重要な手段です。ロックは、囲まれたコードステートメントブロックをクリティカルセクションとしてマークするため、一度に1つのスレッドだけがクリティカルセクションに入り、コードを実行します。

同じプロセス内の2つのマルチスレッド同時ロック

1.ロック

単一プロセスでのマルチスレッド同時実行シナリオの場合、言語ライブラリとクラスライブラリによって提供されるロックを使用できます。次に、ロックがスレッドセーフである方法を示す例としてC#ロックを使用します。最初にサンプルコードを見てみましょう。CountServiceは、パラメーター構築メソッドを提供するカウントサービスクラスです。パラメーターは、ロックするかどうかです。デフォルトでは、追加しません。

  public class CountService
  {
        private int count;
        private readonly object lockObj;
        private readonly bool withLock = true;

        public CountService(bool withLock = false)
        {
            count = 0;
            this.withLock = withLock;
            lockObj = new object();
        }

        public void Increment()
        {
            if (withLock)
            {
                lock (lockObj)
                {
                    count++;
                }
            }
            else
                count++;
        }

        public int GetCountValue()
        {
            return count;
        }
  }

然后模拟多线程调用,代码如下:

class Program{
     static void Main(string[] args)
     {
         for (int i = 0; i < 10; i++)
         {
             var taskList = new List<Task>();
             CountService service = new CountService(false);
        
             for (int j = 0; j < 1000; j++)
             {
                 taskList.Add(
                     Task.Run(() =>
                     {
                         service.Increment();
                     })
                 );
             }
             Task.WaitAll(taskList.ToArray());

             Console.WriteLine(service.GetCountValue());
         }
         Console.Read();
    }}

如果按照单线程执行,预期的结果会在控制台输出10个1000,但真实的结果却是如下图所示,并且可能每次输出的结果都不一致。
在这里插入图片描述
如果在计数服务实例化时,参数改为true,则可以得到预期的结果,所以加锁可以保证计数服务对象是线程安全的。C#中lock 语句获取给定对象的互斥锁(也可以叫作排它锁),执行语句块,然后释放锁。 持有锁时,持有锁的线程可以再次获取并释放锁。 它可以阻止任何其他线程获取锁并等待释放锁。lock是一个语法糖,它的内部实现使用的是Monitor,相当于如下代码。

bool isGetLock = false;
    //lockObj 是私有静态变量Monitor.Enter(lockObj, ref isGetLock);
    try
    {
        do something…    }
    finally
    {
        if(isGetLock == true)
            Monitor.Exit(lockObj);}

2.原理

那Monitor.Enter和Monitor.Exit 究竟是怎么工作的呢?CRL初始化时在堆中分配一个同步块数组,每当一个对象在堆中创建的时候,都有两个额外的开销字段与它关联。第一个是“类型对象指针”,值为类型的“类型对象”的内存地址。第二个是“同步块索引”,值为同步块数据组中的一个整数索引。一个对象在构造时,它的同步块索引初始化为-1,表明不引用任何同步块。然后,调用Monitor.Enter时,CLR在同步块数组中找到一个空白同步块,并设置对象的同步块索引,让它引用该同步块。调用Exit时,会检查是否有其他任何线程正在等待使用对象的同步块。如果没有线程在等待它,同步块就自由了,会将对象的同步块索引设回-1,自由的同步块将来可以和另一个对象关联。下图反映的就是对象与同步块的关联关系。

在这里插入图片描述

3.建议

  1. .NET提供了可以跨进程使用的锁,如Mutex、Semaphore等。 Mutex、Semaphore需要先把托管代码转成本地用户模式代码、再转换成本地内核代码。当释放后需要重新转换成托管代码,性能会有一定的损耗,所以尽量在需要跨进程的场景使用。我们的实际开发中这种场景不多,本文不再详细介绍。可参考微软官方文档:https://docs.microsoft.com/zh-cn/dotnet/standard/threading/threading-objects-and-features

  2. .NET提供了线程安全的集合,这些集合在内部实现了线程同步,我们可以直接使用。

  3. 对于简单的状态更改,如递增、递减、求和、赋值等,微软官方建议使用 Interlocked 类的方法,而不是 lock 语句。虽然 lock 语句是实用的通用工具,但 Interlocked 类提升了更新(必须是原子操作)的性能。如可以实现以下代码的替代。
    在这里插入图片描述
    在这里插入图片描述

4.注意事项

  1. 避免锁定可以被公共访问的对象 lock(this)、lock(typeof(ClassName)) 、lock(public static variable) 、lock(public const variable),都存在可能被其他代码锁定的情况,这样会阻塞你自己的代码。

  2. 禁止锁定字符串 在编译阶段如果两个变量的字符串内容相同的话,CLR会将字符串放在(Intern Pool)驻留池(暂存池)中,以此来保证相同内容的字符串引用的地址是相同的。所以如果有两个地方都在使用lock(“myLock”)的话,它们实际锁住的是同一个对象。

  3. 禁止锁定值类型的对象 Monitor的方法参数为object类型,所以传递值类型会导致值类型被装箱,造成线程在已装箱对象上获取锁。每次调用Moitor.Enter都会在一个完全不同的对象上获取锁,所以完全无法实现线程同步。

  4. 避免死锁 如果两个线程中的每个线程都尝试锁定另一个线程已锁定的资源,则会发生死锁。我们应该保证每块代码锁定对象的顺序一致。尽量避免锁定可被公共访问的对象,因为私有对象只有我们自己用,我们可以保证锁的正确使用。我们还可以利用Monitor.TryEnter来检测死锁,该方法支持设置获取锁的超时时间,比如,Monitor.TryEnter(lockObject,300),如果在300毫秒内没有获取锁,该方法返回false。

三、分布式集群下的多线程并发锁

C#中,lock(Monitor)、Mutex、Semaphore只适用于单机环境,解决不了分布式集群环境中,各节点多线程并发的线程安全问题。对于分布式场景,我们可以使用分布式锁。常用的分布式锁有:

Memcached分布式锁

Memcached的add命令是原子性操作,只有在key不存在的情况下,才能add成功,并返回STORED,也就意味着线程得到了锁,如果key存在,返回NOT_STORED ,则说明有其他线程已经拿到锁。

Zookeeper分布式锁

把ZooKeeper上的一个节点看作是一个锁,获得锁就通过创建临时节点的方式来实现。 ZooKeeper 会保证在所有客户端中,最终只有一个客户端能够创建成功,那么就可以认为该客户端获得了锁。同时,所有没有获取到锁的客户端就需要到/exclusive_lock 节点上注册一个子节点变更的Watcher监听,以便实时监听到lock节点的变更情况。等拿到锁的客户端执行完业务逻辑后,客户端就会主动将自己创建的临时节点删除,释放锁,然后ZooKeeper 会通知所有在 /exclusive_lock 节点上注册了节点变更 Watcher 监听的客户端。这些客户端在接收到通知后,再次重新发起分布式锁获取请求。

Redis分布式锁

和Memcached的方式类似,利用Redis的set命令。此命令同样是原子性操作,只有在key不存在的情况下,才能set成功。当一个线程执行set返回OK,说明key原本不存在,该线程成功得到了锁;当一个线程执行set返回-1,说明key已经存在,该线程抢锁失败。

主要讲一下Redis分布式锁及常见问题

Redis加锁的伪代码:

if(set(key,value,30,NX) == “OK”){
    try
     {
         do something...
     }
     finally
     {
         del(key)
     }}
  • key是锁的唯一标识,一般是按业务来决定命名。比如要给用户注册代码加锁,可以给key命名为 “lock_user_regist_用户手机号”。

  • 30为锁的超时时间,单位为秒,如果不设置超时时间,一但得到锁的线程在执行任务的过程中挂掉,来不及显式地释放锁,这块资源将会永远被锁住,别的线程就再也进不来了。设置了超时时间,即使因不可控因素导致了没有显式的释放锁,最多也就只锁定这些时间便可自动恢复。但是指定了超时时间,还会引出其他问题,后边会讲。

  • NX代表只在键不存在时,才对键进行设置操作,并返回OK。

  • 当业务处理完毕,finally中执行redis del指令将锁删除。

删除锁时可能出现一种异常的场景,比如线程A成功得到了锁,并且设置的超时时间是30秒。因某些原因导致线程A执行了很长时间(过了30秒都没执行完),这时候锁过期自动释放,线程B得到了锁。随后,线程A执行完了任务,线程A接着执行del指令来释放锁。但这时候线程B还没执行完,线程A实际上删除的是线程B加的锁。如何避免这种情况呢?可以在del释放锁之前做一个判断,验证当前的锁是不是自己加的锁。至于具体的实现,可以在加锁的时候生成一个随机数,尽可能的不重复,可以用Guid生成一个随机字符串做value,并在删除之前验证key对应的value是不是当前线程生成的Guid字符串。

加锁伪代码:

string value = Guid.NewGuid().ToString();set(key,value,30,NX);

解锁伪代码:

if(value.Equals(redisClient.get(key)){
	del(key);}

但这又引出了一个新的问题,判断及解锁是两个独立的指令,不是原子性操作,这就得需要借助Lua脚本实现。将解锁的代码封装为Lua脚本,在需要解锁的时候,发送执行脚本的指令。

应用上边讲到的方法,尽管我们避免了线程A误删除掉锁的情况,但是同一时间有A、B两个线程在访问代码,这本身就不是线程安全的。如何保证线程安全呢?产生该现象的原因就在于我们给锁指定了超时时间,不是说超时时间加的不对,而是我们应该想办法能给锁“续命”,即当过去29秒了,线程A还没执行完,我们要有一种机制可以定时重置一下锁的超时时间。思路大概为让获得锁的线程开启一个守护线程,用来重置快要过期的锁的超时时间,如果超时时间设置为30秒,守护线程可以从第29秒开始,每25秒执行一次expire指令,当线程A执行完成后,显式关掉守护线程。

还有一些程序员可能会出现以下写法,不管if条件有没有成立,finally都会执行删除锁的命令,即使锁没有过期也会出现线程锁被误删除的情况,大家一定要注意。当然如果你已经应用上边讲的改进方案,避免了锁被其他线程误删,但是这个也是得不偿失的,没有获取到锁的线程没有必要去执行删除锁的命令。

错误的Redis加锁伪代码:

try{
	if(set(key,value,30,NX) == “OK”)
    {
       do something...
    }}finally{
    del(key)}

总结

本文对多线程并发环境中,保证线程安全的“锁”方案进行了尽可能详细的讲解,平时我们在设计高性能、低延迟开发方案时,务必要考虑因并发访问导致的数据安全性问题。


おすすめ

転載: blog.51cto.com/14952124/2540194