🗒️Golang 并发和并行
00 分钟
2023-9-21
2023-12-9
type
status
date
slug
summary
tags
category
icon
password

Golang goroutine channel 实现并发和并行

为什么要使用 goroutine

需求:要统计 1-10000000 的数字中那些是素数,并打印这些素数?
素数:就是除了 1 和它本身不能被其他数整除的数实现方法:
1、传统方法,通过一个 for 循环判断各个数是不是素数
2、使用并发或者并行的方式,将统计素数的任务分配给多个 goroutine 去完成,这个时候就用到了 goroutine
3、goroutine 结合 channel

进程、线程以及并行、并发

关于进程和线程

进程(Process)就是程序在操作系统中的一次执行过程,是系统进行资源分配和调度的基本单位,进程是一个动态概念,是程序在执行过程中分配和管理资源的基本单位,每一个进程都有一个自己的地址空间。一个进程至少有 5 种基本状态,它们是:初始态,执行态,等待状态,就绪状态,终止状态。通俗的讲进程就是一个正在执行的程序。
线程 是进程的一个执行实例,是程序执行的最小单元,它是比进程更小的能独立运行的基本单位一个进程可以创建多个线程,同一个进程中的多个线程可以并发执行,一个程序要运行的话至少有一个进程。
notion image
notion image

关于并行和并发

并发:多个线程同时竞争一个位置,竞争到的才可以执行,每一个时间段只有一个线程在执行。
并行:多个线程可以同时执行,每一个时间段,可以有多个线程同时执行。
通俗的讲多线程程序在单核 CPU 上面运行就是并发,多线程程序在多核 CUP 上运行就是并行,如果线程数大于 CPU 核数,则多线程程序在多个 CPU 上面运行既有并行又有并发
notion image
notion image

Golang 中的协程**(** goroutine )以及主线程

golang 中的主线程:(可以理解为线程/也可以理解为进程),在一个 Golang 程序的主线程
上可以起多个协程Golang 中多协程可以实现并行或者并发。
  • *协程:**可以理解为用户级线程,这是对内核透明的,也就是系统并不知道有协程的存在,是完全由用户自己的程序进行调度的。Golang 的一大特色就是从语言层面原生支持协程,在函数或者方法前面加 go 关键字就可创建一个协程。可以说 Golang 中的协程就是goroutine。
notion image
Golang 中的多协程有点类似其他语言中的多线程。
  • *多协程和多线程:**Golang 中每个 goroutine (协程) 默认占用内存远比 Java 、C 的线程少。OS 线程(操作系统线程)一般都有固定的栈内存(通常为 2MB 左右),一个 goroutine (协程) 占用内存非常小,只有 2KB 左右,多协程 goroutine 切换调度开销方面远比线程要少。这也是为什么越来越多的大公司使用 Golang 的原因之一。

Goroutine 的使用以及 sync.WaitGroup

并行执行需求:
在主线程(可以理解成进程)中,开启一个 goroutine, 该协程每隔 50 毫秒秒输出 "你好 golang" 在主线程中也每隔 50 毫秒输出"你好 golang", 输出 10 次后,退出程序,要求主线程和goroutine 同时执行。
上面代码看上去没有问题,但是要注意主线程执行完毕后即使协程没有执行完毕,程序会退出,所以我们需要对上面代码进行改造。
notion image
sync.WaitGroup 可以实现主线程等待协程执行完毕。

启动多个 Goroutine

在 Go 语言中实现并发就是这样简单,我们还可以启动多个 goroutine。让我们再来一个例子:
(这里使用了 sync.WaitGroup 来实现等待 goroutine 执行完毕)
多次执行上面的代码,会发现每次打印的数字的顺序都不一致。这是因为 10 个 goroutine是并发执行的,而 goroutine 的调度是随机的。

设置 Golang 并行运行的时候占用的 cup 数量

Go 运行时的调度器使用 GOMAXPROCS 参数来确定需要使用多少个 OS 线程来同时执行 Go代码。默认值是机器上的 CPU 核心数。例如在一个 8 核心的机器上,调度器会把 Go 代码同时调度到 8 个 OS 线程上。Go 语言中可以通过 runtime.GOMAXPROCS()函数设置当前程序并发时占用的 CPU 逻辑核心数。
Go1.5 版本之前,默认使用的是单核心执行。Go1.5 版本之后,默认使用全部的 CPU 逻辑核心数。

Goroutine 统计素数

需求:要统计 1-120000 的数字中那些是素数?
1 、通过传统的 for 循环来统计
2 、goroutine 开启多个协程统计
  • *问题:**上面我们使用了 goroutine 已经能大大的提升新能了,但是如果我们想统计数据和打印数据同时进行,这个时候如何实现呢,这个时候我们就可以使用管道。

Channel 管道

管道是 Golang 在语言级别上提供的 goroutine 间的通讯方式,我们可以使用 channel 在多个 goroutine 之间传递消息。如果说 goroutine 是 Go 程序并发的执行体,channel 就是它们之间的连接。channel 是可以让一个 goroutine 发送特定值到另一个 goroutine 的通信机制。
Golang 的并发模型是 CSP(Communicating Sequential Processes),提倡通过通信共享内存而不是通过共享内存而实现通信
Go 语言中的管道(channel)是一种特殊的类型。管道像一个传送带或者队列,总是遵循先入先出(First In First Out **)**的规则,保证收发数据的顺序。每一个管道都是一个具体类型的导管,也就是声明 channel 的时候需要为其指定元素类型。

channel 类型

channel 是一种类型,一种引用类型。声明管道类型的格式如下:
举几个例子:

创建 channel

声明的管道后需要使用 make 函数初始化之后才能使用。
创建 channel 的格式如下:
举几个例子:

channel 操作

管道有发送(send)、接收(receive)和关闭(close)三种操作。发送和接收都使用<-符号。
现在我们先使用以下语句定义一个管道:
1、发送(将数据放在管道内)
将一个值发送到管道中。
2、接收(从管道内取值)
从一个管道中接收值。
3、关闭管道
我们通过调用内置的 close 函数来关闭管道。
关于关闭管道需要注意的事情是,只有在通知接收方 goroutine 所有的数据都发送完毕的时候才需要关闭管道。管道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要做的,但关闭管道不是必须的。
关闭后的管道有以下特点:
  1. 对一个关闭的管道再发送值就会导致 panic。
  1. 对一个关闭的管道进行接收会一直获取值直到管道为空。
  1. 对一个关闭的并且没有值的管道执行接收操作会得到对应类型的零值。
  1. 关闭一个已经关闭的管道会导致 panic。
4、管道阻塞
1、无缓冲的管道:
如果创建管道的时候没有指定容量,那么我们可以叫这个管道为无缓冲的管道无缓冲的管道又称为阻塞的管道。我们来看一下下面的代码:
上面这段代码能够通过编译,但是执行的时候会出现以下错误:
2、有缓冲的管道:
解决上面问题的方法还有一种就是使用有缓冲区的管道。我们可以在使用 make 函数初始化管道的时候为其指定管道的容量,例如:
只要管道的容量大于零,那么该管道就是有缓冲的管道,管道的容量表示管道中能存放元素的数量。就像你小区的快递柜只有那么个多格子,格子满了就装不下了,就阻塞了,等到别人取走一个快递员就能往里面放一个。
管道阻塞具体代码如下:
解决办法:
5、for range 从管道循环取值
当向管道中发送完数据时,我们可以通过 close 函数来关闭管道。
当管道被关闭时,再往该管道发送值会引发 panic,从该管道取值的操作会先取完管道中的值,再然后取到的值一直都是对应类型的零值。那如何判断一个管道是否被关闭了呢?
我们来看下面这个例子:
从上面的例子中我们看到有两种方式在接收值的时候判断该管道是否被关闭,不过我们通常使用的是 for range 的方式。使用 for range 遍历管道,当管道被关闭的时候就会退出 for range。

Goroutine 结合 Channel 管道

需求 1:定义两个方法,一个方法给管道里面写数据,一个给管道里面读取数据。要求同步进行。
1、开启一个 fn1 的的协程给向管道 inChan 中写入 100 条数据
2、开启一个 fn2 的协程读取 inChan 中写入的数据
3、注意:fn1 和 fn2 同时操作一个管道
4、主线程必须等待操作完成后才可以退出
需求 2 :goroutine 结合 channel 实现统计 1-120000 的数字中那些是素数?

单向管道

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

select 多路复用

传统的方法在遍历管道时,如果不关闭会阻塞而导致 deadlock,在实际开发中,可能我们不好确定什么关闭该管道。
你也许会写出如下代码使用遍历的方式来实现:
这种方式虽然可以实现从多个管道接收值的需求,但是运行性能会差很多。为了应对这种场景,Go 内置了 select 关键字,可以同时响应多个管道的操作。
select 的使用类似于 switch 语句,它有一系列 case 分支和一个默认的分支。每个 case 会对应一个管道的通信(接收或发送)过程。select 会一直等待,直到某个 case 的通信操作完成时,就会执行 case 分支对应的语句。具体格式如下:
举个小例子来演示下 select 的使用:
使用 select 语句能提高代码的可读性。
  • 可处理一个或多个 channel 的发送/接收操作。
  • 如果多个 ce 同时满足,select 会随机选择一个。
  • 对于没有 case 的 select{}会一直等待,可用于阻塞 main 函数。

Golang 并发安全和锁

  • *需求:**现在要计算 1-60 的各个数的阶乘,并且把各个数的阶乘放入到 map 中。最后显示出来。要求使用 goroutine 完成。
思路
  1. 编写一个函数,来计算各个数的阶乘,并放入到 map 中.
  1. 启动多个协程,将统计的将结果放入到 map 中
只使用 Goroutine 实现,运行的时候可能会出现资源争夺问题 concurrent map writes

1、互斥锁

互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个 goroutine 可以访问共享资源。Go 语言中使用 sync 包的 Mutex 类型来实现互斥锁。 使用互斥锁来修复上面代码的问题:
使用互斥锁能够保证同一时间有且只有一个 goroutine 进入临界区,其他的 goroutine 则在等待锁;当互斥锁释放后,等待的 goroutine 才可以获取锁进入临界区,多个 goroutine 同时等待一个锁时,唤醒的策略是随机的。
虽然使用互斥锁能解决资源争夺问题,但是并不完美,通过全局变量加锁同步来实现通讯,并不利于多个协程对全局变量的读写操作。这个时候我们也可以通过另一种方式来实现上面的功能管道(Channel)。

2、读写互斥锁

互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的去读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。读写锁在 Go 语言中使用 sync 包中的 RWMutex 类型。
读写锁分为两种:读锁和写锁。当一个 goroutine 获取读锁之后,其他的 goroutine 如果是获取读锁会继续获得锁,如果是获取写锁就会等待;当一个 goroutine 获取写锁之后,其他的goroutine 无论是获取读锁还是写锁都会等待。
读写锁示例:
需要注意的是读写锁非常适合读多写少的场景,如果读和写的操作差别不大,读写锁的优势就发挥不出来。

Goroutine Recover 解决协程中出现的 Panic

 
 

评论
  • Twikoo