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

server/2025/1/15 12:18:11/

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 = 1for num <= 5 {fmt.Println(num)time.Sleep(time.Second * 1)num += 2}
}func printEven() {var num = 0for 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 mainimport ("fmt""sync""time"
)// 声明全局计数器锁
var wg sync.WaitGroupfunc printOdd() {defer wg.Done() // 结束执行后计数器-1var num = 1for num <= 5 {fmt.Println(num)time.Sleep(time.Second * 1)num += 2}
}func printEven() {defer wg.Done() // 结束执行后计数器-1var num = 0for num <= 5 {fmt.Println(num)time.Sleep(time.Second * 1)num += 2}
}func main() {// 计数器+2wg.Add(2)// 启动分线程1go printOdd()// 启动分线程2go printEven()// 阻塞等待计数器变为0wg.Wait()
}

程序运行结果:

3. 线程安全问题

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

package mainimport ("fmt""sync"
)// 全局变量
var num = 0// 计数器锁
var wg sync.WaitGroupfunc 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 mainimport ("fmt""sync"
)// 全局变量
var num = 0// 计数器锁
var wg sync.WaitGroup// 互斥锁
var lock sync.Mutexfunc 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 <- 1ch1 <- 2ch1 <- 3// 取值fmt.Println(<-ch1)fmt.Println(<-ch1)fmt.Println(<-ch1)
}

程序运行结果:

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

程序运行结果:

4.2.3 案例三
func main() {// 案例3var ch3 = make(chan int, 3)x := 10ch3 <- xx = 20fmt.Println(<-ch3)var ch4 = make(chan *int, 3)y := 10ch4 <- &yy = 20fmt.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 <- 10ch <- 20var ch2 = chfmt.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 <- 1ch <- 2ch <- 3for value := range ch {fmt.Println(value)}
}

程序运行结果:

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

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

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

程序运行结果:

4.5 生产者消费者模型

生产者消费者模型代码:

package mainimport ("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 := <-chif ok {// 没有关闭fmt.Println("consumer ", val)} else {break}}
}// 计数器锁
var wg sync.WaitGroupfunc 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则已经关闭

http://www.ppmy.cn/server/158553.html

相关文章

【数学】概率论与数理统计(五)

文章目录 [toc] 二维随机向量及其分布随机向量离散型随机向量的概率分布律性质示例问题解答 连续型随机向量的概率密度函数随机向量的分布函数性质连续型随机向量均匀分布 边缘分布边缘概率分布律边缘概率密度函数二维正态分布示例问题解答 边缘分布函数 二维随机向量及其分布 …

《自动驾驶与机器人中的SLAM技术》ch8:基于 IESKF 的紧耦合 LIO 系统

目录 基于 IESKF 的紧耦合 LIO 系统 1 IESKF 的状态变量和运动过程 1.1 对名义状态变量的预测 1.2 对误差状态变量的预测及对协方差矩阵的递推 2 观测方程中的迭代过程 3 高维观测中的等效处理 4 NDT 和 卡尔曼滤波的联系 5 紧耦合 LIO 系统的主要流程 5.1 IMU 静止初始化 …

【高阶数据结构】线段树加乘(维护序列)详细解释乘与加懒标记

文章目录 1.题目[AHOI2009] 维护序列 2.懒标记处理先加后乘的形式1. 先加后乘的操作 先乘后加的形式2. 先乘后加的操作**乘法操作****加法操作** 懒标记的下传 3.代码 1.题目 题目来源:https://www.luogu.com.cn/problem/P2023 [AHOI2009] 维护序列 题目背景 老师交给小可可…

QLineEdit 按回车/失去焦点

1、目的 因为QLineEdit在写值时回车和失去焦点都会发出editingFinished&#xff0c;现在自定义控件回车或失去焦点并且值有改变才会处理一次&#xff0c;并能够处理的int或double型数据去除多余的0。 2、方法 处理回车应该重写控件事件keyPressEvent函数&#xff0c;失去焦点…

【ArcGIS微课1000例】0137:色彩映射表转为RGB全彩模式

本文讲述ArcGIS中,将tif格式的影像数据从色彩映射表转为RGB全彩模式。 参考阅读:【GlobalMapper精品教程】093:将tif影像色彩映射表(调色板)转为RGB全彩模式 文章目录 一、色彩映射表预览二、色彩映射表转为RGB全彩模式一、色彩映射表预览 加载配套数据包中的0137.rar中的…

CSS语言的多线程编程

CSS语言的多线程编程探讨 在当今网络应用中&#xff0c;网页的交互性能和用户体验显得尤为重要。用户对页面的加载速度、界面响应的流畅性有着越来越高的要求。为了实现更好的性能表现&#xff0c;前端开发中采用了多线程编程的理念。而在谈及多线程编程时&#xff0c;CSS言及…

高级java每日一道面试题-2025年01月13日-框架篇[Spring篇]-Spring 是怎么解决循环依赖的?

如果有遗漏,评论区告诉我进行补充 面试官: Spring 是怎么解决循环依赖的? 我回答: 在Java高级面试中&#xff0c;Spring框架如何解决循环依赖是一个重要且常见的问题。以下是对Spring解决循环依赖的详细解释&#xff1a; 循环依赖的定义与类型 循环依赖是指两个或多个Bea…

5 list 语法

在 Shell 脚本中&#xff0c;列表&#xff08;数组&#xff09;是一种非常有用的数据结构&#xff0c;可以用来存储多个值。 定义数组 # 定义一个空数组 my_array()# 定义一个带有初始值的数组 my_array("value1" "value2" "value3")访问数组元…