为什么要使用锁
锁(Locks)是并发编程中一个非常重要的概念,它主要用于解决多线程环境下的数据访问冲突问题。使用锁的主要原因如下:
- 数据一致性:在多线程环境中,如果多个线程同时访问并修改共享数据,可能会导致数据不一致。使用锁可以确保在任何时刻只有一个线程能够访问和修改共享数据。
- 避免竞态条件:竞态条件(Race Condition)是指在多线程程序中,由于线程的执行顺序不确定,导致程序的行为不可预测。锁可以防止这种情况的发生。
- 实现线程同步:锁提供了一种机制,允许线程之间进行同步。一个线程可能需要等待另一个线程完成某些操作后才能继续执行。
- 保护临界区:临界区(Critical Section)是一段代码,其中包含对共享资源的访问。使用锁可以确保在任何时刻只有一个线程能够执行临界区的代码。
- 简化编程模型: 锁提供了一种相对简单的方式来处理并发问题,使得开发者可以更容易地编写并发程序。
锁的常用类型
锁在并发编程中有多种类型,每种锁都有其特定的使用场景和特点。以下是一些常见的锁类型及其使用场景:
- 互斥锁(Mutex)
- 作用: 确保同一时间只有一个线程可以访问共享资源。
- 使用场景: 保护共享数据,防止多个线程同时修改。
- 读写锁(Reader-Writer Lock)
- 作用: 允许多个读操作同时进行,但写操作是互斥的。
- 使用场景: 当读操作远多于写操作时,可以提高性能。
- 自旋锁(Spinlock)
- 作用: 在尝试获取锁时不会立即阻塞,而是循环检查锁是否可用。
- 使用场景: 在锁持有时间短且线程不希望在等待时放弃CPU时间的情况下。
- 乐观锁
- 作用: 通过数据版本控制(如CAS操作)来实现,不实际锁定数据,而是在更新时检查数据是否被其他线程修改。
- 使用场景: 适用于冲突较少的场景,可以减少锁的开销。
- 悲观锁
- 作用: 假设会发生冲突,因此在访问数据时直接加锁。
- 使用场景: 在高冲突环境中,确保数据一致性。
- 分布式锁
- 作用: 在分布式系统中,用于确保跨多个节点的资源访问一致性。
- 使用场景: 在分布式数据库或缓存中同步数据。
实际案例
在网约车派单场景下,最近团队接到一个需求,要能够支持一个订单可以派送两个司机。每轮派单时,先派送一个司机;派送完第一个司机后,可以继续支持派送第二个司机。订单派送第一个司机和第二个司机的策略不同,需要进行区分。
开发人员的解决方案:
- 在订单上增加一个策略标识,订单派送第一个司机时标识为A策略,再派送第二个司机时标识为B策略。
现在面临并发冲突的场景是:
- 订单已经派送完了第一个司机,但是由于司机拒绝接单,第一次派单结果需要废弃,重新进行派单处理。
- 订单第一次派单结果废弃通知时,线程1来处理;此时订单正好派送第二个司机,线程2来处理。
开发人员的解决方案:
- 由于第一次派单结果废弃经过线程1处理会影响订单的派单策略;订单第二次派送完成经过线程2处理也会影响订单的派单策略。当两个独立的线程需要对同一资源进行修改时,此时就需要加锁 控制,来避免出现竞态条件。
- 上面的锁是互斥锁:当一个线程占用锁时,另一个线程再来抢占锁,直接提示抢占失败,防止出现等待时间过长,影响接口的性能。
上面的方案以及并发冲突进行的加锁控制,从表面上来看好像都是正常逻辑,没有什么问题。但是我们仔细一琢磨,就会发现不对劲,第一个司机拒绝订单导致的结果,和订单派送第二个司机,业务上理解并没有出现共享数据的问题,为什么需要锁来解决?
原来一开始的解决方案设计就出现了问题,不能针对订单增加标识来解决。针对同一个订单,由于需要派送两个司机,当两个司机并发处理操作时,就必然对订单这个共享资源产生了并发竞争问题。
解决方案:
- 订单派送完第一个司机A后,不再增加标识,而是新增一个订单和司机A的关系;后面订单再派送第二个司机B时,和第一个司机A对订单的操作,从资源上就隔离了。派单时,基于订单和司机的关系统计派送的司机数,来决定使用什么策略。
- 针对同一个订单,A司机和B司机的操作,不再影响派单策略;同时,由于不加锁,也进一步提升了接口的性能。
总结
在我们的日常工作中,对加锁这个词要提高敏感度,尽量避免加锁引起的复杂度和低效问题。对每个加锁场景仔细确认清楚下面三个问题:
- 共享的资源,是否设计合理,有没有优化的空间。
- 多线程竞争资源,能否避免,使用单线程来解决。
- 能否使用CAS的原子性操作来避免锁的问题,同时又不增加过多的复杂度。
如果以上三个场景,确实都无法覆盖,再考虑使用哪种类型的锁,来解决不同场景的问题。