美文网首页
第十篇:并发编程

第十篇:并发编程

作者: 意一ineyee | 来源:发表于2018-03-29 16:31 被阅读68次

    Go语言区别于其它语言最核心的优势就是:Go对并发编程的大力支持。

    • 一来Go是通过协程goroutine作为执行体实现并发的,而一个CPU一秒钟可以很轻松地调度上百万个协程而不会使系统资源枯竭,所以Go的并发性能是不容置疑的,Go目前的定位就是开发高并发的服务端程序。
    • 二来Go只需要轻轻一写go关键字就可以发起一个goroutine,实现起来非常简单,同时Go采用的消息传递来实现执行体之间的通信,所以并发编程也更加安全

    目录

    一、并发的一些基础知识

    二、Go并发编程关键词之:协程goroutine和go关键字

    三、Go并发编程关键词之:消息传递实现执行体之间的通信和channel作为消息通道


    一、并发的一些基础知识

    1、为什么需要并发编程?

    • 如果我们的程序只是串行执行,一个操作就会阻塞程序的执行,导致其它的操作无法及时地执行,程序的执行效率低,而并发编程就可以很大程度地提高程序的执行效率;具体比如说:

      • 一方面我们需要及时地响应用户界面,另一方面我们还需要进行大量的运算操作,而我们需要界面响应和运算同时进行;
      • 当我们的服务器面对大量的用户请求时,就需要更多的“服务器工作单元”来分别响应这些请求;
    • 计算机的CPU从单核向多核发展,如果我们的程序只是串行程序,那么计算机的能力得不到发挥,浪费资源,而并发编程就可以很好地利用CPU多核的特点,充分利用硬件资源。

    2、有哪些方式可以实现并发编程(即并发编程的执行体有哪些)?

    我们可以通过以下四种执行体来实现并发编程:

    • 多进程:多进程是操作系统层面实现并发的模式,这种方式简单,但是系统开销极大。

    • 多线程:多线程是我们目前为止使用最多也是最有效的并发编程模式,系统开销要比多进程小得多,但是在高并发的模式下,大量地开辟多线程会很快地消耗完系统的内存和CPU资源,系统开销也非常大,而且效率也会受到影响。

    • 基于回调的异步IO:这种方式就是为了解决多线程在高并发下的极大开销而出现的,它可以尽可能地帮助我们少开线程,使用异步IO来使得服务器持续运转,但是编程比较复杂。

    • 协程:协程是寄存于线程中的,它不需要操作系统来进行抢占式地调度,所以它的系统开销极小,可以有效避免高并发场景下多线程的缺点。使用协程实现并发非常简单,缺点是使用协程需要语言级别的支持,否则用户就需要在程序中自己实现调度器,非常麻烦,目前在语言级别支持协程的还很少,说了这么多,其实只是想说Go支持协程。协程相比线程的最大优势就在于一颗CPU每秒可以轻松调度上百万个协程而不会导致资源衰竭,而进程和协程最多也就不超过一万个,所以协程在高并发的场景下性能是非常牛逼的。

    3、提到并发还不得不提执行体之间的通信:

    执行体之间的通信,有两种方式:

    • 执行体通过共享数据来通信:这种方式需要考虑执行体的同步互斥:当多个执行体之间存在共享同一份数据的时候,为保证数据的准确性,我们就需要对访问这个共享数据的多个执行体进行互斥。当多个执行体之间存在逻辑上的时序关系时,我们就需要需要对执行体进行同步。当然提到执行体的互斥,我们就会想到有死锁这两个关键字。

    • 执行体通过消息传递来通信:当执行体之间进行消息传递的时候,通常需要基于一个消息队列或者进程邮箱这样的设施来进行通信,Go语言中就内置了一个这样的消息队列,叫作通道(channel),两个goroutine之间就可以很方便地通过channel来进行消息的传递,达到通信的目的。

    前者是大多数语言采用的执行体间通信的方式,尽管Go也保留了传统的执行体通过共享资源来通信的方式,允许适度地使用,但是Go更推荐使用执行体通过消息传递来通信

    二、Go并发编程关键词之:协程goroutine和go关键字

    goroutine是Go语言中的协程,它是Go语言中实现并发编程的执行体。一个CPU一秒钟可以很轻松地调度上百万个协程而不会使系统资源枯竭,所以goroutine使得Go的并发性能不容置疑。

    如果我们想开辟一个协程,让一个函数在一个协程中并发执行,那我们只需要在这个函数前面加上go关键字就可以了,当这个函数结束时,这条goroutine也就结束了,注意如果这个函数有返回值,那么这个返回值会被自动丢弃。非常简单吧。

    如没有并发的情况下:

    (清单2.1)

    // goroutine
    package main
    
    import (
        "fmt"
    )
    
    func main() {
    
        for i := 0; i < 10; i++ {
    
            z := Add(i, i)
            fmt.Println(z)
        }
    }
    
    func Add(x, y int) (z int) {
    
        z = x + y
        return z
    }
    
    
    打印:
    0
    2
    4
    6
    8
    10
    12
    14
    16
    18
    成功: 进程退出代码 0.
    

    我们可以正常地拿到函数的返回值z,并打印。

    当让Add()函数并发执行的时候:

    (清单2.2)

    func main() {
    
        for i := 0; i < 10; i++ {
    
            z := go Add(i, i) // 在函数前加上go关键字
            fmt.Println(z)
        }
    }
    

    这样写是会编译报错的,因为Go里并发执行的函数会自动丢弃函数的返回值。所以我们把代码改成这样:

    (清单2.3)

    // goroutine
    package main
    
    import (
        "fmt"
    )
    
    func main() {
    
        for i := 0; i < 10; i++ {
    
            go Add(i, i)
        }
    }
    
    func Add(x, y int) (z int) {
    
        z = x + y
        fmt.Println(z)
        return z
    }
    

    编译不报错了,这段代码的意思是:我们开辟了是个协程,每个协程执行一个Add()函数。

    但是,运行,我们看不到控制台打印东西啊,什么情况?要回答这个问题我们需要了解下Go语言的执行机制了:

    每个Go程序都是从main包的main()函数开始执行,等main()函数执行结束后,整个程序也就执行结束了,并不会等待每个goroutine都执行结束。

    那么,上面的例子中,我们在主函数中开辟了十个协程,主函数就返回了,因此程序已经执行结束了,这是个协程还没来得及执行,所以不会有打印输出。

    那么要想正常打印输出,我们就必须得等十个协程都执行完才能让主函数返回,那我们如何才能知道是个协程执行完了呢?这就引出了下一节的内容--Go执行体间通过消息传递来通信,消息传递的通道被称为channel。

    不过在开始下一节前,我们可以先使用共享数据的执行体通信方式来解决一下这个问题,然后和下一节的消息传递的执行体通信方式来对比一下。代码如下:

    (清单2.4)

    // goroutine
    package main
    
    import (
        "fmt"
        "runtime"
        "sync"
    )
    
    var counter int = 0 // 创建一个变量来记录有几个协程执行完毕,十个协程会共享这个变量
    
    func main() {
    
        lock := &sync.Mutex{} // 创建一把锁
    
        for i := 0; i < 10; i++ {
    
            go Add(i, i, lock)
        }
    
        for { // 给主函数一个死循环,保证主函数不会立即退出,而是由我们来掌控主函数的退出时间
    
            runtime.Gosched()
            if counter >= 10 {
    
                break
            }
        }
    }
    
    func Add(x, y int, lock *sync.Mutex) (z int) { // 该函数新增一个参数为互斥锁,来保证同一时间只有一个协程能访问锁之间的操作
    
        lock.Lock() // 加锁
    
        counter++
    
        z = x + y
        fmt.Println(z)
    
        lock.Unlock() // 解锁
    
        return z
    
    }
    
    
    打印:
    2
    18
    4
    6
    8
    10
    12
    14
    16
    0
    成功: 进程退出代码 0.
    

    可见,通过共享数据的通信方式我们解决了这个问题,但这才是这么简单的一个例子,那么当我们遇到高并发的场景时,就需要无数的共享数据、无数的锁,再加上业务逻辑,无疑是个噩梦。那并发编程作为Go语言的招牌,它会提倡怎么解决呢?下一节,Go执行体间通过消息传递来通信,消息传递的通道被称为channel。

    三、Go并发编程关键词之:消息传递实现执行体之间的通信和channel作为消息通道

    Go语言推荐使用消息传递的方式来实现执行体间的通信,通信通道为channel。当然消息传递只是一种方式,具体实现是靠channel来实现的,所以接下来我们会学习channel。

    在具体讲解之前,我们先用消息传递和channel实现上面的例子,对比一下,方便我们对消息传递和channel有个直观的认识。

    (清单3.1)

    // goroutine
    package main
    
    import (
        "fmt"
    )
    
    func main() {
    
        chs := make([]chan int, 10) // 创建一个数组切片,用来存放是个channel
    
        for i := 0; i < 10; i++ {
    
            chs[i] = make(chan int) // 创建channel
            go Add(i, i, chs[i])    // 为每一个协程添加一个channel
        }
    
        for _, ch := range chs {
    
            <-ch // 这里是读取channel里的数据,同样的,一个channel在写入数据之前,读这个操作也是阻塞的,也就是说如果一个channel没有写入数据,那它的读取操作会一直挂在那阻塞,因此这就保证了channel必须是写入了数据,即该routine执行完了Add()
        }
    }
    
    func Add(x, y int, ch chan int) (z int) { // 该函数新增一个参数为channel,以便给每个协程一个通信的消息通道
    
        z = x + y
        fmt.Println(z)
    
        ch <- 1 // 每有一个协程执行Add()函数,就像这个协程对应的channel里写一个1,当然这个channel在读取前,写这个操作是是被阻塞的,也就是说如果一个channel没被读取,那它的写入操作会一直挂在那阻塞,注意要写在代码的后面,保证代码执行完了才写入
    
        return z
    }
    
    
    打印:
    18
    10
    12
    14
    16
    0
    4
    6
    8
    2
    成功: 进程退出代码 0.
    

    可见,这种方式消息传递要比共享数据的通信方式简洁多了,而且不必再去考虑锁的问题,因此实现并发编程也更安全。

    1、channel的定义

    channel是类型相关的,也就是说channel只能传递某一种类型的值,所以我们在声明channel的时候,就指定好它可以传递哪种类型的值。如:

    声明一个可以传递int类型的channel

    var ch chan int
    

    我们其实可以把chan int看做是一个channel的类型,即整型channel。

    又如,我们创建了一个字典,其value的类型为bool型channel

    var dict map[string] chan bool
    
    2、channel的创建与赋值
    var ch chan int     // 声明channel
    ch = make(chan int) // 创建channel并赋值
    
    ch1 := make(chan int)
    
    3、channel的操作
    • 写入
    ch <- value
    

    这个就是channel的写入操作。一个channel在读取前,写这个操作是是被阻塞的,也就是说如果一个channel没被读取,那它的写入操作会一直挂在那阻塞。

    • 读取
    value <- ch
    

    这个就是channel的读取操作。同样的,一个channel在写入数据之前,读这个操作也是阻塞的,也就是说如果一个channel没有写入数据,那它的读取操作会一直挂在那阻塞,因此这就保证了channel必须是写入了数据。

    • 关闭channel
    close(ch) // 关闭channel
    
    _, ok := <-ch // 判断channel是否关闭
    
    4、带缓冲的channel

    上面的例子中,我们演示的是不带缓冲的channel,即只能写入单个数据,但有的场景是需要持续输入大量数据的,因此就需要用到带缓冲的channel。如:

    ch := make(chan int, 1024)
    

    上面的代码就创建了一个整型channel,这个整型channel可以写入1024个字节大小的整型数据。当然这种带缓冲的channel,就是连续不断地写入数据的,所以在写入数据写完之前是不会被阻塞的,整体的写入和读取的阻塞关系还是和单个数据的是一样的,只不过这个数据长一点而已嘛。

    Go的并发编程还有很多别的知识,如果需要,逐步深入。

    相关文章

      网友评论

          本文标题:第十篇:并发编程

          本文链接:https://www.haomeiwen.com/subject/dtxmcftx.html