有人把Go语言比作 21 世纪的C语言,第一是因为Go语言设计简单,第二则是因为 21 世纪最重要的就是并发程序设计,而 Go 从语言层面就支持并发。同时实现了自动垃圾回收机制。Go语言的并发机制运用起来非常简便,在启动并发的方式上直接添加了语言级的关键字就可以实现,和其他编程语言相比更加轻量。
1、并发技术
1.1、操作系统的进程、线程发展
1.2、进程概念
我们都知道计算机的核心是CPU,它承担了所有的计算任务;而操作系统是计算机的管理者,它负责任务的调度、资源的分配和管理,统领整个计算机硬件;应用程序则是具有某种功能的程序,程序是运行于操作系统之上的。
进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。
进程是一种抽象的概念,从来没有统一的标准定义。进程一般由程序、数据集合和进程控制块三部分组成。
程序用于描述进程要完成的功能,是控制进程执行的指令集;
数据集合是程序在执行时所需要的数据和工作区;
程序控制块(Program Control Block,简称PCB),包含进程的描述信息和控制信息,是进程存在的唯一标志。
进程具有的特征:
动态性:进程是程序的一次执行过程,是临时的,有生命期的,是动态产生,动态消亡的;
并发性:任何进程都可以同其他进程一起并发执行;
独立性:进程是系统进行资源分配和调度的一个独立单位;
结构性:进程由程序、数据和进程控制块三部分组成。
1.3、线程的概念
在早期的操作系统中并没有线程的概念,进程是能拥有资源和独立运行的最小单位,也是程序执行的最小单位。任务调度采用的是时间片轮转的抢占式调度方式,而进程是任务调度的最小单位,每个进程有各自独立的一块内存,使得各个进程之间内存地址相互隔离。
后来,随着计算机的发展,对CPU的要求越来越高,进程之间的切换开销较大,已经无法满足越来越复杂的程序的要求了。于是就发明了线程。
线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是处理器调度和分派的基本单位。一个进程可以有一个或多个线程,各个线程之间共享程序的内存空间(也就是所在进程的内存空间)。一个标准的线程由线程ID、当前指令指针(PC)、寄存器和堆栈组成。而进程由内存空间(代码、数据、进程空间、打开的文件)和一个或多个线程组成。
1.4、任务调度
线程是什么?要理解这个概念,需要先了解一下操作系统的一些相关概念。大部分操作系统(如Windows、Linux)的任务调度是采用时间片轮转的抢占式调度方式。
在一个进程中,当一个线程任务执行几毫秒后,会由操作系统的内核(负责管理各个任务)进行调度,通过硬件的计数器中断处理器,让该线程强制暂停并将该线程的寄存器放入内存中,通过查看线程列表决定接下来执行哪一个线程,并从内存中恢复该线程的寄存器,最后恢复该线程的执行,从而去执行下一个任务。
上述过程中,任务执行的那一小段时间叫做时间片,任务正在执行时的状态叫运行状态,被暂停的线程任务状态叫做就绪状态,意为等待下一个属于它的时间片的到来。
这种方式保证了每个线程轮流执行,由于CPU的执行效率非常高,时间片非常短,在各个任务之间快速地切换,给人的感觉就是多个任务在“同时进行”,这也就是我们所说的并发。
1.5、进程与线程的区别
前面讲了进程与线程,但可能你还觉得迷糊,感觉他们很类似。的确,进程与线程有着千丝万缕的关系,下面就让我们一起来理一理:
线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;
一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;
进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号),某进程内的线程在其它进程不可见;
调度和切换:线程上下文切换比进程上下文切换要快得多。
1.6、线程的生命周期
当线程的数量小于处理器的数量时,线程的并发是真正的并发,不同的线程运行在不同的处理器上。但当线程的数量大于处理器的数量时,线程的并发会受到一些阻碍,此时并不是真正的并发,因为此时至少有一个处理器会运行多个线程。
在单个处理器运行多个线程时,并发是一种模拟出来的状态。操作系统采用时间片轮转的方式轮流执行每一个线程。现在,几乎所有的现代操作系统采用的都是时间片轮转的抢占式调度方式,如我们熟悉的Unix、Linux、Windows及macOS等流行的操作系统。
我们知道线程是程序执行的最小单位,也是任务执行的最小单位。在早期只有进程的操作系统中,进程有五种状态,创建、就绪、运行、阻塞(等待)、退出。早期的进程相当于现在的只有单个线程的进程,那么现在的多线程也有五种状态,现在的多线程的生命周期与早期进程的生命周期类似。
线程的生命周期
# 创建:一个新的线程被创建,等待该线程被调用执行;
# 就绪:时间片已用完,此线程被强制暂停,等待下一个属于它的时间片到来;
# 运行:此线程正在执行,正在占用时间片;
# 阻塞:也叫等待状态,等待某一事件(如IO或另一个线程)执行完;
# 退出:一个线程完成任务或者其他终止条件发生,该线程终止进入退出状态,退出状态释放该线程所分配的资源。
1.7、协程(Coroutines)
协程,英文Coroutines,是一种基于线程之上,但又比线程更加轻量级的存在,这种由程序员自己写程序来管理的轻量级线程叫做『用户空间线程』,具有对内核来说不可见的特性。因为是自主开辟的异步任务,所以很多人也更喜欢叫它们纤程(Fiber),或者绿色线程(GreenThread)。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。
协程解决的是线程的切换开销和内存开销的问题
* 用户空间 首先是在用户空间, 避免内核态和用户态的切换导致的成本。
* 由语言或者框架层调度
* 更小的栈空间允许创建大量实例(百万级别)
1.8、三种线程模型
无论语言层面何种并发模型,到了操作系统层面,一定是以线程的形态存在的。而操作系统根据资源访问权限的不同,体系架构可分为用户空间和内核空间;内核空间主要操作访问CPU资源、I/O资源、内存资源等硬件资源,为上层应用程序提供最基本的基础资源,用户空间呢就是上层应用程序的固定活动空间,用户空间不可以直接访问资源,必须通过“系统调用”、“库函数”或“Shell脚本”来调用内核空间提供的资源。我们现在的计算机语言,可以狭义的认为是一种“软件”,它们中所谓的“线程”,往往是用户态的线程,和操作系统本身内核态的线程(简称KSE),还是有区别的。
一、用户级线程模型(M : 1)
将多个用户级线程映射到一个内核级线程,线程管理在用户空间完成。此模式中,用户级线程对操作系统不可见(即透明)。
优点: 这种模型的好处是线程上下文切换都发生在用户空间,避免的模态切换(mode switch),从而对于性能有积极的影响。
缺点:
无法利用多核资源:协程的本质是个单线程,它不能同时将 单个CPU 的多个核用上,协程需要和进程配合才能运行在多CPU上.当然我们日常所编写的绝大部分应用都没有这个必要,除非是cpu密集型应用。
进行阻塞(Blocking)操作(如IO时)会阻塞掉整个程序
二、内核级线程模型(1:1)
将每个用户级线程映射到一个内核级线程。
每个线程由内核调度器独立的调度,所以如果一个线程阻塞则不影响其他的线程。 优点:在多核处理器的硬件的支持下,内核空间线程模型支持了真正的并行,当一个线程被阻塞后,允许另一个线程继续执行,所以并发能力较强。
缺点:每创建一个用户级线程都需要创建一个内核级线程与其对应,这样创建线程的开销比较大,会影响到应用程序的性能。
三、两级线程模型(M:N)
内核线程和用户线程的数量比为 M : N,内核用户空间综合了前两种的优点。
一个进程中可以对应多个内核级线程,但是进程中的线程不和内核线程一一对应;这种线程模型会先创建多个内核级线程,然后用自身的用户级线程去对应创建的多个内核级线程,自身的用户级线程需要本身程序去调度,内核级的线程交给操作系统内核去调度。这使得大部分的线程上下文切换都发生在用户空间,而多个内核线程又可以充分利用处理器资源。
Go语言的线程模型就是一种特殊的两级线程模型(GPM调度模型)。
2、goroutine的基本使用
goroutine 是 Go语言中的轻量级线程实现,由 Go 运行时(runtime)管理。Go 程序会智能地将 goroutine 中的任务合理地分配给每个 CPU。Go 程序从 main 包的 main() 函数开始,在程序启动时,Go 程序就会为 main() 函数创建一个默认的 goroutine。
package main
import (
"fmt"
"time"
)
func foo() {
fmt.Println("foo")
time.Sleep(time.Second)
fmt.Println("foo end")
}
func bar() {
fmt.Println("bar")
time.Sleep(time.Second*2)
fmt.Println("bar end")
}
func main() {
go foo()
bar()
}
2.2、sync.WaitGroup
Go语言中可以使用sync.WaitGroup来实现并发任务的同步。 sync.WaitGroup有以下几个方法:
方法名 | 功能 |
---|---|
(wg * WaitGroup) Add(delta int) | 计数器+delta |
(wg *WaitGroup) Done() | 计数器-1 |
(wg *WaitGroup) Wait() | 阻塞直到计数器变为0 |
sync.WaitGroup内部维护着一个计数器,计数器的值可以增加和减少。例如当我们启动了N 个并发任务时,就将计数器值增加N。每个任务完成时通过调用Done()方法将计数器减1。通过调用Wait()来等待并发任务执行完,当计数器值为0时,表示所有并发任务已经完成。
package main
import (
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
func foo() {
defer wg.Done()
fmt.Println("foo")
time.Sleep(time.Second)
fmt.Println("foo end")
}
func bar() {
defer wg.Done()
fmt.Println("bar")
time.Sleep(time.Second*2)
fmt.Println("bar end")
}
func main() {
start:=time.Now()
wg.Add(2)
go foo()
go bar()
wg.Wait()
fmt.Println("程序结束,运行时间为",time.Now().Sub(start))
}
2.3、GOMAXPROCS
Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码。默认值是机器上的CPU核心数。例如在一个8核心的机器上,调度器会把Go代码同时调度到8个OS线程上(GOMAXPROCS是m:n调度中的n)。
Go语言中可以通过runtime.GOMAXPROCS()函数设置当前程序并发时占用的CPU逻辑核心数。
Go1.5版本之前,默认使用的是单核心执行。Go1.5版本之后,默认使用全部的CPU逻辑核心数。
我们可以通过将任务分配到不同的CPU逻辑核心上实现并行的效果,这里举个例子:
package main
import (
"fmt"
"runtime"
"sync"
)
var wg sync.WaitGroup
func foo() {
for i := 1; i < 10; i++ {
fmt.Println("A:", i)
//time.Sleep(time.Millisecond*20)
}
wg.Done()
}
func bar() {
for i := 1; i < 10; i++ {
fmt.Println("B:", i)
//time.Sleep(time.Millisecond*30)
}
wg.Done()
}
func main() {
wg.Add(2)
fmt.Println(runtime.NumCPU())
runtime.GOMAXPROCS(1)// 改为4
go foo()
go bar()
wg.Wait()
}
14.3、GPM调度器
GPM是GO语言运行时(runtime)层面得实现,是go语言自己实现得一套调度系统 区别于操作系统调度得OS线程
M指的是Machine,一个M直接关联了一个内核线程。由操作系统管理。 P指的是”processor”,代表了M所需的上下文环境,也是处理用户级代码逻辑的处理器。它负责衔接M和G的调度上下文,将等待执行的G与M对接。 G指的是Goroutine,其实本质上也是一种轻量级的线程。包括了调用栈,重要的调度信息,例如channel等。
P的数量由环境变量中的GOMAXPROCS决定,通常来说它是和核心数对应,例如在4Core的服务器上回启动4个线程。G会有很多个,每个P会将Goroutine从一个就绪的队列中做Pop操作,为了减小锁的竞争,通常情况下每个P会负责一个队列。
Goroutine调度策略
每次调用go的时候,都会:
/*
A、创建一个G对象,加入到本地队列或者全局队列
B、如果有空闲的P,则创建一个M
C、M会启动一个底层线程,循环执行能找到的G任务
D、G任务的执行顺序是先从本地队列找,本地没有则从全局队列找(一次性转移(全局G个数/P个数)个,再去其它P中找(一次性转移一半)。
E、G任务执行是按照队列顺序(即调用go的顺序)执行的。
*/
创建一个M过程如下:
/*
A、先找到一个空闲的P,如果没有则直接返回。
B、调用系统API创建线程,不同的操作系统调用方法不一样。
C、 在创建的线程里循环执行G任务
*/
如果一个系统调用或者G任务执行太长,会一直占用内核空间线程,由于本地队列的G任务是顺序执行的,其它G任务就会阻塞。因此,Go程序启动的时候,会专门创建一个线程sysmon,用来监控和管理,sysmon内部是一个循环:
/*
A、记录所有P的G任务计数schedtick,schedtick会在每执行一个G任务后递增。
B、如果检查到 schedtick一直没有递增,说明P一直在执行同一个G任务,如果超过一定的时间(10ms),在G任务的栈信息里面加一个标记。
C、G任务在执行的时候,如果遇到非内联函数调用,就会检查一次标记,然后中断自己,把自己加到队列末尾,执行下一个G。
D、如果没有遇到非内联函数(有时候正常的小函数会被优化成内联函数)调用,会一直执行G任务,直到goroutine自己结束;如果goroutine是死循环,并且GOMAXPROCS=1,阻塞。
*/
(1) 局部优先调度
(2) steal working
(3) 阻塞调度
(4) 抢占式调度
14.4、数据安全与锁
14.4.1、互斥锁
互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个goroutine可以访问共享资源。Go语言中使用sync包的Mutex类型来实现互斥锁。
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
var lock sync.Mutex
var x = 0
func add() {
//lock.Lock()
x++
//lock.Unlock()
wg.Done()
}
func main() {
wg.Add(1000)
for i:=0;i<1000 ;i++ {
go add()
}
wg.Wait()
fmt.Println(x)
}
使用互斥锁能够保证同一时间有且只有一个goroutine进入临界区,其他的goroutine则在等待锁;当互斥锁释放后,等待的goroutine才可以获取锁进入临界区,多个goroutine同时等待一个锁时,唤醒的策略是随机的。
4.2、读写锁
在读多写少的环境中,可以优先使用读写互斥锁(sync.RWMutex),它比互斥锁更加高效。sync 包中的 RWMutex 提供了读写互斥锁的封装。
package main
import (
"fmt"
"sync"
"time"
)
// 效率对比
// 声明读写锁
var rwlock sync.RWMutex
var mutex sync.Mutex
var wg sync.WaitGroup
// 全局变量
var x int
// 写数据
func write() {
//mutex.Lock()
rwlock.Lock()
x += 1
fmt.Println("x",x)
time.Sleep(10 * time.Millisecond)
//mutex.Unlock()
rwlock.Unlock()
wg.Done()
}
func read(i int) {
//mutex.Lock()
rwlock.RLock()
time.Sleep(time.Millisecond)
fmt.Println(x)
//mutex.Unlock()
rwlock.RUnlock()
wg.Done()
}
// 互斥锁执行时间:18533117400
// 读写锁执行时间:1312065700
func main() {
start := time.Now()
wg.Add(1)
go write()
for i := 0; i < 1000; i++ {
wg.Add(1)
go read(i)
}
wg.Wait()
fmt.Println("运行时间:", time.Now().Sub(start))
}
4.3、map锁
Go语言中内置的map不是并发安全的.
并发读是安全的
package main
import (
"fmt"
"sync"
)
func main() {
wg := sync.WaitGroup{}
m := make(map[int]int)
// 添一些假数据
for i := 0; i < 5; i++ {
m[i] = i*i
}
// 遍历打印
for i := 0; i < 5; i++ {
wg.Add(1)
go func(x int) {
fmt.Println(m[x], "\t")
wg.Done()
}(i)
}
wg.Wait()
fmt.Println(m)
}
并发写则不安全
package main
import (
"fmt"
"sync"
)
func main() {
wg := sync.WaitGroup{}
//m := make(map[int]int)
var m = sync.Map{}
// 并发写
for i := 0; i < 100; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
m.Store(i,i*i)
}(i)
}
wg.Wait()
fmt.Println(m.Load(1))
fmt.Println(m.Load(2))
}
4.4、原子性操作
加锁操作比较耗时,整数可以使用原子操作保证线程安全
原子操作在用户态就可以完成,因此性能比互斥锁高
AddXxx():加减操作
CompareAndSwapXxx():比较并交换
LoadXxx():读取操作
StoreXxx():写入操作
SwapXxx():交换操作
原子操作与互斥锁性能对比:
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
// 效率对比
// 原子操作需要接收int32或int64
var x int32
var wg sync.WaitGroup
var mutex sync.Mutex
// 互斥锁操作
func add1() {
for i := 0; i < 500; i++ {
mutex.Lock()
x += 1
mutex.Unlock()
}
wg.Done()
}
// 原子操作
func add2() {
for i := 0; i < 500; i++ {
atomic.AddInt32(&x, 1)
}
wg.Done()
}
func main() {
start := time.Now()
for i := 0; i < 10000; i++ {
wg.Add(1)
go add1()
//go add2()
}
wg.Wait()
fmt.Println("x:", x)
fmt.Println("执行时间:", time.Now().Sub(start))
}
14.5、channel(管道)
channel一个类型管道,通过它可以在goroutine之间发送和接收消息。它是Golang在语言层面提供的goroutine间的通信方式。Go依赖于成为CSP的并发模型,通过Channel实现这种同步模式。Golang并发的核心哲学是不要通过共享内存进行通信。
在地铁站、食堂、洗手间等公共场所人很多的情况下,大家养成了排队的习惯,目的也是避免拥挤、插队导致的低效的资源使用和交换过程。代码与数据也是如此,多个 goroutine 为了争抢数据,势必造成执行的低效率,使用队列的方式是最高效的,channel 就是一种队列一样的结构。
Go语言中的通道(channel)是一种特殊的类型。在任何时候,同时只能有一个 goroutine 访问通道进行发送和获取数据。goroutine 间通过通道就可以通信。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。
14.5.1、声明创建通道
通道本身需要一个类型进行修饰,就像切片类型需要标识元素类型。通道的元素类型就是在其内部传输的数据类型,声明如下:
var 通道变量 chan 通道类型
通道是引用类型,需要使用 make 进行创建,格式如下:
通道实例 := make(chan 数据类型) // 不带缓冲的chan
通道实例 := make(chan 数据类型,数量) // 带缓冲的chan
5.2、channel基本操作
ch := make(chan 数据类型)
// 向通道发送数据
ch <- 值
// 从通道接收数据
<- ch
package main
import "fmt"
type Stu struct {
Name string
age int
}
func main() {
// 案例1
ch := make(chan int, 10)
fmt.Println(len(ch), cap(ch))
ch <- 1
ch <- 2
ch <- 3
fmt.Println(<-ch) // FIFO
fmt.Println(<-ch)
fmt.Println(<-ch)
// 案例2
ch2 := make(chan interface{}, 3)
ch2 <- 100
ch2 <- "hello"
ch2 <- Stu{"yuan", 22}
fmt.Println(<-ch2)
fmt.Println(<-ch2)
fmt.Println(<-ch2)
// s := <-ch2
//fmt.Println(s.Name)
// fmt.Println(s.(Stu).Name)
// 案例3
ch3 := make(chan int, 3)
x := 10
ch3 <- x // 值拷贝
x = 20
fmt.Println(<-ch3)
ch4 := make(chan *int, 3)
y := 20
ch4 <- &y
y = 30
p := <-ch4
fmt.Println(*p)
}
5.3、chan是引用类型
通道的结构hchan,源码再src/runtime/chan.go下:
type hchan struct {
qcount uint // total data in the queue 当前队列里还剩余元素个数
dataqsiz uint // size of the circular queue 环形队列长度,即缓冲区的大小,即make(chan T,N) 中的N
buf unsafe.Pointer // points to an array of dataqsiz elements 环形队列指针
elemsize uint16 //每个元素的大小
closed uint32 //标识当前通道是否处于关闭状态,创建通道后,该字段设置0,即打开通道;通道调用close将其设置为1,通道关闭
elemtype *_type // element type 元素类型,用于数据传递过程中的赋值
sendx uint // send index 环形缓冲区的状态字段,它只是缓冲区的当前索引-支持数组,它可以从中发送数据
recvx uint // receive index 环形缓冲区的状态字段,它只是缓冲区当前索引-支持数组,它可以从中接受数据
recvq waitq // list of recv waiters 等待读消息的goroutine队列
sendq waitq // list of send waiters 等待写消息的goroutine队列
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex //互斥锁,为每个读写操作锁定通道,因为发送和接受必须是互斥操作
}
// sudog 代表goroutine
type waitq struct {
first *sudog
last *sudog
}
han内部实现了一个环形队列作为其缓冲区,队列的长度是创建chan时指定的。
环形队列
下图展示了一个可缓存6个元素的channel示意图:
dataqsiz指示了队列长度为6,即可缓存6个元素;
buf指向队列的内存,队列中还剩余两个元素;
qcount表示队列中还有两个元素(len(chan)可查询chan的队列元素个数);
sendx指示后续写入的数据存储的位置,取值[0, 6);
recvx指示从该位置读取数据, 取值[0, 6);
package main
import "fmt"
func foo(c chan int) {
c <- 50
}
func main() {
// 引用类型
var ch5 = make(chan int, 3)
var ch6 = ch5
ch5 <- 100
ch5 <- 200
fmt.Println(<-ch6)
fmt.Println(<-ch5)
var ch7 = make(chan int, 3)
foo(ch7)
fmt.Println(<-ch7)
}
5.4、管道的关闭与循环
当向通道中发送完数据时,我们可以通过close函数来关闭通道。关闭 channel 非常简单,直接使用Go语言内置的 close() 函数即可,关闭后的通道只可读不可写。
ch3 := make(chan int, 10)
ch3 <- 1
ch3 <- 2
ch3 <- 3
close(ch3)
fmt.Println(<-ch3)
ch3 <- 4
如果不close掉channel是会发生死锁的,原因是当for循环读完channel后会继续尝试读取下一个,而由于channel没有写入的协程且没关闭,会一直阻塞形成死锁。
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 10)
ch <- 1
ch <- 2
ch <- 3
// 方式1
go func() {
time.Sleep(time.Second * 10)
ch <- 4
}()
for v := range ch {
fmt.Println(v, len(ch))
// 读取完所有值后,ch的sendq中没有groutine
if len(ch) == 0 { // 如果现有数据量为0,跳出循环
break
}
}
close(ch)
for i := range ch {
fmt.Println(i)
}
}
在介绍了如何关闭 channel 之后,我们就多了一个问题:如何判断一个 channel 是否已经被关闭?我们可以在读取的时候使用多重返回值的方式:
x, ok := <-ch
这个用法与 map 中的按键获取 value 的过程比较类似,只需要看第二个 bool 返回值即可,如果返回值是 false 则表示 ch 已经被关闭。
生产者消费者模型
package main
import (
"fmt"
"sync"
"time"
)
func producer(ch chan int) {
for i := 1; i < 11; i++ {
ch <- i
fmt.Println("插入值",i)
}
wg.Done()
}
func consumer(ch chan int) {
for i := 1; i < 11; i++ {
time.Sleep(time.Second)
fmt.Println("取出值",<-ch)
}
wg.Done()
}
var wg sync.WaitGroup
func main() {
ch := make(chan int, 100)
wg.Add(2)
go producer(ch)
go consumer(ch)
wg.Wait()
fmt.Println("process end")
}
5.5、缓冲通道
无缓冲的通道是指在接收前没有能力保存任何值的通道
有缓冲的通道是一种在被接收前能存储一个或者多个值的通道
无缓冲的通道又称为阻塞的通道。我们来看一下下面的代码:
func main() {
ch := make(chan int)
ch <- 10
fmt.Println("发送成功")
}
上面这段代码能够通过编译,但是执行的时候会出现以下错误:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
.../src/github.com/Q1mi/studygo/day06/channel02/main.go:8 +0x54
为什么会出现deadlock错误呢?
因为我们使用ch := make(chan int)创建的是无缓冲的通道,无缓冲的通道只有在有人接收值的时候才能发送值。简单来说就是无缓冲的通道必须有接收才能发送。
func recv(c chan int) {
ret := <-c
fmt.Println("接收成功", ret)
}
func main() {
ch := make(chan int)
go recv(ch) // 启用goroutine从通道接收值
ch <- 10
fmt.Println("发送成功")
}
无缓冲通道上的发送操作会阻塞,直到另一个goroutine在该通道上执行接收操作,这时值才能发送成功,两个goroutine将继续执行。相反,如果接收操作先执行,接收方的goroutine将阻塞,直到另一个goroutine在该通道上发送一个值。
使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道也被称为同步通道。
have a try:生产者消费者模型案例改为无缓冲通道的运行结果?
5.6、死锁(deadlock)
当程序一直在等待从信道里读取数据,而此时并没有人会往信道中写入数据。此时程序就会陷入死循环,造成死锁。
package main
import (
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
func main() {
pipline := make(chan int)
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < 10; i++ {
time.Sleep(time.Second)
pipline <- i
}
close(pipline) // 关闭chan,循环chan的协程就可以退出循环了,否则因为chan的sendq为空陷入deadlock
}()
go func() {
defer wg.Done()
for v := range pipline {
fmt.Println("v:", v)
}
}()
wg.Wait()
}
解决方法很简单,只要在发送完数据后,手动关闭信道,告诉 range 信道已经关闭,无需等待就行。
package main
import (
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
func recv(c chan interface{}) {
defer wg.Done()
for true {
time.Sleep(time.Second)
ret := <-c
if ret == "exit"{
close(c)
break
}
fmt.Println("接收成功", ret)
}
}
func send(c chan interface{}) {
defer wg.Done()
for i:=0;i<10;i++{
c<- i
}
c<- "exit"
// time.Sleep(time.Second*10) // 写的协程结束,导致死锁
}
func main() {
ch := make(chan interface{})
wg.Add(2)
go recv(ch) // 启用goroutine从通道接收值
go send(ch) // 启用goroutine从通道接收值
wg.Wait()
fmt.Println("end")
}
5.7、单向通道
Go语言的类型系统提供了单方向的 channel 类型,顾名思义,单向 channel 就是只能用于写入或者只能用于读取数据。当然 channel 本身必然是同时支持读写的,否则根本没法用。
我们在将一个 channel 变量传递到一个函数时,可以通过将其指定为单向 channel 变量,从而限制该函数中可以对此 channel 的操作,比如只能往这个 channel 中写入数据,或者只能从这个 channel 读取数据。
单向 channel 变量的声明非常简单,只能写入数据的通道类型为chan<-,只能读取数据的通道类型为<-chan,格式如下:
var 通道实例 chan<- 元素类型 // 只能写入数据的通道
var 通道实例 <-chan 元素类型 // 只能读取数据的通道
package main
import (
"fmt"
"sync"
"time"
)
func producer(ch chan<- int) {
for i := 1; i < 11; i++ {
ch <- i
fmt.Println("插入值", i)
}
wg.Done()
}
func consumer(ch <-chan int) {
for i := 1; i < 11; i++ {
time.Sleep(time.Second)
fmt.Println("取出值", <-ch)
}
wg.Done()
}
var wg sync.WaitGroup
func main() {
ch := make(chan int)
wg.Add(2)
go producer(ch)
go consumer(ch)
wg.Wait()
fmt.Println("end")
}
5.8、select语句
golang中的select语句格式如下
select {
case <-ch1:
// 如果从 ch1 信道成功接收数据,则执行该分支代码
case ch2 <- 1:
// 如果成功向 ch2 信道成功发送数据,则执行该分支代码
default:
// 如果上面都没有成功,则进入 default 分支处理流程
}
可以看到select的语法结构有点类似于switch,但又有些不同。select里的case后面并不带判断条件,而是一个信道的操作,不同于switch里的case,对于从其它语言转过来的开发者来说有些需要特别注意的地方。golang 的 select 就是监听 IO 操作,当 IO 操作发生时,触发相应的动作每个case语句里必须是一个IO操作,确切的说,应该是一个面向channel的IO操作。
注:Go 语言的 select 语句借鉴自 Unix 的 select() 函数,在 Unix 中,可以通过调用 select() 函数来监控一系列的文件句柄,一旦其中一个文件句柄发生了 IO 动作,该 select() 调用就会被返回(C 语言中就是这么做的),后来该机制也被用于实现高并发的 Socket 服务器程序。Go 语言直接在语言级别支持 select关键字,用于处理并发编程中通道之间异步 IO 通信问题。
注意:如果 ch1 或者 ch2 信道都阻塞的话,就会立即进入 default 分支,并不会阻塞。但是如果没有 default 语句,则会阻塞直到某个信道操作成功为止。
(1)select语句只能用于信道的读写操作
package main
import "fmt"
func main() {
size := 10
ch1 := make(chan int, size)
for i := 0; i < size; i++ {
ch1 <- 1
}
ch2 := make(chan int, size+1)
for i := 0; i < size; i++ {
ch2 <- 2
}
// select中的case语句是随机执行的
select {
case a := <-ch1:
fmt.Println("a", a)
case b := <-ch2:
fmt.Println("b", b)
case ch2 <- 200:
fmt.Println("插值成功")
default: // 如果 ch1 和 ch2 信道都阻塞的话,就会立即进入default分支
fmt.Println("default")
}
}
(2)超时用法
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
go func(c chan int) {
// 修改时间后,再查看执行结果
time.Sleep(time.Second * 3)
ch <- 1
}(ch)
select {
case v := <-ch:
fmt.Print(v)
case <-time.After(2 * time.Second): // 等待 2s
fmt.Println("no case ok")
}
}
(3)空select
空select指的是内部不包含任何case,例如:
select{
}
空的 select 语句会直接阻塞当前的goroutine,使得该goroutine进入无法被唤醒的永久休眠状态。
14.6、并发案例
6.1、聊天室案例
服务器:
package main
import (
"encoding/json"
"fmt"
"net"
"strings"
)
//保存用户信息的结构体
type Client struct {
Chan chan []byte //传递用户数据
Addr string //客户端的IP
}
// 消息类型
type Msg struct {
Content string // 消息内容
User string // 消息发布者
}
var onlineClients = make(map[string]Client) //保存所有用户 {"Addr":{"Addr":Addr,"Chan":Chan}}
var broadcast = make(chan Msg) // 广播管道
//监听broadcast通道中的数据,一旦有数据,循环写入每一个Client的Chan中,进而是每一个客户端收到该广播数据
func MessageManager() {
for {
msg := <-broadcast //读取message通道中的数据,如果通道中没有数据,就会一直等待。
for _, client := range onlineClients {
msgBytes, _ := json.Marshal(msg)
client.Chan <- msgBytes
}
}
}
//监听该客户端的管道,一旦有广播数据,写入socket管道,发给客户端
func WriteMsgToClient(conn net.Conn, client Client) {
for {
msgBytes := <-client.Chan //读取C通道中的数据,如果没有数据,就会一直等待
_, _ = conn.Write(msgBytes) //把json字节串数据输出到客户端
}
}
//监听该客户端的socket管道,一旦有数据,写入broadcast管道,进而发给每一个客户端
func read(conn net.Conn) {
for true {
data := make([]byte, 1024)
n, _ := conn.Read(data)
content := strings.TrimSpace(string(data[:n]))
broadcast <- Msg{Content: content, User: conn.RemoteAddr().String()}
fmt.Printf("%s发来消息:%s\n", conn.RemoteAddr().String(), content)
}
}
//为每一个客户端开启协程处理函数
func HandleConnect(conn net.Conn) {
//把客户端的用户信息保存在map对象
addr := conn.RemoteAddr().String() //获取客户端的IP
fmt.Printf("来自客户端【%s】的连接\n", addr)
//把用户信息封装成Client
client := Client{make(chan []byte), addr}
onlineClients[addr] = client
//向所有用户广播消息
content := client.Addr + "已上线!"
broadcast <- Msg{Content: content, User: "系统消息"}
//启动WriteMsgToClient的Go程
go read(conn)
go WriteMsgToClient(conn, client)
}
//主协程
func main() {
fmt.Println("聊天室服务端启动了...")
//创建一个监听器
listener, err := net.Listen("tcp", "127.0.0.1:8080")
if err != nil {
fmt.Println("net.Listen err: ", err)
return //结束主协程
}
//负责监听广播通道中的数据
go MessageManager()
for {
conn, err := listener.Accept() //阻塞方法,监听客户端的连接
if err != nil {
fmt.Println("listener.Accept err: ", err)
continue //结束当次循环
}
go HandleConnect(conn)
}
}
客户端:
package main
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"sync"
)
// 消息类型
type Msg struct {
Content string // 消息内容
User string // 消息发布者
}
func read(conn net.Conn) {
for {
res := make([]byte, 1024)
n, err := conn.Read(res)
if err != nil {
fmt.Println(err)
return
}
result := res[:n]
var msg Msg
json.Unmarshal(result,&msg)
fmt.Printf("[%s]:%s\n",msg.User,msg.Content)
}
}
func write(conn net.Conn){
for true {
reader := bufio.NewReader(os.Stdin) // 从标准输入生成读对象
content, _ := reader.ReadBytes('\n') // 读到换行
// 发送数据
conn.Write(content)
}
}
var wg sync.WaitGroup
func main() {
// 1.连接服务端
conn, err := net.Dial("tcp", "127.0.0.1:8080")
if err != nil {
fmt.Println(err)
return
}
defer conn.Close()
go read(conn)
write(conn)
}
6.2、爬虫案例
**(1)爬虫程序
package main
import (
"fmt"
"io/ioutil"
"net/http"
)
func main() {
resp, err := http.Get("http://www.baidu.com")
if err != nil {
fmt.Println("err", err)
}
data, _ := ioutil.ReadAll(resp.Body)
ioutil.WriteFile("baidu.html", data, 0666)
}
(2)正则匹配
package main
import (
"fmt"
"regexp"
)
func main() {
const (
cityListReg = `<a href="(.*?)">(.*?)</a>`
)
contents := `<a href="http://www.baidu.com">百度</a> <a href="http://www.jd.com">京东</a>`
compile := regexp.MustCompile(cityListReg)
submatch := compile.FindAllSubmatch([]byte(contents), -1)
for _, m := range submatch {
//fmt.Println("url:", string(m[1]), "city:", string(m[2]))
fmt.Println("content match:", string(m[0]), string(m[1]), string(m[2]))
}
}
(3)爬虫斗图案例版本1
package main
import (
"fmt"
"io/ioutil"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
var pageNum = 3
var reImg = `data-original="(https?://img.pkdoutu.com/production/uploads/image/[\s\S]+?.(jpg|png|jpeg|gif|null))`
var SliceImageUrls []string
func main() {
// 构建起始时间
start := time.Now().Unix()
// 2.爬虫协程:d多个协程向管道添加图片链接
for i := 1; i <= pageNum; i++ {
fmt.Println("i", i)
getImgUrls("https://www.pkdoutu.com/photo/list/?page=" + strconv.Itoa(i))
}
ConsumerImgUrl()
end := time.Now().Unix()
fmt.Println("总计用时:", end-start)
}
// 处理异常
func HandleError(err error) {
if err != nil {
fmt.Println("err:", err)
return
}
}
func ConsumerImgUrl() {
for _, url := range SliceImageUrls {
fmt.Println("url:::", url)
DownloadImg(url)
}
}
func getImgUrls(pageUrl string) {
// 获取页面字符串
pageStr := string(spiderData(pageUrl))
// 根据正则匹配筛选到符合要求的img的URL
re := regexp.MustCompile(reImg)
results := re.FindAllSubmatch([]byte(pageStr), -1)
for _, item := range results {
SliceImageUrls = append(SliceImageUrls, string(item[1]))
}
}
func getFileName(url string) string {
// 1.获取文件名和文件应该存哪
// lastIndex是最后一个/的位置
lastIndex := strings.LastIndex(url, "/")
filename := url[lastIndex+1:]
// 创建时间戳,防止重名
timePre := strconv.Itoa(int(time.Now().UnixNano()))
filename = "Doutu/" + timePre + "_" + filename
fmt.Println("filename", filename)
return filename
}
// 爬虫网络数据
func spiderData(_url string) []byte {
// 发送请求
fmt.Println("_url", _url)
resp, err := http.Get(_url)
if err != nil {
HandleError(err)
}
defer resp.Body.Close()
// 接数据
data, _ := ioutil.ReadAll(resp.Body)
return data
}
// DownloadImg 下载图片
func DownloadImg(url string) {
filename := getFileName(url)
// 2.保存文件
data := spiderData(url)
// 写文件
err := ioutil.WriteFile(filename, data, 0666)
if err != nil {
fmt.Printf("%s 下载失败 \n", filename)
HandleError(err)
}
fmt.Printf("%s 下载成功 \n", filename)
}
(4)爬虫斗图案例版本2
package main
import (
"fmt"
"net/http"
"sync"
)
var wg sync.WaitGroup
func main() {
var c = make(chan int, 5)
wg.Add(1)
go func() {
defer wg.Done()
c <- 1
c <- 2
c <- 3
close(c)
}()
wg.Add(1)
go func() {
defer wg.Done()
for i := range c {
fmt.Println(i)
}
}()
wg.Wait()
fmt.Println("end")
}
func foo(_url string) {
res, _ := http.Get(_url)
defer res.Body.Close()
}
很神奇的现象,存在一个包含http.Get的函数,即使没有执行,也会使range c出现阻塞而不是死锁!!!
package main
import (
"fmt"
"io/ioutil"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
var waitGroup sync.WaitGroup
var chanImageUrls = make(chan string, 10000)
var pageNum = 10
var reImg = `data-original="(https?://img.pkdoutu.com/production/uploads/image/[\s\S]+?.(jpg|png|jpeg|gif|null))`
var chanTask = make(chan string, pageNum)
func CheckProducerDone() {
var count int
for {
url := <-chanTask
fmt.Printf("%s 完成了爬取任务", url)
count++
if count == pageNum {
// 生产者完成生产任务,关闭数据管道,消费者遍历完管道数据会自动退出循环
close(chanImageUrls)
// 完成任务,跳出循环
break
}
}
}
func main() {
// 构建起始时间
start := time.Now().Unix()
// 2.爬虫协程:d多个协程向管道添加图片链接
for i := 1; i <= pageNum; i++ {
fmt.Println("i", i)
waitGroup.Add(1)
go getImgUrls("https://www.pkdoutu.com/photo/list/?page=" + strconv.Itoa(i))
}
go CheckProducerDone()
waitGroup.Add(1)
go ConsumerImgUrl()
waitGroup.Wait()
end := time.Now().Unix()
fmt.Println("总计用时:", end-start)
}
// 处理异常
func HandleError(err error) {
if err != nil {
fmt.Println("err:", err)
return
}
}
func ConsumerImgUrl() {
defer waitGroup.Done()
for i := 0; i < 10; i++ {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
for url := range chanImageUrls {
fmt.Println("url:::", url)
DownloadImg(url)
}
}()
}
}
func getImgUrls(pageUrl string) {
waitGroup.Done()
// 获取页面字符串
pageBytes := spiderData(pageUrl)
// 根据正则匹配筛选到符合要求的img的URL
re := regexp.MustCompile(reImg)
results := re.FindAllSubmatch(pageBytes, -1)
for _, item := range results {
chanImageUrls <- string(item[1])
}
fmt.Println("len chanImageUrls", len(chanImageUrls))
chanTask <- pageUrl
}
func getFileName(url string) string {
// 1.获取文件名和文件应该存哪
// lastIndex是最后一个/的位置
lastIndex := strings.LastIndex(url, "/")
filename := url[lastIndex+1:]
// 创建时间戳,防止重名
timePre := strconv.Itoa(int(time.Now().UnixNano()))
filename = "Doutu/" + timePre + "_" + filename
fmt.Println("filename", filename)
return filename
}
// 爬虫网络数据
func spiderData(_url string) []byte {
// 发送请求
fmt.Println("_url", _url)
resp, err := http.Get(_url)
if err != nil {
HandleError(err)
}
defer resp.Body.Close()
// 接数据
data, _ := ioutil.ReadAll(resp.Body)
return data
}
// DownloadImg 下载图片
func DownloadImg(url string) {
filename := getFileName(url)
// 2.保存文件
data := spiderData(url)
// 写文件
err := ioutil.WriteFile(filename, data, 0666)
if err != nil {
fmt.Printf("%s 下载失败 \n", filename)
HandleError(err)
}
fmt.Printf("%s 下载成功 \n", filename)
}