进程与线程
进程与线程都是os用来运行程序的基本单元。其中进程是正在执行的程序的实例,它包含了程序代码、数据、文件和系统资源等。进程是os资源分配的基本单元,每个进程都有自己独立的地址空间、文件描述符、网络连接、进程ID等系统资源。进程与进程之间有较好的隔离性,但是进程之间的通信困难,创建一个进程耗时且耗资源,因此多进程并不是高并发场景下的最佳选择。
由于多进程在并发条件下的不足,os抽象出一个轻量级的资源------线程。一个进程可以包含多个线程,这些线程共享进程的资源,包括内存,文件和网络描述符等。同时,每个线程都有独立的栈空间、程序计数器和线程本地存储等资源。线程是os资源调度的基本单位,它比进程更轻量级,可以被更快地创建和销毁,且线程间地切换开销比进程小,因此在多任务处理中,使用线程可以提高程序地并发性和性能。
线程与协程
协程一般被认为是轻量级地线程,os感知不到协程的存在,协程的管理依赖Go语言运行时自身提供的调度器。因此准确的说,Go语言中的协程是从属某一个线程的,只有协程和实际线程绑定,才有执行的机会。
下面从调度方式、上下文切换的速度、调度策略、栈的大小这4个方面分析一下线程和协程的不同之处。
- 调度方式
Go语言中的协程从属于某一个线程的,协程与线程是多对多的对应关系。Go语言调度器可以将多个协程调度到同一个线程中执行,一个协程也可以在多个线程中切换。 - 上下文切换速度
协程上下文切换的速度要快于线程,因为切换协程不必同时切换开发者态与os内核态,而且在Go语言中,切换协程只需要保留极少的状态和寄存器值,切换线程则会保留额外的寄存器值。在一般情况下,线程切换的速度大约为1~2微秒,Go语言中协程切换的速度比它快数倍,为0.2微秒左右。 - 调度策略
线程的调度在多数时间里是抢占式的,os调度器为了均衡每个线程的执行周期,会定时发出中断信号强制切换线程上下文。而Go语言中的协程在一般情况下是协作式调度的,当一个协程处理完自己的任务后,可以主动将执行权限让渡给其他协程。这意味着协程可以更好地在规定时间内完成自己的工作,而不会轻易被抢占。只有当一个协程运行了太长时间时,Go语言调度器才会强制抢占其任务的执行。 - 栈的大小
线程的栈的大小一般是在创建时指定的。为了避免出现栈溢出的情况,默认的栈较大(例如2MB),这意味着每创建1000个线程就需要消耗2GB的虚拟内存,大大限制了可以创建的线程的数量,而Go语言中的协程栈默认是2KB,所以在实践中,经常会看到成千上万的协程存在。
另外,线程的栈在运行时不能更改,但是Go语言中的协程栈在Go运行时的帮助下会动态检测栈的大小,并动态地进行扩容,因此在实践中,我们可以将协程看作轻量的资源。
协程的数据争用
在Go语言中,当两个以上协程同时访问相同的内存空间,并且至少有一个写操作时,可能出现并发安全问题,这种现象也叫做数据争用。而要解决数据争用问题,我们需要一些机制来保证某一时刻只有一个协程主席特定操作,比较传统的方案是锁,包括原子锁、互斥锁与读写锁。
-
原子锁
govar count int64 =0 func add(){ atomic.AddInt64(&count,1) } func main(){ go add() go add() }
sync/atomic包中还有一个重要的功能------CompareAndSwap,它能够对比并替换元素值。在下面这个例子中,atomic.CompareAndSwapInt64会判断flag变量的值是否为0,如果是0,则将flag的值设置为1.这一系列操作都是原子性的,不会发生数据争用,也不会出现内存操作乱序问题。sync/atomic包中的原子操作能够构建起一种自旋锁,只有获取该锁,才能执行区域中的代码。
govar count int64 =0 var flag int64 = 0 func add(){ for{ if atomic.CompareAndSwapInt64(&flag,0,1){ count++ atomic.StoreInt64(&flag,0) return } } } func main(){ go add() go add() }
-
互斥锁
通过原子操作构建起的自旋锁,虽然简单高效却不是万能的。例如,当某一个协程长时间霸占锁时,其他协程仍在继续抢占锁,这会导致CPU资源持续无意义地被浪费。同时,当许多协程同时获取锁时,可能有协程始终抢占不到锁。为了解决这种问题,os的锁接口提供了终止与唤醒的机制,这就避免了频繁自旋造成的浪费。不过,调用os级别的锁会锁住整个线程使之无法运行,另外所得抢占还涉及线程之间的上下文切换。Go语言借助协程实现了一种比传统os级别的锁更加轻量级别的互斥锁。
govar count int64=0 var m sync.Mutex func add(){ m.Lock() count++ m.Unlock() } func main(){ go add() go add() }
这里,sync.Mutex构建起了互斥锁,在同一时刻,只会有一个获取了锁的协程会继续执行任务,其他的协程将陷入等待状态。借助协程的休眠与调度器的调度,这种锁会变得非常轻量。
-
读写锁
由于在同一时间内只能有一个协程获取互斥锁并执行操作,因此在多读少写的情况下,如果长时间没有写操作,读取到的会是完全相同的值,使用互斥锁就显得没有必要了,这时使用读写锁更加恰当。
读写锁通过两种锁来实现,一种为读锁,一种为写锁。当进行读取操作时,需要加读锁,当进行写入操作时,需要加写锁。多个协程可以同时获得读锁并执行,但只能有一个协程获得写锁。如果此时有协程申请了写锁,那么该协程需要等待所有的读锁都被释放才能获取写锁并执行。如果当前的协程申请了读锁时已经存在写锁,那么需要等待写锁被释放再获取读锁并执行。
gotype Stat struct{ counters map[string]int64 mutex sync.RWMutex } func (s *Stat)getCounter(name string) int64{ s.mutex.RLock() defer s.mutex.RUnlock() return s.counters[name] } func (s *Stat) SetCounter(name string){ s.mutex.Lock() defer s.mutex.Unlock() s.counters[name]++ }
Go并发控制库
-
sync.WaitGroup
sync.WaitGroup能够协调多个协程之间的并发执行,它会等待多个协程执行完毕再继续执行后续代码。先来看下这样一个场景:在加载配置的过程中,我们希望多个协程可以同时加载不同的配置文件,同时希望这些协程都加载完毕程序才提供服务。这时,使用sleep函数进行休眠是一种低效的解决方案,更高效的方案是使用Go语言标准库中的sync.WaitGroup。
sync.WaitGroup提供了3种方法:Add、Done和Wait。其中Add方法将等待的数量加1,Done方法将等待的数量减1,Wait方法则会陷入等待,直到等待的数量为0。因此,一般在开启协程前调用Add方法;然后开启多个工作协程,在每个协程结束时延迟调用Done方法,将等待的数量减1;在末尾调用Wait方法,该方法会陷入阻塞,等待所有协程执行完毕再继续执行后续代码。
gofunc worker(id int){ //..... } func main(){ var wg sync.WaitGroup for i :=1;i<=5;i++{ wg.Add(1) i :=i go func(){ defer wg.Done() worker(i) }() } wg.Wait() }
-
sync.Once
sync.Once可以保证某一个过程只执行一次,它在实践中被广泛使用,用于防止内存泄漏、资源重复关闭等异常情况。例如,我们希望再程序启动时仅加载一次配置、初始化一次日志组件。
govar(once sync.Once) func DbOnce()(*sql.DB,error){ once.Do(func(){ db,dbErr }) }
-
sync.Map
sync.Map是Go语言标准库提供的一种线程安全的map类型。与常规的map类型不同,sync.Map是并发安全的,可以在多个协程之间共享访问。
sync.Map的使用非常简单,只需要使用sync.Map的内置方法进行读写操作即可。例如,可以使用Load方法读取某个Key对应的Value,使用Store方法存储Key-Value对,使用Delete方法删除指定的Key,等等。具体来说,sync.Map支持以下几个方法。
- Load(key interface{})(interface{},bool):加载指定Key对应的Value。
- Store(key, value interface{}):存储Key-Value对。
- LoadOrStore(key,value interface{})(actual interface{},loaded bool):加载Key对应的Value,如果Key不存在,则存储Key-Value对,并返回(value,false);如果Key已经存在,则返回已经存在的Value,并返回(value,true)。
- Delete(key interface{}):删除指定的Key-Value对。
- Range(f func(key,value interface{})bool):遍历sync.Map中的所有Key-Value对,并对每个Key-Value对执行指定的函数f。如果函数f返回false,则Range方法会停止遍历。
gofunc main(){ var m sync.Map m.Store("foo","bar") m.Store("hello","world") val,ok := m.Load("foo") if ok { fmt.Println(val)//bar } newVal,loaded :=m.LoadOrStore("foo","baz") if loaded{ fmt.Println(newVal)//bar }else{ fmt.Println("Stored value for key 'foo'") } m.Range(func (key,value interface{})bool{ fmt.Println("key: %v,value: %v\n",key,value) return true }) }
需要注意的是,由于sync.Map内部实现了一些复杂的算法,因此在性能上可能略逊于普通的map类型。另外,由于sync.Map中的key和value都是interface{}类型,因此在使用时需要进行类型断言。
-
sync.Cond
sync.Cond是Go语言提供的一种类似条件变量的同步机制,它能够让协程陷入阻塞,直到某个条件发生再继续执行。sync.Cond包含了3个重要的API:Wait()、Signal()和Broadcast()。其中,Wait()表示等待条件的发生,会释放所持有的锁,并使当前协程陷入等待状态;Signal用于唤醒等待队列中的一个协程;而Broadcast会唤醒所有等待的协程。要注意的是,使用Wait之前必须调用Cond.L.Lock进行枷锁,结束后还需要调用Cond.L.UnLock()进行解锁。
使用sync.Cond的正确方法是:协程A会用for循环判断是否满足条件,如果不满足则陷入休眠状态。协程B会在恰当的时候调用c.Broadcast()唤醒等待的协程。当协程被唤醒后,需要再次检查条件是否满足,如果不满足则需要重新陷入等待。
使用sync.Cond可以实现某种程度上的解耦:消息的发出者不需要知道具体的判断条件,这样可以增强代码的可维护性和可扩展性。
go//协程A c.L.Lock() for !condition(){ c.Wait() } ... c.L.Unlock() //协程B c.Broadcast()
在实践中,并不经常使用sync.Cond,因为在很多场景下都可以使用更为强大的通道。
Go并发模式
前面讲了很多传统的同步模式,但是在实践中协调协程时,使用最多的还是通道。通道最厉害之处在于,在通道的过程中完成了数据所有权的转移,数据只可能在某一个协程中执行,这在无形中解决了并发安全的问题。
-
ping-pong模式
收到数据的协程可以在不加锁的情况下对数据进行处理,而不必担心并发冲突。
gofunc main(){ var Ball int table :=make(chan int) go player(table) go player(table) table<-Ball time.Sleep(1*time.Second) <-table } func player(table chan int){ for{ ball :=<-table ball++ time.Sleep(1*time.Second) table <- ball } }
-
fan-in模式
多个协程把数据写入通道,但只有一个协程等待读取通道数据。
gofunc search(msg string)chan string{ var ch = make(chan string) go func(){ var i int for{ ch <- fmt.Sprintf("get %s %d",msg,i) i++ time.Sleep(1*time.Second) } }() return ch } func main(){ ch1 := search("jonson") ch2 := search("olaya") for{ select{ case msg := <-ch1: fmt.Println(msg) case msg := <-ch2: fmt.Println(msg) } } }
-
fan-out模式
一个协程完成数据写入,多个协程争夺同一个通道中的数据。fan-out模式通常用来分配任务。例如,程序消费kafka等中间件的数据,多个协程会监听同一个通道中的数据,并在读取到数据后立即进行后续处理,处理完毕再继续读取,循环往复。
gofunc worker(tasksCh <- chan int,wg *sync.WaitGroup){ defer wg.Done() for{ task,ok:=<-tasksCh if !ok{ return } d := time.Duration(task)*time.Millisecond time.Sleep(d) fmt.Println("processing task",task) } } func pool(wg *sync.WaitGroup,workers,tasks int){ tasksCh := make(chan int) for i:=0;i<workers;i++{ go worker(tasksCh,wg) } for i:=0;i<tasks;i++{ tasksCh <- i } close(tasksCh) } func main(){ var wg sync.WaitGroup wg.Add(36) go pool(&wg,36,50) wg.Wait() }
-
pipeline模式
指由通道连接的一系列连续的阶段,以类似流的形式进行计算。每个阶段由一组执行特定任务的协程组成,通过通道获取上游传递过来的值,经过处理后,再将新的值发送给下游。
gofunc Generate(ch chan<- int){ for i:=2;;i++{ ch <- i } } func Filter(in <-chan int,out chan <- int,prime int){ for{ i := <-in if i%prime!=0{ out <- i } } } func main(){ ch := make(chan int) go Generate(ch) for i:=0;i<10000;i++{ prime := <-ch fmt.Println(prime) ch1 := make(chan int) go Filter(ch,ch1,prime) ch = ch1 } }