本文涵盖了go语言并行编程goroutine
以及通道
的基本用法,所有的程序都配有详细的注释。在文章的最后,给出了三个并行编程的具体的需求,并有对应的代码实现以供参考,用于巩固所学。
goroutine
Go可以轻易创建数万个协程,可以将协程理解为轻量级的线程。
Go协程特点:
- 每个协程有独立的栈空间
- 共享程序的堆空间
- 调度由程序员控制
- 协程是轻量级的线程
通过go关键字,开启一个协程,下例实现了协程和主程序并行打印信息。
package main
import (
"fmt"
"time"
)
// 每隔100ms打印一行信息,重复t次
func hello(threadName string, t int) {
for i := 0; i < t; i++ {
fmt.Printf("thread-%v, id = %v\n", threadName, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
// 开启一个协程
go hello("0001", 100)
hello("main", 10)
}
运行上面程序后不难看出,程序在主线程执行结束后,就直接退出了,并不会等待协程结束。
如果我们协程的运行时间长一点,主线程就直接夹断了。为了保证程序在所有协程执行完毕后才结束,就需要引入通道技术。
读者可以结合最后需求样例中的
例1 时间服务器
、例2 回声服务器
,进行协程的练习。
通道
通道实现了goroutine
之间的通信,针对传输数据类型的不同,需要建立指定类型的通道。通道传输机制也分为有缓冲区
和无缓冲区
两种,首先介绍较为简单的无缓冲通道。
无缓冲通道
无缓冲通道也被称为同步通道,数据的传输和接收为阻塞过程。即发送方传输的数据被接受之后,发送方才会继续运行;接收方在从通道中接收到数据后,才会继续执行。
下面的例子定义了一个通道,并展示了写入和读取数据的过程。
package main
import "fmt"
func write(ch chan int) {
ch <- 1
}
func main() {
// 定义一个int类型的通道
ch := make(chan int)
// 开启一个新的协程,写入数据。
// 注意不要读写同时在主线程,否则通道内数据未被读取,会阻塞
go write(ch)
// 读取数据
x := <-ch
fmt.Printf("x: %v\n", x)
}
除了在协程之间传输数据,我们也可以使用无缓冲通道阻塞的机制,来控制程序运行的先后顺序。比如,可以实现让主程序挂起,等待所有其他程序执行完成后再结束。下面对一开始的打印信息的例子进行了改进。
package main
import (
"fmt"
"time"
)
func hello(threadName string, t int, fin chan int) {
for i := 0; i < t; i++ {
fmt.Printf("thread-%v, id = %v\n", threadName, i)
time.Sleep(100 * time.Millisecond)
}
// 写入数据,代表执行完毕
fin <- 1
}
func main() {
fin := make(chan int)
// 开启一个协程
go hello("0001", 20, fin)
// 主线程一直阻塞,直到通道有数据写入
<-fin
}
管道
使用一个协程来处理两个通道传输的信息,这种协程被称为管道。A->B->C中,B的处理称为管道。下面这个例子实现了从一个协程接收整数,平方后传给另一个协程。
package main
import (
"fmt"
"time"
)
func main() {
// 传输原始数据的通道
natural := make(chan int)
// 传输平方后数据的通道
square := make(chan int)
// 判断主协程是否能够结束
fin := make(chan int)
go func() {
// 一种特殊的写法,直到通道关闭,一直进行读取
for x := range natural {
// 平方后传入通道中
square <- x * x
}
close(square)
}()
go func() {
for x := range square {
// 读取平方后的数据,打印
fmt.Println(x)
time.Sleep(time.Millisecond * 20)
}
fmt.Println("turnal closed.")
fin <- 1
}()
for i := 0; i < 20; i++ {
natural <- i
}
close(natural)
<-fin
}
结束程序时,没有必要手动关闭每一个通道,只有在通知接收方goroutine所有数据都发送完毕时才需要关闭通道。但是文件不一样,结束时对每个文件调用Close方法非常重要,注意不要混淆。
单向通道
以上用到的都是双向通道,go中还能定义单向通道,主要是用于编译时检查。双向通道可以转换为单向通道,反之则不可以。
chan<-int
是一个只能发送的通道chan->int
是一个只能接收的通道
我们常在调用的函数形参中,定义单向通道,以规范化该通道在函数中的操作。下面的代码对上个例子中用到的通道进行了输入和输出的规范化处理。
package main
import (
"fmt"
"time"
)
func count(in chan<- int) {
for i := 0; i < 20; i++ {
in <- i
}
close(in)
}
// out管道输出数据,in管道接受数据
func square(out <-chan int, in chan<- int) {
for x := range out {
in <- x * x
}
close(in)
}
func printer(out <-chan int, fin chan<- int) {
for x := range out {
fmt.Println(x)
time.Sleep(time.Millisecond * 20)
}
fin <- 1
}
func main() {
num := make(chan int)
sq := make(chan int)
// 判断主协程是否能够结束
fin := make(chan int)
go count(num)
go square(num, sq)
go printer(sq, fin)
<-fin
}
缓冲通道
缓冲通道在非缓冲通道的基础上,增加了一个缓冲区用于存储数据。这个缓冲区类似于队列,先进先出。多了一个缓冲区,协程没那么容易阻塞,缓冲通道的定义和使用方式如下。
注意,make(chan int, 1)和make(chan int)有本质的区别,前者在写入一个数据后,协程可以继续执行。而非缓冲通道在没有对通道数据读取之前,协程都是处于阻塞状态。
package main
import "fmt"
func main() {
// 定义容量为3的缓冲通道
ch := make(chan int, 3)
// 添加数据
ch <- 1
ch <- 1
ch <- 1
// 接收数据
fmt.Println(<-ch)
// 获取最大容量
fmt.Println(cap(ch))
// 获取当前通道中数据的个数,常用于错误诊断和性能优化
fmt.Println(len(ch))
}
下面是一个缓冲通道的应用,获取三个HTTP请求中,最早有结果的那个。这边如果使用无缓冲通道,那么除了第一个到达的goroutine,剩下的两个goroutine中有一个会因为数据没有写入而阻塞,导致goroutine泄露。泄露的goroutine不会被自动回收,所以虽然不需要我们去手动回收goroutine,但我们也得保证goroutine在不再需要的时候能够自动结束。
package main
import (
"fmt"
"io"
"net/http"
)
// 获取网站html
func fetch(url string) string {
resp, err := http.Get(url)
if err != nil {
return fmt.Sprintf("fetch: %v\n", err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
return fmt.Sprintf("%v\n%v\n", string(data), url)
}
func fastestSites(websites []string) string {
resp := make(chan string, len(websites))
for i := range websites {
// t_i用于保证执行的时候,调用的下标正确
go func(t_i int) { resp <- fetch(websites[t_i]) }(i)
}
return <-resp
}
func main() {
// 测试这三个网站的返回速度
websites := []string{"https://www.baidu.com", "https://www.zhihu.com", "https://www.csdn.com"}
fmt.Print(fastestSites(websites))
}
需求样例3 计算质数个数中,展示了go并行编程循环的常用写法。
select多路复用
使用select可以在各种可以执行的case情况中,自动检测未被阻塞的进程,随机选择一个来执行。下例中,ch通道的缓冲区大小为一,在这个循环中,不断经历着:空、满两个状态来回切换。
package main
import "fmt"
func main() {
ch := make(chan int, 1)
for i := range 10 {
select {
// 如果通道缓冲区未满,能写入
case ch <- i:
// 如果通道缓冲区有数据,能读取
case x := <-ch:
fmt.Println(x)
}
}
}
select多路复用也可以用来实现取消操作,在代码的关键位置检查是否有取消操作,可以阻止协程代码继续执行。
func cancelled(done chan int) bool {
select {
case <-done:
return true
default:
return false
}
}
需求样例
例1 时间服务器
时间服务器并行处理tcp连接
服务端代码:
package main
import (
"io"
"log"
"net"
"time"
)
// 定时向客户端发送时间信息
func sendTime(conn net.Conn) {
log.Printf("connection received: %v\n", conn)
for {
_, err := io.WriteString(conn, time.Now().Format(time.RFC1123)+"\n")
if err != nil {
log.Printf("connection closed: %v\n", conn)
return
}
time.Sleep(time.Second)
}
}
func main() {
var server = "localhost:8080"
// 监听tcp连接
listener, err := net.Listen("tcp", server)
if err != nil {
log.Fatalf("server create error: %v \n", err)
}
log.Printf("clock server running on %v\n", server)
for {
conn, err := listener.Accept()
if err != nil {
log.Printf("connection establish error: %v\n", err)
}
go sendTime(conn)
}
}
客户端代码,用来建立tcp连接,这边也是开协程,同时建立了100个tcp连接。
package main
import (
"io"
"log"
"net"
"os"
"time"
)
// 建立tcp连接
func connect() {
conn, err := net.Dial("tcp", "localhost:8080")
if err != nil {
log.Fatalf("connection establishment error: %v\n", err)
}
defer conn.Close()
// 将接收到的数据传入标准输出窗口
io.Copy(os.Stdout, conn)
}
func main() {
for i := 0; i < 100; i++ {
go connect()
}
for {
time.Sleep(time.Second)
}
}
展示的是开一个协程时的效果。
例2 回声服务器
服务端,监听用户发来的信息,并渐弱回复五条信息。
package main
import (
"bufio"
"io"
"log"
"net"
"strings"
"time"
)
// 每隔一段时间往连接里塞一个渐弱的字符串,重复五次
func echoBack(conn net.Conn, shout string, t time.Duration) {
for i := 4; i >= 0; i-- {
var back = "\t" + strings.ToUpper(shout)
for j := 0; j < i; j++ {
back += "!"
}
back += "\n"
io.WriteString(conn, back)
time.Sleep(t)
}
}
// 接收到连接后,进行处理
func handleConn(conn net.Conn) {
log.Printf("connection received: %v\n", conn)
for {
var input = bufio.NewScanner(conn)
for input.Scan() {
go echoBack(conn, input.Text(), time.Second)
}
}
}
func main() {
// 监听tcp连接
var server = "localhost:8080"
listener, err := net.Listen("tcp", server)
if err != nil {
log.Fatalf("server create error: %v \n", err)
}
log.Printf("echo server running on %v\n", server)
for {
// 接收到tcp连接
conn, err := listener.Accept()
if err != nil {
log.Printf("connection establish error: %v\n", err)
}
go handleConn(conn)
}
}
客户端,向服务端发送信息
package main
import (
"io"
"log"
"net"
"os"
"time"
)
func connect() {
// 发起tcp连接
conn, err := net.Dial("tcp", "localhost:8080")
if err != nil {
log.Fatalf("connection establishment error: %v\n", err)
}
defer conn.Close()
// 将接受到的消息重定向到标准输出
go io.Copy(os.Stdout, conn)
// 将标准输入重定向到tcp连接,传输给服务器
io.Copy(conn, os.Stdin)
}
func main() {
go connect()
for {
time.Sleep(time.Second)
}
}
在学习了通道和select多路复用后,可以将代码优化,添加超时结束对话功能。将服务端的handleConn替换为如下形式。
func handleConn(conn net.Conn) {
log.Printf("connection received: %v\n", conn)
shout := make(chan string, 10)
go func() {
input := bufio.NewScanner(conn)
for input.Scan() {
shout <- input.Text()
}
}()
for {
// ticker是一个通道,这边在20秒后会定时往通道中传入一个数据,类似于计时器
ticker := time.NewTicker(20 * time.Second)
select {
case <-ticker.C:
// 超时了
io.WriteString(conn, "overtime, bye!~")
conn.Close()
return
case x := <-shout:
// 停止倒计时
ticker.Stop()
go echoBack(conn, x, time.Second)
}
}
}
例3 计算质数个数
使用多个协程分别计算质数个数,当然有更高效的算法,这边只是为了看一下使用协程后程序运行时间能优化多少。同时,下面这个例子展示了并行循环的一般模式,使用waitgroup去等待所有协程执行完毕,关闭通道。
package main
import (
"fmt"
"sync"
"time"
)
func isPrime(num int) bool {
for i := 2; i*i <= num; i++ {
if num%i == 0 {
return false
}
}
return true
}
func main() {
// 计算 2~num的质数个数
num := 200 //50000000
thread_num = min(thread_num, num)
// 上取整
gap := (num-2)/thread_num + 1
res := make(chan int, thread_num)
var wg sync.WaitGroup
// 将要计算的数分组
for i := 2; i <= num; {
// wait group 添加1
wg.Add(1)
j := min(i+gap-1, num)
go func(l, r int) {
// wait group 减少1
defer wg.Done()
cnt := 0
for k := l; k <= r; k++ {
if isPrime(k) {
cnt++
}
}
res <- cnt
}(i, j)
i = j + 1
}
// close chan
go func() {
wg.Wait()
close(res)
fmt.Println("chan closed.")
}()
tot := 0
for range thread_num {
x := <-res
tot += x
}
fmt.Println(tot)
}
我使用了之前博客中提到的go的测试模块,设置不同的thread_num测试程序运行所用的时间。效果显著,结果图如下。我的计算机是32核,当设定的协程数量超过计算机的核数时,提升就不是很明显了。当然,这也和我程序的写法有关,我给每个协程分配的任务量是不一样的,后面的那些协程计算量大,是程序的瓶颈。