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

news/2025/1/16 3:08:52/

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/news/1563492.html

相关文章

Redis集群的键分布机制

Redis 集群的键分布机制基于哈希槽&#xff08;Hash Slot&#xff09;&#xff0c;它是一种无中心的分布式哈希算法&#xff0c;用于确定键存储在哪个节点上。以下是 Redis 集群键分布机制的详细说明&#xff1a; 1、键分布基本原理 1.1 哈希槽的概念 Redis 集群将所有的键分…

android framework.jar 在应用中使用

在开发APP中&#xff0c;有时会使用系统提供的framework.jar 来替代 android.jar, 在gradle中配置如下&#xff1a; 放置framework.jar 依赖配置 3 优先级配置 gradle.projectsEvaluated {tasks.withType(JavaCompile) {Set<File> fileSet options.bootstrapClasspat…

关于使用FastGPT 摸索的QA

近期在通过fastGPT&#xff0c;创建一些基于特定业务场景的、相对复杂的Agent智能体应用。 工作流在AI模型的基础上&#xff0c;可以定义业务逻辑&#xff0c;满足输出对话之外的需求。 在最近3个月来的摸索和实践中&#xff0c;一些基于经验的小问题点&#xff08;自己也常常…

【高阶数据结构】布隆过滤器+海量数据处理

布隆过滤器 一.什么是布隆过滤器&#xff1f;二.布隆过滤器器误判率推导三.布隆过滤器代码实现四.布隆过滤器删除问题五.布隆过滤器的应用六.海量数据处理问题1.10亿个整数中求最大的前100个2.100亿个整数中&#xff0c;求某个整数是否出现3.给两个文件&#xff0c;分别有100亿…

PowerBuilder中调用Excel OLE对象的方法

在 PowerBuilder 中调用 Excel OLE 对象&#xff0c;首先使用 CREATE OLEObject 创建 Excel 实例&#xff0c;通过 ConnectToNewObject("Excel.Application") 连接。然后可以通过 ole_excel.Workbooks.Add() 创建新工作簿&#xff0c;操作工作表并保存文件。使用 ole…

MySQL程序之:简要概述

MySQL安装中有许多不同的程序。本节简要概述了它们。后面的部分提供了每个程序的更详细描述&#xff0c;但NDB集群程序除外。每个程序的描述表明了它的调用语法和它支持的选项。&#xff0c;“NDB集群程序”&#xff0c;描述了特定于NDB集群的程序。 大多数MySQL发行版包括所有…

C++笔记:打包独立运行的exe(在静态库中使用MFC)

从window7到window11都默认安装有C依赖库&#xff0c;见如下 但是一些企业用的特殊window版本可能没有这个依赖库&#xff0c;导致Visual Studio生成的exe无法运行&#xff08;报缺失dll&#xff09;&#xff0c;就需要打包生成时使用静态库依赖。 共两步&#xff1a; 第一步…

Spring Boot 项目启动后自动加载系统配置的多种实现方式

Spring Boot 项目启动后自动加载系统配置的多种实现方式 在 Spring Boot 项目中&#xff0c;可以通过以下几种方式实现 在项目启动完成后自动加载系统配置缓存操作 的需求&#xff1a; 1. 使用 CommandLineRunner CommandLineRunner 是一个接口&#xff0c;可以用来在 Spring…