Golang学习笔记4——并发编程

1. 并发基础

1.1 概念

并发与并行的区别:

  • 并发:逻辑上具有处理多个同时性任务的能力。即看起来是多个任务同时执行,但并不一定是同一时刻,例如单核并发,通过多线程共享单核CPU利用时间片切换串行执行(并发非并行)。
  • 并行:物理上同一时刻执行多个并发任务。一般依赖多核CPU达到多个任务在同一个时刻执行(并发且并行)。

二者概念的区别是是否同时执行,比如吃饭时,电话来了,需要停止吃饭去接电话,接完电话继续吃饭,这是并发执行,但是吃饭时电话来了,边吃边接是并行。

并发的主流实现模型:

实现模型说明特点
多进程操作系统层面的并发模式处理简单,互不影响,但开销大
多线程系统层面的并发模式有效,开销较大,高并发时影响效率
基于回调的非阻塞/异步IO多用于高并发服务器开发中编程复杂,开销小
协程用户态线程,不需要操作系统抢占调度,寄存于线程中编程简单,结构简单,开销极小,但需要语言的支持

共享内存系统:线程之间采用共享内存的方式通信,通过加锁来避免死锁或资源竞争。

消息传递系统:将线程间共享状态封装在消息中,通过发送消息来共享内存,而非通过共享内存来通信。

1.2 协程

执行体是个抽象的概念,在操作系统中分为三个级别:进程(process),进程内的线程(thread),进程内的协程(coroutine,轻量级线程)。

1.2.1 进程

在操作系统运行过程中,可以产生很多进程,在unix/linux系统中,正常情况下,子进程是通过父进程fork创建的,子进程再创建新的进程。父进程无法预测子进程到底什么时候结束,当一个进程完成它的工作终止后,它的父进程需要调用系统取得子进程的终止态。

多进程容易产生的问题:

孤儿进程:父进程先于子进程结束,则子进程成为孤儿进程,其父进程变成了系统最开始的init进程,称为init进程领养孤儿进程。

僵尸进程:进程终止,父进程尚未回收,子进程残留资源(PCB)于内核中,变成僵尸进程。

1.2.2 线程

注意:在win下只有线程。 线程是Linux为了模仿,基于进程开启的轻量级进程,与进程一样拥有独立的PCB,但是没有独立的地址空间,地址空间是共享的。

  • 线程:系统最小的执行单位,即cpu分配时间轮片的对象
  • 进程:系统最小的资源分配单位

线程同步:同步即按预定的先后次序运行,线程发出某一个功能调用时,在没有得到结果之前,该调用不返回,且其他线程为保证数据一致性,不能调用该功能。线程同步是为了避免引起数据混乱,解决与时间有关的错误,实际上,进程,线程,信号之间都需要同步机制。

1.2.3 协程

协程:协程的优势在于其轻量级,可以轻松创建上百万个协程而不会造成系统资源衰竭。

线程需要上下文不停切换,协程不会主动交出使用权,除非代码中主动要求切换,或者发生IO。(主动切换函数:runtime.Gosched)

这样如果没有主动切换、IO,那么协程一直运行下去,遇到IO,则立即切换到被的协程。
可以用下面的代码演示

1.3 并发通信

并发编程的难度在于协调,协调需要通过通信,并发通信模型分为共享数据和消息。共享数据即多个并发单元保持对同一个数据的引用,数据可以是内存数据块,磁盘文件,网络数据等。数据共享通过加锁的方式来避免死锁和资源竞争。Go语言则采取消息机制来通信,每个并发单元是独立的个体,有独立的变量,不同并发单元间这些变量不共享,每个并发单元的输入输出只通过消息的方式。

2. 协程

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通信获取数据

3. 信道channel

信道按功能可以分成只读、只写和可读可写三种,按容量可以分为有缓冲和缓冲两种。

//模板,声明双向通道
//通道名 := 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)
	}
}

4. select

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常见的用法之一:判断超时。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值