目录
锁的本质
- 锁的本质,就是一种资源,是由操作系统维护的一种专门用于同步的资源,就是一块存储了标记位的内存空间。当这个空间被赋值为1的时候表示加锁了,赋值为0的时候表示解锁了。多个线程抢一个锁,就是抢着要把这块内存空间赋值为1。
- 在单核环境中,只需要在加锁之前,关闭中断,加锁完成后,打开中断。就可以保证加锁过程的原子性,只有一个线程可以抢到锁。
- 在多核环境中,内存空间是共享的,每个核上各跑一个线程。此时要保证一次只有一个线程抢到锁,就需要硬件层面的某种支持。
- 最简单的办法就是将自己的资源和操作系统定义好的锁绑定到一起。也就是说,进程要获取资源之前,必须要获得操作系统的锁。
go的锁
Golang的提供的同步机制有sync模块下的Mutex、WaitGroup以及语言自身提供的chan等。这些同步的方法都是以runtime中实现的底层同步机制(cas、atomic、spinlock、sem)为基础的
自旋锁(spinlock)
- 自旋锁是指当一个线程在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待,然后不断地判断是否能够被成功获取,知直到获取到锁才会退出循环。
- 获取锁的线程一直处于活跃状态。
- Golang中的自旋锁用来实现其他类型的锁,与互斥锁类似,不同点在于,它不是通过休眠来使进程阻塞,而是在获得锁之前一直处于活跃状态(自旋)
mutex的必要性
在某些场景下为什么不能把互斥锁替换成原子(atomic)操作?
锁在高度竞争时会不断挂起恢复线程从而让出cpu资源,原子变量在高度竞争时会一直占用cpu。
线程安全
-
线程安全:多个线程执行流对临界资源的不安全争抢操作。
-
常见临界资源有全局变量和静态变量。
-
栈是保证一个执行流独立运行的基本条件,所以线程有自己独立的栈空间。大部分情况下线程使用的变量都是局部变量,变量的地址空间在线程栈空间内。这种情况下,一个变量属于单线程,其他线程无法获得这种变量。但是有些时候,需要一些变量在多个线程之间共享,这种变量称之为共享变量。可以通过数据在线程之间的交互,完成多个线程的交互。但是多个线程并发的操作共享变量,就会导致线程安全问题(数据不一致)。此处可以吧共享变量看做临界资源。
-
同步和互斥机制实现线程安全。互斥:同一时间对临界资源的唯一访问。同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步(临界资源:操作系统一个资源同时只能有一个执行访问)
中断
- 打断计算机正常执行的这种现象叫中断(Interrupt);
- 发出中断信号(也叫中断请求irq)的设备称为中断源。
- 中断发生后,计算机处理中断事件称为中断处理,处理中断的程序代码成为中断服务程序(ISP)。
- 中断发生后计算机首先会自动中断当前程序执行,保存当前系统状态(保存中断现场),然后跳转中断服务程序处(中断向量,中断地址)并执行,执行完毕后,然后计算机自动跳转到中断处,恢复现场(中断恢复),开始执行正常的流程。
- 不同的中断用不同的数字表示和区分(中断号)。
中断的作用
-
引入中断以后,当处理器发出设备请求后就可以立即返回以处理其他任务,而当设备完成动作后,发送中断信号给处理器,后者就可以再回过头获取处理结果。这样,在设备进行处理的周期内,处理器可以执行其他一些有意义的工作,而只需要付出一些很小的切换所引发的时间代价。
-
在多线程编程中,为了保证数据操作的一致性,操作系统引入了锁机制,用于保证临界区代码的安全。通过锁机制,能够保证在多核多线程环境中,在某一个时间点上,只能有一个线程进入临界区代码,从而保证临界区中操作数据的一致性。
-
锁机制的一个特点是它的同步原语都是原子操作。
-
操作系统之所以能构建锁之类的同步原语,是因为硬件已经提供了一些原子操作,比如:中断禁止和启用(interrupt enable/disable),内存加载和存入(load/store)测试与设置(test and set)指令。禁止中断这个操作是一个硬件步骤,中间无法插入别的操作。同样,中断启用,测试与设置均为一个硬件步骤的指令。在这些硬件原子操作之上,便可以构建软件原子操作:锁,睡觉与叫醒,信号量等。
原子操作
-
百度百科:“原子操作(atomic operation)是不需要synchronized”,这是多线程编程的老生常谈了。所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch [1] (切换到另一个线程)。
-
原子操作即是进行过程中不能被中断的操作,针对某个值的原子操作在被进行的过程中,CPU绝不会再去进行其他的针对该值的操作。
-
为了实现这样的严谨性,原子操作仅会由一个独立的CPU指令代表和完成。
-
原子操作是无锁的,常常直接通过CPU指令直接实现。
语言支持
Go 语言的sync/atomic包提供了对原子操作的支持,用于同步访问整数和指针。
原子操作中的比较并交换简称CAS(Compare And Swap),在sync/atomic包中,这类原子操作由名称以CompareAndSwap为前缀的若干个函数提供
CAS
- CAS是compare and swap的缩写。
- cas是一种基于锁的操作,而且是乐观锁。
- 悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。
- CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。
- 如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。
- CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。
CAS操作基于CPU提供的原子操作指令实现。对于Intel X86处理器,可通过在汇编指令前增加LOCK前缀来锁定系统总线,使系统总线在汇编指令执行时无法访问相应的内存地址。而各个编译器根据这个特点实现了各自的原子操作函数。
- C语言,C11的头文件<stdatomic.h>。由GNU提供了对应的__sync系列函数完成原子操作。 [6][7]
- C++11,STL提供了atomic系列函数。[8][7]
- JAVA,sun.misc.Unsafe提供了compareAndSwap系列函数。[9]
- C#,通过Interlocked方法实现。[10]
- Go, 通过import "sync/atomic"包实现。[11]
一个CAS操作等价于以下c代码的原子实现:
int cas(long *addr, long old, long new)
{
/* Executes atomically. */
if(*addr != old)
return 0;
*addr = new;
return 1;
}
在使用上,通常会记录下某块内存中的旧值,通过对旧值进行一系列的操作后得到新值,然后通过CAS操作将新值与旧值进行交换。如果这块内存的值在这期间内没被修改过,则旧值会与内存中的数据相同,这时CAS操作将会成功执行 使内存中的数据变为新值。如果内存中的值在这期间内被修改过,则一般[2]来说旧值会与内存中的数据不同,这时CAS操作将会失败,新值将不会被写入内存。
比较交换(compare-and-swap,CAS)和上锁(locking)是两种不同的并发控制机制,它们在处理共享资源时有一些重要的区别:
-
机制:
- CAS: CAS 是一种基于硬件层面的原子操作。它比较内存中的值与给定的期望值,如果相等,则将该内存位置的值更新为新值。这是一个非阻塞操作,意味着它不会等待其他线程,而是立即返回结果。
- 上锁: 上锁使用互斥锁(mutex)或信号量等同步原语,以确保在任何时刻只有一个线程可以访问共享资源。当一个线程获取到锁时,其他线程会被阻塞,直到锁被释放。
-
性能:
- CAS: CAS 操作通常比锁定操作更轻量级,因为它是非阻塞的,不会引起线程的切换和上下文切换。
- 上锁: 上锁操作可能涉及到线程的切换和上下文切换,这可能会导致一定的性能开销。
-
死锁:
- CAS: 由于CAS是非阻塞的,不会发生死锁情况。
- 上锁: 如果线程A持有锁并且在释放之前尝试获取另一个锁,而线程B持有了线程A需要的锁并尝试获取线程A持有的锁,就可能导致死锁。
-
适用场景:
- CAS: 适用于高并发环境下,特别是当竞争较少时,CAS 可以提供更好的性能。它通常用于实现乐观锁机制,例如在无锁数据结构(比如无锁队列)中。
- 上锁: 当需要确保一段代码或资源只能被一个线程访问时,上锁是一个更为直接的选择。例如,在对共享资源进行读写时,需要保证数据的一致性时,就可以使用锁。
总的来说,CAS 更适合于并发度高、竞争少的情况,而上锁适用于需要强制同步和保护共享资源的情况。在实际应用中,通常会根据具体情况选择使用CAS还是锁,或者结合两者以取得最佳的性能和数据一致性。
ABA问题
CAS 的使用使得在一些情况下可以避免使用显式的锁,从而提高了并发性能。然而,它也有一些限制和注意事项,比如在高并发场景下可能会出现ABA问题,需要特殊的处理。
ABA问题是无锁结构实现中常见的一种问题,可基本表述为:
- 进程P1读取了一个数值A
- P1被挂起(时间片耗尽、中断等),进程P2开始执行
- P2修改数值A为数值B,然后又修改回A
- P1被唤醒,比较后发现数值A没有变化,程序继续执行。
对于P1来说,数值A未发生过改变,但实际上A已经被变化过了,继续使用可能会出现问题。在CAS操作中,由于比较的多是指针,这个问题将会变得更加严重。
试想如下情况:
top
|
V
0x0014
| Node A | --> | Node X | --> ……
有一个栈(先入后出)中有top和节点A,节点A目前位于栈顶top指针指向A。现在有一个进程P1想要pop一个节点,因此按照如下无锁操作进行
pop()
{
do{
ptr = top; // ptr = top = NodeA
next_ptr = top->next; // next_ptr = NodeX
} while(CAS(top, ptr, next_ptr) != true);
return ptr;
}
而进程P2在执行CAS操作之前打断了P1,并对栈进行了一系列的pop和push操作,使栈变为如下结构:
top
|
V
0x0014
| Node C | --> | Node B | --> | Node X | --> ……
进程P2首先pop出NodeA,之后又push了两个NodeB和C,由于内存管理机制中广泛使用的内存重用机制,导致NodeC的地址与之前的NodeA一致。
这时P1又开始继续运行,在执行CAS操作时,由于top依旧指向的是NodeA的地址(实际上已经变为NodeC),因此将top的值修改为了NodeX,这时栈结构如下:
top
|
0x0014 V
| Node C | --> | Node B | --> | Node X | --> ……
经过CAS操作后,top指针错误地指向了NodeX而不是NodeB。
原子操作与互斥锁的区别
-
atomic操作的优势是更轻量,CAS可以在不形成临界区和创建互斥量的情况下完成并发安全的值替换操作。这可以大大的减少同步对程序性能的损耗。
-
使用CAS操作的做法趋于乐观,总是假设被操作值未曾被改变(即与旧值相等),并一旦确认这个假设的真实性就立即进行值替换,那么在被操作值被频繁变更的情况下,CAS操作并不那么容易成功。而使用互斥锁的做法则趋于悲观,我们总假设会有并发的操作要修改被操作的值,并使用锁将相关操作放入临界区中加以保护。
临界区的概念和互斥的理解
- 临界区:一段代码需要访问的资源是共享资源。访问共享资源的代码称为临界区。
- 互斥:临界区的进程只有一个,不允许出现多个进程进入临界区访问。多个进程访问会出现不确定性
- 死锁:当两个进程都拥有一定的资源且还需要其他资源时,有可能相互等待。A等B、B等A,谁也无法继续执行。
- 饥饿:一个进程持续等待,而且可能长期存在而无限期等待。
临界区
临界区的特性
1.互斥:任何时候只有一个线程或者进程可以访问临界区
2.前进(Progress):一个进程或者线程想进入临界区,则最终他会进入临界区,不会一直等待。
3.有限等待:如果一个进程只需等待有限的时间段就能确保它可以进入临界区去执行。
4.无忙等待【可选】:在临界区之外等着,如果是在做死循环这种忙等,如果不能确保很快进入临界区,则过于消耗CPU资源。
临界资源
-
一次仅允许一个进程使用的共享资源。
-
临界区不是内核对象,而是系统提供的一种数据结构,程序中可以声明一个该类型的变量,之后用它来实现对资源的互斥访问。当欲访问某一临界资源时,先将该临界区加锁(若临界区不空闲则等待),用完该资源后,将临界区释放。
-
临界区也是代码的称呼,所以一个进程可能有多个临界区,分别用来访问不同的临界资源。
调度规则
- 如果有若干进程请求进入空闲的临界区(空闲即0进程访问),一次仅允许一个进程进入。
- 任何时候,处于临界区内的进程不可多于一个(0 或 1),若已有进程进入自己的临界区,则其它想进入自己临界区的进程必须等待。
- 进行临界区的进程要在有限时间内退出,以便其它进程能及时进入自己的临界区。
- 如果其它进程不能进入自己的临界区,则应让出 CPU,避免进程出现 “忙等” 现象
并发安全和锁
互斥锁
var x int64
var wg sync.WaitGroup
var lock sync.Mutex
func add() {
for i := 0; i < 5000; i++ {
lock.Lock() // 加锁
x = x + 1
lock.Unlock() // 解锁
}
wg.Done()
}
func main() {
wg.Add(2)
go add()
go add()
wg.Wait()
fmt.Println(x)
}
使用互斥锁能够保证同一时间有且只有一个goroutine进入临界区,其他的goroutine则在等待锁;当互斥锁释放后,等待的goroutine才可以获取锁进入临界区,多个goroutine同时等待一个锁时,唤醒的策略是随机的。
读写互斥锁
互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的去读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。读写锁在Go语言中使用sync包中的RWMutex类型。
读写锁分为两种:读锁和写锁。当一个goroutine获取读锁之后,其他的goroutine如果是获取读锁会继续获得锁,如果是获取写锁就会等待;当一个goroutine获取写锁之后,其他的goroutine无论是获取读锁还是写锁都会等待。
var (
x int64
wg sync.WaitGroup
lock sync.Mutex
rwlock sync.RWMutex
)
func write() {
// lock.Lock() // 加互斥锁
rwlock.Lock() // 加写锁
x = x + 1
time.Sleep(10 * time.Millisecond) // 假设读操作耗时10毫秒
rwlock.Unlock() // 解写锁
// lock.Unlock() // 解互斥锁
wg.Done()
}
func read() {
// lock.Lock() // 加互斥锁
rwlock.RLock() // 加读锁
time.Sleep(time.Millisecond) // 假设读操作耗时1毫秒
rwlock.RUnlock() // 解读锁
// lock.Unlock() // 解互斥锁
wg.Done()
}
func main() {
start := time.Now()
for i := 0; i < 10; i++ {
wg.Add(1)
go write()
}
for i := 0; i < 1000; i++ {
wg.Add(1)
go read()
}
wg.Wait()
end := time.Now()
fmt.Println(end.Sub(start))
}
Sync
sync.WaitGroup
方法名 | 功能 |
---|---|
(wg * WaitGroup) Add(delta int) | 计数器+delta |
(wg *WaitGroup) Done() | 计数器-1 |
(wg *WaitGroup) Wait() | 阻塞直到计数器变为0 |
sync.Once
var icons map[string]image.Image
func loadIcons() {
icons = map[string]image.Image{
"left": loadIcon("left.png"),
"up": loadIcon("up.png"),
"right": loadIcon("right.png"),
"down": loadIcon("down.png"),
}
}
// Icon 被多个goroutine调用时不是并发安全的
func Icon(name string) image.Image {
if icons == nil {
loadIcons()
}
return icons[name]
}
多个goroutine并发调用Icon函数时不是并发安全的,现代的编译器和CPU可能会在保证每个goroutine都满足串行一致的基础上自由地重排访问内存的顺序。
func loadIcons() {
icons = make(map[string]image.Image)
icons["left"] = loadIcon("left.png")
icons["up"] = loadIcon("up.png")
icons["right"] = loadIcon("right.png")
icons["down"] = loadIcon("down.png")
}
在这种情况下就会出现即使判断了icons不是nil也不意味着变量初始化完成了。考虑到这种情况,我们能想到的办法就是添加互斥锁,保证初始化icons的时候不会被其他的goroutine操作,但是这样做又会引发性能问题。
var icons map[string]image.Image
var loadIconsOnce sync.Once
func loadIcons() {
icons = map[string]image.Image{
"left": loadIcon("left.png"),
"up": loadIcon("up.png"),
"right": loadIcon("right.png"),
"down": loadIcon("down.png"),
}
}
// Icon 是并发安全的
func Icon(name string) image.Image {
loadIconsOnce.Do(loadIcons)
return icons[name]
}
sync.Once其实内部包含一个互斥锁和一个布尔值,互斥锁保证布尔值和数据的安全,而布尔值用来记录初始化是否完成。这样设计就能保证初始化操作的时候是并发安全的并且初始化操作也不会被执行多次。
sync.Map
Go语言中内置的map不是并发安全的。
Go语言的sync包中提供了一个开箱即用的并发安全版map–sync.Map。开箱即用表示不用像内置的map一样使用make函数初始化就能直接使用。同时sync.Map内置了诸如Store、Load、LoadOrStore、Delete、Range等操作方法。
var m = sync.Map{}
func main() {
wg := sync.WaitGroup{}
for i := 0; i < 20; i++ {
wg.Add(1)
go func(n int) {
key := strconv.Itoa(n)
m.Store(key, n)
value, _ := m.Load(key)
fmt.Printf("k=:%v,v:=%v\n", key, value)
wg.Done()
}(i)
}
wg.Wait()
}
原子操作(atomic包)
代码中的加锁操作因为涉及内核态的上下文切换会比较耗时、代价比较高。针对基本数据类型我们还可以使用原子操作来保证并发安全,因为原子操作是Go语言提供的方法它在用户态就可以完成,因此性能比加锁操作更好。Go语言中原子操作由内置的标准库sync/atomic提供。
atomic包
方法 | 解释 |
---|---|
func LoadInt32(addr int32 ) (val int32)func LoadInt64(addr int64 ) (val int64)func LoadUint32(addr uint32 ) (val uint32)func LoadUint64(addr uint64 ) (val uint64)func LoadUintptr(addr uintptr ) (val uintptr)func LoadPointer(addr unsafe.Pointer ) (val unsafe.Pointer) | 读取操作 |
func StoreInt32(addr *int32 , val int32)func StoreInt64(addr *int64 , val int64)func StoreUint32(addr *uint32 , val uint32)func StoreUint64(addr *uint64 , val uint64)func StoreUintptr(addr *uintptr , val uintptr)func StorePointer(addr *unsafe.Pointer , val unsafe.Pointer) | 写入操作 |
func AddInt32(addr *int32 , delta int32) (new int32)func AddInt64(addr *int64 , delta int64) (new int64)func AddUint32(addr *uint32 , delta uint32) (new uint32)func AddUint64(addr *uint64 , delta uint64) (new uint64)func AddUintptr(addr *uintptr , delta uintptr) (new uintptr) | 修改操作 |
func SwapInt32(addr *int32 , new int32) (old int32)func SwapInt64(addr *int64 , new int64) (old int64)func SwapUint32(addr *uint32 , new uint32) (old uint32)func SwapUint64(addr *uint64 , new uint64) (old uint64)func SwapUintptr(addr *uintptr , new uintptr) (old uintptr)func SwapPointer(addr *unsafe.Pointer , new unsafe.Pointer) (old unsafe.Pointer) | 交换操作 |
func CompareAndSwapInt32(addr *int32 , old, new int32) (swapped bool)func CompareAndSwapInt64(addr *int64 , old, new int64) (swapped bool)func CompareAndSwapUint32(addr *uint32 , old, new uint32) (swapped bool)func CompareAndSwapUint64(addr *uint64 , old, new uint64) (swapped bool)func CompareAndSwapUintptr(addr *uintptr , old, new uintptr) (swapped bool)func CompareAndSwapPointer(addr *unsafe.Pointer , old, new unsafe.Pointer) (swapped bool) | 比较并交换操作 |
var x int64
var l sync.Mutex
var wg sync.WaitGroup
// 普通版加函数
func add() {
// x = x + 1
x++ // 等价于上面的操作
wg.Done()
}
// 互斥锁版加函数
func mutexAdd() {
l.Lock()
x++
l.Unlock()
wg.Done()
}
// 原子操作版加函数
func atomicAdd() {
atomic.AddInt64(&x, 1)
wg.Done()
}
func main() {
start := time.Now()
for i := 0; i < 10000; i++ {
wg.Add(1)
// go add() // 普通版add函数 不是并发安全的
// go mutexAdd() // 加锁版add函数 是并发安全的,但是加锁性能开销大
go atomicAdd() // 原子操作版add函数 是并发安全,性能优于加锁版
}
wg.Wait()
end := time.Now()
fmt.Println(x)
fmt.Println(end.Sub(start))
}