多线程并发编程“锁”事


前言

多线程技术是提高系统并发能力的重要技术,在应用多线程技术时需要注意很多问题,如线程退出问题、CPU及内存资源利用问题、线程安全问题等,本文主要讲线程安全问题及如何使用“锁”来解决线程安全问题。


一、相关概念

在了解锁之前,首先阐述一下线程安全问题涉及到的相关概念:

1.线程安全

如果你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他变量的值也和预期的是一样的,则是线程安全的。线程安全问题是由共享资源引起的,可以是一个全局变量、一个文件、一个数据库表中的某条数据,当多个线程同时访问这类资源的时候,就可能存在线程安全问题。

2.临界资源

临界资源是一次仅允许一个进程(线程)使用的共享资源,当其他进程(线程)访问该共享资源时需要等待。

3.临界区

临界区是指一个访问共享资源的代码段。

4.线程同步

为了解决线程安全问题,通常采用“序列化访问临界资源”的方案,或者叫“串行化访问临界资源”,即在同一时刻,保证只能有一个线程访问临界资源,也称线程同步互斥访问。

5.锁

锁是实现线程同步的重要手段,它将包围的代码语句块标记为临界区,这样一次只有一个线程进入临界区执行代码。

二、同一个进程内多线程并发锁

1.Lock

对于单进程内的多线程并发场景,我们可以使用语言和类库提供的锁,以下以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,value30,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)
}

总结

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

[JAVA工程师必会知识点之并发编程] 1、现在几乎100%的公司面试都必须面试并发编程,尤其是互联网公司,对于并发编程的要求更高,并发编程能力已经成为职场敲门砖。 2、现在已经是移动互联和大数据时代,对于应用程序的性能、处理能力、处理时效性要求更高了,传统的串行化编程无法充分利用现有的服务器性能。 3、并发编程是几乎所有框架的底层基础,掌握好并发编程更有利于我们学习各种框架。想要让自己的程序执行、接口响应、批处理效率更高,必须使用并发编程。 4、并发编程是中高级程序员的标配,是拿高薪的必备条件。 【优惠说明】 1、120余节视频课,原价299元,今日报名立减100,仅需199元 2、现在购课,就送价值800元的编程大礼包! 备注:请添加微信:itxy41,按提示获取讲师答疑服务。 【主讲讲师】 尹洪亮Kevin: 现任职某互联网公司首席架构师,负责系统架构、项目群管理、产品研发工作。 10余年软件行业经验,具有数百个线上项目实战经验。 擅长JAVA技术栈、高并发高可用伸缩式微服务架构、DevOps。 主导研发的蜂巢微服务架构已经成功支撑数百个微服务稳定运行 【推荐你学习这门课的理由:知识体系完整+丰富学习资料】 1、 本课程总计122课时,由五大体系组成,目的是让你一次性搞定并发编程。分别是并发编程基础、进阶、精通篇、Disruptor高并发框架、RateLimiter高并发访问限流吗,BAT员工也在学。 2、课程附带附带3个项目源码,几百个课程示例,5个高清PDF课件。 3、本课程0基础入门,从进程、线程、JVM开始讲起,每一个章节只专注于一个知识点,每个章节均有代码实例。 【课程分为基础篇、进阶篇、高级篇】 一、基础篇 基础篇从进程与线程、内存、CPU时间片轮训讲起,包含线程的3种创建方法、可视化观察线程、join、sleep、yield、interrupt,Synchronized、重入、对象、类、wait、notify、线程上下文切换、守护线程、阻塞式安全队列等内容。 二、进阶篇 进阶篇课程涵盖volatied关键字、Actomic类、可见性、原子性、ThreadLocal、Unsafe底层、同步类容器、并发类容器、5种并发队列、COW容器、InheritableThreadLocal源码解析等内容。 三、精通篇 精通篇课程涵盖JUC下的核心工具类,CountDownLath、CyclicBarrier、Phaser、Semaphore、Exchanger、ReentrantLock、ReentrantReadWriteLock、StampedLock、LockSupport、AQS底层、悲观、乐观、自旋、公平、非公平、排它、共享、重入、线程池、CachedThreadPool、FixedThreadPool、ScheduledThreadPool、SingleThreadExecutor、自定义线程池、ThreadFactory、线程池切面编程、线程池动态管理等内容,高并发设计模式,Future模式、Master Worker模式、CompletionService、ForkJoin等 课程中还包含 Disruptor高并发框架讲解:Disruptor支持每秒600万订单处理的恐怖能力。深入到底层原理和开发模式,让你又懂又会用。 高并发访问限流讲解:涵盖木桶算法、令牌桶算法、Google RateLimiter限流开发、Apache JMeter压力测试实战。 【学完后我将达到什么水平?】 1、 吊打一切并发编程相关的笔试题、面试题。 2、 重构自己并发编程的体系知识,不再谈并发色变。 3、 精准掌握JAVA各种并发工具类、方法、关键字的原理和使用。 4、 轻松上手写出更高效、更优雅的并发程序,在工作中能够提出更多的解决方案。 【面向人群】 1、 总感觉并发编程很难、很复杂、不敢学习的人群。 2、 准备跳槽、找工作、拿高薪的程序员。 3、 希望提高自己的编程能力,开发出更高效、性能更强劲系统的人群。 4、 想要快速、系统化、精准掌握并发编程的人群。 【课程知识体系图】
©️2020 CSDN 皮肤主题: 深蓝海洋 设计师:CSDN官方博客 返回首页