作者:禅与计算机程序设计艺术
1.简介
“分布式系统”这个词汇在我们眼中似乎是高大上、神秘的词汇。如果你是软件开发人员或者架构师,那么你对分布式系统可能不陌生。但是如果你是一个工程师或者技术负责人,你或许对“分布式系统”这个词语有很多误解。我个人认为“分布式系统”是一个广义的概念,它可以定义成一组计算机硬件、软件组件和服务的集合体,这些组件和服务之间通过网络通信协同工作,共同完成某项任务。特别是在微服务架构兴起之后,“分布式系统”也越来越成为一种架构模式。在本文中,我将介绍如何实现分布式锁与同步,也就是实现多个服务之间的数据共享和资源访问控制。当然,还包括一些扩展阅读资料。
2.基本概念术语说明
分布式锁
分布式锁(Distributed Lock)是控制分布式系统之间对共享资源进行独占访问的一种方式。一般来说,为了保证系统的高可用性,需要对关键业务流程进行分布式部署,从而使得不同节点上的服务能够共同协作处理业务请求。在这种情况下,如果不同的服务节点同时对某个资源进行读写操作,就会导致数据不一致的问题。为了避免此类问题发生,引入了分布式锁机制。分布式锁机制允许一个节点获取到锁之后才能对该资源进行操作,其他节点则需要等待获取锁的释放后才能对资源进行操作。
分布式锁的实现主要涉及两类角色:持有者(Holder)和待获取者(Waiting)。只有持有者才有权利对资源进行操作。当某个节点获取到锁之后,他就是持有者,其他节点都是待获取者。待获取者只能进入队列等候,直至锁被释放。在锁被释放之前,所有节点都无法对资源进行操作。
同步机制
同步机制(Synchronization Mechanisms)是指用于控制线程、进程或者中断的执行顺序的方法和协议。同步机制是多核编程技术所需的基本功能之一。其作用是确保并发进程或线程按照正确的顺序执行,因此每个时刻只允许单个进程或线程执行。
常用的同步机制有信号量(Semaphore)、互斥锁(Mutex)、事件对象(Event Object)和条件变量(Condition Variable)。其中信号量用于控制对共享资源的访问,互斥锁用于实现线程之间的互斥,事件对象用于协调线程的同步,条件变量用于线程间的通知和同步。在本文中,我们主要关注基于锁的同步机制。
抢占式上下文切换
抢占式上下文切换(Preemptive Context Switching)是指内核态运行的进程或线程暂停运行,转而执行由其他进程或线程抢占它的位置的过程。它是操作系统用来管理内存分配、进程调度和虚拟内存等基础设施的一种重要机制。当高优先级的进程或线程因时间片已用完而被阻塞,或发生硬件异常等情况,就会发生抢占式上下文切换。
由于抢占式上下文切换可以在任意时刻发生,所以当线程或者进程被阻塞的时候,系统会自动选择另一个就绪的线程或者进程来运行。这是因为系统需要保证总是能够满足实时性要求,无论什么时候都不能让系统完全空闲。
在单核系统中,抢占式上下文切换的频率较低,但在多核系统中,由于每个CPU都有自己独立的运行序列,而且可能需要进行任务切换,因此抢占式上下文切换的频率更高。如果抢占过于频繁,那么系统的吞吐量可能会受到影响;如果抢占过于频繁,系统性能也可能出现下降。因此,抢占式上下文切换应尽量减少,保证系统的响应速度。
3.分布式锁的基本原理
首先,分布式锁最主要的目的是控制对共享资源的独占访问。共享资源就是多个服务或进程之间共享的数据、文件等。每个服务或进程都需要先申请锁,才能对共享资源进行操作。锁的申请和释放是通过网络进行的。
其次,分布式锁机制最基本的原理是“先来先得”。对于每个共享资源,任何一个节点都只能获得锁。其他节点在申请锁失败时,只能进入等待队列,直至当前持有锁的节点释放了锁之后,才有机会获得锁。这样做的目的就是确保同一时刻只有一个节点拥有共享资源的独占权。
最后,分布式锁的功能相对简单,所以它适用于各种场景。比如,Apache Zookeeper的分布式锁、Redis的基于SETNX命令的分布式锁、Etcd的第三方库go-etcd-lock/go-etcd-distlock等都是典型的分布式锁实现。
4.Go语言中的分布式锁实现
使用sync包的RWMutex实现分布式锁
Go语言提供了标准库sync包,其中提供了RWMutex类型,通过该类型的两个方法Lock()和Unlock()实现加锁解锁。当调用RLock()函数时,表示只读锁,只能读取共享资源,不会阻塞其他线程,其他线程也可以访问共享资源。当调用Lock()函数时,表示写锁,可对共享资源进行写入,会阻塞其他线程。
因此,可以通过sync.RWMutex类型实现分布式锁。我们只需要使用读锁来进行资源访问,使用写锁来进行资源修改,就可以保证数据安全。具体如下:
type MyStruct struct {
mu sync.RWMutex
field int
}
func (m *MyStruct) AccessResource() {
m.mu.RLock() // acquire read lock
defer m.mu.RUnlock()
// access shared resource: m.field...
...
}
func (m *MyStruct) ModifyResource(value int) {
m.mu.Lock() // acquire write lock
defer m.mu.Unlock()
m.field = value
...
}
上述示例代码中,MyStruct结构体包含一个字段field和一个读写锁mu。AccessResource()函数通过RLock()函数获取读锁,对共享资源m.field进行读操作,用defer语句在函数返回前释放读锁。ModifyResource()函数通过Lock()函数获取写锁,对共享资源m.field进行写操作,用defer语句在函数返回前释放写锁。这样就可以保证资源的安全访问。
悲观锁和乐观锁
上述示例代码采用悲观锁的方式,即每次访问共享资源都会上锁。这种锁机制较为严格,能够有效防止多线程并发访问共享资源。但是,获取锁和释放锁的开销比较大,会增加系统的整体延迟。因此,对于短期内只需要访问一次的资源,可以使用乐观锁,即每次访问共享资源前,先尝试获取锁。如果获取成功,则可以继续访问,否则放弃访问。下面是两种不同方式的乐观锁实现示例:
使用版本号实现乐观锁
在乐观锁的过程中,会使用共享资源的一个版本号来标识该资源的状态。当更新资源时,版本号会递增。每个线程在获取资源时,会记录当前的版本号,然后再提交更新,此时若检测到版本号没有变化,说明资源没有被其他线程修改,可以成功提交。否则,说明资源已经被其他线程修改,重新尝试获取锁。
type MyStruct struct { mu sync.RWMutex version int field int } func (m *MyStruct) AccessResource() bool { m.mu.RLock() // acquire read lock if m.version!= <current version> { m.mu.RUnlock() // release read lock return false } // access shared resource: m.field... ... m.mu.RUnlock() // release read lock return true } func (m *MyStruct) ModifyResource(delta int) bool { m.mu.Lock() // acquire write lock m.field += delta m.version++ ... m.mu.Unlock() // release write lock return true }
上述示例代码中,MyStruct结构体包含一个版本号version和一个字段field。AccessResource()函数通过RLock()函数获取读锁,然后判断当前的版本号是否和记录的一致。如果一致,则表示资源没有被修改,可以成功访问;否则,表示资源已经被修改,重新尝试获取锁。ModifyResource()函数通过Lock()函数获取写锁,修改资源field的值和版本号version的值,提交更新。如果提交成功,则表示资源修改成功,否则重试。
- 使用CAS(Compare and Swap)操作实现乐观锁
CAS(Compare and Swap)操作是一种利用硬件指令实现的原子操作,它将内存中的值与预期值进行比较,如果相同,则将内存中的值设置为新值。CAS操作属于弱读、弱写的模型。这种锁机制要比使用版本号的乐观锁效率高,并且使用起来比较方便。
type MyStruct struct { mu sync.RWMutex field int } func (m *MyStruct) AccessResource() bool { for { m.mu.RLock() if m.field == <expected value> { break // the expected value is found, exit loop } m.mu.RUnlock() // release read lock before sleeping time.Sleep(<sleep duration>) // wait for a short while } // access shared resource: m.field... ... m.mu.RUnlock() // release read lock return true } func (m *MyStruct) ModifyResource(delta int) bool { for { m.mu.Lock() oldVal := m.field newVal := oldVal + delta if atomic.CompareAndSwapInt(&m.field, oldVal, newVal) { break // update succeeded, exit loop } m.mu.Unlock() // release write lock before sleeping time.Sleep(<sleep duration>) // wait for a short while } ... m.mu.Unlock() // release write lock return true }
上述示例代码中,MyStruct结构体包含一个字段field。AccessResource()函数通过for循环不断尝试获取读锁,获取到读锁后查看当前的field的值是否和预期的值一样。如果一样,则退出循环;否则,释放读锁并休眠一定时间,重新尝试。这里的预期值和休眠时间应该根据实际情况设置。ModifyResource()函数也是类似的逻辑,通过for循环不断尝试获取写锁,获取到写锁后查看当前的field的值,然后计算出新的值,提交给CAS函数。如果CAS成功,则退出循环;否则,释放写锁并休眠一定时间,重新尝试。
- 对比两种锁实现的区别
两种锁实现的区别主要在于乐观锁的更新机制,使用版本号的乐观锁更新机制较为复杂,但是只需要记录一个版本号,因此实现起来比较简单;使用CAS的乐观锁更新机制不需要记录额外信息,因此实现起来也比较简单。不过,当多个线程同时更新资源时,版本号的乐观锁容易出现死锁,而CAS的乐观锁就不存在这个问题。因此,在实际使用时,应该结合具体场景进行选择。