Golang 学习笔记:并发编程

Go 语言的并发原理

Go语言的语法运行时直接内置了对并发的支持。Go语言里的并发是指能让某个函数独立于其他函数运行的能力。当一个函数创建成为协程goroutine时,会将其视为一个独立的工作单元,这个单元会被调度到可用的逻辑处理器上运行。

Go语言中,goroutine被称为协程,它们实际是属于用户态线程!

并发与并行

  • 并发: 单位时间段内,多个任务都在执行 (单位时间内不一定同时执行)
  • 并行: 单位时刻内,多个任务同时执行

在操作系统中,我们可以知道,一个线程是一个执行空间,这个空间会被操作系统调度来运行函数中写的代码,每个进程至少包含一个线程,每个进程的初始线程被称作主线程。操作系统会在物理处理器上调度线程来执行,而Go语言的运行时会在逻辑处理器上调度goroutine来运行,每个逻辑处理器都分别绑定到单个操作系统线程。

并发(concurrency)并不是并行(parallelism),并行是让不同的代码片段同时在不同的物理处理器上运执行,并行的关键是同时做很多事情,而并发是指同时管理很多事情,这些事情可能只做来一半就被叫去做其他事情来,并不是百分之百执行完毕的。如果希望goroutine实现并行,就必须要增加一个及以上的逻辑处理器。一旦逻辑处理器数量增加,调度器会将goroutine平等分配到每个逻辑处理器上,这会让goroutine在不同的线程上运行。不过要想真的实现并行效果,还是得需要多个物理处理器的支持才行;否则,goroutine依然会在同一个物理处理器上并发运行,达不到并发的效果!

一个并发程序可以在一个处理器或者内核上使用多个线程来执行任务,但是只有同一个程序在某个时间点同时运行在多核或者多处理器上才是真正的并行并行是一种通过使用多处理器以提高速度的能力。所以并发程序可以是并行的,也可以不是。

并行可以通过增加CPU核心数对性能进行调优。

主流的并发模型实现

  • 多进程

    多进程是在操作系统层面进行并发的基本模式。同时也是开销最大的模式。在 Linux 平台上,很多工具链正是采用这种模式在工作。比如某个 Web 服务器,它会有专门的进程负责网络端口的监听和链接管理,还会有专门的进程负责事务和运算。这种方法的好处在于简单、进程间互不影响,坏处在于系统开销大,因为所有的进程都是由内核管理的。

  • 多线程

    多线程在大部分操作系统上都属于系统层面的并发模式,也是我们使用最多的最有效的一种模式。目前,我们所见的几乎所有工具链都会使用这种模式。它比多进程的开销小很多,但是其开销依旧比较大,且在高并发模式下,效率会有影响。

  • 基于回调的非阻塞/异步IO

    这种架构的诞生实际上来源于多线程模式的危机。在很多高并发服务器开发实践中,使用多线程模式会很快耗尽服务器的内存和 CPU 资源。而这种模式通过事件驱动的方式使用异步 IO,使服务器持续运转,且尽可能地少用线程,降低开销,它目前在Node.js中得到了很好的实践。但是使用这种模式,编程比多线程要复杂,因为它把流程做了分割,对于问题本身的反应不够自然。

  • 协程

    协程(Coroutine)本质上是一种用户态线程,不需要操作系统来进行抢占式调度,且在真正的实现中寄存于线程中;因此,系统开销极小,可以有效提高线程的任务并发性,而避免多线程的缺点。使用协程的优点是编程简单,结构清晰;缺点是需要语言的支持,如果不支持,则需要用户在程序中自行实现调度器。目前,原生支持协程的语言还很少。

Goroutine 生命周期(运行步骤)

  • 如果创建一个goroutine准备运行,那么就会被放到调度器的全局运行队列中;
  • 调度器就将这些对列中的goroutine分配给一个逻辑处理器,并放到这个逻辑处理器对应的本地运行队列中;
  • 本地运行队列中的goroutine会一直等待,直到自己被分配到的逻辑处理器执行。

指定CPU核心数

Go语言在语言层面上原生支持并发,因此可以通过调用相应的标准库flag包来实现指定CPU的核心数:

import (
    "fmt"
    "time"
)

func longWait()  {
    fmt.Println("开始longWait()")
    time.Sleep(5 * 1e9) //5秒
    fmt.Println("结束longWait()")
}

func shortWait()  {
    fmt.Println("开始shortWait()")
    time.Sleep(2 * 1e9) //2秒
    fmt.Println("结束shortWait()")
}

func main()  {
    fmt.Println("这里是main()开始的地方:")
    go longWait() //5秒
    go shortWait() //2秒

    fmt.Println("挂起main()")
    //挂起时间以纳秒ns为单位
    time.Sleep(10 * 1e9) //10秒
    fmt.Println("这里是main()结束的地方:") 
}

/*
这里是main()开始的地方:
挂起main()
开始longWait()
开始shortWait()
结束shortWait()
结束longWait()
这里是main()结束的地方:
*/

main()longWait()shortWait()三个方法作为独立的处理单元按顺序启动,然后开始并行运行,每一个方法都在开始和结束输出了它们通过调用time.Sleep()方法,就可以模拟它们的运算时间消耗。这里使用纳秒ns为单位:1e9 ns等于1s,即10^9纳秒等于1秒。

由于longWait()shortWait()都是在main()中调用,因此期望的执行顺序应该是,main()函数总是要在前面两个方法执行完毕之后才开始以并行的方式执行:longWait()执行了5秒,shortWait()执行了2秒,main()执行了10秒,最后打印这里是main()结束的地方这个语句。

如果让main()执行3秒,shortWait()可以执行,那么longWait()就不能执行完毕;如果不在main()中等待,协程会随着程序的结束而消亡。当main函数返回的时候,程序退出:它不会等待任何非main协程的结束,这就是为什么在服务器程序中,每一个请求都会启动一个协程来处理,server()函数必须保持运行状态。

另外,协程是独立的运行单元,我们无法确定它是什么时候开始被执行的,代码逻辑必须独立于协程调用的顺序

如果是使用一个线程连续调用的情况,移除Go语言关键字,重新运行程序:

func longWait()  {
    fmt.Println("开始longWait()")
    time.Sleep(5 * 1e9) //5秒
    fmt.Println("结束longWait()")
}

func shortWait()  {
    fmt.Println("开始shortWait()")
    time.Sleep(2 * 1e9)     //2秒
    fmt.Println("结束shortWait()")
}

func main()  {
    fmt.Println("这里是main()开始的地方:")
    longWait()         //5秒
    shortWait()     //2秒

    fmt.Println("挂起main()")
    //挂起时间以纳秒ns为单位
    time.Sleep(10 * 1e9)     //10秒
    fmt.Println("这里是main()结束的地方:")     //等候了17秒才执行
}

longWait()执行了5秒,shortWait()执行了2秒,main()执行了10秒,最后打印这里是main()结束的地方这个语句,总共耗时17秒。

多线程会带来的问题?

并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏上下文切换死锁还有受限于硬件和软件的资源闲置问题

使用多线程的应用难以做到准确,最主要的问题是内存中的数据共享,它们会被多线程以无法预知的方式进行操作,导致一些无法重现或者随机的结果,称作 竞态。不要使用全局变量或者共享内存,它们会给你的代码在并发运算的时候带来危险。解决办法在于同步不同的线程对数据加锁,这样同时就只有一个线程可以变更数据

通过使用标准库中的sync.Mutex互斥锁可以对一些低级别的代码中实现加锁!但这种做法会带来相当高的复杂度,并不适用于现代的多核/多处理器编程。

上下文切换

多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换

Go语言在语言级别支持轻量级线程,称为goroutine;轻量级线程的切换管理不再依赖于系统的线程和进程,也不依赖于CPU核心数量,而是交给Go语言运行时runtime统一调度(也允许手动控制)。

启动 goroutine

goroutineGo语言并行设计的核心,可以将它视为协程。Go语言内部已经实现了goroutine之间的内存共享和资源分配,执行只需要极少的栈内存(大概是4KB~5KB,会根据相应的数据动态调整),因此,程序可以同时运行成千上万的并发任务,goroutinethread更易用、高效和简便。

goroutine是通过Go程序的runtime管理的一个线程管理器,通过关键字go实现。

go hello (a,b,c) #启动一个routine

引入标准库runtime包,调用runtime.Gosched()函数,可以使当前goroutine放弃CPU的资源,下次的某个时候会恢复执行,紧接着让其它goroutine使用CPU资源。

import (
    "fmt"
    "runtime"  //标准库:包含与Go的运行时系统进行交互的操作,例如用于控制协程的函数
)

func say(s string){
    for i:= 0;i<5;i++{
        runtime.Gosched() // 资源
        fmt.Println(s)
    }
}

func main()  {
    go say("hello") //创建一个goroutine开始执行
    say("hello") //当前goroutine开始执行
}

/*
hello
hello
hello
hello
hello
hello
hello
hello
hello
*/

hello函数在main函数被调用,因此是按照顺序执行的方式打印输出信息:

func hello(){
    fmt.Println("hello")
}

func main(){
    hello() //顺序执行
    fmt.Println("main")
}

/*
hello
main
*/

使用go关键字可以创建goroutine并开始执行绑定在上面的方法或函数,随后main函数会立即结束,而创建的goroutinehello函数)也会结束。此外,goroutine对应的函数结束来,goroutine也会结束:

func hello(){
    fmt.Println("hello") //goroutine结束,不打印
}

func main(){
    go hello() //创建一个单独的goroutine并开始执行hello函数
    fmt.Println("main") 
}

/*
main
*/

使用匿名函数启动goroutine来实现打印i这个功能,而匿名函数调用的是外部的i变量,这就是闭包;外部for循环的i会遍历得更快,因此打印的i值就会有重复信息

func main(){
    for i:=0;i<10000000 ; i++ {
        //go hello(i) //创建一个goroutine并开始执行hello函数
        go func() { //匿名函数
            fmt.Println(i) //闭包,调用的是函数外部的i
        }()
    }
    fmt.Println("main")
    time.Sleep(1*1e9) 
}

/*
... 
999 重复的i值
999
984
984
...
*/

为了解决这个问题,可以让匿名函数作为参数传递,而非外部的i

func main(){
    for i:=0;i<100000000 ; i++ {
        //go hello(i) //创建一个goroutine并开始执行hello函数
        go func(i int) { 
            fmt.Println(i) //调用匿名函数参数的i
        }(i)
    }
    fmt.Println("main")
    time.Sleep(1*1e9)
}

上述例子说明了,启动goroutine需要耗费一定的时间和系统资源。

### math/rand( ) 方法

调用math包的rand方法,将Unix时间戳作为随机种子,打印输出信息:

func f(){
    rand.Seed(time.Now().UnixNano()) //Unix时间戳作为随机种子
    for i:= 0 ;  i < 5 ; i++  {
        r1 := rand.Int() //Int64
        r2 := rand.Intn(10) //[0:10)
        fmt.Println(r1,r2)
    }
}

func main(){
    f()
}

/*
5573427335287695331 1
5618001700544303732 2
4984746980924766804 8
2085722169286122110 4
1098102190678553769 9
*/

sync.WaitGroup( ) 方法

sync包中type WaitGroup()struct类型(值类型,不能被复制),用于等待一组线程的结束,比time.Sleep()方法要优雅得多,它由以下三个方法实现:

  • wg.Add()

    父线程调用Add方法来设定应等待的线程的数量,计数器增加

  • wg.Wait()

    主线程里可以调用Wait方法阻塞至所有线程结束,计数器减至0

  • wg.Done()

    每个被等待的线程在结束时应调用Done方法,线程结束后执行,释放内存资源

func f(){
    rand.Seed(time.Now().UnixNano()) //Unix时间戳作为随机种子
    for i:= 0 ;  i < 5 ; i++  {
        r1 := rand.Int() //Int64
        r2 := rand.Intn(10) //[0:10)
        fmt.Println(r1,r2)
    }
}

func f1(i int){
    defer wg.Done() //线程结束后执行
    time.Sleep(time.Millisecond * time.Duration(rand.Intn(300))) //使用time.Duration()强转类型
    fmt.Println(i)
}

var wg sync.WaitGroup //WaitGroup方法用于等待一组线程的结束,属于值类型,不能被复制

func main(){
    for i:= 0; i<10 ; i++ { //启动10次goroutine
        wg.Add(1) //启动一个goroutine计数器加1
        go f1(i)
    }
    //time.Sleep()
    wg.Wait() //等待计数器减至0
}

发表评论

电子邮件地址不会被公开。 必填项已用*标注