-
博文分类专栏
- Jquery基础教程
-
- 文章:(15)篇
- 阅读:46583
- shell命令
-
- 文章:(42)篇
- 阅读:154304
- Git教程
-
- 文章:(36)篇
- 阅读:234924
- leetCode刷题
-
- 文章:(76)篇
- 阅读:131950
-
程序中什么是锁?golang中的锁使用2018-02-26 18:10 阅读(5771) 评论(0)
一、简介
在说锁之前,我们先要明白,什么是锁?为什么要使用锁?锁是如何保证数据共享的呢?锁分哪些类型?是不是必须得使用锁呢?
先来举个例子吧,现在假设,甲、乙、丙三人,步行出发,去同一个目的地,发现了一辆ofo小黄车,三人不能同时骑车,于是他们打算按照年龄大小依次交替着骑行N分钟,然后停车继续步行,不必等下一个骑行人到来。下一位骑行人发现了车子,就开始骑行N分钟,接着继续步行(不考虑停车后,小黄车被其他人骑走的情况,N不超过1小时)。
车子太少,却都要骑车,由于骑车的速度比步行的速度快,于是就有个问题,即乙怎么知道甲把小黄车停到哪呢?丙怎么知道乙把车停到哪了哪?
针对这个问题,怎么解决呢?
解决方案一:大家自然而然就想到,打电话问啊。
解决方案二:只让甲、乙、丙中的一个人骑。
在程序中,甲、乙、丙就好比三个线程(或进程或任务),设为l1、l2、l3,一辆ofo小黄车就好比三个线程或进程都要操作的数据,设为d1,现在l1、l2、l3都会依赖读取的数据d1,然后做相应的逻辑处理并修改的d1值(处理数据就会耗时,相当于每人骑车N分钟,改变停车位置),l1、l2、l3哪个先读取数据d1呢(就好比甲乙丙三人骑车的先后顺序),且当l1、l2、l3中任何一个对数据d1做了修改,怎么保证不影响另外两个线程(或进程或任务)呢?
解决方案一:l1、l2、l3中任何一个对数据d1做了修改,都通知另外两个线程(或进程)
解决方案二:只让l1、l2、l3中的一个,对数据进行操作。
在上面的分析中,
解决方案一就是利用的多线程或多进程的信号机制;
解决方案二就是利用的多线程或多进程的锁机制。
现在,再来说看看,什么是锁,即程序中为了保证数据共享、处理竞争的一种机制。使用锁是为了保证数据的一致性,即同一时刻,只能有一个进程或是线程操作数据。常见的锁有互斥锁、读写锁、乐观锁、悲观锁等等。除了使用锁,我们也可以使用消息通信来解决并发的问题。
锁是如何保证数据共享的呢?这就要提到一个新的概念“临界区”,即会导致竞争条件的程序片段就叫做临界区,锁就是保证同时只有一个线程(进程或任务)处于临界区内来实现数据的共享。
1、锁的分类
锁主要分为乐观锁和悲观锁。悲观锁,即对数据任何操作都会先加锁,操作完之后在解锁。比如共享锁、排它锁、独占锁是对悲观锁的一种实现;乐观锁,即对数据任何操作都不会加锁。
2、锁的特点
从锁的操作行为来看,主要分加锁和解锁。
从数据的操作行为来看,有读锁和写锁。
3、常见的锁
互斥锁:线程(或进程或任务)在操作数据前,先判断数据锁的状态,若已经锁定,则进行该数据的待操作队列,否认就加锁。一旦数据被加互斥锁了,其他线程(或进程或任务)不能在对数据进行任何操作或是加减锁,只有对数据实施加锁的那个线程(或进程或任务)才可以对数据进行操作或是解锁。
自旋锁:即线程(或进程或任务)试图取得锁失败的时候,选择忙等待而不是阻塞自己。选择忙等待的优点,在于如果该线程(或进程或任务)在获取CPU时间片内拿到了锁,即不用进行上下文切换了(因为阻塞,需要进行上下文切换);选择忙等待的缺点,是如果该线程(或进程或任务)在获取CPU时间片内一直获取不到锁,那么CPU就空转了,有点浪费。所以针对锁占用时间短,可以考虑自旋锁。
读写锁:读写锁,根据线程(或进程或任务)对共享数据操作行为来决定锁的占用情况。将锁划状态分为读锁(读取数据,读线程或进程可以进入,写线程或进程不可以进入)、写锁(更新数据,读和写线程或进程都不可以进入)以及无锁(读和写线程或进程都可以进入,并开启锁)。
4、什么是原子操作
一般提到锁的地方,往往会说一下原子操作。在多进程(或线程)的操作系统中,那些不能够被打断的操作被称为原子操作。在系统中,进程的抢占以及并发的执行往往会打断操作。
4、go语言中有哪些锁
在go语言中,提供了互斥锁、读写锁。
二、go语言中互斥锁的使用
互斥锁 mutex 是独占型,只能 lock 一次, unlock 一次,针对已经 lock的数据,再次lock的时候,就会阻塞。在go语言的sync包中,提供了Mutex类型,来进行互斥操作,Mutex类型类型了Locker接口,Locker接口如下:
// A Locker represents an object that can be locked and unlocked. type Locker interface { Lock() Unlock() }
其中,Lock方法,用于加锁,Unlock方法,用于解锁。
创建一个互斥锁类型指针的变量方式如下:
var p sync.Mutex = new(sync.Mutex)
案例2.1,如下:
package main import ( "fmt" "sync" "time" ) func main() { //设置访问路由 var p = new(sync.Mutex) go func1(p, "A1") go func1(p, "A2") go func1(p, "A3") time.Sleep(time.Second * 6) } func func1(s *sync.Mutex, curNum string) { fmt.Println("I'm ", curNum) s.Lock() //加锁 fmt.Println(curNum, "已经加锁") fmt.Println("这部分的代码,同一时刻,只让一个G来执行") time.Sleep(2) fmt.Println(curNum, "准备解锁") s.Unlock() //执行结束时候解锁 }
案例里面的func1之所以使用了sync.Mutex类型的指针作为参数,就是保证是对同一个锁变量加锁和解锁操作。
通过go func1()来启动了3个goroutine,每个gorouting在执行func1,前都会先进行锁定操作。当执行s.Lock()锁定以后,其他goroutine在执行s.Lock()时候,就会被阻塞。上面案例执行结果如下:
三、go中原子操作
原子操作可以在避免程序中出现大量的锁操作,需底层硬件支持,由一个独立的CPU指令代表和完成。
go语言提供的原子操作都是非入侵式的。
侵入式和非侵入式,是从调用关系角度来说的,比如模块A,调用了模块B中部分功能。那么模块A是将模块B作为自己的一部分,还是将B作为一个独立的部分,然后再去调用。如果将模块B作为模块A的一部分,就是侵入式;如果将B作为一个独立的部分,则为非侵入式。侵入式,往往则意味着高耦合。
go的原子操作主要包括:增或减、比较并交换、载入、存储和交换。
1、增或减
使用的是AddInt**和AddUint**系列函数。
2、比较并交换
使用的是CompareAndSwapInt**和CompareAndSwapUInt**系列函数。
如func CompareAndSwapInt32(addr *int
32, old, new int32) ,当addr对应的值,与old相等,则将addr对应的值改为new
3、载入
载入操作主要是为了防止在读取某个变量的时候,有其他线程或是进程对变量进行修改,说白了,就是为了安全的读取。比如线程A正在读取变量L,此时有个线程B正在对变量L进行写入操作,那么有可能线程A读取仅仅是变量L部分数据。载入操作系列函数是以load开始的函数,如下:
4、存储
存储和载入是相对的,是为了保证某个变量的写安全。存储操作函数以Store开头。
5、交换
交换是以Swap开头的系列函数。
四、go语言中读写锁的使用
sync.RWMutex实现了go语言里面的读写锁。该锁可以加多个读锁或者一个写锁,往往用于读次数远远多于写次数的场景。sync.RWMutex里面的主要方法如下:
//对写操作 加锁和去锁 func (rw *RWMutex) Lock() func (rw *RWMutex) Unlock() //对读操作 加锁和去锁 func (rw *RWMutex) Lock() func (rw *RWMutex) Unlock()
写锁:在添加写锁之前,如果有其他的读锁或写锁,则会阻塞。
写锁优先级高于读锁,即有写锁和读锁的时候,写锁优先。
如果仅仅使用写锁,那么效果和互斥锁是一样的。
案例3.1,如下:
func main() { //设置访问路由 var p = sync.RWMutex{} go func1(p, "A1") go func1(p, "A2") go func1(p, "A3") time.Sleep(time.Second * 5) } func func1(s sync.RWMutex, curNum string) { fmt.Println("I'm ", curNum) s.RLock() //加读锁 fmt.Println(curNum, "已经加读锁") fmt.Println("这部分的代码,被加了读锁") time.Sleep(2) fmt.Println(curNum, "准备解读锁") s.RUnlock() //执行结束时候解锁 }
执行效果如下: