《深入学习 Golang》并发编程

并发介绍

进程和线程

  • 进程是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。
  • 线程是进程的一个执行实体,是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。
  • 一个进程可以创建和撤销多个线程,同一个进程中的多个线程之间可以并发执行。

并发和并行

  • 多线程程序在一个核的 CPU 上运行,就是并发

  • 多线程程序在多个核的 CPU 上运行,就是并行

并发不是并行

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

协程和线程

  • 协程:独立的栈空间,共享堆空间,调度由用户自己控制。

本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。

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

goroutine 只是官方实现的超级线程池

每个实例 4 ~ 5 KB 的栈内存占用,以及由于实现机制从而大幅减了的创建和销毁开销。

goroutine

Java / C++ 中,实现并发编程需要开发者手动维护一个线程池,需要手动调度线程并维护上下文切换。

Go 语言在语言层面已经内置了调度和上下文切换机制,不需要开发者去维护。

在 Go 中需要让某个任务并发执行,只需要将其包装成一个函数,开启一个 goroutine 去执行该函数。


启动一个 goroutine:在调用函数的时候在前面加上 go 关键字即可

func hello() {
    
    
  fmt.Println("Hello Goroutine!")
}

func main() {
    
    
  go hello() // 启动另外一个goroutine去执行hello函数
  fmt.Println("main goroutine done!")
}

启动多个 goroutine:每次打印的顺序都不一致,因为 10 个 goroutine 是并发执行的。

var wg sync.WaitGroup

func hello(i int) {
    
    
	defer wg.Done() // goroutine结束就登记-1
	fmt.Println("Hello Goroutine!", i)
}
func main() {
    
    
	for i := 0; i < 10; i++ {
    
    
		wg.Add(1) // 启动一个goroutine就登记+1
		go hello(i)
	}
	wg.Wait() // 等待所有登记的goroutine都结束
}

如果主协程退出,其他任务就不会继续执行了。

func main() {
    
    
	go func() {
    
    
		i := 0
		for {
    
    
			i++
			fmt.Printf("new goroutine: i = %d\n", i)
			time.Sleep(time.Second)
		}
	}()
	i := 0
	for {
    
    
		i++
		fmt.Printf("main goroutine: i = %d\n", i)
		time.Sleep(time.Second)
		if i == 2 {
    
    
			break
		}
	}
}

goroutine 与线程

OS 线程(操作系统线程)一般都有固定的栈内存(通常为 2MB)

一个 goroutine 的栈在其生命周期开始时只有很小(典型情况下 2KB)

  • goroutine 的栈内存不固定,可以按需增大缩小,最大限制可以达到 1GB
  • 所以 Go 语言中可以一次创建 10万左右的 goroutine

runtime 包

Go 语言中操作系统线程和 goroutine 的关系:

  • 一个操作系统线程对应多个 goroutine
  • go 程序可以同时使用多个操作系统线程
  • goroutine 和 OS 线程是多对多关系( m : n )

runtime.Gosched()

让出 CPU 时间片,重新等待安排任务,允许其他 goroutine 运行,但不会结束当前 goroutine。

func main() {
    
    
	go func(s string) {
    
    
		for i := 0; i < 2; i++ {
    
    
			fmt.Println(s)
		}
	}("world")
	// 主协程
	for i := 0; i < 2; i++ {
    
    
		// 让出时间片,重新等待安排任务
		runtime.Gosched()
		fmt.Println("hello")
	}
}

runtime.Goexit()

退出当前协程。

func main() {
    
    
	go func() {
    
    
		defer fmt.Println("A.defer")
		func() {
    
    
			defer fmt.Println("B.defer")
			// 结束协程
			runtime.Goexit()
			defer fmt.Println("C.defer")
			fmt.Println("B")
		}()
		fmt.Println("A")
	}()
	time.Sleep(time.Second)
}

runtime.GOMAXPROCS()

Go 运行时的调度器使用 GOMAXPROCS 参数来确定需要使用多少个 OS 线程来同时执行 Go 代码。

  • 默认值是机器上的 CPU 核心数。例如在一个 8 核机器上,调度器会把 Go 代码同时调度到 8 个 OS 线程上。

    GOMAXPROCS 是 m:n 调度中的 n

  • 通过 runtime.GOMAXPROCS() 函数设置当前程序并发时占用的 CPU 逻辑核心数。

Go 1.5 之前,默认使用单核执行。Go 1.5 以后,默认使用全部的 CPU 逻辑核心数。

看下面的例子:

  • 逻辑核心设为 1,则只有单核在执行(可以并发不能并行)。
func a() {
    
    
	for i := 1; i < 10000; i++ {
    
    
		fmt.Println("A:", i)
	}
}

func b() {
    
    
	for i := 1; i < 10000; i++ {
    
    
		fmt.Println("B:", i)
	}
}

func main() {
    
    
	runtime.GOMAXPROCS(1)
	go a()
	go b()
	time.Sleep(time.Second)
}
  • 设置 2 个逻辑核心,则两个任务并行执行
func a() {
    
    
	for i := 1; i < 100; i++ {
    
    
		fmt.Println("A:", i)
	}
}

func b() {
    
    
	for i := 1; i < 100; i++ {
    
    
		fmt.Println("B:", i)
	}
}

func main() {
    
    
	runtime.GOMAXPROCS(8)
	go a()
	go b()
	time.Sleep(time.Second)
}

channel

单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行的意义

虽然可以使用共享内存进行数据交换,但是共享内存在不同的 goroutine 中容易发生竞态问题。为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法势必造成性能问题。

Go 语言的并发模型是 CSP,提倡通过通信共享内存而不是通过共享内存而实现通信

CSP:Communicating Sequential Processes,通信顺序进程

channel 是可以让一个 goroutine 发送特定值到另一个 goroutine 的通信机制。


channel 是一种引用类型,声明 channel 的格式如下:

// var 变量 chan 元素类型
var ch1 chan int   // 声明一个传递整型的通道
var ch2 chan bool  // 声明一个传递布尔型的通道
var ch3 chan []int // 声明一个传递int切片的通道

由于是引用类型,通道的空值是 nil。创建 channl 格式如下:

// make(chan 元素类型, [缓冲大小])
ch4 := make(chan int)
ch5 := make(chan bool)
ch6 := make(chan []int)

channel 操作

通道有发送(send)、接收(receive)和关闭(close)三种操作。

发送 和 接收 都使用 <- 符号。

func main() {
    
    	
  ch := make(chan int)	
  
  go func() {
    
    		
    ch <- 10 // 将10发送到ch中	
  }()	
  
  x := <-ch // 从ch中接收值并赋值给x	
  
  fmt.Println(x) // 10
  close(ch) // 关闭通道
}
func main() {
    
    
	ch := make(chan int)

	go func() {
    
    
		ch <- 10 // 将10发送到ch中
	}()

	<-ch // 从ch中接收值,忽略结果
  
  close(ch) // 关闭通道
}

关闭通道注意事项:

  • 只有在通知接收方 goroutine 所有的数据都发送完毕时菜需要关闭通道,通道是可以被垃圾回收的。

  • 和关闭文件不一样,结束操作后关闭文件是必须要做的,但是关闭通道不是必须的

关闭后的通道有以下特点:

  • 对一个关闭的通道再发送值就会导致 panic。
  • 对一个关闭的通道进行接收会一直获取值直到通道为空。
  • 对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值。
  • 关闭一个已经关闭的通道会导致 panic。

无缓冲的通道

无缓冲的通道又被称为阻塞的通道,先看以下代码:

func main() {
    
    
	ch := make(chan int)
	ch <- 10
	fmt.Println("发送成功")
}

上面代码可以通过编译,但执行时会报错:deadlock(死锁)

fatal error: all goroutines are asleep - deadlock!

由于使用 ch := make(chan int) 创建的是无缓冲的通道,无缓冲的通道必须有接收才能发送

解决方案:启用一个 goroutine 去接收值

func recv(c chan int) {
    
    
	ret := <-c
	fmt.Println("接收成功", ret)
}

func main() {
    
    
	ch := make(chan int)
	go recv(ch)
	ch <- 10
	fmt.Println("发送成功")
}

无缓冲通道上的操作

  • 发送操作先执行则会阻塞,直到另一个 goroutine 在该通道上执行接收操作,才能发送成功。

  • 接收操作先执行,则接收方的 goroutine 将阻塞,直到另一个 goroutine 在该通道上发送一个值。

使用无缓冲通道进行通信将导致发送和接收的 goroutine 同步化,因此无缓冲通道也称为同步通道

有缓冲的通道

解决上面问题的方法还有一种就是使用有缓冲区的通道。

可以在使用 make 函数初始化通道的时候为其指定通道的容量,例如:

func main() {
    
        
  ch := make(chan int, 1) // 创建一个容量为1的有缓冲区通道    
  ch <- 10    
  fmt.Println("发送成功")
}

close()

通过 close() 函数关闭 channel,如果不再往通道里发送值或者取值的时候,记得关闭通道。

func main() {
    
    
	c := make(chan int)
	go func() {
    
    
		for i := 0; i < 5; i++ {
    
    
			c <- i
		}
		close(c)
	}()
	for {
    
    
		if data, ok := <-c; ok {
    
    
			fmt.Println(data)
		} else {
    
    
			break
		}
	}
	fmt.Println("main结束")
}

从通道循环取值

当通过通道发送有限的数据时,可以通过 close 函数关闭通道来告知从该通道接收值的 goroutine 停止等待。

当通道关闭后,再往该通道发送值会引发 panic,从通道中接收出来的值都是类型零值。

下面例子演示如何判断一个通道是否被关闭:

  • 方法一:通过从通道取值操作的第 2 个返回值 ok 来判断,通道关闭再取值则 ok = false
  • 方法二:通过 for range 循环,通道关闭后会自动退出循环
func main() {
    
    
	ch1 := make(chan int)
	ch2 := make(chan int)
	// 开启goroutine将0~100的数发送到ch1中
	go func() {
    
    
		for i := 0; i < 100; i++ {
    
    
			ch1 <- i
		}
		close(ch1)
	}()
	// 开启goroutine从ch1中接收值,并将该值的平方发送到ch2中
	go func() {
    
    
		for {
    
    
			i, ok := <-ch1 // 法1、通道关闭后再取值 ok=false
			if !ok {
    
    
				break
			}
			ch2 <- i * i
		}
		close(ch2)
	}()
	// 在主goroutine中从ch2中接收值打印
	for i := range ch2 {
    
     // 法2、通道关闭后会退出for range循环
		fmt.Println(i)
	}
}
01491625...

单向通道

有的时候我们会将通道作为参数在多个任务函数间传递,很多时候在不同的任务函数中使用通道都会对其进行限制,比如限制通道在函数中只能发送或只能接。

Go 语言提供了单向通道来处理这种情况。例如,改造上面的例子如下:

// chan<- int是一个只能发送的通道,可以发送但是不能接收
func counter(out chan<- int) {
    
    
	for i := 0; i < 100; i++ {
    
    
		out <- i
	}
	close(out)
}

// <-chan int是一个只能接收的通道,可以接收但是不能发送
func squarer(out chan<- int, in <-chan int) {
    
    
	for i := range in {
    
    
		out <- i * i
	}
	close(out)
}

func printer(in <-chan int) {
    
    
	for i := range in {
    
    
		fmt.Println(i)
	}
}

func main() {
    
    
	ch1 := make(chan int)
	ch2 := make(chan int)

	go counter(ch1)
	go squarer(ch2, ch1)
  printer(ch2)
}
0
1
4
9
16
25
...

在函数传参及任何赋值操作中,将双向通道转换为单向通道是可以的,反过来不可以。

通道总结

关闭已经关闭的 channel 也会引发 panic。

goroutine 池

worker pool(goroutine 池)

  • 本质上是生产者消费者模型
  • 可以有效控制 goroutine 数量,防止暴涨

需求:

  • 计算一个数字的各个位数之和,例如数字 123,结果为 1+2+3 = 6

  • 随机生成数字进行计算

  • 控制台输出如下:

type Job struct {
    
    
	Id      int
	RandNum int
}

type Result struct {
    
    
	job *Job
	sum int
}

func main() {
    
    
	// 需要2个管道
	// 1.job管道
	jobChan := make(chan *Job, 128)
	// 2.结果管道
	resultChan := make(chan *Result, 128)
	// 3.创建工作池
	createPool(64, jobChan, resultChan)
	// 4.开个打印的协程
	go func(resultChan chan *Result) {
    
    
		// 遍历结果管道打印
		for result := range resultChan {
    
    
			fmt.Printf("job id:%v randnum:%v result:%d\n",
				result.job.Id, result.job.RandNum, result.sum)
		}
	}(resultChan)
	var id int
	// 循环创建job,输入到管道
	for {
    
    
		id++
		// 生成随机数
		r_num := rand.Int()
		job := &Job{
    
    
			Id:      id,
			RandNum: r_num,
		}
		jobChan <- job
	}
}

// 创建工作池
// num:开几个协程
func createPool(num int, jobChan chan *Job, resultChan chan *Result) {
    
    
	// 根据开协程个数,去跑运行
	for i := 0; i < num; i++ {
    
    
		go func(jobChan chan *Job, resultChan chan *Result) {
    
    
			// 执行运算
			// 遍历job管道所有数据,进行相加
			for job := range jobChan {
    
    
				// 接收随机数
				r_num := job.RandNum
				// 随机数每位相加
				sum := sumAll(r_num)
				r := &Result{
    
    
					job: job,
					sum: sum,
				}
				// 运算结果发送到通道
				resultChan <- r
			}
		}(jobChan, resultChan)
	}
}

func sumAll(num int) int {
    
    
	var sum int
	for num != 0 {
    
    
		tmp := num % 10
		sum += tmp
		num /= 10
	}
	return sum
}

定时器

timer

timer:时间到了,执行只执行 1 次

1、timer 的基本使用

func main() {
    
    
	timer := time.NewTimer(2 * time.Second)
	t1 := time.Now()
	fmt.Printf("t1: %v\n", t1)
	t2 := <-timer.C
	fmt.Printf("t2: %v\n", t2)
}
t1: 2022-02-15 21:02:27.049498 +0800 CST m=+0.000203569
t2: 2022-02-15 21:02:29.04963 +0800 CST m=+2.000350300

2、timer 只能响应 1 次

func main() {
    
    
	timer := time.NewTimer(time.Second)
	for {
    
    
		<-timer.C
		fmt.Println("时间到")
	}
}
时间到
fatal error: all goroutines are asleep - deadlock!

3、timer 实现延时的功能

func main() {
    
    
	// 方法1:
	time.Sleep(time.Second)
	fmt.Println("1秒到")

	// 方法2:
	timer := time.NewTimer(2 * time.Second)
	<-timer.C
	fmt.Println("2秒到")

	// 方法3:
	<-time.After(2 * time.Second)
	fmt.Println("2秒到")
}

4、停止定时器

func main() {
    
    
	timer := time.NewTimer(2 * time.Second)

	go func() {
    
    
		fmt.Println("准备开始定时")
		<-timer.C
		fmt.Println("定时器执行了")
	}()

	time.Sleep(time.Second)
	b := timer.Stop()
	if b {
    
    
		fmt.Println("timer已经关闭")
	}
}
准备开始定时
timer已经关闭

5、重置定时器

func main() {
    
    
	timer := time.NewTimer(3 * time.Second)
	timer.Reset(1 * time.Second) // 重置为1秒
	fmt.Println(time.Now())
	fmt.Println(<-timer.C)
}

ticker

ticker:时间到了,多次执行。

func main() {
    
    
	ticker := time.NewTicker(1 * time.Second)
	i := 0
	go func() {
    
    
		for {
    
    
			i++
			fmt.Println(i, <-ticker.C)
			if i == 3 {
    
    
				ticker.Stop()
			}
		}
	}()

	// 卡住主协程,如果主协程退出,其他任务不会继续执行
	for {
    
    
	}
}

select

select 多路复用

在某些场景下我们需要同时从多个通道接收数据。通道在接收数据时,如果没有数据可以接收将会发生阻塞。

一般会想到写出如下代码使用遍历的方式来实现:

for {
    
    
	// 尝试从ch1接收值
  data, ok := <-ch1
  // 尝试从ch2接收值
  data, ok := <-ch2
  // ...
}

这种方式可以实现需求,但是运行性能会差很多。


为了应对以上场景,Go 内置了 select 关键字,可以同时相应多个通道的操作。

select 的使用和 switch 类似,每个 case 对应一个通道的通信过程(接收或发送)。

select 会一直等待,直到某个 case 的通信操作完成时,就会执行 case 分支对应的语句。

select {
    
    
case <-chan1:
	// 如果chan1成功读到数据,则进行该case处理
case chan2 <- 1:
  // 如果成功向chan2写入数据,则进行该case处理
default:
  // 如果上面都没有成功,则进入default处理流程
}
  • select 可以同时监听一个或多个 channel,直到其中一个 channel ready
func test1(ch chan string) {
    
    
	time.Sleep(time.Second * 5)
	ch <- "test1"
}

func test2(ch chan string) {
    
    
	time.Sleep(time.Second * 2)
	ch <- "test2"
}

func main() {
    
    
	output1, output2 := make(chan string), make(chan string)
	// 跑2个协程,写数据
	go test1(output1)
	go test2(output2)
	// 用select监控
	select {
    
    
	case s1 := <-output1:
		fmt.Printf("s1: %v\n", s1)
	case s2 := <-output2:
		fmt.Printf("s2: %v\n", s2)
	}
}
  • 如果多个 channel 同时 ready,则随机选择一个执行
func main() {
    
    
	int_chan := make(chan int)
	str_chan := make(chan string)
  // 跑2个协程,写数据
	go func() {
    
    
		int_chan <- 1
	}()
	go func() {
    
    
		str_chan <- "hello"
	}()
  // select 监控
	select {
    
    
	case value := <-int_chan:
		fmt.Printf("value: %v\n", value)
	case value := <-str_chan:
		fmt.Printf("value: %v\n", value)
	}
	fmt.Println("main end")
}
  • 可以用于判断管道是否存满
func main() {
    
    
	output := make(chan string, 5)
	// 子协程写数据
	go write(output)
	// 取数据
	for s := range output {
    
    
		fmt.Println("res: ", s)
		time.Sleep(time.Second)
	}
}

func write(ch chan string) {
    
    
	for {
    
    
		select {
    
    
		// 写数据
		case ch <- "hello":
			fmt.Println("write hello")
		// 无法写数据,则管道已满
		default:
			fmt.Println("channel full")
		}
		time.Sleep(time.Millisecond * 500)
	}
}

sync

sync.WaitGroup

以下代码无法保证 hello() 函数中的输出与 main 函数中的输出都执行:(除非 main 等待一段时间)

func hello() {
    
    
	fmt.Println("Hello Goroutine!")
}

func main() {
    
    
	go hello() // 启动一个goroutine执行hello()函数
	fmt.Println("main goroutine done!")
  // time.Sleep(time.Second)
}

在代码中生硬的使用 time.Sleep 是不合适的,Go 语言使用 sync.WaitGroup 来实现并发任务的同步。

方法名 功能
(wg *WaitGroup) Add(delta int) 计数器数字 + delta
(wg *WaitGroup) Done() 计数器数字 - 1
(wg *WaitGroup) Wait() 阻塞直到计数器变为 0

sync.WaitGroup 内部维护着一个计数器,计数器的值可以增加和减少。

  • 当启动了 N 个并发任务时,就将计数器值增加 N。
  • 每个任务完成时通过调用 Done() 方法将计数器减 1。
  • 通过调用 Wait() 来等待并发任务执行完。当计数器值为 0 时,表示所有并发任务完成。
var wg sync.WaitGroup

func hello() {
    
    
	defer wg.Done() // 计数器 - 1
	fmt.Println("Hello Goroutine!")
}

func main() {
    
    
	wg.Add(1)  // 计数器 + 1
	go hello() // 启动一个goroutine执行hello()函数
	wg.Wait() // 阻塞直到计数器变为0
  fmt.Println("main goroutine done!")
}
Hello Goroutine!
main goroutine done!

需要注意 sync.WaitGroup 是一个结构体,传递的时候要传递指针。

sync.Once

很多场景下需要确保某些操作在高并发场景下只执行一次,例如只加载一次配置文件、只关闭一次通道等。

Go 语言中的 sync 包中提供了一个针对只执行一次场景的解决方案 sync.Once

sync.Once 只有一个 Do 方法,其签名如下:

func (o *Once) Do(f func()) {
    
    }

注意:如果要执行的函数 f 需要传递参数就需要搭配闭包来使用。

加载配置文件示例

延迟一个开销很大的初始化操作到真正用到它的时候再执行是一个很好的实践。预先初始化一个变量(比如在 init 函数中完成初始化)会增加程序的启动耗时,而且有可能该变量用不上,那么这个初始化操作就不是必须要做的。

示例如下:

var icons map[string]image.Image

func loadIcons() {
    
    
    icons = map[string]image.Image{
    
    
        "left":  loadIcon("left.png"),
        "up":    loadIcon("up.png"),
        "right": loadIcon("right.png"),
        "down":  loadIcon("down.png"),
    }
}

// Icon 函数被多个goroutine调用时不是并发安全的
func Icon(name string) image.Image {
    
    
    if icons == nil {
    
    
        loadIcons()
    }
    return icons[name]
}

多个 goroutine 并发调用 Icon 函数时不是并发安全的,因为现代的编译器和 CPU 可能会在保证每个 goroutine 都满足串行一致的基础上自由地重排访问内存的顺序。loadIcons 函数可能会被重排为以下结果:

func loadIcons() {
    
    
    icons = make(map[string]image.Image) // nil
    icons["left"] = loadIcon("left.png")
    icons["up"] = loadIcon("up.png")
    icons["right"] = loadIcon("right.png")
    icons["down"] = loadIcon("down.png")
}

相当于并发情况下,每次执行 icons = make(map[string]image.Image) 操作会将 icons 置为 nil

使用 sync.Once 改造示例代码:

var icons map[string]image.Image

var loadIconsOnce sync.Once

func loadIcons() {
    
    
    icons = map[string]image.Image{
    
    
        "left":  loadIcon("left.png"),
        "up":    loadIcon("up.png"),
        "right": loadIcon("right.png"),
        "down":  loadIcon("down.png"),
    }
}

// Icon 函数是并发安全的
func Icon(name string) image.Image {
    
    
    loadIconsOnce.Do(loadIcons)
    return icons[name]
}

sync.Once 其实内部包含一个互斥锁和一个布尔值,互斥锁保证布尔值和数据的安全,而布尔值用来记录初始化是否完成。这样设计就能保证初始化操作的时候是并发安全的并且初始化操作也不会被执行多次。

sync.Map

Go 语言中内置的 map 不是并发安全的。示例如下:

var m = make(map[string]int)

func get(key string) int {
    
    
	return m[key]
}

func set(key string, value int) {
    
    
	m[key] = value
}

func main() {
    
    
	wg := sync.WaitGroup{
    
    }
	for i := 0; i < 20; i++ {
    
    
		wg.Add(1)
		go func(n int) {
    
    
			key := strconv.Itoa(n)
			set(key, n)
			fmt.Printf("k=:%v,v:=%v\n", key, get(key))
			wg.Done()
		}(i)
	}
	wg.Wait()
}

以上代码在 goroutine 较少时可能没有问题,并发多了以后会报错:

fatal error: concurrent map writes

这种场景需要为 map 加锁来保证并发的安全性,sync 包中提供了一个并发安全版 map:sync.Map

sync.Map 不需要使用 make 函数初始化就能直接使用。同时内置了 Store、Load、LoadOrStore、Delete、Range 等操作方法。

var m = sync.Map{
    
    }

func main() {
    
    
	wg := sync.WaitGroup{
    
    }
	for i := 0; i < 20; i++ {
    
    
		wg.Add(1)
		go func(n int) {
    
    
			key := strconv.Itoa(n)
			m.Store(key, n)
			value, _ := m.Load(key)
			fmt.Printf("k=:%v,v:=%v\n", key, value)
			wg.Done()
		}(i)
	}
	wg.Wait()
}

并发安全和锁

有时候代码中多个 goroutine 需要同时操作一个资源(临界区),这种情况会发生竞态问题

下面代码开启两个 goroutine 去累加 x 的值,这两个 goroutine 在访问和修改 x 变量的时候会存在数据竞争,导致最后的结果和预期的不符。

var x int64
var wg sync.WaitGroup

func add() {
    
    
	for i := 0; i < 5000; i++ {
    
    
		x = x + 1
	}
	wg.Done()
}

func main() {
    
    
	wg.Add(2)
	go add()
	go add()
	wg.Wait()
	fmt.Println(x) // 随机数
}

互斥锁

互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个 goroutine 可以访问共享资源。

使用互斥锁修复前面的代码:Go 语言中使用 sync 包的 Mutex 类型来实现互斥锁。

var x int64
var wg sync.WaitGroup
var lock sync.Mutex // 互斥锁

func add() {
    
    
	for i := 0; i < 5000; i++ {
    
    
		lock.Lock() // 加锁
		x = x + 1
		lock.Unlock() // 解锁
	}
	wg.Done()
}

func main() {
    
    
	wg.Add(2)
	go add()
	go add()
	wg.Wait()
	fmt.Println(x) // 10000
}

使用互斥锁能够保证同一时间有且只有一个 goroutine 进入临界区,其他 goroutine 则在等待锁。

当互斥锁释放后,等待的 goroutine 才能进入临界区,多个 goroutine 等待时,唤醒策略是随机的。

读写互斥锁

互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当需要并发的去读取一个资源不涉及资源修改的时候,没有必要加锁。

读写锁分为两种:读锁、写锁。

  • 当一个 goroutine 获取读锁后,其他 goroutine 如果获取到 读锁 则继续获得,获取 写锁 则等待。
  • 当一个 goroutine 获取写锁后,其他 goroutine 无论获取 读锁 还是 写锁 都会等待。

读写锁示例:Go 语言使用 sync 包中的 RWMutex 类型实现读写锁。

var (
	x      int64
	wg     sync.WaitGroup
	rwlock sync.RWMutex // 读写互斥锁
)

func write() {
    
    
	rwlock.Lock() // 加写锁
	x = x + 1
	// 假设写操作耗时10毫秒
	time.Sleep(10 * time.Millisecond)
	rwlock.Unlock() // 解写锁
	wg.Done()
}

func read() {
    
    
	rwlock.RLock() // 加读锁
	// 假设读操作耗时1毫秒
	time.Sleep(time.Millisecond)
	rwlock.RUnlock() // 解读锁
	wg.Done()
}

func main() {
    
    
	start := time.Now()
	for i := 0; i < 10; i++ {
    
    
		wg.Add(1)
		go write()
	}
	for i := 0; i < 1000; i++ {
    
    
		wg.Add(1)
		go read()
	}
	wg.Wait()
	end := time.Now()
	fmt.Println(end.Sub(start))
}

读写锁非常适合 读多写少 的场景,如果读和写的操作差不多,读写锁无法发挥优势。

原子操作

代码中的加锁操作由于涉及内核态的上下文切换会比较耗时,代价比较大。

针对基本数据类型还可以使用原子操作来保证并发安全。

原子操作:Go 语言提供的方法,由于它在用户态就可以完成,所以性能比加锁更好。

Go 语言中原子操作由内置的标准库 sync/atomic 提供。

atomic 包

方法 解释
func LoadInt32(addr *int32) (val int32)
func LoadInt64(addr *int64) (val int64)
func LoadUint32(addr*uint32) (val uint32)
func LoadUint64(addr*uint64) (val uint64)
func LoadUintptr(addr*uintptr) (val uintptr)
func LoadPointer(addr*unsafe.Pointer) (val unsafe.Pointer)
读取操作
func StoreInt32(addr *int32, val int32)
func StoreInt64(addr *int64, val int64)
func StoreUint32(addr *uint32, val uint32)
func StoreUint64(addr *uint64, val uint64)
func StoreUintptr(addr *uintptr, val uintptr)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
写入操作
func AddInt32(addr *int32, delta int32) (new int32)
func AddInt64(addr *int64, delta int64) (new int64)
func AddUint32(addr *uint32, delta uint32) (new uint32)
func AddUint64(addr *uint64, delta uint64) (new uint64)
func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)
修改操作
func SwapInt32(addr *int32, new int32) (old int32)
func SwapInt64(addr *int64, new int64) (old int64)
func SwapUint32(addr *uint32, new uint32) (old uint32)
func SwapUint64(addr *uint64, new uint64) (old uint64)
func SwapUintptr(addr *uintptr, new uintptr) (old uintptr)
func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)
交换操作
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)
func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)
比较并交换操作

示例:比较互斥锁与原子操作的性能

var x int64
var l sync.Mutex
var wg sync.WaitGroup

// 普通版加函数
func add() {
    
    
	x++
	wg.Done()
}

// 互斥版加函数
func mutexAdd() {
    
    
	l.Lock()
	x++
	l.Unlock()
	wg.Done()
}

// 原子版加函数
func atomicAdd() {
    
    
	atomic.AddInt64(&x, 1)
	wg.Done()
}

func main() {
    
    
	start := time.Now()
	for i := 0; i < 10000; i++ {
    
    
		wg.Add(1)
		// go add() // 普通版add函数 不是并发安全的
		// go mutexAdd() // 加锁版add函数 是并发安全的
		go atomicAdd() // 原子操作版add函数 是并发安全的,性能优于加锁版
	}
	wg.Wait()
	end := time.Now()
	fmt.Printf("x: %v\n", x)
	fmt.Println(end.Sub(start))
}

atomic 包提供了底层的原子级内存操作,对于同步算法的实现很有用。

爬虫小案例

爬虫步骤

  • 明确目标:明确在哪个网站搜索
  • :爬下内容
  • :筛选想要的
  • 处理数据:根据需求处理
// 这个只是一个简单的版本只是获取QQ邮箱并且没有进行封装操作,另外爬出来的数据也没有进行去重操作
var (
	// 提取QQ邮箱的正则表达式:\d是数字
	reQQEmail = `(\d+)@qq.com`
)

// 爬邮箱
func GetEmail() {
    
    
	// 1.去网站拿数据
	resp, err := http.Get("https://tieba.baidu.com/p/6051076813?red_tag=1573533731")
	HandleError(err, "http.Get url")
	defer resp.Body.Close()
	// 2.读取页面内容
	pageBytes, err := ioutil.ReadAll(resp.Body)
	HandleError(err, "ioutil.ReadAll")
	// 字节转字符串
	pageStr := string(pageBytes)
	// fmt.Println(pageStr)
	// 3.过滤数据,过滤qq邮箱
	re := regexp.MustCompile(reQQEmail)
	// -1代表取全部
	results := re.FindAllStringSubmatch(pageStr, -1)
	fmt.Println(results)

	// 遍历结果
	for _, result := range results {
    
    
		fmt.Println("email:", result[0])
		fmt.Println("qq:", result[1])
	}
}

// 处理异常
func HandleError(err error, why string) {
    
    
	if err != nil {
    
    
		fmt.Println(why, err)
	}
}
func main() {
    
    
	GetEmail()
}

正则表达式

文档:https://studygolang.com/pkgdoc

API:

// 传入正则表达式,得到正则表达试对象
re := regexp.MustCompile(reStr)

// 用正则对象,获取页面,srcStr 是页面内容,-1代表获取全部
ret := re.FindAllStringSubmatch(srcStr, -1)

案例:

  • 爬邮箱
  • 爬链接
  • 爬手机号
  • 爬身份证号
  • 爬图片
var (
	// w 代表大小写字母+数字+下划线
	reEmail = `\w+@\w+\.\w+`
	// s? 有或者没有s
	// + 代表出1次或多次
	// \s\S 各种字符
	// +? 代表贪婪模式
	reLinke  = `href="(https?://[\s\S]+?)"`
	rePhone  = `1[3456789]\d\s?\d{4}\s?\d{4}`
	reIdcard = `[123456789]\d{5}((19\d{2})|(20[01]\d))((0[1-9])|(1[012]))((0[1-9])|([12]\d)|(3[01]))\d{3}[\dXx]`
	reImg    = `https?://[^"]+?(\.((jpg)|(png)|(jpeg)|(gif)|(bmp)))`
)

// 处理异常
func HandleError(err error, why string) {
    
    
	if err != nil {
    
    
		fmt.Println(why, err)
	}
}

// 抽取根据url获取页面的 HTML内容
func GetPageStr(url string) (pageStr string) {
    
    
	// 1.往网址发送请求,获取响应
	resp, err := http.Get(url) // resp - HTTP请求的响应
	HandleError(err, "http.Get url")
	defer resp.Body.Close()
	// 2.读取页面内容 (读出来是字节)
	pageBytes, err := io.ReadAll(resp.Body)
	HandleError(err, "ioutil.ReadAll")
	// 3.字节转字符串
	pageStr = string(pageBytes) // 网页的 HTML 内容字符串
	return pageStr
}

func main() {
    
    
	// 2.爬邮箱
	// GetEmail2("https://tieba.baidu.com/p/6051076813?red_tag=1573533731")
	// 3.爬链接
	// GetLink("http://www.baidu.com/s?wd=%E8%B4%B4%E5%90%A7%20%E7%95%99%E4%B8%8B%E9%82%AE%E7%AE%B1&rsv_spt=1&rsv_iqid=0x98ace53400003985&issp=1&f=8&rsv_bp=1&rsv_idx=2&ie=utf-8&tn=baiduhome_pg&rsv_enter=1&rsv_dl=ib&rsv_sug2=0&inputT=5197&rsv_sug4=6345")
	// 4.爬手机号
	GetPhone("https://www.zhaohaowang.com/")
	// 5.爬身份证号
	// GetIdCard("https://henan.qq.com/a/20171107/069413.htm")
	// 6.爬图片
	// GetImg("http://image.baidu.com/search/index?tn=baiduimage&ps=1&ct=201326592&lm=-1&cl=2&nc=1&ie=utf-8&word=%E7%BE%8E%E5%A5%B3")
}

// 爬邮箱
func GetEmail2(url string) {
    
    
	pageStr := GetPageStr(url)
	re := regexp.MustCompile(reEmail)
	results := re.FindAllStringSubmatch(pageStr, -1)
	for _, result := range results {
    
    
		fmt.Println(result)
	}
}

// 爬身份证号
func GetIdCard(url string) {
    
    
	pageStr := GetPageStr(url)
	re := regexp.MustCompile(reIdcard)
	results := re.FindAllStringSubmatch(pageStr, -1)
	for _, result := range results {
    
    
		fmt.Println(result)
	}
}

// 爬链接
func GetLink(url string) {
    
    
	pageStr := GetPageStr(url)
	re := regexp.MustCompile(reLinke)
	results := re.FindAllStringSubmatch(pageStr, -1)
	for _, result := range results {
    
    
		fmt.Println(result[1])
	}
}

// 爬手机号
func GetPhone(url string) {
    
    
	pageStr := GetPageStr(url)
	re := regexp.MustCompile(rePhone)
	results := re.FindAllStringSubmatch(pageStr, -1)
	for _, result := range results {
    
    
		fmt.Println(result)
	}
}

// 爬图片
func GetImg(url string) {
    
    
	pageStr := GetPageStr(url)
	re := regexp.MustCompile(reImg)
	results := re.FindAllStringSubmatch(pageStr, -1)
	for _, result := range results {
    
    
		fmt.Println(result[0])
	}
}

并发爬取美图

目标网站:https://www.bizhizu.cn/shouji/tag-%E5%8F%AF%E7%88%B1/1.html

失效就换一个

package main

import (
	"fmt"
	"io/ioutil"
	"net/http"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"time"
)

func HandleError(err error, why string) {
    
    
	if err != nil {
    
    
		fmt.Println(why, err)
	}
}

// 下载图片,传入的是图片名称
func DownloadFile(url string, filename string) (ok bool) {
    
    
	resp, err := http.Get(url)
	HandleError(err, "http.get.url")
	defer resp.Body.Close()
	bytes, err := ioutil.ReadAll(resp.Body)
	HandleError(err, "resp.body")
	// 目录必须存在,否则会下载失败
	filename = "/Users/yusael/code/golang/project/GolangStudy/imgs/" + filename
	fmt.Printf("filename: %v\n", filename)
	// 写出数据
	err = ioutil.WriteFile(filename, bytes, 0666)
	if err != nil {
    
    
		return false
	} else {
    
    
		return true
	}
}

// 并发爬思路:
// 1.初始化数据管道
// 2.爬虫写出:26个协程向管道中添加图片链接
// 3.任务统计协程:检查26个任务是否都完成,完成则关闭数据管道
// 4.下载协程:从管道里读取链接并下载

var (
	// 存放图片链接的数据管道
	chanImageUrls chan string
	waitGroup     sync.WaitGroup
	// 用于监控协程
	chanTask chan string
	reImg    = `https?://[^"]+?(\.((jpg)|(png)|(jpeg)|(gif)|(bmp)))`
)

func main() {
    
    
	// DownloadFile("http://i1.shaodiyejin.com/uploads/tu/201909/10242/e5794daf58_4.jpg", "1.jpg")

	// 1.初始化管道
	chanImageUrls = make(chan string, 1000000)
	chanTask = make(chan string, 26)
	// 2.爬虫协程
	for i := 1; i < 27; i++ {
    
    
		waitGroup.Add(1)
		// 分页爬取
		go getImgUrls("https://www.bizhizu.cn/shouji/tag-%E5%8F%AF%E7%88%B1/" + strconv.Itoa(i) + ".html")
	}
	// 3.任务统计协程,统计26个任务是否都完成,完成则关闭管道
	waitGroup.Add(1)
	go CheckOK()
	// 4.下载协程:从管道中读取链接并下载
	for i := 0; i < 5; i++ {
    
    
		waitGroup.Add(1)
		go DownloadImg()
	}
	waitGroup.Wait()
}

// 下载图片
func DownloadImg() {
    
    
	for url := range chanImageUrls {
    
    
		filename := GetFilenameFromUrl(url)
		ok := DownloadFile(url, filename)
		if ok {
    
    
			fmt.Printf("%s 下载成功\n", filename)
		} else {
    
    
			fmt.Printf("%s 下载失败\n", filename)
		}
	}
	waitGroup.Done()
}

// 截取url名字
func GetFilenameFromUrl(url string) (filename string) {
    
    
	// url: https://uploadfile.bizhizu.cn/up/82/d0/5f/82d05f8873fbf3229b9c2d6bd69a46ad.jpg
	// 返回最后一个/的位置
	lastIndex := strings.LastIndex(url, "/")
	// 切出来
	filename = url[lastIndex+1:]
	// 时间戳解决重名
	timePrefix := strconv.Itoa(int(time.Now().UnixNano()))
	filename = timePrefix + "_" + filename
	return
}

// 任务统计协程
func CheckOK() {
    
    
	var count int
	for {
    
    
		url := <-chanTask
		fmt.Printf("%s 完成了爬取任务\n", url)
		count++
		if count == 26 {
    
    
			close(chanImageUrls)
			break
		}
	}
	waitGroup.Done()
}

// 爬图片链接到管道
// url是传的整页链接
func getImgUrls(url string) {
    
    
	urls := getImgs(url)
	// 遍历切片里所有链接,存入数据管道
	for _, url := range urls {
    
    
		chanImageUrls <- url
	}
	// 标识当前协程完成
	// 每完成一个任务,写一条数据
	// 用于监控协程知道已经完成了几个任务
	chanTask <- url
	waitGroup.Done()
}

// 获取当前页图片链接
// @return urls 图片链接切片
func getImgs(url string) (urls []string) {
    
    
	pageStr := GetPageStr(url)
	// 根据正则过滤出图片链接
	re := regexp.MustCompile(reImg)
	results := re.FindAllStringSubmatch(pageStr, -1)
	fmt.Printf("共找到%d条结果\n", len(results))
	for _, result := range results {
    
    
		url := result[0]
		urls = append(urls, url)
	}
	return
}

// 抽取根据url获取内容
func GetPageStr(url string) (pageStr string) {
    
    
	// 1.发送HTTP请求,获取响应
	resp, err := http.Get(url)
	HandleError(err, "http.Get url")
	defer resp.Body.Close()
	// 2.读取页面内容
	pageBytes, err := ioutil.ReadAll(resp.Body)
	HandleError(err, "ioutil.ReadAll")
	// 3.字节转字符串
	pageStr = string(pageBytes)
	return pageStr
}

猜你喜欢

转载自blog.csdn.net/weixin_43734095/article/details/123008927