Golang并发编程-Go程(Goroutine)实战篇

         Golang并发编程-Go程(Goroutine)实战篇

                               作者:尹正杰

版权声明:原创作品,谢绝转载!否则将追究法律责任。

 

 

 

一.并行和并发概述

1>.什么是并行(parallel)

  并行(parallel):
    如下图所示,指在同一时刻,有多条指令在多个处理器上同时执行。

2>.什么是并发(concurrency)

  并发(concurrency):
    如下图所示,指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,通过cpu时间片轮转使多个进程快速交替的执行。

3>.并行和并发的区别

  如下图所示:
    并行是两个队列同时使用两台咖啡机(真正的多任务)
    并发是两个队列交替使用一台咖啡机(假的多任务)

 

二.常见的并发编程技术

1>.进程并发

  程序:
    是指编译好的二进制文件,在磁盘上,不占用系统资源

  进程:
    是一个抽象的概念,与操作系统原理联系紧密。进程是活跃的程序,占用系统资源,在内存中执行。换句话说,程序运行起来,产生一个进程。

  进程状态:
    进程基本的状态有5种。分别为初始态,就绪态(等待CPU分配时间片),运行态(占用CPU),挂起态(等待除CPU以外的其它资源主动放弃CPU)与终止态。其中初始态为进程准备阶段,常与就绪态结合来看。

  在使用进程 实现并发时会出现什么问题呢?
    1>.系统开销比较大,占用资源比较多,开启进程数量比较少;
    2>.在unix/linux系统下,还会产生"孤儿进程""僵尸进程”
      孤儿进程:
        父进程先于子进程结束,则子进程成为孤儿进程,子进程的父进程成为init进程,称为init进程领养孤儿进程。
      僵尸进程:
        进程终止,父进程尚未回收,子进程残留资源(PCB)存放于内核中,变成僵尸(Zombie)进程。  

  温馨提示:
    在操作系统运行过程中,可以产生很多的进程。在unix/linux系统中,正常情况下,子进程是通过父进程fork创建的,子进程再创建新的进程。并且父进程永远无法预测子进程到底什么时候结束。 当一个进程完成它的工作终止之后,它的父进程需要调用系统调用取得子进程的终止状态。  
    Windows下的进程和Linux下的进程是不一样的,它比较懒惰,从来不执行任何东西,只是为线程提供执行环境。然后由线程负责执行包含在进程的地址空间中的代码。当创建一个进程的时候,操作系统会自动创建这个进程的第一个线程,成为主线程。

2>.线程并发

  线程概念:
    线程是轻量级的进程(light weight process),本质仍是进程(Linux下)

  进程:
    独立地址空间,拥有PCB(进程控制块) 

  线程:
    有独立的PCB(进程控制块),但没有独立的地址空间(即和其所在的进程共享用户空间)

  线程同步:
    指一个线程发出某一功能调用时,在没有得到结果之前,该调用不返回。同时其它线程为保证数据一致性,不能调用该功能。
    "同步"的目的,是为了避免数据混乱,解决与时间有关的错误。实际上,不仅线程间需要同步,进程间、信号间等等都需要同步机制。因此,所有"多个控制流,共同操作一个共享资源"的情况,都需要同步。

  常见锁的应用如下所示:
    互斥量(mutex):
      Linux中提供一把互斥锁mutex(也称之为互斥量)。每个线程在对资源操作前都尝试先加锁,成功加锁才能操作,操作结束解锁。
      资源还是共享的,线程间也还是竞争的,但通过""就将资源的访问变成互斥操作,而后与时间有关的错误也不会再产生了。但应注意"同一时刻,只能有一个线程持有该锁"。
      举个例子:
        当A线程对某个全局变量加锁访问,B在访问前尝试加锁,拿不到锁,B阻塞。C线程不去加锁,而直接访问该全局变量,依然能够访问,但会出现数据混乱。
        综上所述,互斥锁实质上是操作系统提供的一把"建议锁"(又称"协同锁"),建议程序中有多线程访问共享资源的时候使用该机制。但并没有强制限定。因此,即使有了mutex,如果有线程不按规则来访问数据,依然会造成数据混乱。

    读写锁
      与互斥量类似,但读写锁允许更高的并行性。其特性为:写独占,读共享。
      读写锁状态:
        特别强调:读写锁只有一把,但其具备两种状态,即读模式下加锁状态(读锁),写模式下加锁状态(写锁)。
      读写锁特性:
        读写锁是"写模式加锁"时,解锁前,所有对该锁加锁的线程都会被阻塞。
        读写锁是"读模式加锁"时,如果线程以读模式对其加锁会成功;如果线程以写模式加锁会阻塞。
        读写锁是"读模式加锁"时,既有试图以写模式加锁的线程,也有试图以读模式加锁的线程。那么读写锁会阻塞随后的读模式锁请求。优先满足写模式锁。读锁、写锁并行阻塞,写锁优先级高
      温馨提示:
        读写锁也叫共享-独占锁。当读写锁以读模式锁住时,它是以共享模式锁住的;当它以写模式锁住时,它是以独占模式锁住的。写独占、读共享。
        读写锁非常适合于对数据结构读的次数远大于写的情况。

3>.进程和线程的区别

  进程:
    是并发执行的程序在执行过程中分配和管理资源的基本单位。

  线程:
    是进程的一个执行单元,是比进程还要小的独立运行的基本单位。一个程序至少有一个进程,一个进程至少有一个线程。

  进程和线程的主要区别如下:
    根本区别:
      进程是资源分配最小单位,线程是程序执行的最小单位。 计算机在执行程序时,会为程序创建相应的进程,进行资源分配时,是以进程为单位进行相应的分配。每个进程都有相应的线程,在执行程序时,实际上是执行相应的一系列线程。
 
    地址空间:
      进程有自己独立的地址空间,每启动一个进程,系统都会为其分配地址空间,建立数据表来维护代码段、堆栈段和数据段;线程没有独立的地址空间,同一进程的线程共享本进程的地址空间。

    资源拥有:
      进程之间的资源是独立的;同一进程内的线程共享本进程的资源。

    执行过程:
      每个独立的进程程有一个程序运行的入口、顺序执行序列和程序入口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。

    调度单位:  
      线程是处理器调度的基本单位,但是进程不是。由于程序执行的过程其实是执行具体的线程,那么处理器处理的也是程序相应的线程,所以处理器调度的基本单位是线程。  
      Windows系统下,可以直接忽略进程的概念,只谈线程。因为线程是最小的执行单位,是被系统独立调度和分派的基本单位。而进程只是给线程提供执行环境。
    系统开销:             进程执行开销大,线程执行开销小。

4>.协程并发(coroutine)

  协程:
    coroutine。也叫轻量级线程。
    与传统的系统级线程和进程相比,协程最大的优势在于"轻量级"。可以轻松创建上万个而不会导致系统资源衰竭。而线程和进程通常很难超过1万个。这也是协程别称"轻量级线程"的原因。
    一个线程中可以有任意多个协程,但某一时刻只能有一个协程在运行,多个协程分享该线程分配到的计算机资源。
    协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行),这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。
    综上所述,我们可以总结说协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

  子程序调用:
    或者称为函数,在所有语言中都是层级调用,比如A调用B,B在执行过程中又调用了C,C执行完毕返回,B执行完毕返回,最后是A执行完毕。所以子程序调用是通过栈实现的,一个线程就是执行一个子程序。子程序调用总是一个入口,一次返回,调用顺序是明确的。而协程的调用和子程序不同。
    协程在子程序内部是可中断的,然后转而执行别的子程序,在适当的时候再返回来接着执行。 
 
  多数语言在语法层面并不直接支持协程,而是通过库的方式支持,但用库的方式支持的功能也并不完整,比如仅仅提供协程的创建、销毁与切换等能力。关于协程调度的实现理论上分为以下三类模型:
    一对多:
      即用户态中的多个协程对应内核态的一个线程。
      如果在这样的轻量级线程中调用一个同步IO操作,比如网络通信、本地文件读写,都会阻塞其他的并发执行轻量级线程,从而无法真正达到轻量级线程本身期望达到的目标。     一对一:
      即用户态中的一个协程对应内核态的一个线程。
      虽然解决一对多的阻塞问题,但是本质上还是线程之间的切换。
    多对多:
      即用户态中的多个协程对应内核态的多个线程。
      相比一对多方案解决了阻塞问题,现在的协程调度器都是使用类似的模型。
  在协程中,调用一个任务就像调用一个函数一样,消耗的系统资源最少!但能达到进程、线程并发相同的效果。   在一次并发任务中,进程、线程、协程均可以实现。从系统资源消耗的角度出发来看,进程相当多,线程次之,协程最少。

5>.Go并发

  Go在语言级别支持协程,叫goroutine。Go语言标准库提供的所有系统调用操作(包括所有同步IO操作),都会出让CPU给其他goroutine。这让轻量级线程的切换管理不依赖于系统的线程和进程,也不需要依赖于CPU的核心数量。

  有人把Go比作21世纪的C语言。第一是因为Go语言设计简单,第二,21世纪最重要的就是并行程序设计,而Go从语言层面就支持并发。同时,并发程序的内存管理有时候是非常复杂的,而Go语言提供了自动垃圾回收机制。

  Go语言为并发编程而内置的上层API基于顺序通信进程模型CSP(communicating sequential processes)。这就意味着显式锁都是可以避免的,因为Go通过相对安全的通道发送和接受数据以实现同步,这大大地简化了并发程序的编写。

  Go语言中的并发程序主要使用两种手段来实现。goroutine和channel。

  温馨提示:
    Goroutine早期调度算法:
      早期Goroutine调度存在会频繁的加锁解锁,最好的情况就是那个线程创建的协程就由哪个线程执行;
      早期的协程调度存在资源拷贝的弊端,频繁的在线程间切换会增加系统开销。
    Goroutine新版调度器算法(MPG):
      M:
        os线程(即操作系统内核提供的线程)       G:
        goroutine,其包含了调度一个协程所需要的堆栈以及instruction pointer(IP指令指针),以及其他一些重要的调度信息。       P:
        M与P的中介,实现m:n 调度模型的关键,M必须拿到P才能对G进行调度,P其实限定了golang调度其的最大并发度。         P默认和CPU核数相等,可按需设置。       M要去抢占P,如果抢到了P然后去领取G,如果没有任务会从其它的P或者全局的任务队列获取G。 

 

三.Goroutine实战案例

1>.什么是Goroutine

  Goroutine是Go语言并行设计的核心,有人称之为go程。 Goroutine从量级上看很像协程,它比线程更小,十几个goroutine可能体现在底层就是五六个线程,Go语言内部帮你实现了这些goroutine之间的内存共享。执行goroutine只需极少的栈内存(大概是4~5KB),当然会根据相应的数据伸缩。也正因为如此,可同时运行成千上万个并发任务。goroutine比thread更易用、更高效、更轻便。

  一般情况下,一个普通计算机跑几十个线程就有点负载过大了,但是同样的机器却可以轻松地让成百上千个goroutine进行资源竞争。

2>.创建Goroutine

package main

import (
    "fmt"
    "time"
)

func Task(start int, end int, desc string) {
    for index := start; index <= end; index += 2 {
        fmt.Printf("%s %d\n", desc, index)
        time.Sleep(1 * time.Second)
    }
}

func main() {
    /**
    创建Goroutine:
        只需在函数调⽤语句前添加Go关键字,就可创建并发执⾏单元。开发⼈员无需了解任何执⾏细节,调度器会自动将其安排到合适的系统线程上执行。
        在并发编程中,我们通常想将一个过程切分成几块,然后让每个goroutine各自负责一块工作,当一个程序启动时,主函数在一个单独的goroutine中运行,我们叫它main goroutine。新的goroutine会用go语句来创建。而go语言的并发设计,让我们很轻松就可以达成这一目的。
    
    Goroutine特性:
        为了避免类似孤儿进程的存在,如果main协程挂掉,所有协程都挂掉。
        换句话说,主goroutine退出后,其它的工作goroutine也会自动退出。
    */
    go Task(10, 30, "Task Func Say: index =")

    Task(11, 30, "Main Say: index =")
}

3>.Goexit函数

package main

import (
    "fmt"
    "runtime"
    "time"
)

func main() {
    go func() {
        defer fmt.Println("Goroutine 666666")

        func() {
            defer fmt.Println("Goroutine 88888888")

            /**
            return、Goexit() 和 os.Exit()的区别:
                return:
                    一般用于函数的返回,只能结束当前所在的函数.
                Goexit():
                    一般用于协程的退出
                    具有击穿特性,能结束掉当前所在的Goroutine,无论存在几层函数调用
                os.Exit():
                    主动退出主Goroutine,换句话说,直接终止整个程序的运行。
            */
            runtime.Goexit() //终止当前Goroutine
            //return
            //os.Exit(100)
            fmt.Println("AAAA")
        }()

        fmt.Println("CCCCC")
    }()

    //我们的主Goroutine会运行15s,有充足时间使得上面的子Go程代码执行完毕哟~
    for index := 1; index <= 30; index += 2 {
        fmt.Printf("Main Say: index = %d\n", index)
        time.Sleep(1 * time.Second)
    }
}

4>.Go程的安全机制

  博主推荐阅读:
    https://www.cnblogs.com/yinzhengjie2020/p/12657206.html

 

posted @ 2020-03-24 07:17  JasonYin2020  阅读(2470)  评论(0编辑  收藏  举报