go学习 ------ 并发

go并发


1.相关概念

进程/线程
进程是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。

​ 线程是进程的一个执行实体,是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。

​ 一个进程可以创建和撤销多个线程,同一个进程中的多个线程之间可以并发执行。

并发/并行
多线程程序在单核心的 cpu 上运行,称为并发;多线程程序在多核心的 cpu 上运行,称为并行。

​ 并发与并行并不相同,并发主要由切换时间片来实现“同时”运行,并行则是直接利用多核实现多线程的运行,Go程序可以设置使用核心数,以发挥多核计算机的能力。

协程/线程
协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。

​ 线程:一个线程上可以跑多个协程,协程是轻量级的线程。

2.Goroutine

1) goroutine 其实就是线程,但是它比线程更小,十几个 goroutine 可能体现在底层就是五六个线程,而且Go语言内部也实现了 goroutine 之间的内存共享。

一个函数可以被创建多个 goroutine,一个 goroutine 必定对应一个函数。

//go 关键字放在方法调用前新建一个 goroutine 并执行方法体
//使用 go 关键字创建 goroutine 时,被调用函数的返回值会被忽略。如果需要在 goroutine 中返回数据,请使用后面介绍的通道(channel)特性,通过通道把数据从 goroutine 中作为返回值传出。
go GetThingDone(param1, param2);

//新建一个匿名方法并执行
go func(param1, param2) {
}(val1, val2)

//直接新建一个 goroutine 并在 goroutine 中执行代码块
go {
    //do someting...
}

goroutine 是 Go语言中的轻量级线程实现,由 Go 运行时(runtime)管理。Go 程序会智能地将 goroutine 中的任务合理地分配给每个 CPU。

3.channel
1)使用make进行创建

​ channel 是类型相关的,也就是说,一个 channel 只能传递一种类型的值,这个类型需要在声明 channel 时指定。

​ 通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。

ci := make(chan int)
cs := make(chan string)
cf := make(chan interface{})
2)无缓冲通道
a.发送数据
//通道变量 <- 值

// 创建一个空接口通道
ch := make(chan interface{})

// 将0放入通道中
ch <- 0

// 将hello字符串放入通道中
ch <- "hello"
b.发送阻塞
package main
func main() {
    // 创建一个整型通道
    ch := make(chan int)
    // 尝试将0通过通道发送
    ch <- 0
}

//报错:fatal error: all goroutines are asleep - deadlock!

运行时发现所有的 goroutine(包括main)都处于等待 goroutine。

c.接受数据
//1.阻塞接收数据
//直到接收数据并赋值给data
data := <-ch

//2.非阻塞接收数据
//data:表示接收到的数据。未接收到数据时,data 为通道类型的零值。ok:表示是否接收到数据
//非阻塞的通道接收方法可能造成高的 CPU 占用,因此使用非常少。
data,ok := <-ch

//3.接收任意数据,忽略接收的数据
//执行该语句时将会发生阻塞,直到接收到数据,但接收到的数据会被忽略。
//这个方式实际上只是通过通道在 goroutine 间阻塞收发实现并发同步
func main() {
    // 构建一个通道
    ch := make(chan int)
    // 开启一个并发匿名函数
    go func() {
        fmt.Println("start goroutine")       
        ch <- 0               // 通过通道通知main的goroutine
        fmt.Println("exit goroutine")
    }()
    fmt.Println("wait goroutine")
    <-ch                    // 等待匿名goroutine
    fmt.Println("all done")
}

//4.循环接收
func main() {
   
    ch := make(chan int)     // 构建一个通道    
    go func() {     // 开启一个并发匿名函数
        for i := 3; i >= 0; i-- {           
            ch <- i     // 发送3到0之间的数值           
            time.Sleep(time.Second)   // 每次发送完时等待
        }
    }()
    
    for data := range ch {    // 遍历接收通道数据        
        fmt.Println(data)     // 打印通道数据       
        if data == 0 {        // 当遇到数据0时, 退出接收循环
                break
        }
    }
}
d.单向通道
//声明
ch := make(chan int)

// 声明一个只能发送的通道类型, 并赋值为ch
var chSendOnly chan<- int = ch

//声明一个只能接收的通道类型, 并赋值为ch
var chRecvOnly <-chan int = ch
e.关闭
close(ch)

//带返回值
x, ok := <-ch
3)带缓冲通道

​ 只有在通道中没有要接收的值时,接收动作才会阻塞。只有在通道没有可用缓冲区容纳被发送的值时,发送动作才会阻塞。

a.创建
//通道实例 := make(chan 通道类型, 缓冲大小)
ch := make(chan int, 3)
fmt.Println(len(ch))    // 查看当前通道的大小,此时为0
4)超时机制

select 机制不是专门为超时而设计的,却能很方便的解决超时问题,因为 select 的特点是只要其中有一个 case 已经完成,程序就会继续往下执行,而不会考虑其他 case 的情况。

​ select 有比较多的限制,其中最大的一条限制就是每个 case 语句里必须是一个 IO 操作

select {
    case <-chan1:
    // 如果chan1成功读到数据,则进行该case处理语句
    case chan2 <- 1:
    // 如果成功向chan2写入数据,则进行该case处理语句
    default:
    // 如果上面都没有成功,则进入default处理流程
}

在一个 select 语句中,Go语言会按顺序从头至尾评估每一个发送和接收的语句。

如果其中的任意一语句可以继续执行(即没有被阻塞),那么就从那些可以执行的语句中任意选择一条来使用。

如果没有任意一条语句可以执行(即所有的通道都被阻塞),那么有如下两种可能的情况:

  • 如果给出了 default 语句,那么就会执行 default 语句,同时程序的执行会从 select 语句后的语句中恢复;
  • 如果没有 default 语句,那么 select 语句将被阻塞,直到至少有一个通信可以进行下去。
func main() {
    ch := make(chan int)
    quit := make(chan bool)
    //新开一个协程
    go func() {
        for {
            select {
            case num := <-ch:
                fmt.Println("num = ", num)
            case <-time.After(3 * time.Second):
                fmt.Println("超时")
                quit <- true
            }
        }
    }() //别忘了()
    for i := 0; i < 5; i++ {
        ch <- i
        time.Sleep(time.Second)
    }
    <-quit
    fmt.Println("程序结束")
}
4.并发通信模型

​ 在工程上,有两种最常见的并发通信模型:共享数据和消息。

​ 共享数据是指多个并发单元分别保持对同一个数据的引用,实现对该数据的共享。被共享的数据可能有多种形式,比如内存数据块、磁盘文件、网络数据等。在实际工程应用中最常见的无疑是内存了,也就是常说的共享内存

5.锁
1)atmoic包

​ AddInt64 ()同步整型值的加法,强制同一时刻只能有一个 gorountie 运行并完成这个加法操作

 atomic.AddInt64(&counter, 1) //安全的对counter加1

​ LoadInt64 ()安全地读

​ StoreInt64 ()安全地写一个整型值

2)sync 包

Go语言包中的 sync 包提供了两种锁类型:sync.Mutex 和 sync.RWMutex。

Mutex (互斥锁)

​ Mutex 是最简单的一种锁类型,同时也比较暴力,当一个 goroutine 获得了 Mutex 后,其他 goroutine 就只能乖乖等到这个 goroutine 释放该 Mutex。

(调用 Lock() 方法加锁,Unlock()解锁)

RWMutex (读写互斥锁)

​ RWMutex 是经典的单写多读模型。在读锁占用的情况下,会阻止写,但不阻止读,也就是多个 goroutine 可同时获取读锁会阻止任何其他 goroutine(无论读和写)进来,整个锁相当于由该 goroutine 独占。

(调用 RLock() 方法加锁,RUnlock()解锁)

任何一个 Lock() 或 RLock() 均需要保证对应有 Unlock() 或 RUnlock() 调用与之对应,否则可能导致等待该锁的所有 goroutine 处于饥饿状态,甚至可能导致死锁。

经典使用模式

package main
import (
    "fmt"
    "sync"
)
var (
    // 逻辑中使用的某个变量
    count int
    // 与变量对应的使用互斥锁
    countGuard sync.Mutex
)
func GetCount() int {
    // 锁定
    countGuard.Lock()
    // 在函数退出时解除锁定
    defer countGuard.Unlock()
    return count
}
func SetCount(c int) {
    countGuard.Lock()
    count = c
    countGuard.Unlock()
}
func main() {
    // 可以进行并发安全的设置
    SetCount(1)
    // 可以进行并发安全的获取
    fmt.Println(GetCount())
}

​ 一旦 countGuard 发生加锁,如果另外一个 goroutine 尝试继续加锁时将会发生阻塞,直到这个 countGuard 被解锁。

3)死锁

​ 死锁是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

死锁的条件:

  • 互斥条件

    线程对资源的访问是排他性的,如果一个线程对占用了某资源,那么其他线程必须处于等待状态,直到该资源被释放。

  • 请求和保持条件

    线程 T1 至少已经保持了一个资源 R1 占用,但又提出使用另一个资源 R2 请求,而此时,资源 R2 被其他线程 T2 占用,于是该线程 T1 也必须等待,但又对自己保持的资源 R1 不释放。

  • 不剥夺条件

    线程已获得的资源,在未使用完之前,不能被其他线程剥夺,只能在使用完以后由自己释放。

  • 环路等待条件

    在死锁发生时,必然存在一个“进程 - 资源环形链”,即:{p0,p1,p2,…pn},进程 p0(或线程)等待 p1 占用的资源,p1 等待 p2 占用的资源,pn 等待 p0 占用的资源。

死锁的解决办法:

  • 如果并发查询多个表,约定访问顺序;
  • 在同一个事务中,尽可能做到一次锁定获取所需要的资源;
  • 对于容易产生死锁的业务场景,尝试升级锁颗粒度,使用表级锁;
  • 采用分布式事务锁或者使用乐观锁。
4)活锁

​ 当多个相互协作的线程都对彼此进行相应而修改自己的状态,并使得任何一个线程都无法继续执行时,就导致了活锁。

​ 活锁通常发生在处理事务消息中,如果不能成功处理某个消息,那么消息处理机制将回滚事务,并将它重新放到队列的开头。这样,错误的事务被一直回滚重复执行,这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误地将不可修复的错误认为是可修复的错误。

​ 要解决这种活锁问题,需要在重试机制中引入随机性。

5)饥饿

​ 饥饿是指一个可运行的进程尽管能继续执行,但被调度器无限期地忽视,而不能被调度执行的情况。饥饿通常意味着有一个或多个贪婪的并发进程,它们不公平地阻止一个或多个并发进程,以尽可能有效地完成工作,或者阻止全部并发进程。

​ 与死锁不同的是,饥饿锁在一段时间内,优先级低的线程最终还是会执行的,比如高优先级的线程执行完之后释放了资源。

6.sync.WaitGroup

​ 主线程为了等待goroutine都运行完毕,不得不在程序的末尾使用time.Sleep() 来睡眠一段时间,等待其他线程充分运行。但是对于实际生活的大多数场景来说,1秒是不够的,并且大部分时候我们都无法预知for循环内代码运行时间的长短。

​ WaitGroup 对象内部有一个计数器,最初从0开始,它有三个方法:Add(), Done(), Wait() 用来控制计数器的数量。

Add(n) 把计数器设置为n

Done() 每次把计数器-1

Wait() 会阻塞代码的运行,直到计数器地值减为0。如果此时 wg 维护的计数为零,则此 wg.Wait() 此操作为一个空操作(noop)

​ WaitGroup对象不是一个引用类型,在通过函数传值的时候需要使用地址:*wg sync.WaitGroup

import(
	"fmt"
	"net/http"
	"sync"
)

func main(){
	var wg sync.WaitGroup

	var urls=[]string{
		"https://www.github.com/",
		"https://www.baidu.com/",
		"https://www.qq.com/",
	}
	for _,url := range urls{
		wg.Add(1)
		go func(url string){
			defer wg.Done()
			_,err := http.Get(url)  //使用http访问提供的地址
			fmt.Println(url,err)    //打印地址和可能发生的错误
		}(url)
	}
	wg.Wait()   //等待所有的任务完成后停止阻塞
	fmt.Printf("over")
}
7.runtime.Gosched()

​ 这个函数的作用是让当前goroutine让出CPU,好让其它的goroutine获得执行的机会。同时,当前的goroutine也会在未来的某个时间点继续运行。

//未使用runtime.Gosched()
package main
import (
	"fmt"
	//"runtime"
)
func say(s string) {
	for i := 0; i < 2; i++ {
		fmt.Println(s, i)
	}
}
func main() {
	go say("world")
	say("hello")
}
//执行输出:  
//hello 0
//hello 1
//主线程运行太快
//使用runtime.Gosched()
package main
import (
	"fmt"
	"runtime"
)
func say(s string) {
	for i := 0; i < 2; i++ {
		runtime.Gosched()
		fmt.Println(s, i)
	}
}
func main() {
	go say("world")
	say("hello")
}
8.资源争用
package main

import (
	"fmt"
	"runtime"
	"sync"
)

var (
	count int32
	wg  sync.WaitGroup
)

func incCount(){
	defer wg.Done()   //计数器-1
	for i:=0;i<2;i++{
		value:=count
		runtime.Gosched()    //让出CPU时间片
		value++
		count=value
	}
}

func main(){
	wg.Add(2)    //计数器设为2
	go incCount()
	go incCount()
	wg.Wait()      //wait()会阻塞代码的运行,直到计数器地值减为0
	fmt.Println(count)
}
  • g1 读取到 count 的值为 0;
  • 然后 g1 暂停了,切换到 g2 运行,g2 读取到 count 的值也为 0;
  • g2 暂停,切换到 g1,g1 对 count+1,count 的值变为 1;
  • g1 暂停,切换到 g2,g2 刚刚已经获取到值 0,对其 +1,最后赋值给 count,其结果还是 1;
  • 可以看出 g1 对 count+1 的结果被 g2 给覆盖了,两个 goroutine 都 +1 而结果还是 1。

通过上面的分析可以看出,之所以出现上面的问题,是因为两个 goroutine 相互覆盖结果。

9.GOMAXPROCS

​ Go语言程序运行时(runtime)实现了一个小型的任务调度器

​ 一般情况下,可以使用 runtime.NumCPU() 查询 CPU 数量,并使用 runtime.GOMAXPROCS() 函数进行设置

runtime.GOMAXPROCS(runtime.NumCPU())

​ Go语言在 GOMAXPROCS 数量与任务数量相等时,可以做到并行执行,但一般情况下都是并发执行。

10.time.Sleep和time.Tick

Sleep是使用睡眠完成定时任务,需要被调度唤醒。

Tick函数是使用channel阻塞当前协程,完成定时任务的执行。

for range time.Tick(30 * time.Millisecond) {
    repaint()
}

​ time.Tick()返回的是一个channel,每隔指定的时间会有数据从channel中出来,for range不仅能遍历map,slice,array还能取出channel中数据,range前面可以不用变量接收,所以可以简写成上面的形式。

for {
    time.Sleep(30 * time.Millisecond)
    repaint()
}
11.同步方式
1)互斥量 sync.Mutex
2)无缓存通道
3)带缓存通道
4)sync.WaitGroup
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值