并发编程在当前软件领域是一个非常重要的概念,随着CPU等硬件的发展,我们无一例外的想让我们的程序运行的快一点、再快一点。而并发编程可以帮助我们更好地利用多核处理器的能力,提高程序的性能和响应速度。Go语言在语言层面天生支持并发,充分利用现代CPU的多核优势,这也是Go语言能够大范围流行的一个很重要的原因。
基本概念
并发是指在同一时间内执行多个任务或操作的能力。在Go语言中,通过使用goroutine和通道(channel)可以方便地实现并发编程。
串行、并发与并行
-
串行(Serial):串行执行是指任务按照顺序依次执行,每个任务必须在前一个任务完成后才能开始执行。(我们都是先读小学,小学毕业后再读初中,读完初中再读高中)。
-
并发(Concurrency):并发是指多个任务在同一时间段内交替执行,每个任务都有可能被中断或暂停,然后切换到其他任务继续执行。在并发执行中,多个任务共享时间片,通过任务切换来实现任务间的交替执行。同一时间段内执行多个任务(你在用微信和两个女朋友聊天)。
-
并行(Parallelism):并行是指多个任务同时执行,每个任务都在不同的处理单元上独立运行,互不干扰。在并行执行中,多个任务可以并发地进行,充分利用多个处理单元的能力。同一时刻执行多个任务(你和你朋友都在用微信和女朋友聊天)。
总结来说,串行是按照顺序依次执行任务,没有并发和并行的概念;并发是多个任务交替执行,共享时间片;而并行是多个任务同时执行,充分利用多个处理单元。并发可以提高系统的响应性和吞吐量,而并行可以提高任务的执行速度和系统的性能。
进程、线程和协程
-
进程(Process):进程是操作系统中的一个执行实体,它拥有独立的内存空间和资源。每个进程都是独立运行的,有自己的地址空间、堆栈、文件描述符等。多个进程之间的切换需要操作系统的调度器来完成,切换代价较高。
-
线程(Thread):线程是进程中的一个执行单元,一个进程可以包含多个线程。线程共享进程的地址空间和资源,它们可以访问共享的数据和变量。线程的切换代价较低,因为它们共享了进程的上下文。
-
协程(Coroutine):协程是一种轻量级的线程,也被称为用户级线程。与线程相比,协程的切换更加灵活和高效。协程的切换是由程序自身控制的,不需要操作系统的干预,切换代价非常低。协程可以在同一个线程中并发执行,通过协程的切换来实现任务的交替执行。协程之间的通信可以通过消息传递的方式,如通道(channel)。
goroutine
goroutine是Go语言中的一种轻量级线程,用于实现并发。与传统的线程相比,goroutine的创建和销毁的代价更小,并且可以高效地复用系统资源。
Goroutine 是 Go 语言支持并发的核心,在一个Go程序中同时创建成百上千个goroutine是非常普遍的,一个goroutine会以一个很小的栈开始其生命周期,一般只需要2KB。区别于操作系统线程由系统内核进行调度, goroutine 是由Go运行时(runtime)负责调度。例如Go运行时会智能地将 m个goroutine 合理地分配给n个操作系统线程,实现类似m:n的调度机制,不再需要Go开发者自行在代码层面维护一个线程池。
在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能------goroutine,当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个 goroutine 去执行这个函数就可以了,就是这么简单粗暴。
go关键字
Go语言中使用 goroutine 非常简单,只需要在函数或方法调用前加上go
关键字就可以创建一个 goroutine ,从而让该函数或方法在新创建的 goroutine 中执行。
scss
go f() // 创建一个新的 goroutine 运行函数f
匿名函数也支持使用go
关键字创建 goroutine 去执行。
go
go func(){
// ...
}()
启动单个goroutine
启动 goroutine 的方式非常简单,只需要在调用函数(普通函数和匿名函数)前加上一个go
关键字。
go
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("Hello, goroutine!")
}
func main() {
go sayHello() // 创建一个goroutine并执行sayHello函数
time.Sleep(time.Second) // 等待一秒钟,确保goroutine有足够的时间执行
}
在上面的示例中,通过关键字"go"创建了一个goroutine,并在其中执行了sayHello函数。在main函数中,使用time.Sleep函数等待一秒钟,以确保goroutine有足够的时间执行。执行上述代码后,将会同时输出"Hello, goroutine!"和"Hello, main!",表明两个函数在不同的goroutine中并发执行。
启动多个goroutine
在 Go 语言中实现并发就是这样简单,我们还可以启动多个 goroutine 。这里使用了sync.WaitGroup
来实现 goroutine 的同步。
go
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
func hello(i int) {
defer wg.Done() // goroutine结束就登记-1
fmt.Println("hello", i)
}
func main() {
for i := 0; i < 10; i++ {
wg.Add(1) // 启动一个goroutine就登记+1
go hello(i)
}
wg.Wait() // 等待所有登记的goroutine都结束
}
多次执行上面的代码会发现每次终端上打印数字的顺序都不一致。这是因为10个 goroutine 是并发执行的,而 goroutine 的调度是随机的。
动态栈
操作系统的线程一般都有固定的栈内存(通常为2MB),而 Go 语言中的 goroutine 非常轻量级,一个 goroutine 的初始栈空间很小(一般为2KB),所以在 Go 语言中一次创建数万个 goroutine 也是可能的。并且 goroutine 的栈不是固定的,可以根据需要动态地增大或缩小, Go 的 runtime 会自动为 goroutine 分配合适的栈空间。
channel
单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行函数的意义。
虽然可以使用共享内存进行数据交换,但是共享内存在不同的 goroutine 中容易发生竞态问题。为了保证数据交换的正确性,很多并发模型中必须使用互斥量对内存进行加锁,这种做法势必造成性能问题。
Go语言采用的并发模型是CSP(Communicating Sequential Processes)
,提倡通过通信共享内存 而不是通过共享内存而实现通信。
如果说 goroutine 是Go程序并发的执行体,channel
就是它们之间的连接。channel
是可以让一个 goroutine 发送特定值到另一个 goroutine 的通信机制。
Go 语言中的通道(channel)是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型。
初始化channel
声明的通道类型变量需要使用内置的make
函数初始化之后才能使用。具体格式如下:
go
make(chan 元素类型, [缓冲大小])
- channel的缓冲大小是可选的。
go
ch4 := make(chan int)
ch5 := make(chan bool, 1) // 声明一个缓冲区大小为1的通道
channel操作
通道共有发送(send)、接收(receive)和关闭(close)三种操作。而发送和接收操作都使用<-
符号。
发送
将一个值发送到通道中。
arduino
ch <- 10 // 把10发送到ch中
接收
从一个通道中接收值。
go
x := <- ch // 从ch中接收值并赋值给变量x
<-ch // 从ch中接收值,忽略结果
关闭
我们通过调用内置的close
函数来关闭通道。
scss
close(ch)
注意: 一个通道值是可以被垃圾回收掉的。通道通常由发送方执行关闭操作,并且只有在接收方明确等待通道关闭的信号时才需要执行关闭操作。它和关闭文件不一样,通常在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的。
通道的发送和接收操作默认是阻塞的,即发送操作会等待接收者接收数据,接收操作会等待发送者发送数据。这种阻塞机制可以实现两个或多个goroutine之间的同步。
单向通道
Go语言中提供了单向通道来处理需要限制通道只能进行某种操作的情况。
go
<- chan int // 只接收通道,只能接收不能发送
chan <- int // 只发送通道,只能发送不能接收
其中,箭头<-
和关键字chan
的相对位置表明了当前通道允许的操作,这种限制将在编译阶段进行检测。另外对一个只接收通道执行close也是不允许的,因为默认通道的关闭操作应该由发送方来完成。
以下是一个使用通道进行数据传递的简单示例:
go
package main
import "fmt"
func sendData(ch chan<- int) {
ch <- 1 // 向通道发送数据
}
func receiveData(ch <-chan int) {
data := <-ch // 从通道接收数据
fmt.Println(data)
}
func main() {
ch := make(chan int) // 创建一个通道
go sendData(ch) // 在goroutine中向通道发送数据
receiveData(ch) // 在主goroutine中从通道接收数据
}
在上面的示例中,通过make函数创建了一个通道ch。在sendData函数中,使用ch <- 1
向通道发送了数据1。在receiveData函数中,使用data := <-ch
从通道接收数据,并打印输出。通过使用通道,实现了goroutine之间的数据传递。
需要注意的是,通道可以是有缓冲的或无缓冲的。无缓冲的通道保证发送和接收的同步,发送和接收操作会阻塞,直到发送者和接收者都准备好。有缓冲的通道可以在一定程度上解耦发送和接收操作,发送操作只有在通道缓冲区已满时才会阻塞,接收操作只有在通道缓冲区为空时才会阻塞。
select多路复用
在某些场景下我们可能需要同时从多个通道接收数据。通道在接收数据时,如果没有数据可以被接收那么当前 goroutine 将会发生阻塞。Go 语言内置了select
关键字,使用它可以同时响应多个通道的操作。
Select 的使用方式类似于之前学到的 switch 语句,它也有一系列 case 分支和一个默认的分支。每个 case 分支会对应一个通道的通信(接收或发送)过程。select 会一直等待,直到其中的某个 case 的通信操作完成时,就会执行该 case 分支对应的语句。具体格式如下:
go
select {
case <-ch1:
//...
case data := <-ch2:
//...
case ch3 <- 10:
//...
default:
//默认操作
}
Select 语句具有以下特点。
- 可处理一个或多个 channel 的发送/接收操作。
- 如果多个 case 同时满足,select 会随机选择一个执行。
- 对于没有 case 的 select 会一直阻塞,可用于阻塞 main 函数,防止退出。
总结
-
goroutine是Go语言中的一种轻量级线程,用于实现并发编程。通过关键字"go"可以创建goroutine,并在其中并发执行任务。
-
goroutine的创建和销毁的代价较小,可以高效地复用系统资源,同时可以创建成百上千甚至更多的goroutine。
-
通过goroutine,可以同时执行多个任务,提高系统的吞吐量和响应速度。
-
通道(channel)是用于实现goroutine之间通信和同步的一种特殊类型。通道可以看作是一种类型安全的消息队列,用于在goroutine之间传递数据。
-
通道可以通过内置的make函数创建,可以是有缓冲的或无缓冲的。
-
无缓冲通道保证发送和接收的同步,发送和接收操作会阻塞,直到发送者和接收者都准备好。
-
有缓冲通道可以在一定程度上解耦发送和接收操作,发送操作只有在通道缓冲区已满时才会阻塞,接收操作只有在通道缓冲区为空时才会阻塞。
-
通道的发送和接收操作默认是阻塞的,可以通过select语句结合default子句实现非阻塞的发送和接收操作。
-
通道可以实现线程安全的数据传递和同步,避免了共享内存带来的竞争条件和死锁问题。
使用goroutine和通道,可以简化并发编程的实现,提高程序的可读性和可维护性,同时充分利用多核处理器的优势,实现高效的并发执行。
关于如何学习和使用goroutine和通道的思考和建议:
-
学习并理解基本概念:在开始使用goroutine和通道之前,确保你对它们的基本概念有一个清晰的理解。理解goroutine是轻量级的线程,可以在Go程序中并发地执行任务。通道是用于在goroutine之间进行通信和数据同步的机制。
-
实践编写并发代码:学习并发编程最好的方式是通过实践。编写一些简单的并发代码,例如使用goroutine并发地执行一些任务,然后使用通道来传递数据等等。通过实践,你将更好地理解并发编程的概念和用法。
-
阅读相关文档和教程:Go语言官方文档和一些优秀的教程提供了丰富的关于goroutine和通道的信息和示例代码。阅读这些文档和教程可以帮助更深入地理解并发编程的概念和用法。
-
了解并发编程的模式和最佳实践:并发编程有很多常见的模式和最佳实践,例如任务分发、工作池、扇入扇出等等。了解这些模式和实践可以帮助更好地设计和实现并发程序。
-
注意并发编程的错误和陷阱:并发编程可能会引发一些常见的错误和陷阱,例如竞态条件、死锁、活锁等等。学习和了解这些错误和陷阱,以及如何避免它们,对于编写可靠的并发程序非常重要。
总之,学习goroutine和通道需要不断地实践和探索。通过不断学习和尝试,你将能够更好地理解并发编程的概念和用法,并能够编写出高效、可靠的并发程序。