golang并发(1)

golang在启动并发的方式上直接添加了语言级的关键字就可以实现,和其他编程语言相比更加轻量。

一、goroutine(协程)

  1. 一些概念

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

线程:支持进程运行的一个执行实体,是cpu调度的最小单位。一个进程可以启动多个线程。

并发:单核cpu通过对线程的调度(分时间片运行)实现对外的同时运行。

并行:多核cpu场景下, 一个cpu运行一个线程。

开发者需要维护线程池中线程与 CPU 核数的对应关系,以充分利用多核CPU,go中使用的是下面方法

runtime.GOMAXPROCS(runtime.NumCPU())

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

从main函数开始,go程序就会为main函数创建一个默认的goroutine

默认goroutine都是运行在同一个cpu上的,除非在main函数创建goroutine之前用上述方法设置好使用的cpu核数

2.创建一个goroutine

三种方式创建:

//使用go 调用已有函数
go 函数名( 参数列表 )

//使用匿名函数
go func( 参数列表 ){
    函数体
}( 调用参数列表 )

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

所有的goroutine会在main函数结束后终止。

goroutine在调度性能上没有线程细致,其细致程度取决于goroutine调度器的实现和运行环境

二、goroutine之间的并发通信

并发通信最常见的就是数据共享和利用消息机制。

数据在协程间的共享需要在每次使用数据前加锁,使用后释放锁,如果变量很多,锁很多,反而会让代码显得臃肿,可读性差(go没有synchronized、volatile这种关键字操作,也没有java的生态去操作),于是go用的是消息机制来实现协程间通信,这样每个协程只需要关系业务逻辑,不用关心共享变量的锁,这种机制称为channel。

线程与协程的区别:

线程是系统调度的基本单位。go协程由go语言运行时的调度器进行调度,操作系统内核感知不到协程的存在

在多核处理场景中,线程是并发与并行同时存在的,而go协程依托于线程,因此多核处理场景下,go协程也是并发与并行同时存在的。因为go协程从属于某一个线程,所以即便在单核处理器上某一时刻运行一个线程,在线程内go语言调度器也会切换多个协程执行,这时协程是并发的。在多核心处理器上,如果多个协程被分配给了不同的线程,而这些线程同时被不同的CPU核心所处理,这时协程就是并行处理的。

go协程与线程存在着很多不同之处:

1、调度方式

线程: 线程是根据CPU时间片进行抢占式调度的。操作系统通过中断信号(定时器中断、I/O设备中断等)执行线程的上下文切换。当发生线程上下文切换时,需要从操作系统用户态转移到内核态,并保存状态信息;当切换到下一个要执行的线程时,需要加载状态信息并从内核态转移到操作系统用户态。

协程: 协程存在于用户态,由go语言运行时调度器进行调度。协程从属于某一个线程,多个协程可以调度到一个线程中,一个协程也可能切换到多个线程中执行,因此协程与线程是多对多(M:N)的关系。

2、调度策略

线程: 抢占式调度。操作系统调度器为了均衡每个线程的执行周期,会定时发出中断信号强制执行线程上下文切换。

协程: 协作式调度。一个协程处理完自己的任务后,可以主动将执行权限让渡给其他协程,不会被轻易抢占。只有在协程运行了过长时间后,go语言调度器才会强制抢占其执行。

3、上下文切换速度

线程: 线程上下文的切换需要经过操作系统用户态与内核态的切换,切换速度大约为1~2微秒。

协程: 协程属于用户态轻量级的线程,协程的切换不需要经过用户态与内核态的切换,且切换时只需要保存极少的状态值,因此切换速度快数倍,大约为0.2微秒左右。(大约10倍于线程的切换速度)

4、栈的大小

线程: 线程的栈大小一般是在创建时指定的,linux及mac上默认的栈大小一般为8MB(可以通过ulimit -s查看)。2000个线程需要消耗16G虚拟内存。

协程: go协程栈大小默认为2KB, 16G虚拟内存可以创建800多万个协程。在实践中,经常可以看到存在成千上万的协程。

GMP调度模型:

Go里面GMP分别代表:G:goroutine,M:线程(真正在CPU上跑的),P:调度器。

调度器是M和G之间桥梁。

go进行调度过程:

1、某个线程尝试创建一个新的G,那么这个G就会被安排到这个线程的G本地队列LRQ中,如果LRQ满了,就会分配到全局队列GRQ中;
2、尝试获取当前线程的M,如果无法获取,就会从空闲的M列表中找一个,如果空闲列表也没有,那么就创建一个M,然后绑定G与P运行。
3、进入调度循环:
    找到一个合适的G
    执行G,完成以后退出

三、goroutine之间的竞争

有并发,就有竞争。

常见的例子就是一段程序做value++,启动两个协程运行这段程序,结束后查看value结果

那么在go中,可以使用go build -race生成一个可执行文件,运行它,便可输出整个协程的运行过程和竞争情况

但其实,go也提供了一些公共库来实现变量的加锁、原子操作这些,例如atomic 和 sync

atomic.AddInt64(&counter, 1) //安全的对counter加1
atomic.LoadInt64(&counter)   //安全的读取counter
atomic.StoreInt64(&counter)  //安全的写入counter

互斥锁:

var mutex sync.Mutex
//加锁
mutex.Lock()
//释放
mutex.Unlock()

四、通道channel

channels 是协程间通信的机制。一个 channels 是一个通信机制,它可以让一个 goroutine 通过它给另一个 goroutine 发送信息

  1. 通道特性

在任何时候,同时只能有一个 goroutine 访问通道进行发送和获取数据,通道即一个队列,总是遵循先入先出的规则,保证收发数据的顺序。

2. 声明

var chanName chan int

3.初始化

chanName := make(chan int)

//定义一个结构体
type Equip struct{ 
    a int
    c string
}
ch2 := make(chan *Equip) 

//可以是interface{}类型
chanName := make(chan interface{})
ch <- 0
ch <- "hello"

4.使用channel收发数据

(1)发

通道变量 <- 值
chanName <- 1
值可以是表达式、函数返回值等等,只要类型没错就行

(2)收

通道每次只能接收一个数据。

data := <-ch
data, ok := <-ch //没有值时,data为ch中数据类型的零值,ok表示是否接收到数据,一般用于接收超时检测,和select、计时器一起使用,不单独使用
<-ch //忽略值

//循环接收
for data := range ch {
  //一定要写停止循环语句,否则其他goroutine发送完了之后,这里还一直在接收,会触发宕机报错
}

(3)收发对应(无缓冲通道)

某个goroutine发出数据后,当前goroutine中的程序会一直阻塞,直到消息被接收才会继续向下运行,但其他的goroutine这时候还可以向通道内发送数据

所以,收发不能单独存在,且要在不同的goroutine中进行

func main() {
    // 创建一个整型通道
    ch := make(chan int)
    // 尝试将0通过通道发送
    ch <- 0
}

这个代码会报错,因为ch没有被接收

5.单向通道

var 通道实例 chan<- 元素类型    // 只能写入数据的通道
var 通道实例 <-chan 元素类型    // 只能读取数据的通道

但是,如果一个通道只写不读,那写进去又有什么意义呢?同样的,一个通道只读不写,那消息来源在哪里呢?

所以,单向通道是成对存在的

ch := make(chan int)
// 声明一个只能写入数据的通道类型, 并赋值为ch
var chSendOnly chan<- int = ch
//声明一个只能读取数据的通道类型, 并赋值为ch
var chRecvOnly <-chan int = ch
//如果向chRecvOnly中写数据,会报错,同理chSendOnly

//如果直接创建一个只读通道,虽然没有写操作,但程序运行是没有问题的,只是没有任何意义
ch := make(<-chan int)

timer包中的计时器操作,就会有一个只读不写的单向通道

6.关闭通道

close(ch)

验证:

//当ok为false时,说明通道关闭,未读取到x时会阻塞,读取到x时ok=true
x, ok := <-ch

7.无缓冲的通道

指在接收前没有能力保存任何值的通道,要求收发的goroutine同时准备好,否则就会阻塞,即前面说的收发对应。常用于一收一发、某件事的传递

类似于手递手传东西,有一方没准备好,另一方都会等待

ch := make(chan int)

8.有缓冲的通道

指可以接收一个或多个值,这种情况下,只有当通道中无内容时,收操作才会阻塞;也只有当通道满了,发操作才会阻塞,并不像无缓冲通道一样,需要对应。不要求收发的goroutine都准备好

类似于通过快递传东西,发送的只需要把数据扔给快递就行了。

ch := make(chan int, 4)   //要写容量
其实无缓冲通道也就是容量为1的有缓冲通道

9.为什么通道必须限制长度?

因为通道是用来数据交换的,如果不限制长度,意味着goroutine可以一直收发消息,当发的速度大于收时,通道内的数据将不断膨胀,占用的内存也会不断增加,直到应用崩溃

五、通道的使用

1.超时机制(select)

select {
    case <-chan1:
    // 如果chan1成功读到数据,则进行该case处理语句
    case chan2 <- 1:
    // 如果成功向chan2写入数据,则进行该case处理语句
    default:
    // 如果上面都没有成功,则进入default处理流程
    // 如果没有写default,select就会阻塞,一直到case中有一个可以被执行
}

与switch相比,select有一些限制,最大的限制就是其case中必须是一个io操作。

超时的写法就是加一个time的case

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("程序结束")
}

2.多路复用

使用对讲机时,需要按下通话按钮,才能说话(发消息),松开按钮才能听到别人的说话(收消息),这就是单边通信

而电话可以一边说,一边听,是双向通信的,电话是一种多路复用的设备

电脑联网后,可以同时上传和下载,也是多路复用

即可以同时收发就可实现多路复用

go中的多路复用,同样是使用select语句

select{
    case 操作1:
        响应操作1
    case 操作2:
        响应操作2
    …
    default:
        没有操作情况
}

其中,case的条件既可以收 <- ch,也可以发ch <- 100;

ch := make(chan int, 1)
for {    
    select {        
        case ch <- 0:        
        case ch <- 1:    
    }    
    i := <-ch    
    fmt.Println("Value received:", i)
}

如果有多个case都满足,会随机执行一个

3.缓冲通道中,如果发送方的消息全部发完了,接收方还没处理完,发送方可以结束goroutine吗?

不可以的,发送方会等待通道内消息全部接收并处理完,才会接收本goroutine,缓冲通道还有数据前,本goroutine会阻塞的

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值