【Go】Go并发编程基础详解

1. 操作系统发展历程

1.1 进程概念

进程是程序的一次运行过程,进程这个概念是比较抽象的,从来就没有标准统一的定义,进程主要包含三部分要素:程序、数据、进程控制块

  • 程序:用于描述进程要完成的功能,是控制进程执行的指令集
  • 数据:指程序在运行过程中所需的数据和工作区
  • 进程控制块(Program Control Block,简称PCB):包含进程的描述信息和控制信息,是进程存在的唯一标识

但是最早的计算机操作系统只是一个 单进程操作系统 ,当时科学家们使用计算机都需要 “约钟” 来预定时间,这就意味着同一段时间内只有一个进程才能工作;并且操作系统全部资源都交给一个进程,这样的设定存在两个主要问题:

  1. 在时间上效率比较低下,CPU无法同时调度多个进程
  2. 在空间上资源利用效率低下,内存空间完全交由一个进程

为了解决上述问题,于是引入了 “多道技术” ,简单理解就是在时间层面和空间层面划分多道给多个不同的进程,并设定一种操作系统调度算法(时间片+I/O阻塞)让多个进程并发执行

画板

此时的进程具有的特征:

  • 动态性:进程是程序的一次运行过程,是存在生命周期的
  • 并发性:进程可以与其他进程并发运行
  • 独立性:进程是操作系统分配资源的基本单位
  • 结构性:进程包含程序、数据、进程控制块

1.2 线程概念

实际上基于上述结构我们已经能够实现 “并发编程” 的需求,但是这种多进程编程模式的时空开销是比较大的,因为调度进程需要完成 PCB 数据结构的切换,还要发起系统调用操作硬件资源、还需要重新分配资源给不同的进程

💡 提示:试想一下你的电脑同时运行100个应用程序是不是非常卡顿!

因此诞生了 “多线程编程”,线程也被称为 “轻量级进程”,其实就是将一个进程的资源拆分多份分配给不同的线程,引入线程技术之后,操作系统调度的最基本单位就变成了线程!其调度模式简化如下图所示:

画板

首先操作系统分配资源的基本单位仍然是进程,但是执行的基本单位是线程,线程向进程获取所需的运行资源,带来的好处就是节省了频繁创建销毁进程的开销

1.3 常见面试题——进程线程的区别

面试题:请你解释一下操作系统中进程和线程的区别是什么?

参考答案:

  1. 进程是操作系统分配资源的基本单位,线程是操作系统调度的基本单位
  2. 一个进程由一个或更多线程组成
  3. 进程之间相互独立,有不同的进程地址空间;同一进程内的多个线程共享进程的内存空间
  4. 进程调度切换比较大;线程调度切换开销比较小

1.4 协程概念

随着时代的发展,这种 “多线程” 编程模式切换的开销也逐渐变大,因此又诞生了 “协程” 机制,也被称为纤程、用户级线程、轻量级线程,但是协程不是简单地继续划分线程空间,其体系图简化如下:

画板

此时用户态线程对于操作系统是不可见的,操作系统调度的基本单位仍然是线程,但是在应用程序中程序员(在Go语言中是编译器完成的工作)可以自定义调度算法,分配时间片轮转策略和I/O切换模拟并发执行多个协程任务单元,其调度的开销就大大降低了

  • 避免了用户态和内核态的切换
  • 在语言层面提供调度并发逻辑

2. Goroutine的基本使用

2.1 Goroutine基本语法

在Go语言之中,开发者无需关注创建的是内核级线程还是用户级线程,Go编译器底层有一套GPM调度模型会自适应,使用层面只需要一个关键字go即可创建一个线程并发执行

现在我们就来体会Go的并发编程的魅力:

func printOdd() {
	var num = 1
	for num <= 5 {
		fmt.Println(num)
		time.Sleep(time.Second * 1)
		num += 2
	}
}

func printEven() {
	var num = 0
	for num <= 5 {
		fmt.Println(num)
		time.Sleep(time.Second * 1)
		num += 2
	}
}

func main() {
	// 线程1打印奇数
	go printOdd()
	// 线程2打印偶数
	go printEven()
	// 阻塞5s等待执行完毕
	time.Sleep(time.Second * 5)
}

程序运行结果:

程序运行分析:我们使用go关键字启动了两个分线程,当前一共有三个线程在运行:main主线程、printOdd分线程、printEven分线程。其中主线程执行time.Sleep的原因是:主线程使用go启动分线程后就会继续向下执行,当主线程结束运行则分线程也会强制终止,因此为了能看到效果必须在主线程中阻塞。两个分线程并发执行,调度逻辑下运行结果是不确定的

❗ 注意:主线程结束运行后分线程也会强制终止运行!

2.2 sync.WaitGroup

在上一小节中,我们使用time.Sleep阻塞一定时间等待分线程运行完毕,这种实现方式并不优雅,因为我们不知道具体要阻塞多长时间,Go语言也提供了相关的并发组件可供使用,比如sync包中的WaitGroup(类似于Java当中的CountDownLatch),相关方法如下:

  • Add(delta int):计数器+delta
  • Done():计数器-1
  • Wait():阻塞知道计数器变为0
package main

import (
	"fmt"
	"sync"
	"time"
)

// 声明全局计数器锁
var wg sync.WaitGroup

func printOdd() {
	defer wg.Done() // 结束执行后计数器-1
	var num = 1
	for num <= 5 {
		fmt.Println(num)
		time.Sleep(time.Second * 1)
		num += 2
	}
}

func printEven() {
	defer wg.Done() // 结束执行后计数器-1
	var num = 0
	for num <= 5 {
		fmt.Println(num)
		time.Sleep(time.Second * 1)
		num += 2
	}
}

func main() {
	// 计数器+2
	wg.Add(2)
	// 启动分线程1
	go printOdd()
	// 启动分线程2
	go printEven()
	// 阻塞等待计数器变为0
	wg.Wait()
}

程序运行结果:

3. 线程安全问题

Go语言当中一样会存在线程安全问题,比如观察如下代码判断运行结果:

package main

import (
	"fmt"
	"sync"
)

// 全局变量
var num = 0

// 计数器锁
var wg sync.WaitGroup

func foo() {
	defer wg.Done()
	for i := 0; i < 5000; i++ {
		num += 1
	}
}

func bar() {
	defer wg.Done()
	for i := 0; i < 5000; i++ {
		num += 1
	}
}

func main() {
	wg.Add(2)
	go foo()
	go bar()
	wg.Wait()
	fmt.Println("num: ", num)
}

程序运行结果:

实际上每次运行的结果都是不同的!有些没学过相关知识的小伙伴可能就比较好奇,难道不是10000吗?两个线程就算并发运行不影响最终结果呀!这里原因不过多赘述,详见我另一篇博客:【Java多线程】线程安全问题_java多线程 线程安全问题-CSDN博客

这里直接提供解决方案:需要借助到Go当中的互斥锁sync.Mutex

  • Lock():加锁
  • Unlock():释放锁

解决代码:

package main

import (
	"fmt"
	"sync"
)

// 全局变量
var num = 0

// 计数器锁
var wg sync.WaitGroup

// 互斥锁
var lock sync.Mutex

func foo() {
	defer wg.Done()
	lock.Lock()
	for i := 0; i < 5000; i++ {
		num += 1
	}
	lock.Unlock()
}

func bar() {
	defer wg.Done()
	lock.Lock()
	for i := 0; i < 5000; i++ {
		num += 1
	}
	lock.Unlock()
}

func main() {
	wg.Add(2)
	go foo()
	go bar()
	wg.Wait()
	fmt.Println(num)
}

程序运行结果:

4. channel管道

Go语言的并发设计哲学就是 不要依赖共享内存进行通信 ,因此提供了chan这种管道数据类型,用于在多个goroutine之间进行数据通信

channel是一种类似于阻塞队列的数据结构,遵循先进先出(FIFO)的规则,有了channel就可以实现 “生产者消费者模型”

4.1 声明创建管道

channel用于存放数据,因此定义管道时不仅需要chan关键字声明管道,还需要声明内部数据的类型

声明语法格式:var 管道实例 chan 数据类型

管道是一个引用类型,因此需要使用make函数初始化

创建语法格式:var 管道实例 = make(chan 数据类型, 数量)

4.2 channel基本操作

  • 给管道插入值:chan变量 <- value
  • 从管道中取值:<-chan变量
4.2.1 案例一
func main() {
	var ch1 = make(chan int, 10)
	// 插入值
	ch1 <- 1
	ch1 <- 2
	ch1 <- 3
	// 取值
	fmt.Println(<-ch1)
	fmt.Println(<-ch1)
	fmt.Println(<-ch1)
}

程序运行结果:

4.2.2 案例二
type Student struct {
	Name string
	Age  int
}

func main() {
	// 案例2
	var ch2 = make(chan interface{}, 10)
	// 插入值
	ch2 <- 100
	ch2 <- true
	ch2 <- Student{Name: "rice", Age: 21}
	// 取值
	fmt.Println(<-ch2)
	fmt.Println(<-ch2)
	fmt.Println(<-ch2)
}

程序运行结果:

4.2.3 案例三
func main() {
	// 案例3
	var ch3 = make(chan int, 3)
	x := 10
	ch3 <- x
	x = 20
	fmt.Println(<-ch3)
	var ch4 = make(chan *int, 3)
	y := 10
	ch4 <- &y
	y = 20
	fmt.Println(*(<-ch4))
}

程序运行结果:

4.3 channel底层结构

chan是一种引用数据类型,其源码在src/runtime/chan.go当中:

其对应内存结构图如下:

  • qcount:当前队列剩余元素个数
  • dataqsize:环形队列长度
  • buf:数据缓冲区指针
  • sendx:指向下一个插入位置的索引
  • recvx:指向下一个取出位置的索引
  • sendq:生产者队列
  • recvq:消费者队列

画板

易错点:

func foo(ch chan int) {
	ch <- 100
}

func main() {
	// 引用类型易错
	var ch = make(chan int, 3)
	ch <- 10
	ch <- 20
	var ch2 = ch
	fmt.Println(<-ch2)
	fmt.Println(<-ch)
	var ch3 = make(chan int, 3)
	foo(ch3)
	fmt.Println(<-ch3)
}

程序运行结果:

💡 易错点:channel类型在拷贝过程中虽然结构当中有基本类型比如qcount、dataqsize等,但是可以认为操作的都是同一个channel类型

4.4 channel的关闭与循环

当向管道中发送完数据之后,需要使用close内置函数进行关闭,关闭后的管道只可以读不可写,如果不进行关闭,管道会一直等待写入最后造成deadlock死锁!

func main() {
	var ch = make(chan int, 3)
	ch <- 1
	ch <- 2
	ch <- 3
	for value := range ch {
		fmt.Println(value)
	}
}

程序运行结果:

因为循环channel的数据时,没有关闭导致最后一直阻塞等待输入!

解决方案:在写入完毕后执行close(ch)即可

func main() {
	var ch = make(chan int, 3)
	ch <- 1
	ch <- 2
	ch <- 3
	close(ch)
	for value := range ch {
		fmt.Println(value)
	}
}

程序运行结果:

4.5 生产者消费者模型

生产者消费者模型代码:

package main

import (
	"fmt"
	"sync"
)

// producer 生产者
func producer(ch chan int) {
	defer wg.Done()
	defer close(ch) // 写入完毕后关闭
	for i := 0; i < 100; i++ {
		fmt.Println("producer ", i)
		ch <- i
	}
}

// consumer 消费者
func consumer(ch chan int) {
	defer wg.Done()
	for true {
		val, ok := <-ch
		if ok {
			// 没有关闭
			fmt.Println("consumer ", val)
		} else {
			break
		}
	}
}

// 计数器锁
var wg sync.WaitGroup

func main() {
	wg.Add(2)
	var ch = make(chan int, 100)
	go consumer(ch)
	go producer(ch)
	wg.Wait() // 阻塞等待
	fmt.Println("process end...")
}

💡 注意:

  1. 生产者写入数据完毕后要关闭管道:clsoe(ch)
  2. 消费者可以通过多重返回值val, ok := <-ch判断管道是否关闭,ok为false则已经关闭
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值