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 ", 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 ", index) time.Sleep(1 * time.Second) } }
4>.Go程的安全机制
博主推荐阅读: https://www.cnblogs.com/yinzhengjie2020/p/12657206.html