• 设为首页
  • 点击收藏
  • 手机版
    手机扫一扫访问
    迪恩网络手机版
  • 关注官方公众号
    微信扫一扫关注
    公众号

Go中的并发编程和goroutine

原作者: [db:作者] 来自: [db:来源] 收藏 邀请

并发编程对于任何语言来说都不是一件简单的事情。Go在设计之初主打高并发,为使用者提供了goroutine,使用的方式虽然简单,但是用好却不是那么容易,我们一起来学习Go中的并发编程。

1. 并行和并发

并行(parallel): 指在同一时刻,有多条指令在多个处理器上同时执行。

并发(concurrency): 指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,通过cpu时间片轮转使多个进程快速交替的执行。

2. 进程和线程

进程: 我们在操作系统中的每一次操作相当于触发了一个进程,打开一个浏览器,点开任务管理器,等等。

线程: 轻量级的进程,本质仍是进程 。独立地址空间,拥有PCB,最小分配资源单位,可看成是只有一个线程的进程;而线程是程序的最小的执行单位,有独立的PCB,线程拥有自己的栈空间,但没有独立的地址空间 ,共享当前进程的地址空间。

对操作系统来说,线程是最小的执行单元,进程是最小的资源管理单元。

无论进程还是线程,都是由操作系统所管理的。

3.协程

协程: coroutine,协同式程序。协程不是轻量级的线程, 协程与线程的关系并不像是线程与进程的关系。

先了解一些概念:

CPU切换

在每个任务运行前, CPU 都需要知道任务从哪里加载,又从哪里开始运行。也就是说,需要系统事先给他设置好 CPU 寄存器和程序计数器(Program Counter,PC)

  • CPU 寄存器:是 CPU 内置的容量小、但速度极快的内存

  • 程序计数器:是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置

它们都是 CPU 在运行任何任务前,必须依赖的环境,因此也被叫做 CPU 上下文。

上下文切换:就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

根据任务的不同,又分为进程上下文切换线程上下文切换中断上下文切换

进程的上下文切换

进程的运行态:

Linux 按照特权等级,把进程的运行空间分为内核空间用户空间 。在这两种空间中运行的进程状态分别称为内核态用户态

  • 内核空间(Ring 0):具有最高权限,可以直接访问所有资源
  • 用户空间(Ring 3):只能访问受限资源,不能直接访问内存等硬件设备,必须通过系统调用进入到内核中,才能访问这些特权资源

系统调用:

从用户态到内核态的转变,需要通过系统调用来完成。比如查看文件时,需要执行多次系统调用:open、read、write、close等。系统调用的过程如下:

  1. 把 CPU 寄存器里原来用户态的指令位置保存起来;
  2. 为了执行内核代码,CPU 寄存器需要更新为内核态指令的新位置,最后跳转到内核态运行内核任务;
  3. 系统调用结束后,CPU 寄存器需要恢复原来保存的用户态,然后再切换到用户空间,继续运行进程;

所以,一次系统调用的过程,其实是发生了两次 CPU 上下文切换。

什么是进程上下文切换:

  • 进程执行终止,它之前顺颂的CPU就会被释放出来,这时就从就绪队列中取出下一个等待时间片的进程;
  • 当某个进程的时间片耗尽,它就会被系统挂起,切换到其他等待CPU的进程运行;
  • 某个进程因为需要的系统资源比较大(比如内存不足),这时候该进程会被挂起,系统会调度其他进程执行;
  • 当有优先级更高的进程(系统操作进程)需要时间片,为了保证优先级更高的进程能够执行,当前进程会被挂起;
  • 如果当前进程中有sleep函数,他也会被挂起;

进程上下文切换和系统调用的区别:

  • 进程上下文切换:进程之间的切换,从一个进程切换到另一个;进程是由内核来管理和调度,进程的切换只发生在内核态;进程上下文切换过程中,需要将该进程的虚拟内存、栈、全局变量保存起来,以供下次使用;
  • 系统调用:是在一个进程中的进程状态切换;切换过程中无需改变进程的虚拟内存,栈,全局变量等相关信息,从内核态到用户态相当于新开辟了一块虚拟内存;
线程的上下文切换

线程是调度的基本单位,而进程则是资源拥有的基本单位。 当进程只有一个线程时,可以认为进程就等于线程。 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源。这些资源在上下文切换时是不需要修改的。线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。

线程上下文切换有两种情况:

  1. 前后两个线程属于不同进程,因为资源不共享,所以切换过程就跟进程上下文切换是一样的;
  2. 前后两个线程属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。
中断上下文切换

中断处理会打断进程的正常调度和执行。在打断其他进程时,需要将进程当前的状态保存下来,中断结束后,进程仍然可以从原来的状态恢复运行。

中断上下文切换并不涉及到进程的用户态。所以,即便中断过程打断了一个正处在用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户态资源。中断上下文,其实只包括内核态中断服务程序执行所必须的状态,包括 CPU 寄存器、内核堆栈、硬件中断参数等。

在有线程的前提下,提出来协程,它到底解决了什么问题呢?

我们知道线程的出现是为了减小进程的切换开销,提高多核的利用率。当程序运行到某个IO发送阻塞的时候,可以切换到其他线程去执行,这样不会浪费CPU时间。而线程的切换完全是通过操作系统去完成的,切换的时候一般会通过系统从用户态切换到内核态。这段话的重点是,线程是内核态的。

我们常见的代码逻辑都是被封装在一个个函数块里面。每次传递一个参数,这个函数就会从头到尾执行一遍,有对应的输出。如果在执行的过程中,发生了线程的抢占切换,那么当前线程就会保存函数当前的上下文信息(放到寄存器里面),去执行其他线程的逻辑。当这个线程重新执行时会根据之前保存的上下文信息继续执行。这段话的重点是,线程的切换需要保存函数的上下文信息。

而且现代操作系统一般都是抢占式的,所以多个线程在执行的时候在什么时候切换我们是无法控制的。所以,多线程编程时为了保证数据的准确性与安全性,我们经常需要加锁。这段话的重点是,线程的执行顺序我们无法控制,什么时候切换我们也几乎无法控制。

由于线程在运行时经常会由于IO阻塞(或者时钟阻塞)而放弃CPU,会导致我们的逻辑不能流畅的执行下去。所以,我们一般采用异步+回调的方式去执行代码。当线程与到阻塞时直接返回,继续执行下面的逻辑。同时注册一个回调函数,当内核数据准备好了之后再通知我们。这种写代码的方式其实不够直观,因为我们一般都习惯顺序执行的逻辑,一段代码能从头跑到尾那是再理想不过了。这段话的重点是,涉及到IO阻塞的多线程编程时,我们一般用异步+回调的方式来解决问题。

协程是用户态的,他是包含在线程里面的,简答来说你可以认为一个线程可以按照你的规则把自己的时间片分给多个协程去执行。

因为一个线程里面可能有多个协程,所以协程的执行需要切换,切换就需要保存当前的上下文信息(一组寄存器和调用堆栈,保存在自身的用户空间内),这样才能在再次执行的时候继续前面的工作。相比线程,协程要保存的东西都很少。

相比线程,协程的切换时机是可以控制的。我们可以告诉协程,代码执行到哪句的时候切换到哪个协程,这样就可以避免线程执行不确定性带来的安全问题,避免了各种锁机制带来的相关问题。

协程的代码看起来是同步的,不需要回调。比如说有两个协程,A协程执行到第3句就一定会切换到B协程的第4句,假如A与B里面都有循环,那展开来看其实就是A与B函数不断的顺序执行。这种感觉有点像并发,同样在一个线程上的A与B不断的切换去执行逻辑。

协程不过是一种用户级别的实现手段,他并不像线程那样有明确的概念与实体,更像是一个语言技巧。他的切换开销很小。

协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此:协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。在并发编程中,协程与线程类似,每个协程表示一个执行单元,有自己的本地数据,与其它协程共享全局数据和其它资源。目前主流语言基本上都选择了多线程作为并发设施,与线程相关的概念是抢占式多任务(Preemptive multitasking),而与协程相关的是协作式多任务

不管是进程还是线程,每次阻塞、切换都需要陷入系统调用(system call),先让CPU跑操作系统的调度程序,然后再由调度程序决定该跑哪一个进程(线程)。而且由于抢占式调度执行顺序无法确定的特点,使用线程时需要非常小心地处理同步问题,而协程完全不存在这个问题(事件驱动和异步程序也有同样的优点)。

协作式的任务,是要用户自己来负责任务的让出的。如果一个任务不主动让出,其他任务就不会得到调度。这是协程的一个弱点,但是好好的规划,这其实是一个可以变得很强大的优点。

总结一下上面的重点:

1.多线程处理,叫做抢占式多任务处理;多协程处理,叫做协作式多任务处理。

2.历史上是先有协程,但是因为它是非抢占式的,导致多任务时间片不能公平分享,所以后来全部废弃了协程改成抢占式的线程。

3.协程是用户态的,是包含在一个线程里面的多个执行单元。意味他是单线程处理的过程。(比如你的main函数比协程修饰的函数先停止,那么协程是没有执行完的)协程都没有参与多核CPU的并行处理。而线程是在多核 CPU上是受操作系统调度并行执行的。

4.由于协程可以在用户空间内切换上下文,不再需要陷入内核来做线程切换,避免了大量的用户空间和内核空间之间的数据拷贝,降低了CPU的消耗,从而避免了追求高并发时CPU早早到达瓶颈的窘境 。

5.协程本质还是单线程下处理多任务,单线程的瓶颈也是协程的瓶颈。我觉得协程最大的意义就是可以用同步方式编写异步代码 。

4. Go并发

goroutine是Go并行设计的核心。 一般会使用goroutine来处理并发任务 。goroutine是go语言中最为NB的设计,也是其魅力所在,goroutine的本质是协程,是实现并行计算的核心。它是处于异步方式运行,你不需要等它运行完成以后在执行以后的代码。

Goroutine是建立在线程之上的轻量级的抽象。它允许我们以非常低的代价在同一个地址空间中并行地执行多个函数或者方法。相比于线程,它的创建和销毁的代价要小很多,并且它的调度是独立于线程的。在Go中创建一个goroutine非常简单,使用“go”关键字即可:

go hello(str)

先来看一个简单的例子:

package main

import (
	"time"
)

func Print() {
	for i := 1; i <= 5; i++ {
		time.Sleep(100 * time.Millisecond)
		println(i)
	}
}

func HelloWorld() {
	println("Hello world")
}

func main() {
	go Print()    // 开启第一个goroutine
	go HelloWorld()    // 开启第二个goroutine
	time.Sleep(2*time.Second)
	println("end")
}

打印:
Hello world
1
2
3
4
5
end

4.1 Go中的CountDownLatch

CountDownLatch是Java中的一个同步辅助类,在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待。

在Go中可以使用sync包中的WaitGroup来实现一样的功能,WaitGroup 等待一组goroutinue执行完毕,主程序调用 Add 添加等待的goroutinue数量,每个goroutinue在执行结束时调用 Done ,此时等待队列数量减1,主程序通过Wait阻塞,直到等待队列为0。

package main

import (
	"fmt"
	"sync"
)

func cal(a int , b int ,n *sync.WaitGroup)  {
	c := a+b
	fmt.Printf("%d + %d = %d\n",a,b,c)
	defer n.Done() //goroutinue完成后, WaitGroup的计数-1

}

func main() {
	var go_sync sync.WaitGroup //声明一个WaitGroup变量
	for i :=0 ; i<10 ;i++{
		go_sync.Add(1) // WaitGroup的计数加1
		go cal(i,i+1,&go_sync)
	}
	go_sync.Wait()  //等待所有goroutine执行完毕
	println("主程序执行完毕")
}

结果:
0 + 1 = 1
1 + 2 = 3
9 + 10 = 19
3 + 4 = 7
4 + 5 = 9
2 + 3 = 5
5 + 6 = 11
6 + 7 = 13
7 + 8 = 15
8 + 9 = 17
主程序执行完毕
4.2 goroutine之间的通讯–channel

channel用于数据传递或数据共享,其本质是一个先进先出的队列,使用goroutine+channel进行数据通讯简单高效,同时也线程安全,多个goroutine可同时修改一个channel,不需要加锁。

channel可分为三种类型:

  • 只读channel:只能读channel里面数据,不可写入
  • 只写channel:只能写数据,不可读
  • 一般channel:可读可写

注意,必须使用make 创建channel:

c1 := make(chan int)
c2 := make(chan string)

channel通过操作符<-来接收和发送数据 :

c1 <- str //发送数据str到c1
newStr := <- c1 //从str中接受数据并赋值给newStr

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

那么有缓存的channel是指在声明的时候指定该channel缓存的容量:

ch := make(chan int, 10)

有缓存的 channel 类似一个阻塞队列(采用环形数组实现)。当缓存未满时,向 channel 中发送消息时不会阻塞,当缓存满时,发送操作将被阻塞,直到有其他 goroutine 从中读取消息;相应的,当 channel 中消息不为空时,读取消息不会出现阻塞,当 channel 为空时,读取操作会造成阻塞,直到有 goroutine 向 channel 中写入消息。

ch := make(chan int, 3)

// 读消息阻塞,因为channel为空
<- ch

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3

// 存入消息阻塞,channel已满,未被读取
ch <- 4

channel的使用:

1.使用channel阻塞主线程,直到子goroutine完成才继续往下走。

c := make(chan int)
go func(){
    //do something
    c <- 1
}()
doAnnotherThing()
<- c

2.消息传递

func test(){
	intChan := make(chan int)

	go func() {
		intChan <- 1
	}()

	value := <- intChan
	fmt.Println("value : ", value)
}

匿名函数中的操作产生一个值,将该值传递到主函数中去。

3.合并多个channel的输出

package main
   
   import (
   	"fmt"
   	"time"
   )
   
   
   func testMergeInput() {
   	input1 := make(chan int)
   	input2 := make(chan int)
   	output := make(chan int)
   
   	//将 channel 1 和 2 中的数据输出到output中
   	go func(in1, in2 <-chan int, out chan<- int) {
   		for {
   			select {
   			case v := <-in1:
   				out <- v
   			case v := <-in2:
   				out <- v
   			}
   		}
   	}(input1, input2, output)
   
   	go func() {
   		for i := 0; i < 10; i++ {
   			input1 <- i
   			time.Sleep(time.Millisecond * 100)
   		}
   	}()
   
   	go func() {
   		for i := 20; i < 30; i++ {
   			input2 <- i
   			time.Sleep(time.Millisecond * 100)
   		}
   	}()
   
   	go func() {
   		for {
   			select {
   			case value := <-output:
   				fmt.Println("输出:", value)
   			}
   		}
   	}()
   
   	time.Sleep(time.Second * 5)
   	fmt.Println("主线程退出")
   }
   
   
   
   func main(){
   	testMergeInput()
   }

4.模拟生产者和消费者模式

package main

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

var(
	lockChan = make(chan int, 1)
	remainMoney = 1000
)

func testSynchronize()  {
	quit := make(chan bool, 2)

	go func() {
		for i:=0; i<10;i++{
			money := (rand.Intn(12) + 1) * 100
			go testSynchronize_expense(money)
			time.Sleep(time.Millisecond * time.Duration(rand.Intn(500)))
		}
		quit <- true
	}()

	go func() {
		for i:=0; i<10; i++{
			money := (rand.Intn(12) + 1) * 100
			go testSynchronize_gain(money)
			time.Sleep(time.Millisecond * time.Duration(rand.Intn(500)))
		}
		quit <- true
	}()
	<- quit
	<- quit
	fmt.Println("主程序退出")
}

func testSynchronize_expense(money int)  {
	lockChan <- 0
	if(remainMoney >= money){
		srcRemainMoney := remainMoney
		remainMoney -= money
		fmt.Printf("原来有%d, 花了%d,剩余%d\n", srcRemainMoney, money, remainMoney)
	}else{
		fmt.Printf("想消费%d钱不够了, 只剩%d\n", money, remainMoney)
	}

	<- lockChan
}

func testSynchronize_gain(money int)  {
	lockChan <- 0
	srcRemainMoney := remainMoney
	remainMoney += money
	fmt.Printf("原来有%d, 赚了%d,剩余%d\n", srcRemainMoney, money, remainMoney)
	<- lockChan
}



func main(){
	testSynchronize()
}

鲜花

握手

雷人

路过

鸡蛋
该文章已有0人参与评论

请发表评论

全部评论

专题导读
上一篇:
21. Go 语言中如何开源自己写的包给别人用?发布时间:2022-07-10
下一篇:
6 款最棒的 Go 语言 Web 框架简介发布时间:2022-07-10
热门推荐
热门话题
阅读排行榜

扫描微信二维码

查看手机版网站

随时了解更新最新资讯

139-2527-9053

在线客服(服务时间 9:00~18:00)

在线QQ客服
地址:深圳市南山区西丽大学城创智工业园
电邮:jeky_zhao#qq.com
移动电话:139-2527-9053

Powered by 互联科技 X3.4© 2001-2213 极客世界.|Sitemap