Go基础之并发编程

一.并发性

1.并发产生的原因

  在直观效果上,处理器是并行处理多项任务,因为我们可以在计算机上同时运行多个程序.但本质上一个处理器在某个时间点只能处理一个任务,属于串行执行。

  在单处理器的情况下,并发问题源于多道程序设计系统的一个基本特性:进程的相对执行速度不可预测,它取决于其他进程的活动、操作系统处理中断的方式以及操作系统的调度策略。

  在分布式环境下,并发产生的可能性就更大了,只要大家有依赖的共享资源,就有并发问题的出现,因为互相调用次序更加没法控制。

2.处理并发的方式

  常见的处理并发的方式有:多进程多线程IO多路复用

  在实际的服务器应用中基于线程的并发模型并不能创建和使用过多的thread。因为thread数目过多于CPU核数,内核调度和切换thread将付出较大代价。因此通常在基于线程的基础上,在用户态设计用户态线程模型与调度器,使得调度不需要陷入内核完成,减小并发调度代价。

二.GO对并发性的支持

1.协程(coroutine)

 1> 协程 (coroutine) 与 进程 (process), 线程 (thread)

  在级别方面,进程(process)和线程(thread)处于操作系统(os)级别,他们是两个实际的“东西”(不说概念是因为这两个家伙的确不仅仅是概念,而是实际存在的,os的代码管理的资源),都是用来模拟“并行”的.写操作系统的程序员通过用一定的策略给不同的进程和线程分配CPU计算资源,来让用户“以为”几个不同的事情在“同时”进行“。在单CPU上,是os代码强制把一个进程或者线程挂起,换成另外一个来计算,所以,实际上是串行的。在现在的多核的cpu上,线程可能是“真正并行的”。与操作系统级别的进程和线程不同,协程 (coroutine) 是编译器级别的。Process和Thread看起来也在语言层次,但是内生原理却是操作系统先有这个东西,然后通过一定的API暴露给用户使用。

  从调度方面,Process和Thread是os通过调度算法,保存当前的上下文,然后从上次暂停的地方再次开始计算,重新开始的地方不可预期,每次CPU计算的指令数量和代码跑过的CPU时间是相关的,跑到os分配的cpu时间到达后就会被os强制挂起。Coroutine是编译器的魔术,通过插入相关的代码使得代码段能够实现分段式的执行.对于Coroutine,是编译器帮助做了很多的事情,来让代码不是一次性的跑到底,而不是操作系统强制的挂起。代码每次跑多少,是可预期的

2> 协程和子程序

  Donald Knuth用一句话总结了协程的特点:“子程序就是协程的一种特例。”

  子程序,或者称为函数,在所有语言中都是层级调用,比如A调用B,B在执行过程中又调用了C,C执行完毕返回,B执行完毕返回,最后是A执行完毕。所以子程序调用是通过栈实现的,一个线程就是执行一个子程序。子程序调用总是一个入口,一次返回,调用顺序是明确的。

  而协程的调用和子程序不同。协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。注意,在一个子程序中中断,去执行其他子程序,不是函数调用,有点类似CPU的中断。比如子程序A、B:

def A():
    print '1'
    print '2'
    print '3'

def B():
    print 'x'
    print 'y'
    print 'z'

假设由协程执行,在执行A的过程中,可以随时中断,去执行B,B也可能在执行过程中中断再去执行A,结果可能是:

1
2
x
y
3
z

 3> 对协程的理解

  • 轻量级"线程"(并不是正真的线程)
  • 非抢占式的多任务处理,由协程主动交出控制权(线程是抢占式的多任务处理,切换控制权不在线程手中,在任何时候都有可能被操作系统进行切换,切换时需要将执行到一半的上下文存起来,使得切换需要消耗更多的资源,切换付出的代价比较大),正是由于非抢占式的多任务处理,只需在几个点进行切换,从而才使得协程更加轻量级
  • 编译器 / 解释器 / 虚拟机层面上的多任务
  • 多个协程可能在一个或多个线程上运行

Go基础之并发编程_第1张图片

 

2.go中的goroutine

  goroutine其实是一种协程,在代码中通过关键字"go"来开启一个goroutine.

func main(){
    
    for i:=0; i < 10; i++ {              //重复开启1000个goroutine
        
        go func(a int){                  //开启一个不断输出 a 的goroutine
            for{
                fmt.Println(a)
            }
        }(i)
    }        
}

  此时运行程序,会发现什么也没有输出,原因是main()函数也可以看做是是一个goroutine, 它与我们定义的goroutine是并发执行的,而main()函数运行太快了,我们定义的goroutine还没来得及运行就被退出了.解决方法是在main()中加一个延时.

func main(){
    
    for i:=0; i < 10; i++ { //重复开启1000个goroutine
        
        go func(a int){  //开启一个不断输出 a 的goroutine
            for{
                fmt.Println(a)
            }
        }(i)
    }    
    
    time.Sleep(time.Millisecond)
}

  对于非抢占式多任务处理的理解:

func main(){
    var a [10]int
    
    for i:=0; i < 10; i++ {    //重复开启1000个goroutine
        
        go func(i int){      //开启一个不断输出 a 的goroutine
            for{
                a[i]++
            }
        }(i)
    }    


    time.Sleep(time.Second)
    fmt.Println(a)    
}

  对上边代码略作修改,开启10个goroutine,每个goroutine的任务是负责对数组a中的一个元素不断进行自增,运行代码发现程序进入死循环,原因是一旦一个goroutine拿到控制权后,由于其任务是对一个数不断的做自增,没有切换点,所以它的控制权不会主动交出来,程序在一个goroutine中跑死,在终端可以通过"top",命令查看该goroutine对cpu的占用率.

   若想让一个gotoutine手动主动交出控制权,可以通过 runtime.Gosched() 实现.

func main(){
    var a [10]int
    
    for i:=0; i < 10; i++ {      //重复开启1000个goroutine
        
        go func(i int){        //开启一个不断输出 a 的goroutine
            for{
                a[i]++
                runtime.Gosched()
            }
        }(i)
    }    
    time.Sleep(time.Second)
    fmt.Println(a)   
}

输出结果:[470114 466847 465613 461176 455453 464213 466159 472736 472090 472590] (每次运行结果都不同)

  另外,对于上述代码还有个安全性的考虑,如果goroutine中的匿名函数是无参的,运行会报错"index out of range"

func main(){
    var a [10]int
    
    for i:=0; i < 10; i++ { //重复开启1000个goroutine
        
        go func(){  //开启一个不断输出 a 的goroutine
            for{
                a[i]++
                runtime.Gosched()
            }
        }()
    }    
    time.Sleep(time.Second)
    fmt.Println(a)    
}

运行报错:"index out of range"

  我们可以在命令行通过"go run -race xxx.go"来查找数据访问冲突的错误(race condition).

  出现这个错误的原因是,在goroutine时没有给匿名函数传参,则根据函数式编程闭包的概念,匿名函数中用到的 i 就是其外部的自由变量 i ,也就是for循环中的 i ,而for循环在执行完退出时,其 i 的值是10, 此时若有某个goroutine拿到控制权时,其内部出现a[10], 所以在匿名函数中出现数组下标越界的错误.而若在goroutine时对匿名函数进行了传参,匿名函数有自己的局部变量,每个goroutine中的 i 都是自己的局部变量的值,是固定下来的,所以不会出错.

 

  • 任何函数只需加上 go 就能送给就能送给调度器运行
  • 不需要在定义时区分是否是异步函数
  • 调度器在合适的点进行切换
  • 使用"go run -race xx.go"来检测数据访问冲突
  • 可能的切换点:

          1.I /O, select         

          2.channel

         3.等待锁 

         4.函数调用(有时)

         5.runtime.Gosched()

         6.只是参考,不能保证在这些点一定切换,也不能保证在其他点不切换

3.goroutine之间的同步与通信 

  golang中实现并发非常简单,只需在需要并发的函数前面添加关键字"go",但是如何处理go并发机制中不同goroutine之间的同步与通信,golang 中提供了sync包和channel机制来解决这一问题.

1> sync 同步机制

  golang中的同步是通过sync.WaitGroup来实现的.WaitGroup的功能:它实现了一个类似队列的结构,可以一直向队列中添加任务,当任务完成后便从队列中删除,如果队列中的任务没有完全完成,可以通过Wait()函数来出发阻塞,防止程序继续进行,直到所有的队列任务都完成为止.

WaitGroup总共有三个方法:Add(delta int), Done(), Wait()

  • Add() :添加或者减少等待goroutine的数量
  • Done() :相当于Add(-1)
  • Wait() :执行阻塞,直到所有的WaitGroup数量变成0

使用场景:

  程序中需要并发,需要创建多个goroutine,并且一定要等这些goroutine 并发全部完成后才继续接下来的程序执行.WaitGroup的特点是Wait()可以用来阻塞当前gouroutine (主程序) 直到队列中的所有任务都完成时才解除阻塞,而不需要sleep一个固定的时间来等待.但是其缺点是无法指定固定的goroutine数目.

package main

import (
	"fmt"
	"sync"
)
type workChan struct{
	in  chan int
//	wg  *sync.WaitGroup
	done func()
}

func worker(id int, w workChan){
	for c := range w.in {
		fmt.Printf("worker %d, %c\n", id, c)
		w.done()
	}
}

func creatWorker(id int, wg *sync.WaitGroup) workChan{
	w := workChan{
		in :  make(chan int),
		done :func(){
			wg.Done()
		},
	}
	
	go worker(id, w)
	
	return w
}

func chandemo() {
	var wg sync.WaitGroup
	var ws [10]workChan
	
	for i:=0; i<10; i++{
		ws[i] = creatWorker(i, &wg)
	}
	
	wg.Add(20)
	for i:=0; i<10; i++{
		ws[i].in<- 'a'+i
	}
	
	for i:=0; i<10; i++{
		ws[i].in<- 'A'+i
	}
	
	wg.Wait()
}

func main(){
	chandemo()	
}

 2> channal 通信机制

  channal可以用来进行goroutine之间的双向通信,其设计思想是"不是通过共享资源来通信,而是通过通信来共享资源".

Go基础之并发编程_第2张图片

 

channal的基本语法:

  • 创建:

    var c chan int      //只是定义了c的变量类型为chan 但具体的chan并没有做出来,c == nil
            c := make(chan int)     //具体做一个chan
              var cs [10]chan<- int / var cs [10] <-chan int        //进一步指定chan的类型,只做输出或只做输入

  • 带有缓冲的channal:

   bc := make(chan int, 3)

   带缓冲区的channal,可以指定缓冲区的大小,在缓冲区填满之前chan中的数据不进行会发送,不需要有另外的goroutine来接收,这样就不需要频繁的切换goroutine,提升了性能

  • 带有close操作的channal:

  channal是可以关闭的,关闭操作永远是发送方进行的,用来通知接收方没有数据发了,对发送方chan加了关闭操作后,接收方也要做关闭确认处理,否则接收方会一直接收下去.

  接收方在发送方关闭后接收的是发送方类型的ZeroValue.

  接收方做关闭确认有两种方式:1.!ok;   2.range

  • 死锁

  何谓死锁? 操作系统有讲过的,所有的线程或进程都在等待资源的释放。若只有一个goroutine, 所以当你向里面取数据或者存数据的话,都会锁死信道, 并且阻塞当前 goroutine, 从而发生死锁。

  1. 只在单一的goroutine里操作无缓冲信道,一定死锁。比如你只在main函数里操作信道:

    func main() {
        ch := make(chan int)
        ch <- 1 // 1流入信道,堵塞当前线, 没人取走数据信道不会打开
        fmt.Println("This line code wont run") //在此行执行之前Go就会报死锁
    }
    
  2. 如下也是一个死锁的例子:

    var ch1 chan int = make(chan int)
    var ch2 chan int = make(chan int)
    
    func say(s string) {
        fmt.Println(s)
        ch1 <- <- ch2 // ch1 等待 ch2流出的数据
    }
    
    func main() {
        go say("hello")
        <- ch1  // 堵塞主线
    }
    

    其中主线等ch1中的数据流出,ch1等ch2的数据流出,但是ch2等待数据流入,两个goroutine都在等,也就是死锁。

  3. 其实,总结来看,为什么会死锁?非缓冲信道上如果发生了流入无流出,或者流出无流入,也就导致了死锁。或者这样理解 Go启动的所有goroutine里的非缓冲信道一定要一个线里存数据,一个线里取数据,要成对才行 。所以下面的示例一定死锁:

    c, quit := make(chan int), make(chan int)
    
    go func() {
       c <- 1  // c通道的数据没有被其他goroutine读取走,堵塞当前goroutine
       quit <- 0 // quit始终没有办法写入数据
    }()
    
    <- quit // quit 等待数据的写
    

    仔细分析的话,是由于:主线等待quit信道的数据流出,quit等待数据写入,而func被c通道堵塞,所有goroutine都在等,所以死锁。

    简单来看的话,一共两个线,func线中流入c通道的数据并没有在main线中流出,肯定死锁。

  但是,是否果真 所有不成对向信道存取数据的情况都是死锁?

  如下是个反例:

    func main() {
        c := make(chan int)
        go func() {
           c <- 1
        }()
    }

  程序正常退出了,很简单,并不是我们那个总结不起作用了,还是因为一个让人很囧的原因,main又没等待其它goroutine,自己先跑完了, 所以没有数据流入c信道,一共执行了一个goroutine, 并且没有发生阻塞,所以没有死锁错误。

那么死锁的解决办法呢?

最简单的,把没取走的数据取走,没放入的数据放入, 因为无缓冲信道不能承载数据,那么就赶紧拿走!

  • channal 基本使用
package main

import (
	"fmt"
	"time"
)

// 处理事情,需并发执行
func worker(c chan int, i int){
	for {
		n := <- c
		fmt.Printf("workers %d received %c\n", i, n)
	}
}

//channal可以作为返回值,是一种常见的写法,里面包含一个正真做事请的goroutine
//可以对返回的chananl做进一步的修饰,用来接收数据的chan: chan<- int; 用来发出数据的chan: <-chan int
//对于下面的reateworker,若返回值是接收数据的chan,则里面的goroutine对chan必须是进行接收处理的
func creatworker(i int) chan<- int{
	c := make(chan int)
	go func(){
		for{
		 	n := <- c
			fmt.Printf("workers %d received %c\n", i, n)
	}
	}()	
	return c
}
func chandemo(){
	//只是定义了c的变量类型为chan 但具体的chan并没有做出来,c == nil
	//var c chan int   
	
	//具体做一个chan
	//v-1.0  c := make(chan int)  
	var cs [10]chan<- int
	
	//从chan中收数据必须通过一个goroutine来实现
	//v-1.0  go worker(c)
	//v-2.0
//	for i := 0; i < 10; i++ {
//		cs[i] = make(chan int)
//			go worker(cs[i], i)	
//	}
	
	//v-3.0 
	for i:= 0; i < 10; i++{
		cs[i] = creatworker(i)
	}
	
	//向chan发数据,由于channal是阻塞式的,在向chan发数据时,必须先有一个接收数据的goroutine
	//v-1.0  c <- 1
	for i := 0; i < 10; i++{
		cs[i] <- 'a'+i
	}	
	//v-1.0 c <- 2
	for i := 0; i < 10; i++ {
		cs[i] <- 'A'+i
	}	
	time.Sleep(time.Millisecond)			  	
}

//带缓冲区的channal,可以指定缓冲区的大小,在缓冲区填满之前chan中的数据不进行会发送,不需要有goroutine来接收,
//这样就不需要频繁的切换goroutine,提升了性能
func bufferedchan(){
	bc := make(chan int, 3)
	go func(){
		for{
			n := <- bc
			fmt.Printf("%d ", n)
		}
	}()
	bc <- 1
	bc <- 2
	bc <- 3 //到此chan中数据还不会进行发送
	bc <- 4	
	time.Sleep(time.Millisecond)	
}

//channal是可以关闭的,关闭操作永远是发送方进行的,用来通知接收方没有数据发了
//对发送方chan加了关闭操作后,接收方也要做关闭确认处理,否则接收方会一直接收下去
//接收方在发送方关闭后接收的是发送方类型的ZeroValue
//接收方做关闭确认有两种方式:1.!ok;  2.range
func chanClosed(){
	cc := make(chan int)
	go func(){
		for {
			//接收方的关闭确认处理
			//方式一: !ok
//			n, ok := <- cc
//			if !ok{
//				break
//			}
//			fmt.Printf("%d ", n)
			
			//方式二:range
			for n := range cc {
				fmt.Printf("%d ", n)
			}
		}
	}()
	cc <- 1
	cc <- 2
	cc <- 3
	//close(cc)
	time.Sleep(time.Millisecond)
}

func main(){
	chandemo()
	bufferedchan()
	fmt.Println()
	chanClosed()
}
  • 通过阻塞主协程来实现goroutine与主协程的通信

  默认的,信道的存消息和取消息都是阻塞的 ,也就是说, 无缓冲的信道在取消息和存消息的时候都会挂起当前的goroutine,除非另一端已经准备好。

package main

import (
	"fmt"
)

type workChan struct{
	in    chan int
	done  chan bool
}

func doWork(w workChan, id int){
		for c := range w.in {
			fmt.Printf("worker %d : %c\n", id, c)
			go func(){
				w.done <- true
			}()		
		}		
}

func creatWorker(id int) workChan {
	w := workChan{
		in :   make(chan int),
		done : make(chan bool), 
	}
	
	go doWork(w,id)
	
	return w	
}

func chandemo(){
	var ws [10]workChan
	
	for i:=0; i<10; i++{
		ws[i] = creatWorker(i)		
	}
	
	//发第一批任务
	for i:=0; i<10; i++{
		ws[i].in <- 'a'+i
	    //发完一次任务就等待回应,则任务之间就有了顺序,等上一次任务执行完收到回应后,主协程才会退出悬挂,继续往下执行
		//<-ws[i].done		
	} 
	//收第一批回复
	for i:=0; i<10; i++{
		<-ws[i].done    //主协程会在这里被阻塞,直到goroutine执行完时,主协程才会取到消息,解除阻塞
	}
	
	//发第二批任务
	for i:=0; i<10; i++{
		ws[i].in <- 'A'+i
		//<-ws[i].done
	}
	//收第二批回复
	for i:=0; i<10; i++{
		<-ws[i].done
	}
	
	//两批回复一起处理,则需要给回复的发送处,另开一个协程,
	for i:=0; i<10; i++{
		<-ws[i].done
		<-ws[i].done
	}	
}

func main() {
	chandemo()	
}
  • 对树进行遍历处理
package main

import (
	"fmt"
)

type node struct{
	data int
	left, right *node
}

func (n *node) traverse(f func(n *node)) {
	if n == nil{
		return
	}
	n.left.traverse(f)
	f(n)
	n.right.traverse(f)
}

func (n *node) traverseWithChan() chan *node {
	c := make(chan *node)
	go func(){
		n.traverse(func(n *node){
			c<- n	
		})
		close(c)
	}()
	return c	
}

func main() {
	
	root := node{0,nil,nil}
	root.left = &node{4,nil,nil}
	root.right = &node{1,nil,nil}
	root.left.left = &node{5,nil,nil}
	root.right.left = &node{3,nil,nil}
	
	root.traverse(func(n *node){
		fmt.Println(n.data)
	})
	
	maxData := 0
	for c := range root.traverseWithChan(){
		if c.data > maxData{
			maxData = c.data
		}
	}
	fmt.Println("max:",maxData)
}

4.使用select对channal进行调度 

  golang 的 select 用来监听 IO 操作,当 IO 操作发生时,触发相应的动作。在执行select语句的时候,运行时系统会自上而下地判断每个case中的发送或接收操作是否可以被立即执行(立即执行:意思是当前Goroutine不会因此操作而被阻塞)

  select的用法与switch非常类似,由select开始一个新的选择块,每个选择条件由case语句来描述。与switch语句可以选择任何可使用相等比较的条件相比,select有比较多的限制,其中最大的一条限制就是每个case语句里必须是一个IO操作,确切的说,应该是一个面向channel的IO操作

  所有channel表达式都会被求值、所有被发送的表达式都会被求值,求值顺序:自上而下、从左到右

  如果有一个或多个IO操作可以完成,则Go运行时系统会随机的选择一个执行,否则的话,如果有default分支,则执行default分支语句,如果连default都没有,则select语句会一直阻塞,直到至少有一个IO操作可以进行,使用带有 default 分支的select可以实现无阻塞channal的 I/O 操作.

  • select 的基本使用
package main

import (
	"fmt"
	"time"
	"math/rand"
)

func genetator() chan int {
	c := make(chan int)
	go func(){
		i := 0
		for{
			time.Sleep(time.Duration(rand.Intn(1500))*time.Millisecond)
			c<- i
			i++
		}
	}()
	return c
}

func main() {
	c1, c2 := genetator(), genetator()
	for{
		select {
			case n := <-c1:
				fmt.Println("from c1:",n)
			case n:= <-c2:
				fmt.Println("from c2:",n)
			default:
				fmt.Println("no value racevied")		
			
		}
	}
}
  • select中的定时器及值为 'nil' 的channal 的使用
package main

import (
	"fmt"
	"time"
	"math/rand"
)

func genetator() <-chan int {
	c := make(chan int)
	go func(){
		i := 0
		for{
			time.Sleep(time.Duration(rand.Intn(1500))*time.Millisecond)
			c<- i
			i++
		}
	}()
	return c
}

func creatWorker(id int) chan<- int {
	c := make(chan int)
	go func(i int){
		for{
			for c:=range c{
				time.Sleep(3*time.Second)  //worker 处理数据的时间为3秒
				fmt.Printf("workerID:%d, recevied:%d  ", id, c)
			}		
		}
	}(id)
	return c
}

func main() {
	c1, c2 := genetator(), genetator()
	w := creatWorker(1)
	var pool []int
	
	tm := time.After(10*time.Second)
	tick := time.Tick(time.Second)
	for{
		//注:值为'nil'的channal在select中会被阻塞
			var activityWorker chan<- int
			var data int
			if len(pool)>0 {
				activityWorker = w
				data = pool[0]
			}
		select {
			//并发的从不同的goroutine接收数据,
			//为了解决接收数据与处理数据所花时间不同,将接收到的数据放到一个数据池中
			case n := <-c1:
				pool = append(pool, n)
				fmt.Println("from c1:",n)
			case n:= <-c2:
				pool = append(pool, n)
				fmt.Println("from c2:",n)
				
			//对接收到的数据进行处理
			case activityWorker<-data: //这里,直接传pool[0]给activityWorker会报错"index out of range",
			     pool = pool[1:]       //原因是:所有channel表达式都会被求值、所有被发送的表达式都会被求值,
		                               //求值顺序:自上而下、从左到右
		                               
		    //在select内进行超时判断                          
		    case <-time.After(800*time.Millisecond):
				fmt.Println("time out")
				
			//通过定时器,对系统内的一些数据进行查看
			case <-tick:
				fmt.Println("num of pool: ",len(pool))
				
			//结束
			case <-tm:
				fmt.Println("bye")
				return	
//			default:
//				fmt.Println("no value racevied")					
		}
	}
}

5.go中传统同步机制

  go中除了CSP模型外,还存在传统的同步机制,如 WaitGroup, Mutex, Cond

原子操作:

  原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切换到另一个线程.原子操作是不可分割的,在执行完毕之前不会被任何其它任务或事件中断 .意思就是这种操作是单位级的操作,执行过程绝对不受影响,执行结果一定.

mutex的简单使用:

package main

import (
	"sync"
	"fmt"
	"time"
)

//实现一个线程安全的(atomic)int,go中有自带.
type atomicInt struct{
	value int
	lock sync.Mutex
}

func (a *atomicInt) increment() {
//	a.lock.Lock()
//	defer a.lock.Unlock()
//	a.value++
	
	//对一个代码块实现锁,可以通过一个匿名函数实现
	fmt.Println("mutex..")
	func(){
		a.lock.Lock()
		defer a.lock.Unlock()
		a.value++
	}()
}

func (a *atomicInt) get() int{
	a.lock.Lock()
	defer a.lock.Unlock()
	return a.value
}

func main() {
	var a atomicInt
	a.increment()
	go func(){
		a.increment()
	}()
	
	time.Sleep(time.Millisecond)
	fmt.Println(a.value)
}

 

参考:探索并发编程(一)------操作系统篇

   Go语言 Goroutine 浅析

   协程(Coroutine)并不是真正的多线程

   协程

   golang中并发sync和channel

           golang语言并发与并行——goroutine和channel的详细理解(一)

你可能感兴趣的:(GoLang)