并发与并行的区别:
二者概念的区别是是否同时执行,比如吃饭时,电话来了,需要停止吃饭去接电话,接完电话继续吃饭,这是并发执行,但是吃饭时电话来了,边吃边接是并行。
并发的主流实现模型:
实现模型 | 说明 | 特点 |
---|---|---|
多进程 | 操作系统层面的并发模式 | 处理简单,互不影响,但开销大 |
多线程 | 系统层面的并发模式 | 有效,开销较大,高并发时影响效率 |
基于回调的非阻塞/异步IO | 多用于高并发服务器开发中 | 编程复杂,开销小 |
协程 | 用户态线程,不需要操作系统抢占调度,寄存于线程中 | 编程简单,结构简单,开销极小,但需要语言的支持 |
共享内存系统:线程之间采用共享内存的方式通信,通过加锁来避免死锁或资源竞争。
消息传递系统:将线程间共享状态封装在消息中,通过发送消息来共享内存,而非通过共享内存来通信。
执行体是个抽象的概念,在操作系统中分为三个级别:进程(process),进程内的线程(thread),进程内的协程(coroutine,轻量级线程)。
在操作系统运行过程中,可以产生很多进程,在unix/linux系统中,正常情况下,子进程是通过父进程fork创建的,子进程再创建新的进程。父进程无法预测子进程到底什么时候结束,当一个进程完成它的工作终止后,它的父进程需要调用系统取得子进程的终止态。
多进程容易产生的问题:
孤儿进程:父进程先于子进程结束,则子进程成为孤儿进程,其父进程变成了系统最开始的init进程,称为init进程领养孤儿进程。
僵尸进程:进程终止,父进程尚未回收,子进程残留资源(PCB)于内核中,变成僵尸进程。
注意:在win下只有线程。 线程是Linux为了模仿,基于进程开启的轻量级进程,与进程一样拥有独立的PCB,但是没有独立的地址空间,地址空间是共享的。
线程同步:同步即按预定的先后次序运行,线程发出某一个功能调用时,在没有得到结果之前,该调用不返回,且其他线程为保证数据一致性,不能调用该功能。线程同步是为了避免引起数据混乱,解决与时间有关的错误,实际上,进程,线程,信号之间都需要同步机制。
协程:协程的优势在于其轻量级,可以轻松创建上百万个协程而不会造成系统资源衰竭。
线程需要上下文不停切换,协程不会主动交出使用权,除非代码中主动要求切换,或者发生IO。(主动切换函数:runtime.Gosched)
这样如果没有主动切换、IO,那么协程一直运行下去,遇到IO,则立即切换到被的协程。
可以用下面的代码演示
并发编程的难度在于协调,协调需要通过通信,并发通信模型分为共享数据和消息。共享数据即多个并发单元保持对同一个数据的引用,数据可以是内存数据块,磁盘文件,网络数据等。数据共享通过加锁的方式来避免死锁和资源竞争。Go语言则采取消息机制来通信,每个并发单元是独立的个体,有独立的变量,不同并发单元间这些变量不共享,每个并发单元的输入输出只通过消息的方式。
Go直接从语言层面支持了并发,goroutine是Go并行设计的核心。goroutine说到底其实就是协程,但是它比线程更小,十几个goroutine可能体现在底层就是五六个线程,Go语言内部帮你实现了这些goroutine之间的内存共享。执行goroutine只需极少的栈内存(大概是4~5KB),当然会根据相应的数据伸缩。也正因为如此,可同时运行成千上万个并发任务。goroutine比thread更易用、更高效、更轻便。goroutine是通过Go的runtime管理的一个线程管理器。goroutine通过go关键字实现了,其实就是一个普通的函数。
func say(s string) {
for i := 0; i < 5; i++ {
runtime.Gosched()
fmt.Println(s)
}
}
func main() { //Go程序在启动时,就会为main函数创建一个默认的goroutine。
runtime.GOMAXPROCS(1) // 设置CPU核心数为 1
go say("world") //开一个新的goroutine执行,每个goroutine对应一个哈数
say("hello") //当前goroutine执行
}
// 以上程序执行后将输出:
// hello
// world
// hello
// world
// hello
// world
// hello
// world
// hello
runtime.Gosched()
表示让CPU把时间片让给别人,下次某个时候继续恢复执行该goroutine
。因此两个函数就能够交替进行,如果不设置使用CPU核心数或者CPU核心数大于1,那么输出结果就是“hello hello hello hello…” 。因为创建协程需要时间,在创建的协程运行前,主协程已经运行结束了。
在实际中可以使用WaitGroup
来监控协程的执行状态。WaitGroup
内部维护一个计数器,通过Add()、Done()和Wait()对计数器进行加1、减1和阻塞等待操作,当计数器为0时,所有协程执行完毕,则主程序结束。
import "sync" //需要导入sync包
var wc sync.WaitGroup //声明WaitGroup变量
func SayHello(){
defer wg.Done() //defer表示函数最后执行,则计数器减1
fmt.Println("Hello, this is earth")
}
func main() {
wg.Add(2) //计数器加2,表示2个协程
go SayHello() //起一个协程,执行完后计数器减1
go SayHello() //再起一个协程,执行完后计数器减1
wg.Wait() //等待计数器变为0,即所有协程执行完毕
}
那协程之间如何竞争资源和通信呢?传统方法是通过共享锁或互斥锁竞争资源,而在golang中建议使用channal,同时golang团队建议:不要通过共享内存来通信,而是通过通信来共享内存。这怎么理解呢?不要试图通过控制内存的使用权限来进一步获取内存数据,而是各个协程利用通信机制直接获取内存数据达到共享的目的。通常我们利用加锁来获取某个变量、代码或内存的使用权,从而获得数据;而在golang中,我们直接通过channal通信获取数据。
信道按功能可以分成只读、只写和可读可写三种,按容量可以分为有缓冲和缓冲两种。
//模板,声明双向通道
//通道名 := make(chan 变量类型,通道容量)
ch1 := make(chan int) //ch为双向通道,没有缓冲
ch2 := make(chan int, 0) //同上
ch3 := make(chan int, 2) //双向通道,可缓冲2个int值
//声明单向通道,只读或只写
var chWrite<- int = ch3 //chWrite为只写通道,可往ch3里写数据
var <-chSend int = ch3 //chSend为只读通道,可从ch3里读数据
channal的读写权限主要看“箭头”的方向,“箭头”方向就是数据流动的方向。
ch := make(chan int, 4) //缓冲4个int型数据
var chRead <-chan int = ch //只读
var chWrite chan<- int = ch //只写
chWrite<-2 //分别写入3个值
chWrite<-3
chWrite<-4
<-chRead //读出一个值
fmt.Println("ch is ", <-ch)
fmt.Println("ch is ", <-ch)
多协程协同工作时,利用公共的channal通信共享数据。并且,只读信道会让程序进入阻塞状态,直到有数据读取,如果没有其他任何一个协程写入值,那么过一定时间超时后会抛出deadlock
错误。
func main() {
ch := make(chan int)
go func() {
ch <- 7
close(ch) //关闭后,其他goroutine可继续从ch中读取
}()
fmt.Println(<-ch) //阻塞直到读取到值7
fmt.Println(<-ch) //可读取到值0
fmt.Println(<-ch) //可读取到值0
var input string
fmt.Scanln(&input)
}
在上述程序中,当协程内的通道使用close
函数关闭时,主协程可以继续从通道中读取到值 0 ,即使往后再继续读取,都会立即返回,不会阻塞程序。如果没有调用close
函数,那么主进程就会一直阻塞读取,直到超时。
另外,为了区分读取到的0值是关闭的信道中读取,还是从真正的业务数据中读取,需要用到 n, ok := <-ch
这种比较优雅的读取方式,如果信道中有值,那么ok返回true,否则返回false。
func main() {
ch := make(chan int)
go func() {
ch <- 0
ch <- 7
close(ch)
}()
for {
if n, ok := <-ch; ok {
fmt.Println(n)
} else {
break
}
}
var input string
fmt.Scanln(&input)
}
除了用for 无限循环来遍历信道中的值以外,还更优雅的方式是通过for ...range
来遍历信道。
func main() {
ch := make(chan int)
go func() {
for i := 0; i < 10; i++ {
ch <- i
}
close(ch)
}()
//for range遍历通道
for n := range ch {
fmt.Println(n)
}
}
select是golang的一个关键字,常常与channal一起使用,主要的作用类似switch等选择语句,但在使用时有几点特性:
1、case后的表达式必须是信道。
2、所有channal表达式都会被求值。
3、当一个或多个case满足条件时,会随机选一个执行。
4、如果有default,则当无case满足条件时执行;若没有defualt语句,则select将阻塞,直到某个case满足条件。
ch1 := make(chan int, 4)
ch2 := make(chan int, 4)
ch3 := make(chan int, 4)
ch3<-3
select {
case ch1<-1:
fmt.Println("case 1")
fmt.Println("ch1 is ", <-ch1)
case ch2<-2:
fmt.Println("case 2")
fmt.Println("ch2 is ", <-ch2)
case <-ch3:
fmt.Println("case 3")
default:
fmt.Println("case default")
}
如上代码所示:
1、三个case都是通信
2、三个case都是channal表达式(2次写入和1次读取),都会被求值;
3、三个case都满足,则随机挑选一个执行,故多次执行后输出可能不同。
ch4 := make(chan int) //无缓存通道
select {
case <-ch4:
fmt.Println("case 4")
case <-time.After(time.Second * 1):
fmt.Println("case timeout")
default:
fmt.Println("case default")
}
4、如上所示,有default语句且其他case语句不满足,则执行default语句输出“case default”;如果没有default语句,那么1秒之后,第二个case语句满足,则会输出“case timeout”,这也是select常见的用法之一:判断超时。