多线程技术是提升系统并发能力的重要技术,在应用多线程技术时须要注意不少问题,如线程退出问题、CPU及内存资源利用问题、线程安全问题等,本文主要讲线程安全问题及如何使用“锁”来解决线程安全问题。
1、相关概念
在了解锁以前,首先阐述一下线程安全问题涉及到的相关概念:程序员
线程安全
若是你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。若是每次运行结果和单线程运行的结果是同样的,并且其余变量的值也和预期的是同样的,则是线程安全的。线程安全问题是由共享资源引发的,能够是一个全局变量、一个文件、一个数据库表中的某条数据,当多个线程同时访问这类资源的时候,就可能存在线程安全问题。redis
临界资源
临界资源是一次仅容许一个进程(线程)使用的共享资源,当其余进程(线程)访问该共享资源时须要等待。数据库
临界区
临界区是指一个访问共享资源的代码段。数组
线程同步
为了解决线程安全问题,一般采用“序列化访问临界资源”的方案,或者叫“串行化访问临界资源”,即在同一时刻,保证只能有一个线程访问临界资源,也称线程同步互斥访问。安全
锁
锁是实现线程同步的重要手段,它将包围的代码语句块标记为临界区,这样一次只有一个线程进入临界区执行代码。多线程
2、同一个进程内多线程并发锁
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); }
原理:
那Monitor.Enter和Monitor.Exit 到底是怎么工做的呢?CRL初始化时在堆中分配一个同步块数组,每当一个对象在堆中建立的时候,都有两个额外的开销字段与它关联。第一个是“类型对象指针”,值为类型的“类型对象”的内存地址。第二个是“同步块索引”,值为同步块数据组中的一个整数索引。一个对象在构造时,它的同步块索引初始化为-1,代表不引用任何同步块。而后,调用Monitor.Enter时,CLR在同步块数组中找到一个空白同步块,并设置对象的同步块索引,让它引用该同步块。调用Exit时,会检查是否有其余任何线程正在等待使用对象的同步块。若是没有线程在等待它,同步块就自由了,会将对象的同步块索引设回-1,自由的同步块未来能够和另外一个对象关联。下图反映的就是对象与同步块的关联关系。
建议:
NET提供了能够跨进程使用的锁,如Mutex、Semaphore等。 Mutex、Semaphore须要先把托管代码转成本地用户模式代码、再转换成本地内核代码。当释放后须要从新转换成托管代码,性能会有必定的损耗,因此尽可能在须要跨进程的场景使用。咱们的实际开发中这种场景很少,本文再也不详细介绍。可参考微软官方文档:https://docs.microsoft.com/zh...。
.NET提供了线程安全的集合,这些集合在内部实现了线程同步,咱们能够直接使用。
对于简单的状态更改,如递增、递减、求和、赋值等,微软官方建议使用 Interlocked 类的方法,而不是 lock 语句。虽然 lock 语句是实用的通用工具,但 Interlocked 类提高了更新(必须是原子操做)的性能。如能够实现如下代码的替代
注意事项:
避免锁定能够被公共访问的对象lock(this)、lock(typeof(ClassName)) 、lock(public static variable) 、lock(public const variable),都存在可能被其余代码锁定的状况,这样会阻塞你本身的代码。
禁止锁定字符串在编译阶段若是两个变量的字符串内容相同的话,CLR会将字符串放在(Intern Pool)驻留池(暂存池)中,以此来保证相同内容的字符串引用的地址是相同的。因此若是有两个地方都在使用lock("myLock")的话,它们实际锁住的是同一个对象。
禁止锁定值类型的对象Monitor的方法参数为object类型,因此传递值类型会致使值类型被装箱,形成线程在已装箱对象上获取锁。每次调用Moitor.Enter都会在一个彻底不一样的对象上获取锁,因此彻底没法实现线程同步。
避免死锁若是两个线程中的每一个线程都尝试锁定另外一个线程已锁定的资源,则会发生死锁。咱们应该保证每块代码锁定对象的顺序一致。尽可能避免锁定可被公共访问的对象,由于私有对象只有咱们本身用,咱们能够保证锁的正确使用。咱们还能够利用Monitor.Enter来检测死锁,该方法支持设置获取锁的超时时间,好比,Monitor.TryEnter(lockObject, 300),若是在300毫秒内没有获取锁,该方法返回false。
3、分布式集群下的多线程并发锁
C#中,lock(Monitor)、Mutex、Semaphore只适用于单机环境,解决不了分布式集群环境中,各节点多线程并发的线程安全问题。对于分布式场景,咱们可使用分布式锁。
经常使用的分布式锁有:
Memcached分布式锁
Memcached的add命令是原子性操做,只有在key不存在的状况下,才能add成功,并返回STORED,也就意味着线程获得了锁,若是key存在,返回NOT_STORED ,则说明有其余线程已经拿到锁。
Redis分布式锁
和Memcached的方式相似,利用Redis的set命令。此命令一样是原子性操做,只有在key不存在的状况下,才能set成功。当一个线程执行set返回OK,说明key本来不存在,该线程成功获得了锁;当一个线程执行set返回-1,说明key已经存在,该线程抢锁失败。
Zookeeper分布式锁
把ZooKeeper上的一个节点看做是一个锁,得到锁就经过建立临时节点的方式来实现。ZooKeeper 会保证在全部客户端中,最终只有一个客户端可以建立成功,那么就能够认为该客户端得到了锁。同时,全部没有获取到锁的客户端就须要到/exclusive_lock 节点上注册一个子节点变动的Watcher监听,以便实时监听到lock节点的变动状况。等拿到锁的客户端执行完业务逻辑后,客户端就会主动将本身建立的临时节点删除,释放锁,而后ZooKeeper 会通知全部在 /exclusive_lock 节点上注册了节点变动 Watcher 监听的客户端。这些客户端在接收到通知后,再次从新发起分布式锁获取请求。
主要讲一下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) }
4、总结本文对多线程并发环境中,保证线程安全的“锁”方案进行了尽量详细的讲解,平时咱们在设计高性能、低延迟开发方案时,务必要考虑因并发访问致使的数据安全性问题。