成功最有效的方法就是向有经验的人学习!

并发编程、channel及锁

并发编程

并发介绍

1、并发和并行

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

并发:本质还是串行
食堂窗口一个大妈(同一时间类只能给一个人打饭)
python本质没有并行的线程

并行:任务分布在不同CPU上,同一时间点同时执行
并行就是有多个食堂大妈,同事给不同人打饭

2、协程和线程

  • 协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。
  • 线程:一个线程上可以跑多个协程,协程是轻量级的线程。
  • 线程和协程最大的区别
    • 开启一个线程需要大概2M空间,而且需要cpu调度才能执行,线程会强cpu
    • 开启一个协程大概只需要2K的空间,而且是有go解释器自己实现的GPM调度,主动退出
    • 所以我们可以同时启动成千上万个goroutine二不会过大的占用内存
    • 相反如果我们开启成千上万个线程,第一,会大量的占用内存,甚至导致机器奔溃,第二,操作系统调度线程,本身也需要耗费大量时间
  • 协程如果需要用CPU才会去使CPU,如果没有使用CPU的需求,他就会主动把cpu让给其他协程执行
  • 线程在时间片内,即使不使用cpu,比如当前正在从磁盘读数据,他也不会让出cpu

goroutine

1、多线程编程缺点

  • 在java/c++中我们要实现并发编程的时候,我们通常需要自己维护一个线程池
  • 并且需要自己去包装一个又一个的任务,同时需要自己去调度线程执行任务并维护上下文切换

2、gouroutine

  • Go语言中的goroutine就是这样一种机制,goroutine的概念类似于线程,但 goroutine是由Go的运行时(runtime)调度和管理的。
  • Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU。
  • Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经 内置了调度和上下文切换的机 制 。
  • 在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能–goroutine
  • 当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数
  • 开启一个goroutine去执行这个函数就可以了,就是这么简单粗暴。

协程基本使用

1、启动一个协程

  • 主线程中每个100毫秒打印一次,总共打印2次
  • 另外开启一个协程,打印10次
  • 情况一:打印是交替,证明是并行的
  • 情况二:开启的协程打印两次,就退出了(因为主线程退出了)
package main

import (
    "fmt"
    "time"
)

func main() {
    // goroutine并行测试
    //test()
    // go中开启一个协程 go关键字 方法名()
    go test()
    for i := 0; i < 10; i++ {
        fmt.Println("main", i)
        time.Sleep(time.Microsecond * 100)
    }

    time.Sleep(time.Second)
    fmt.Println("over")
}

func test() {
    for i := 0; i < 10; i++ {
        fmt.Println("test", i)
        time.Sleep(time.Microsecond * 100)
    }
}

/*
main 0
test 0
main 1
test 1
test 2
main 2
test 3
main 3
test 4
test 5
main 4
main 5
test 6
main 6
test 7
main 7
test 8
main 8
main 9
test 9
over
*/

2、WaitGroup

主线程退出后所有的协程无论有没有执行完毕都会退出
所以我们在主进程中可以通过WaitGroup等待协程执行完毕
sync.WaitGroup内部维护着一个计数器,计数器的值可以增加和减少。
例如当我们启动了N 个并发任务时,就将计数器值增加N。
每个任务完成时通过调用Done()方法将计数器减1
通过调用Wait()来等待并发任务执行完,当计数器值为0时,表示所有并发任务已经完成。

var wg sync.WaitGroup // 第一步:定义一个计数器 wg.Add(1) // 第二步:开启一个协程计数器+1 wg.Done() // 第三步:协程执行完毕,计数器-1
wg.Wait() // 第四步:计数器为0时推出
package main

import (
    "fmt"
    "sync"
    "time"
)

/*
// 计数器怎么知道有一共有多少次的
goroutine开启的时候进行 wg.Add(1) 加一
goroutine结束是进行 wg.Done() 加1
wg.Wait() 会判断当前的goroutine是否为0,为0就退出

var wg sync.WaitGroup       // 第一步:定义一个计数器
wg.Add(1)               // 第二步:开启一个协程计数器+1
wg.Done()               // 第三步:协程执行完毕,计数器-1
wg.Wait()               // 第四步:计数器为0时推出
*/

var wg sync.WaitGroup // 第一步:定义一个计数器

func main() {
    wg.Add(2) // 第二步:开启一个协程计数器+1
    go test()
    go test()
    // wg.Wait()发现没有goroutine在使用而我们的wg不为0,那么就会触发异常
    wg.Wait() // 第四步:计数器为0时推出
    //time.Sleep(time.Second)
    fmt.Println("main over")
}

func test() {
    for i := 0; i < 10; i++ {
        fmt.Println(i)
        time.Sleep(100 * time.Microsecond)
    }
    wg.Done() // 第三步:协程执行完毕,计数器-1
}

3、开启多个协程

在 Go 语言中实现并发就是这样简单,我们还可以启动多个 goroutine。
这里使用了 sync.WaitGroup 来实现等待 goroutine 执行完毕
多次执行上面的代码,会发现每次打印的数字的顺序都不一致。
这是因为 10 个 goroutine是并发执行的,而 goroutine 的调度是随机的。
上面例子中使用循环并多次调用。

channel

1、Channel说明

  • 共享内存交互数据弊端
    • 单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行函数的意义。
  • 虽然可以使用共享内存进行数据交换,但是共享内存在不同的goroutine中容易发生竞态问题。
    • 为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法势必造成性能问题。
  • channel好处
    • Go 语言中的通道(channel)是一种特殊的类型。
    • 通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。
    • 每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型。
    • 如果说goroutine是Go程序并发的执行体,channel就是它们之间的连接。
    • channel是可以让一个goroutine发送特定值到另一个goroutine的通信机制。

2、channel类型

channel 是一种类型,一种引用类型
声明管道类型的格式如下:

var 变量 chan 元素类型
var ch1 chan int // 声明一个传递整型的管道
var ch2 chan bool // 声明一个传递布尔型的管道
var ch3 chan []int // 声明一个传递 int 切片的管道

3、创建channel

声明的管道后需要使用 make 函数初始化之后才能使用。
创建 channel 的格式如下: make(chan 元素类型, 容量)

// 创建一个能存储 10 个 int 类型数据的管道
ch1 := make(chan int, 10)
// 创建一个能存储 4 个 bool 类型数据的管道
ch2 := make(chan bool, 4)
// 创建一个能存储 3 个[]int 切片类型数据的管道
ch3 := make(chan []int, 3)

4、channel操作

package main

import "fmt"

/*
channel是给不同的goroutine进行数据传递,类似于队列
*/
func main() {
    // 1、定义一个channel
    // make可以给切片、map、channel分配内存
    /*
        chan : 定义channel的关键字
        int  : 指定当前这个channel的数据类型
        5    :  定义这个channel的大小
    */
    ch := make(chan int, 5)

    // 2、向channel存入数据
    ch <- 10

    // 3、从channel中取数据
    v1 := <-ch
    fmt.Println("v1", v1)

    // 4、如果取值是一个没有数据且没有close的channel,就会报错
    //v2 := <-ch
    //fmt.Println(v2)

}

5、优雅的从channel取值

当通过通道发送有限的数据时,我们可以通过close函数关闭通道来告知从该通道接收值的goroutine停止等待。
当通道被关闭时,往该通道发送值会引发panic,从该通道里接收的值一直都是类型零值。
那如何判断一个通道是否被关闭了呢?
for range的方式判断通道关闭

package main

import "fmt"

/*
channel是给不同的goroutine进行数据传递,类似于队列
*/
func main() {

    // 5、for range从channel取值
    ch := make(chan int, 5)
    ch <- 1
    ch <- 2
    ch <- 3
    ch <- 4
    ch <- 5
    //ch <- 6
    close(ch)
    // 当我们循环一个没有close的channel,而且还为空就会报错
    // fatal error: all goroutines are asleep - deadlock!
    for i := range ch {
        fmt.Println(i)
    }
}

select 多路复用

1、select说明

传统的方法在遍历管道时,如果不关闭会阻塞而导致 deadlock,在实际开发中,可能我们不好确定什么关闭该管道。
这种方式虽然可以实现从多个管道接收值的需求,但是运行性能会差很多。
为了应对这种场景,Go 内置了 select 关键字,可以同时响应多个管道的操作。
select 的使用类似于 switch 语句,它有一系列 case 分支和一个默认的分支。
每个 case 会对应一个管道的通信(接收或发送)过程。
select 会一直等待,直到某个 case 的通信操作完成时,就会执行 case 分支对应的语句。
具体格式如下:

package main

import (
    "fmt"
    "time"
)

func main() {
    // 定义channel
    intChan := make(chan int, 10)
    for i := 0; i < 10; i++ {
        intChan <- i
    }

    // 定义channel
    strChan := make(chan string, 10)
    for i := 0; i < 10; i++ {
        strChan <- "aaa"
    }

    for {
        select {
        case v := <-strChan:
            // 写业务逻辑
            fmt.Println("strChan", v)
            time.Sleep(time.Microsecond * 100)
        case v := <-intChan:
            fmt.Println("intChan", v)
            time.Sleep(time.Microsecond * 100)
        default:
            fmt.Println("所有数据取出")
            return
        }
    }
}

2、select 的使用

使用 select 语句能提高代码的可读性。
可处理一个或多个 channel 的发送/接收操作。
如果多个 case 同时满足,select 会随机选择一个。
对于没有 caseselect{}会一直等待,可用于阻塞 main 函数。

package main

import (
    "fmt"
    "time"
)

func main() { // 在某些场景下我们需要同时从多个通道接收数据,这个时候就可以用到golang中给我们提供的select多路复用
    // 1.定义一个管道 10个数据int
    intChan := make(chan int, 10)
    for i := 0; i < 10; i++ {
        intChan <- i
    } //2.定义一个管道 5个数据string
    stringChan := make(chan string, 5)
    for i := 0; i < 5; i++ {
        stringChan <- "hello" + fmt.Sprintf("%d", i)
    } //使用select来获取channel里面的数据的时候不需要关闭channel
    for {
        select {
        case v := <-intChan:
            fmt.Printf("从 intChan 读取的数据%d\n", v)
            time.Sleep(time.Millisecond * 50)
        case v := <-stringChan:
            fmt.Printf("从 stringChan 读取的数据%v\n", v)
            time.Sleep(time.Millisecond * 50)
        default:
            fmt.Printf("数据获取完毕")
            return //注意退出...
        }
    }
}

互斥锁

互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个 goroutine 可以访问共享资源。
Go语言中使用 sync 包的 Mutex 类型来实现互斥锁。
使用互斥锁来修复上面代码的问题:

package main

import (
    "fmt"
    "sync"
)

/*
当开启多个协程对同一个资源进行操作的时候,就出现了资源竞争
比如开两个协程对x同时加5000次正确结构是10000,实际结构是不相符
*/
var x int
var wg sync.WaitGroup
var lock sync.Mutex

func main() {

    fmt.Println(x)
    for i := 0; i < 4000; i++ {
        wg.Add(1)
        go add()
    }
    wg.Wait()
    fmt.Println(x) // 期待结构是10000
}

func add() {
    for i := 1; i <= 5000; i++ {
        // 当要对x操作时,先加锁,能获取锁再操作
        // 保证同一时刻只有一个协程能够对变量进行操作
        lock.Lock()
        x = x + 1
        lock.Unlock() // 操作完成释放锁
    }
    wg.Done()
}
赞(0) 打赏
未经允许不得转载:陈桂林博客 » 并发编程、channel及锁
分享到

大佬们的评论 抢沙发

全新“一站式”建站,高质量、高售后的一条龙服务

微信 抖音 支付宝 百度 头条 快手全平台打通信息流

橙子建站.极速智能建站8折购买虚拟主机

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

支付宝扫一扫打赏

微信扫一扫打赏

登录

找回密码

注册