文章

Go并发编程

进程、线程、携程

多线程

image-20221229233645200

image-20221229233713587

image-20221229233755665

并发性和并行性

image-20221229233954449

说明:并行性Parallelism不会总是导致更快的执行时间。这是因为并行运行的组件可能需要相互通信,这种通信开销很高,因此并行程序并不总是导致更快的执行时间。

image-20221229234319985

进程、线程、协程

进程(Process),线程(Thread),协程(Coroutine)

进程

进程是一个程序在一个数据集中的一次动态执行过程,可以简单理解为“正在执行的程序”,它是CPU资源分配和调度的独立单位。

进程一般由程序数据集进程控制块三部分组成,我们编写的程序用来描述进程要完成哪些功能以及如何完成;

数据集则是程序在执行过程中所需要使用的资源;进程控制块用来记录进程的外部特征,描述进程的执行变化过程,系统可以利用它来控制和管理进程,它(进程控制块)是系统感知进程存在的唯一标志。进程的局限是创建撤销切换的开销比较大。

线程

线程是在进程之后发展出来的概念。线程也叫轻量级进程,它是一个基本的CPU执行单元,也是程序执行过程中的最小单元,由线程ID程序计数器寄存器集合堆栈共同组成。一个进程可以包含多个线程。

线程的优点减少了程序并发执行时的开销,提高了操作系统的并发性能缺点线程没有自己的系统资源,同一进程的各线程可以共享进程所拥有的系统资源,如果把进程比作一个车间,那么线程就好比是车间里的工人。不过对于某些独占性资源存在锁机制,处理不当可能会产生“死锁”

协程

协程是一种用户态的轻量级线程,又称微线程,英文名Goroutine,协程的调度完全由用户控制。人们通常将协程和子程序(函数)比较着理解。

子程序调用总是一个入口,一次返回,一旦退出即完成了子程序的执行。

与传统的系统级线程和进程相比,携程的最大优势在于其“轻量级”,可以轻松创建上百万个而不会导致系统资源衰竭,而线程和进程通常最多也不能超过1万个。这也是协程也叫轻量级线程的原因。

Go语言对于并发的实现是靠协程,Goroutine

package main

import "fmt"

func main() {
   go hello()
   sum := 0
   for i := 0; i < 100; i++ {
      fmt.Println("main - ", i)
      sum++
   }
   defer fmt.Println("sum", sum)
}
func hello() {
   sumHello := 0
   for i := 0; i < 100; i++ {
      fmt.Println("hello - ", i)
      sumHello++
   }
   defer fmt.Println("sumHello", sumHello)
}

Goroutine

Go中使用Goroutine来实现并发concurrently

Goroutine是Go语言特有的名词。区别于进程Process,线程Thread,协程Goroutine,因为Go语言的创造者们觉得和他们是有区别的,所以专门创造了Goroutine。

Goroutine是与其他函数或方法同时运行的函数或方法。Goroutine可以被认为是轻量级的线程。与线程相比,创建Goroutine的成本很小,它就是一段代码,一个函数入口。以及在堆上为其分配的一个堆栈(初始大小为4k,会随着程序的执行自动增长删除)。因此它非常廉价,Go应用程序可以轻松并发运行数千个Goroutine。

在Go语言中使用goroutine,在调用函数或者方法前面加上go关键字即可

package main

import "fmt"

func main() {
   go hello()
   sum := 0
   for i := 0; i < 100; i++ {
      fmt.Println("main - ", i)
      sum++
   }
   defer fmt.Println("sum", sum)
}
func hello() {
   sumHello := 0
   for i := 0; i < 100; i++ {
      fmt.Println("hello - ", i)
      sumHello++
   }
   defer fmt.Println("sumHello", sumHello)
}

Goroutine的规则:

  1. 当新的Goroutine开始时,Goroutine调用立即返回。与函数不同,go不等待Goroutine执行结束
  2. 当Goroutine调用,并且Goroutine的任何返回值被忽略之后,go立即执行到下一行代码
  3. main的Goroutine应该为其他的Goroutine执行。如果main的Goroutine终止了,程序将被终止,而其他Goroutine将不会运行

主Goroutine

封装main函数的Goroutine称为主Goroutine

主Goroutine所做的事情并不是执行main函数那么简单,它首先要做的是:设定每一个Goroutine所能申请的栈空间的最大尺寸。在32位计算机系统中此最大尺寸为250MB,而在64位的计算机系统中尺寸为1GB。如果有某个Goroutine的栈空间尺寸大于这个限制,那么运行时系统就会引发一个栈溢出(stack overflow)的运行时恐慌。随后,这个go程序的运行也会终止。

此后,主Goroutine会进行一系列的初始化工作,设计的工作内容大致如下:

  1. 创建一个特殊的defer语句,用于在主Goroutine退出时做必要的善后处理。因为主Goroutine也可能非正常的结束
  2. 启动专用于在后台清扫内存垃圾的Goroutine,并设置GC可用的标识
  3. 执行main包中所引用包下的init函数
  4. 执行main函数

执行完main函数后,它还会检查主Goroutine是否引发了运行时恐慌,并进行必要的处理。

程序运行完毕后,主Goroutine会结束自己以及当前进程的运行。

runtime包

获取系统信息

schedule调度让出时间片,让别的Goroutine先执行

Goexit //终止当前的Goroutine

package main

import (
   "fmt"
   "runtime"
)

func main() {
   fmt.Println("获取goroot目录", runtime.GOROOT())
   fmt.Println("获取操作系统", runtime.GOOS)
   fmt.Println("获取cpu数量", runtime.NumCPU())
   fmt.Println("获取go当前版本", runtime.Version())

   //go 调度
   go func() {
      for i := 0; i < 5; i++ {
         fmt.Println("goroutine")
      }
   }()
   for i := 0; i < 5; i++ {
      //让出时间片,让别的goroutine先执行,不一定可以让成功
      runtime.Gosched()
      fmt.Println("main")
   }
}

image-20221230130407135

package main

import (
   "fmt"
   "runtime"
   "time"
)

func main() {
   go func() {
      fmt.Println("start")
      test()
      fmt.Println("end")
   }()
   time.Sleep(time.Second * 2)
}
func test() {
   defer fmt.Println("test defer")
   //return // 终止函数 会终止当前函数一下的代码
   runtime.Goexit() //终止当前的goroutine
   fmt.Println("test")
}

image-20221230130826918

临界资源安全问题

临界资源:指并发环境中多个进程、线程、协程共享的资源

什么是临界资源安全问题

在并发编程中对临界资源的处理不当,往往会导致数据不一致的问题

package main

import (
   "fmt"
   "time"
)

// 临界资源安全问题
// 在并发编程中对临界资源的处理不当,往往会导致数据不一致的问题
func main() {
   a := 1
   go func() {
      a = 2
      fmt.Println("goroutine a=", a)
   }()
   a = 3
   fmt.Println("main a1=", a)
   time.Sleep(time.Second * 2)
   fmt.Println("main a=", a)
}

售票问题

并发本身并不复杂,但是因为有了资源竞争的问题,就使得我们开发出好的并发程序变得复杂起来,因为会引起很多莫名其妙的问题

如果多个goroutine在访问同一个数据资源的时候,其中一个线程修改了数据,那么这个数值就被修改了,对于其他的goroutine来讲,这个数值可能是不对的。

例如:我们通过并发来实现火车站售票这个程序。共有10张票,4个销售口同时出售。

package main

import (
   "fmt"
   "time"
)

// 全局变量 ticket
var ticket int = 10

func main() {
   go saleTicket("售票口1")
   go saleTicket("售票口2")
   go saleTicket("售票口3")
   go saleTicket("售票口4")
   time.Sleep(time.Second * 5)
}
func saleTicket(name string) {
   for {
      if ticket > 0 {
         time.Sleep(time.Millisecond * 500)
         fmt.Println(name, "剩余票数", ticket)
         ticket--
      } else {
         fmt.Println("票卖完啦")
         break
      }
   }
}

image-20221230132827168

互斥锁

要想解决临界资源安全问题,很多编程语言的解决方案都是同步。通过上锁的方式,某一时间段,只能允许一个goroutine来访问这个共享数据,当前goroutine访问完毕,解决后,其他的goroutine才能来访问。

我们可以借助于sync包下的锁操作

但是实际上,在Go的并发编程中有一句很经典的话:不要以共享内存的方式去通信,而要以通信的方式去共享内存

在Go语言中并不鼓励用锁保护共享状态的方式,在不同的goroutine中分享信息(以共享内存的方式去通信)。而是鼓励通过channel将共享状态或共享状态的变化在各个goroutine之间传递(以通信的方式去共享内存),这样同时能像用锁一样保证在同一的时间只有一个goroutine访问共享状态。

当然,在主流的编程语言中为了保证多线程之间共享数据安全性和一致性,都会提供一套基本的同步工具集,如锁,条件变量,原子操作等等。Go语言标准库也毫不意外的提供了这些同步机制,使用方式也和其他语言也差不多

package main

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

// 全局变量 ticket
var ticket int = 10

// 创建锁
var mutex sync.Mutex

func main() {
   go saleTicket("售票口1")
   go saleTicket("售票口2")
   go saleTicket("售票口3")
   go saleTicket("售票口4")
   time.Sleep(time.Second * 10)
}
func saleTicket(name string) {
   for {
      mutex.Lock()
      if ticket > 0 {
         time.Sleep(time.Millisecond * 500)
         fmt.Println(name, "剩余票数", ticket)
         ticket--
      } else {
         mutex.Unlock()
         fmt.Println("票卖完啦")
         break
      }
      mutex.Unlock()
   }
}

image-20230102110159278

Waitgroup

waitgroup等待组

waitgroup
Add()设置等待组中要执行的字Goroutine数量
wait()让主Goroutine等待
Done()让waitGroup等待的Goroutine数量-1
package main

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

// waitgroup
// Add()设置等待组中要执行的字Goroutine数量
// wait()让主Goroutine等待
// Done()让waitGroup等待的Goroutine数量-1

var wg sync.WaitGroup

func main() {

   wg.Add(2)
   go test1()
   go test2()

   fmt.Println("main ing--")
   wg.Wait() // 阻塞
   fmt.Println("WaitGroup解除...")
}
func test1() {
   for i := 0; i < 10; i++ {
      time.Sleep(time.Millisecond * 500)
      fmt.Println("test1 ----", i)
   }
   wg.Done()
}
func test2() {
   for i := 0; i < 10; i++ {
      time.Sleep(time.Millisecond * 500)
      fmt.Println("test2 ----", i)
   }
   wg.Done()
}

image-20230102113455831

package main

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

// 全局变量 ticket
var ticket int = 10

// 创建锁
var mutex sync.Mutex
var wg sync.WaitGroup

func main() {
   wg.Add(4)
   go saleTicket("售票口1")
   go saleTicket("售票口2")
   go saleTicket("售票口3")
   go saleTicket("售票口4")
   //time.Sleep(time.Second * 10)
   wg.Wait()
}
func saleTicket(name string) {
   defer wg.Done()
   for {
      mutex.Lock()
      if ticket > 0 {
         time.Sleep(time.Millisecond * 500)
         fmt.Println(name, "剩余票数", ticket)
         ticket--
      } else {
         mutex.Unlock()
         fmt.Println("票卖完啦")
         break
      }
      mutex.Unlock()
   }
}

image-20230102113528705

不要以共享内存的方式去通信,而要以通信的方式去共享内存

channel通道

通道可以被认为是Goroutines通信的管道。类似于管道中的水从一端到另一端的流动,数据可以从一端发送到另一端,通过管道接收。

当多个Goroutines想实现共享数据的时候,虽然也提供了传统的同步机制,但是Go语言

强烈建议的是使用channel通道来实现Goroutines之间的通信。

**不要以共享内存的方式去通信,而要以通信的方式去共享内存**这是一句风靡golang社区的经典语

Go从语言层面保证同一个时间只有一个Goroutine能够访问channel里面的数据,使用channel来通信,通过通信来传递内存数据,使得内存数据在不同的Goroutine中传递,而不是使用共享内存来通信。

每个通道都有与其相关的类型。类型是通道允许传输的数据类型。(通道的零值为nil。nil通道没有任何用处,因此通道必须使用类似于map和切片的方式来定义)

//声明通道
var 通道名 chan 数据类型
// 创建通道,如果通道为nil就是不存在,就需要先创建通道
通道名 = make(chan 数据类型)

//发送和接收
data := <-a // read from chan a
a <- data // write to chanel a
// 在通道上箭头的方向指定数据是发送还是接收
package main

import "fmt"

func main() {
   //定义一个bool类型的通道
   var ch chan bool
   ch = make(chan bool)

   go func() {
      for i := 0; i < 10; i++ {
         fmt.Println(i)
      }
      //循环结束后,向通道写数据,表示要结束了
      ch <- true
   }()
   //读取通道中的数据
   data := <-ch
   fmt.Println("读取到通道中的信息,ch = ", data)
}

image-20230102161700254

一个通道发送和接收数据,默认是阻塞的。当一个数据被发送到通道时,在发送语句中被阻塞,直到另一个Goroutine从该通道读取数据。

相对地,当从通道读取数据时,读取被阻塞,直到一个Goroutine将数据写入该通道。

本身channel就是同步的,意味着同一时间,只能有一条Goroutine来操作。

最后:通道是Goroutine之间的连接,所以通道的发送和接收必须处在不同的Goroutine中。

这些通道的特性是帮助Goroutine有效地进行通信,而无需像使用其他编程语言中非常常见的显式锁或条件变量。

死锁

如果创建了chan,没有Goroutine来使用了,则会出现死锁。

使用通道时要考虑的一个重要因素就是死锁。如果Goroutine在一个通道上发送数据,那么预计其他的Goroutine应该接收数据。如果这种情况不发生,那么程序将在运行时出现死锁。

类似地,如果Goroutine正在等待从通道接收数据,那么另一些Goroutine将会在该通道上写入数据,否则程序将会死锁。

关闭通道

发送者可以通过关闭通道,来通知接收方不会有更多的数据被发送到channel上

close(ch)

接收者可以在接收来自通道的数据时使用额外的变量来检查通道是否已经关闭

v,ok := <- ch

在上面的语句中,如果ok的值是true,表示成功的从通道中读取了一个数据value。

如果ok的值是false,这意味着我们正在从一个封闭的通道读取数据。从封闭通道读取的值将是通道类型的零值。

package main

import (
   "fmt"
   "time"
)

func main() {
   ch := make(chan int)
   go test3(ch)
   //for {
   // time.Sleep(time.Second)
   // v, ok := <-ch
   // if !ok {
   //    fmt.Println("数据读取完毕,通道已关闭,OK = ", ok)
   //    break
   // }
   // fmt.Println("读取通道的数据:", v)
   //}
   for v := range ch {
      fmt.Println("读取通道的数据:", v)
   }
}
func test3(ch chan int) {
   for i := 0; i < 10; i++ {
      time.Sleep(time.Second)
      ch <- i
   }
   close(ch)
}

image-20230102223026681

缓冲通道

非缓冲通道

之前学习的所有通道基本上都没有缓冲。发送和接收到一个未缓冲的通道是阻塞的。一次发送操作对应一次接收操作,对于一个Goroutine来讲,它的一次发送,在另一个Goroutine接收之前都是阻塞的。同样的,对于接收来讲,在另一个Goroutine发送之前,他也是阻塞的。

缓冲通道

缓冲通道就是指一个通道,带有一个缓冲区。发送到一个缓冲通道只有在缓冲区满时才被阻塞。类似地,从缓冲通道接收到信息只有在缓冲区为空时才会被阻塞。可以通过将额外的容量参数传递给make函数来创建缓冲通道,该函数指定缓冲区的大小。

ch := make(chan type,capacity)
package main

import (
   "fmt"
   "strconv"
   "time"
)

func main() {
   ch1 := make(chan int)
   fmt.Println(len(ch1), cap(ch1)) // 0 0
   ch2 := make(chan int, 5)
   fmt.Println(len(ch2), cap(ch2)) // 0 5
   ch3 := make(chan int, 5)
   ch3 <- 1
   fmt.Println(len(ch3), cap(ch3)) // 1 5
   ch3 <- 2
   ch3 <- 3
   ch3 <- 4
   ch3 <- 5
   fmt.Println(len(ch3), cap(ch3)) // 5 5

   ch4 := make(chan string, 10)
   go test4(ch4)
   //读
   for v := range ch4 {
      time.Sleep(time.Second)
      fmt.Println(v)
   }

}
func test4(ch chan string) {
   for i := 0; i < 10; i++ {
      ch <- "test -- " + strconv.Itoa(i)
      fmt.Println("goroutine里面写入了数据", "test -- "+strconv.Itoa(i))
   }
   close(ch)
}

image-20230102225307336

单向通道

双向通道

通道,channel,是用于实现goroutine之间的通信的。一个goroutine可以向通道中发送数据,另一条goroutine可以从该通道中获取数据。截止到现在我们所学习的通道,都是既可以发送数据,也可以读取数据,我们又把这种通道叫做双向通道。

a <- data //read from channel
data := <- a // write to channel

单向通道

单向通道,也就是定向通道,单向通道只能发送或者接收数据。

package main

import (
   "fmt"
   "time"
)

func main() {

   //ch1 := make(chan<- int)
   //ch1 <- 200
   //temp := <-ch1//cannot receive from send-only channel ch1 (variable of type chan<- int)
   //ch2 := make(<-chan int)
   //ch2 <- 200//cannot send to receive-only channel ch2

   ch1 := make(chan int)
   go writeOnly(ch1)
   go readOnly(ch1)
   time.Sleep(time.Second * 3)
}
func writeOnly(ch chan<- int) {
   ch <- 100
}
func readOnly(ch <-chan int) {
   data := <-ch
   fmt.Println(data)
}

image-20230102231442659

Select

Select是Go语言中的一个控制结构,select语句类似于switch语句,但是select会随机执行一个可运行的case,如果没有case可运行,它就会阻塞,直到有case可运行。

select {
  case communication clause:
  	statement(s);
  case communication clause:
	  statement(s);
  /*
  你可以定义任意数量的case
  */
  default:/*可选*/
    statement(s);
}

一下描述了select语句的语法:

  • 每个case都必须是一个通道的操作
  • 如果任意某个通信可以进行,它就执行;其他被忽略
  • 如果有多个case都可以运行,Select会随机公平地选出一个执行,其他不会执行

否则:

  • 如果有default子句,则执行该语句
  • 如果没有default子句,select将阻塞,直到某个通信可以运行;Go不会重新对channel或值进行求值。
package main

import (
   "fmt"
   "time"
)

func main() {
   ch1 := make(chan int)
   ch2 := make(chan int)
   go func() {
      time.Sleep(2 * time.Second)
      ch1 <- 100
   }()
   go func() {
      time.Sleep(2 * time.Second)
      ch2 <- 200
   }()

   select {
   case num1 := <-ch1:
      fmt.Println("ch1 : ", num1)
   case num2 := <-ch2:
      fmt.Println("ch2 : ", num2)
      //default:
      // fmt.Println("未匹配到")
   }
}

Timer定时器

主要就是定时器,标准库中的Timer让用户可以定义自己的超时逻辑,尤其是在应对select处理多个channel的超时,单channel读写的超时等形式尤为方便。

Timer是一次性的时间出发时间,这点与Ticker(心跳)不同,ticker是按一定时间间隔持续触发的事件。

Timer常见的创建方式:

	t := time.NewTimer(d)
	t := time.AfterFunc(d, f)
	t := time.After(d)
	
	参数d : 定时时间
	参数f : 触发动作
	返回值 t,c :时间 channel

虽然说创建方式不同,但是原理是相同的

package main

import (
   "fmt"
   "time"
)

func main() {
   //time.NewTimer() 创建一个定时器,在指定时间后触发某个事件执行
   timer := time.NewTimer(3 * time.Second)
   fmt.Println(time.Now()) //打印当前时间
   //通过timer.C 获取通道,然后可以查看其中的数值,发现是3s以后触发的
   //此处会等待channel中的数值,会阻塞3s
   timerChan := timer.C
   fmt.Println(<-timerChan)
   fmt.Println("===============")
   timer2 := time.NewTimer(time.Second * 5)
   go func() {
      <-timer2.C // 5s
      println("子goroutine end")
   }()
   time.Sleep(2 * time.Second)
   stop := timer2.Stop()
   //停止或结束 返回 true,否则返回true
   if stop {
      fmt.Println("timer2停止了")
   }
   //t := time.AfterFunc(d, f)
   //t := time.After(d)

}

image-20230103003801986

package main

import (
   "fmt"
   "time"
)

func main() {
   timerChan := time.After(time.Second * 3)
   fmt.Println(time.Now())
   chantime := <-timerChan
   fmt.Println(chantime)

   //在指定时间后取触发这个函数执行
   time.AfterFunc(time.Second*3, test5)
   time.Sleep(5 * time.Second)

}
func test5() {
   fmt.Println("aaaaaaaaaaaa")
}

image-20230103003730391

小结

Golang是为并发而生的语言,Go语言是为数不多的在语言层面实现并发的语言;

也正是Go语言的并发特性,吸引了很多开发者

Go语言的最大两个亮点,一个是goroutine,一个就是chan

二者合体的典型应用CSP,CSP模型是上个世纪七十年代提出的,不同于传统的多线程通过共享内存来通信,CSP讲究的是“以通信的方式来共享内存”

用于描述两个独立的并发实体通过共享的通讯channel(管道)进行通信的开发模型。

Golang的通信机制channel也很方便,传数据用channel <- data,取数据用 <- channel

在通信过程中,传数据channel <- data和取数据用 <- channel必然会成对出现,因为这边传,那边取,两个goroutine之间才会实现通信

而且不管是传还是取,肯定阻塞,直到另外的goroutine传或者取为止。

License:  CC BY 4.0 test