没有理想的人不伤心

Golang - 并发编程

2025/05/20
3
0

基础知识

进程和线程

  • 进程是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。
  • 线程是进程的一个执行实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。
  • 一个进程可以创建和撤销多个线程;同一个进程中的多个线程之间可以并发执行。

并发和并行

并发主要由切换时间片来实现"同时"运行,并行则是直接利用多核实现多线程的运行,go可以设置使用核数,以发挥多核计算机的能力。

  • 多线程程序在一个核的cpu上运行,就是并发。

1710297038363-c69a5ba9-bc04-4c96-8e40-6054d6a77912.png

线程A、B、C在单核上运行

  • 多线程程序在多个核的cpu上运行,就是并行。

1710297088337-873c2cfb-aeb0-4d12-a893-018e0cd81de6.png

线程A、B、C在多核上运行

协程和线程

协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。

线程:一个线程上可以跑多个协程,协程是轻量级的线程。

goroutine

是由官方实现的超级"线程池",每个实例4~5KB的栈内存占用和由于实现机制而大幅减少的创建和销毁开销是go高并发的根本原因。
goroutine 奉行通过通信来共享内存,而不是共享内存来通信。

在Go语言中,每一个并发的执行单元叫作一个goroutine

goroutine是由Go的运行时(runtime)调度和管理的。Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU。

Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经内置了调度和上下文切换的机制。

在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能——goroutine,当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个goroutine去执行这个函数就可以了,就是这么简单粗暴。

启用goroutine

在调用函数(普通函数和匿名函数都行)时在前面加上go关键字,就可以为一个函数创建一个goroutine。

一个goroutine必定对应一个函数,可以创建多个goroutine去执行相同的函数

func hello() {
    fmt.Println("Hello Goroutine!")
}
func main() {
    go hello()	// 启动另外一个goroutine去执行hello函数
    fmt.Println("main goroutine done!")
}

// 输出
main goroutine done!

为什么没有打印Hello Goroutine!

在程序启动时,Go程序就会为main()函数创建一个默认的goroutine。

当main()函数返回的时候该goroutine就结束了,所有在main()函数中启动的goroutine会一同结束

var wg sync.WaitGroup

func hello(i int) {
    defer wg.Done() // goroutine结束就登记-1
    fmt.Println("Hello Goroutine!", i)
}
func main() {

    for i := 0; i < 10; i++ {
        wg.Add(1) // 启动一个goroutine就登记+1
        go hello(i)
    }
    wg.Wait() // 等待所有登记的goroutine都结束
}

//输出
Hello Goroutine! 5
Hello Goroutine! 7
Hello Goroutine! 3
Hello Goroutine! 0
Hello Goroutine! 1
Hello Goroutine! 2
Hello Goroutine! 6
Hello Goroutine! 8
Hello Goroutine! 4

上面使用了sync.WaitGroup来实现goroutine的同步,多次执行上面的代码,会发现每次打印的数字的顺序都不一致。这是因为10个goroutine是并发执行的,而goroutine的调度是随机的。

goroutine与线程

OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB),一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),goroutine的栈不是固定的,他可以按需增大和缩小,goroutine的栈大小限制可以达到1GB。

goroutine的调用

GPM是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统

channels

channels则是goroutine之间的通信机制,可以让一个goroutine通过它给另一个goroutine发送值信息。

每个channel都有一个特殊的类型,也就是channels可发送数据的类型。一个可以发送int类型数据的channel一般写为chan int

创建

使用make:

  • ch := make(chan int) // 没有缓存类型为int的channel
  • ch = make(chanint, 3) // 缓存为3的channel

chanel是一种引用类型,当复制或进行参数传递时,只是拷贝了一个channel引用

channel零值是nil

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

通信

发送接收数据都使用<-运算符

ch <- x  // 发送
x = <- ch // 接收
<-ch  // 不使用接收结果的接收

若channel中没有数据了,就会产生一个零值的数据。

关闭

使用内置函数close()进行channel的关闭:close(ch)

对于已关闭的channel,任何发送操作会导致panic,而接收操作仍可以接收到之前的数据。

无缓存channels(同步channels)

发送操作将导致发送者goroutine进入阻塞状态,直到同一个channel中的数据被另一个goroutine接收。

反之,若接收操作先发生,那么接收者goroutine也会进入阻塞状态,直到有发送者发送数据。

基于无缓存Channels的发送和接收操作将导致两个goroutine做一次同步操作

因此可以通过无缓存channels来使main goroutine等待另一个goroutine执行完毕

func main() {
    conn, err := net.Dial("tcp", "localhost:8000")
    if err != nil {
        log.Fatal(err)
    }
    done := make(chan struct{})
    go func() {
        io.Copy(os.Stdout, conn) // NOTE: ignoring errors
        log.Println("done")
        done <- struct{}{} // signal the main goroutine
    }()
    mustCopy(conn, os.Stdin)
    conn.Close()
    <- done // wait for background goroutine to finish
}

基于channels发送消息有两个重要方面。首先每个消息都有一个值,但是有时候通讯的事实和发生的时刻也同样重要。当我们更希望强调通讯发生的时刻时,我们将它称为消息事件。有些消息事件并不携带额外的信息,它仅仅是用作两个goroutine之间的同步,这时候我们可以用struct{}空结构体作为channels元素的类型,也可以使用bool或int类型实现同样的功能

带缓冲区的channels

带缓冲区的channels与生产者消费者模式相似

协程资源上锁

sync.Mutex
未完待续。。。