Go语言并发编程简介

版权声明:博客仅作为博主的个人笔记,未经博主允许不得转载。 https://blog.csdn.net/qq_35976351/article/details/81877626

并发的基础知识

进程与线程的回顾总结:

进程的定义:

进程比较通用的几个定义:

  • 进程是程序的一次执行过程
  • 进程是一个程序及其数据在处理机上顺序执行时所发生的活动
  • 进程是具有独立功能的程序在一个数据集合上运行的过程,它是系统进行资源分配和调度的一个独立单位

从定义上可以看出,进程实际上是一个过程的描述,它强调运行的程序的这个过程,而不是像程序那样的物理实体。。

进程的基本特性:

  • 动态性:正如上面描述的,进程是一个进程实体的执行过程
  • 并发性:多个实体进程同时存在与内存中,可以在一个时间段内同时运行。并发不是并行,并行是指同一时刻同时发生,而并发只是在一个时间段内。
  • 独立性:每个进程可以独立运行、独立获得资源和独立接受处理机的调度。每个进程的资源不允许其他进程访问。
  • 异步性:每个进程是各自独立地,按照不可预知的速度进行推进

进程的基本状态

  • 就绪状态:进程已经拥有除了CPU之外的所有执行所需的资源,只要获得CPU就能执行
  • 执行状态:正在CPU上执行
  • 阻塞状态:执行的进程由于发生某事件(I/O处理、申请缓存失败等),暂时无法继续执行的状态。

线程的定义

线程可以视为进程的一个任务,相当于一个更加轻量级的进程。在引入线程的OS中,线程是运行的基本单位。一个进程可以有多个线程,线程之间可以并发或者并行执行;进程之间可以共享线程的资源;每个进程可以访问所属线程的所有空间;系统开销远远小于进程。

进程线程之间的关联

主要参考了这篇博客

对操作系统来说,线程是最小的执行单元,进程是最小的资源管理单元.

进程与线程的关系:

进程与线程的关系

进程和线程的几个转换状态:

并发主流的实现模型

  • 多进程,并发的基本模式
  • 多线程,大部分操作系统属于系统层面的并发模式,使我们使用的最多也是最有效的模式
  • 基于回调的异步I/O操作,Nodejs采用的这种方式,事件循环,异步I/O模式。但是编程的时候需要大量的回调操作。
  • 协程,一种更加轻量级的线程。一个线程可以有多个协程,系统开销极小,不需要操作系统进行抢占式调度。

多线程的实现借助于“共享内存系统”,而Golang的协程借助于消息传递系统,发送消息的时候对状态进行复制,并在消息传递的边界上交出这个状态的所有权。

协程和goroutine

协程与进程想、线程的关系:

扫描二维码关注公众号,回复: 2946759 查看本文章

进程和线程都是由操作系统控制,进程、线程的切换需要操作系统的内核来管理;而协程是由程序本身控制的,所以使用的代价极低。

goroutine是Go语言中的协程,需要使用关键字go来实现。使用了go的函数,在调用时就会在goroutine中执行,函数返回则goroutine结束,如果函数有返回值,则丢弃返回值。当main函数返回时,程序退出,而且程序不等待其他的goroutine(非主goroutine)结束。

代码:

package main

import (
    "fmt"
)

func Add(x, y int) {
    z := x + y
    fmt.Println(z)
}

func main() {
    for i := 0; i < 10; i++ {
        go Add(i, i)
    }
}

代码中的函数不会有任何输出,因为主函数启动了10个goroutine后,就退出了,那10个goroutine还没来得及执行。。

并发通信

  • 共享数据模型:可以理解成加锁的那种模式,用户的线程共享内存单元
  • 共享消息模型:每个并发的单元是独立的个体,他们之间不共享变量等的数据,每个并发单元之间唯一的输入和输出是“消息”,这也是他们唯一的通信方式,不共享内存。

channel

Golang使用channel提供goroutine之间的通信,是类型相关的,一个channel只能传递一种类型的值,需要提前声明。但是,涉及到跨进程通信时,最好采用分布式的方式解决,比如Socket等的协议。

基本语法

声明

var chaName chan ElementType

代码实例:

var ch chan int               // 整型的
var m map[string] chan bool   // 注意map型的

定义:

ch := make(chan int)   // 定义int型的channel

写入数据:

ch <- value   // value数据写入channel

注意:向channel写入数据的操作必须在一个goroutine中执行(即使用关键字go),否则程序报错!

写入数据通常会阻塞程序,知道有其他的goroutinechannel中读取数据。

读取数据:

value := <-ch

如果channel中没有数据,则goroutine也会阻塞,直到channel中被写入数据为止

select操作

基本语法示例:

select {
    case <-chan1:
        // 如果chan1读到数据,则进行该case处理
    case <-chan2:
        // 如果chan2读到数据,则进行该case处理
    case chan3 <- value:
        // 如果写入数据到chan3,则进行该case处理
    default:
        // 都没成功,则在这里处理
}

每个case后面必须是面向channel的操作。

缓冲机制

用于创建channel缓冲队列,适合大规模的数据传输场景:

c := make(chan int, 1024)  // 创建了含有1024个channel的队列

这样,即使没有读取方,写入方也可以一直往channel里面写入数据,缓冲区填满之前不会发生阻塞。

读取方式:

for i := range c {
    fmt.Println("Received: ", i)
}

超时机制

适用于向channel写数据时,channel已满;或者从channel读数据,channel已空。防止产生死锁。借助于select函数实现:

timeout := make(chan bool, 1)
go func() {
    time.Sleep(1e9)  // 等待一秒钟
    timeout <-true
}()

select {
    case <-ch:
        // 从ch中读取数据
    case <-timeout:
        // 一直没有从ch中读到数据,但是从timeout中读到了数据
}

channel的传递

Go语言的channel是一个基本类型,地位等同于map之类的。channel本身定义后也可以通过channel来传递,可以用这个事项管道pipe的特性。管道的知识。但是,GO语言的管道是类型相关的,只能传递一种类型的数据。

type PipeData struct {
    value int
    /*
    *  通用的模式是在这里添加自己需要的数据结构
    */
    handler func(int) int
    next    chan int
}

func handle(queue chan *PipeData) {
    for data := range queue {
        data.next <- data.handler(data.value)
    }
}

代码的解读:

PipeData结构体中封装了数据valuehandler是一个函数指针,用来处理数据,传输和返回值都是int型的,因为Go语言的管道是传递同一种类型的数据;next是一个channel类型,用于存储整型的数据,把每个程序的next相互链接,就组成了管道。

单向channel

单向的channel只能用于发送数据或者只能接收数据。用channel的类型转换机制,实现专门的读或者写的channel

var ch1 chan int        // 普通的channel
var ch2 chan<- float64  // 只能写数据的单向channel
var ch3 <-chan int      // 只能读数据的单向channel 
ch4 := make(chan, int) 
ch5 := <-chan int(ch4)      // 转换成只读的channel
ch6 := chan<- int(ch4)      // 转换成只写的channel

这是为了遵循最小权限的准则:

func Parse(ch <-chan int) {
    for value := range ch {
        fmt.Println("Parsing value", value)
    }
}

方式误写入数据,因为可以理解为channel是传递的引用。。。

关闭channel

使用close()函数关闭channel

close(ch1)
x, ok := close(ch2)  // 只需要看第二个ok,如果是false,那么表示成功关闭。

关闭channel后,不能再向channel中写入数据了,但是可以从中读取残余的数据!

关于channel常用的操作

简单的同步操作

等待同步,等待list排序完成后,再去做其他事情,代码示例:

c := make(chan int)  // Allocate a channel.
// Start the sort in a goroutine; when it completes, signal on the channel.
go func() {
    list.Sort()
    c <- 1  // Send a signal; value does not matter.
}()
doSomethingForAWhile()
<-c   // Wait for sort to finish; discard sent value.

并发任务模式,作为信号量集

channel作为一个信号量使用,实现生产者和消费者模式,代码实例:

var sem = make(chan int, MaxOutstanding)

func handle(r *Request) {
    sem <- 1    // Wait for active queue to drain.
    process(r)  // May take a long time.
    <-sem       // Done; enable next request to run.
}

func Serve(queue chan *Request) {
    for {
        req := <-queue
        go handle(req)  // Don't wait for handle to finish.
    }
}

上述代码中,handle函数用于处理请求;有一个信号量集sem,容量是MaxOutstanding,表示最大的吞吐量;利用channel自身的读写阻塞机制,可以实现最多MaxOutstanding的并发操作。

但是,上述代码的Server存在一些不足:虽然最多同时运行MaxOutstandinggoroutine,但是如果请求来的过快,会导致程序创建出过多的goroutine。即使它们等待运行,但是它们会占用系统的其他资源的。下面给出改进的方案:

func Serve(queue chan *Request) {
    for req := range queue {
        sem <- 1
        go func(req *Request) {
            process(req)
            <-sem
        }(req) // 在这里的这一句,可以看成一个原子操作,防止req被多个goroutine共享
    }
}

// 或者使用下面的等效操作
func Server(queue chan *Request) {
    for req := range queue {
        req := req   // 在闭包内使用局部的,防止共享
        sem <- 1
        gon func() {
            process(req)
            <-sem
        }()
    }
}

另外一种方式,让handle一次性处理一个批次的请求,然后在Server函数中确定goroutine的个数。这样,goroutine的数量就限制了一次性调用process函数的次数。quit变量是用于控制退出的。

func handle(queue chan *Request) {
    for r := range queue {
        process(r)
    }
}

func Serve(clientRequests chan *Request, quit chan bool) {
    // Start handlers
    for i := 0; i < MaxOutstanding; i++ {
        go handle(clientRequests)
    }
    <-quit  // Wait to be told to exit.
}

Channels of channels

客户端代码:

type Request struct {
    args       []int
    f          func([]int) int
    resultChan chan int
}

func sum(a []int) (s int) {
    for _, v := range a {
        s += v
    }
    return
}

request:= &Request{[]int{3, 4, 5}, sum, make(chan int)}

// 发送请求
clientRequests <-request

// 等待服务器回应
fmt.Println("answer: %d\n", <-request.resultChan)

服务端代码:

func handle(queue chan *Request) {
    for req := range queue {
        req.resultChan <- req.f(req.args)
    }
}

多核并行化和出让时间片

计算N个整数的和,把所有的整数分解成M份,M是CPU的数量。让每个CPU计算分给它的那份计算任务,最后把结果做一个累加。

多核并行化代码实例:

type Vector []float64

// // Apply the operation to v[i], v[i+1] ... up to v[n-1]
func (v Vector) DoSome(i, n int, u Vector, c chan int) {
    for ; i < n; i++ {
        v[i] += u.Op(v[i])
    }
    c <- 1 // 完成的信号
}

const NCPU = 16

func (v Vector) DoAll(u Vector) {
    c := make(chan int, NCPU) // 用于接收每个CPU完成的信号
    for i := 0; i < NCPU; i++ {
        go v.DoSome(i*len(v)/NCPU, (i+1)*len(v)/NCPU, u, c)
    }
    for i := 0; i < NCPU; i++ {
        <-c // 取到一个数据,表示计算完成
    }
}

同步

这里主要是处理多个goroutine之间共享数据的问题。

同步锁

sync包中提供了两种类型锁:

  • sync.Mutex:当一个goroutine获得该锁后,其它的goroutine只能等它释放这个锁
  • sync.RWMutex:这是单写多读模式,读锁占用的情况下,会阻止,但不会阻止读,多个goroutine可以同时获得读锁(调用RLock()方法);写锁(调用Lock()方法)阻止其他任何goroutine读写。

任何一个Lock()或者RLock()均需要有对应的UnLock()RUnLock()方法与之对应,否则可能导致死锁。一般使用defer关键字解决这个问题:

var l sync.Mutex
func foo() {
    l.Lock()
    defer l.UnLock()
}

全局唯一性操作

用于处理全局的一次性操作,使用Once类型解决:

var a string
var once sync.Once

func setup() {
    a = "hello world !"
}

func doprint() {
    once.Do(setup)
    print(a)
}

func twoprint() {
    go doprint()
    go doprint()
}

onceDo()方法可以保证在全局值执行一次,其他的goroutine执行到这一句时,会先被阻塞,知道全局唯一的once.Do()调用结束后才返回。

猜你喜欢

转载自blog.csdn.net/qq_35976351/article/details/81877626