Go学习之 - Goroutines和channels

一、Goroutines

如果之前有学习过操作系统,或者其他语言,例如JAVA,那么对这个其实是不陌生了,与 Thread 类似,也就是新创建并执行了一个线程,这个线程可以跟当前的主线程或者其他线程一起执行,从而大大提高并发度,提高对CPU的使用

主要使用关键字:go

我们以一个斐波那契数列为例子,在这个例子当中,使用递归的算法计算它,但是因为数字大,并且计算效率低下,我们需要在计算的过程中,创建一个新的线程,告诉用户用户当前计算正在执行中,我们就可以使用 go 来实现,如下:

复制代码
func main() {
    go spinner(100 * time.Millisecond)
    const n = 45
    fibN := fib(n) // slow
    fmt.Printf("\rFibonacci(%d) = %d\n", n, fibN)
}

func spinner(delay time.Duration) {
    for {
        for _, r := range `-\|/` {
            fmt.Printf("\r%c", r)
            time.Sleep(delay)
        }
    }
}

func fib(x int) int {
    if x < 2 {
        return x
    }
    return fib(x-1) + fib(x-2)
}

二、channel

(一) 什么是 channel ?

继Goroutines出现之后,就有一个问题,如果一个主线程当中,有多个Goroutines,并且这些Goroutines之间还要有一定的先后关系,例如GoroutinesA执行完之后,再执行GoroutinesB

用人话来说,GoroutinesA执行完毕之后,告诉GoroutinesB,好,你可以执行了!

如果需求更多一些,GoroutinesB可能需要GoroutinesA将一些数据,传给GoroutinesB,作为参数去运行

这其实就是Goroutines之间的通信问题,说道这里你也就明白了,channel 是做什么的了

channel: 作为一个管道,将不同的Goroutines建立起来信息通信,确定线程的执行先后顺序

(二) channel 的声明

一般来说,我们都通过make 函数创建 一个无缓存的channel,那么一定还有有缓存的,之后说,五缓存的 channel 创建方式如下:

复制代码
ch := make(chan int) // ch has type 'chan int'

有两个参数,第一个为 chan ,标明是管道类型,第二个是对应管道接收的参数,这里的 int 就代表对应的管道可以用来传输 int 类型的数据

参数传递时,我们只是拷贝了一个channel引用,因此调用者和被调用者将引用同一个channel对象。和其它的引用类型一样,channel的零值也是nil。

此外,两个相同类型的channel可以使用==运算符比较。如果两个channel引用的是相同的对象,那么比较的结果为真。一个channel也可以和nil进行比较。

此外,一个channel主要有发送和接收数据这两个主要操作,全都是用来进行通信的,发送时,使用 : <- [需要传输的数据] 将对应的数据传输到对应类型得 channel 当中

接收时,使用: <- [对应包含数据的 channel]

举一个例子:

复制代码
ch := make(chan int)
ch <- x  // a send statement
x = <-ch // a receive expression in an assignment statement
<-ch     // a receive statement; result is discarded

如果对应的channel没有传输数据,但是存在一个channel去接收数据,那么此时就会阻塞线程,直到这个 channel 接收到了数据,并被消费数据,否则就会一直被阻塞。

但是有一点需要注意!看代码:

复制代码
func main(){
ch := make(chan int)
ch <- 10
fmt.Println("代码执行完毕")
}

虽然编译没有问题,但是一运行,这里就会在第三行出现问题,出现:fatal error: all goroutines are asleep - deadlock! 的错误

这是因为对于无缓存的channel来说,就相当于是一个快递员送东西的,但是没有快递站存储快递,为什么有快递员?就是因为有用户买东西了,才有了!也就是说,对于无缓存的channel来说,必须要有其他的消费数据,才能有数据存储,这一点需要记住

简单的说,如果一个channel 没有被关闭,那么对应消费数据时,就会一直等着数据的到来,哪怕实际上已经没数据了!

这个时候就会有聪明人问了:那么怎么保证一些数据被线程接收和消费完毕了,当前的主线程就不再接收数据,选择结束,也就是不再阻塞了呢?

哎!我们可以通过自主的关闭一个channel来做到

关闭的时候,之后再次对这个 channel 发起任何的调用都会出现 panic 异常,如下:

复制代码
close(ch)

同时,关闭了之后,对应的接收数据接收完之后,就不会再进行阻塞式的等待了

(三) Goroutines 的线程同步

我们需要先理解一个概念,在并发编程中,我们说线程A先于线程B执行或者发生,意思不是说线程A执行的比B早,而是说要保证,B执行的时候,A已经执行完毕了,之后就可以放心的依赖这个工作结果去完成自己的任务了,如下:

复制代码
ch := make(chan struct{})

    go func() {
        log.Println("goroutine1正在执行,等待5S执行完毕")
        time.Sleep(5 * time.Second)
        log.Println("goroutine1已经执行完毕!")
        ch <- struct{}{}
    }()

    <-ch
    fmt.Println("主线程已经关闭")

代码中,我们的一个线程正在执行一个任务,逻辑我们暂时定位睡5S

其实在主线程中,我们的运行结果早已经到了 <- ch 这一步,但是一直都在被阻塞,因为还没接收到对应 chnnel 发送的数据,所以一直在阻塞

从而做到了Goroutines当中的执行,一定发生在 main 结束之前

这里我们channel 类型为 struct{},其实没有什么意义,就是代表需要进行线程同步,标识一下,没有实际内容或者数据,也可以用int 或者 bool,代表对应线程是否执行完毕

三、串联的 channels (Pipeline)

一个 channel 可以接收Goroutines的数据并且传输,那么要是一个流程需要多个线程之间相关传输信息,怎么办?就像一个流水线一样,需要多个搬运工去传输信息

这个时候我们只需要多添加几个 channel ,形成一个Pipeline,就行啦

例如 GoroutinesA -> channelA -> GoroutinesB -> channelB -> Println

这里就是两个线程信息的传递,我们使用了两个channel 去保证信息的传递,这就是 pipeline

举一个例子,如下:

复制代码
func main() {
    naturals := make(chan int)
    squares := make(chan int)

    // Counter
    go func() {
        for x := 0; ; x++ {
            naturals <- x
        }
    }()

    // Squarer
    go func() {
        for {
            x := <-naturals
            squares <- x * x
        }
    }()

    // Printer (in main goroutine)
    for {
        fmt.Println(<-squares)
    }
}

但是有一个问题,对应需要传输的数据应该都是有限的,要是这样,最后的 for 不就一直接收一直大一i你,那就是一个永远不停的了,这可不对,我们对Counter 修改,例如只需要传输10个数据

复制代码
func main() {
    naturals := make(chan int)
    squares := make(chan int)

    // Counter
    go func() {
        for x := 0;x < 10 ; x++ {
            naturals <- x
        }
    }()

    // Squarer
    go func() {
        for {
            x := <-naturals
            squares <- x * x
        }
    }()

    // Printer (in main goroutine)
    for {
        fmt.Println(<-squares)
    }
}

但是接收起来,最后一个 for 循环打印信息还是停不下来,怎么办?

有聪明的同学就想起来了,这是因为 channel 没有关闭,导致对应接收一直在等待!

是的,当channel被关闭了,之后接收的时候就不会阻塞,直接返还0值,但是依旧挑不出最后的 for

别怕,其实获取数据的时候,还有一个参数:

复制代码
x, ok := <-naturals

没错,ok作为第二个参数,代表对应的 channel 是否被关闭!

我们可以通过这个来结束死循环

但是还有更简单的方法,记得 for range 吗?我们直接 range 对应的 channel 即可,这样没有数据接收之后直接结束!更新代码如下:

复制代码
func main() {
    naturals := make(chan int)
    squares := make(chan int)

    // Counter
    go func() {
        for x := 0; x < 100; x++ {
            naturals <- x
        }
        close(naturals)
    }()

    // Squarer
    go func() {
        for x := range naturals {
            squares <- x * x
        }
        close(squares)
    }()

    // Printer (in main goroutine)
    for x := range squares {
        fmt.Println(x)
    }
}

四、channel buffer

上面我们说过,channel采用make去确定chan的类型,这是一种无缓存的通道创建,但其实后面可以再跟上一个参数,用来确定对应的channel的缓存数量,创建方式如下:

复制代码
channelA := make(chan int,2)

代表创建了一个缓存大小为2的管道,但是这个缓存有什么用呢?

哎!用处大了,记得之前说的吗?无缓存的通道,每次接收只能接收一个数据,满了之后并且没有人接收,那么就会阻塞,无法接收新的数据

但是有了缓存,我们就可以获取多个数据,那么没有被及时的接收,只要缓存未被占满,就能继续接收,不会阻塞!直到缓存被占满

最好想到的一个作用,就是对一些操作进行限制:

例如,现在我们做了一个文件大小遍历程序,用来查看对应文件夹下所有文件大小的总和,通常采用并发迭代的方法去遍历,但是如果一直让其打开对应的文件,不加以限制,就可能会导致资源被占用过多,甚至造成崩溃!所以我们可以对打开文件的多少进行限制

这个时候我们就可以采用缓存channel了!设置对应buffer大小,被占满了,就不准再打开新的文件夹了,直到有一个被使用

相关推荐
半桶水专家36 分钟前
Go 语言时间处理(time 包)详解
开发语言·后端·golang
编程点滴37 分钟前
Go 重试机制终极指南:基于 go-retry 打造可靠容错系统
开发语言·后端·golang
实心儿儿1 小时前
C++ —— 模板进阶
开发语言·c++
敲敲了个代码1 小时前
CSS 像素≠物理像素:0.5px 效果的核心密码是什么?
前端·javascript·css·学习·面试
萧鼎1 小时前
Python PyTesseract OCR :从基础到项目实战
开发语言·python·ocr
二川bro2 小时前
第57节:Three.js企业级应用架构
开发语言·javascript·架构
sali-tec2 小时前
C# 基于halcon的视觉工作流-章62 点云采样
开发语言·图像处理·人工智能·算法·计算机视觉
这人很懒没留下什么3 小时前
SpringBoot2.7.4整合Oauth2
开发语言·lua
ZHOUZAIHUI3 小时前
WSL(Ubuntu24.04) 安装PostgreSQL
开发语言·后端·scala