当前位置: 首页 > news >正文

并发安全与锁

总述

这篇文章,我想谈一谈自己对于并发变成的理解与学习。主要涉及以下三个部分:goroutine,channel以及lock

临界区

首先,要明确下面两组概念

并发和并行

并行:指几个程序每时每刻都同时进行

并发:指在单位时间内同时运行

go的并发模型在内存共享方面有点类型SMP模型:

相似之处:

  1. 多处理器利用
    • SMP:SMP 系统利用多个处理器(或核心)来并行处理任务,系统中的所有处理器共享同一内存空间。
    • Go: Go 通过 goroutines 和调度器来利用多个处理器,能够在多核处理器上并行执行多个 goroutine。Go 的调度器会将 goroutine 分配到可用的处理器上,从而实现并行计算。
  2. 共享内存
    • SMP:所有处理器访问同一内存空间,可以直接共享数据。
    • Go: goroutines 可以通过共享内存进行通信,虽然 Go 提供了通道(channels)作为主要的同步和通信机制,但共享内存仍然是可能的。Go 的同步机制,如互斥锁(mutex)和原子操作,帮助避免数据竞争和保持一致性。

不同之处:

  1. 并发模型
    • SMP:SMP 更多地关注于物理硬件层面的多处理器架构和资源共享,不直接涉及编程模型。
    • Go: Go 语言提供了一种高层次的并发编程模型,通过 goroutines 和 channels 来简化并发编程。Go 的调度器负责将 goroutines 映射到系统线程和处理器上,程序员可以更高效地编写并发程序,而不需要直接管理线程。
  2. 调度和管理
    • SMP:在 SMP 系统中,操作系统负责调度和管理线程,确保线程能够在多个处理器上运行。
    • Go: Go 运行时提供了一个轻量级的调度器,称为 GOMAXPROCS,管理 goroutines 的执行。Go 的调度器将 goroutines 调度到系统线程上,而不是直接由操作系统的线程调度机制来管理。
  3. 编程模型
    • SMP:编程模型通常需要考虑线程同步、数据竞争和缓存一致性等底层细节。
    • Go: Go 的并发模型通过 goroutines 和通道提供了较高的抽象,程序员不需要直接处理线程和锁的细节,使并发编程更为简洁和安全。

总的来说,虽然 Go 的并发模型与 SMP 在利用多处理器的并行能力上有相似之处,但 Go 的模型更专注于简化并发编程,而 SMP 更加关注底层的处理器和内存管理。

协程 进程 线程

1. 进程(Process)

定义
进程是计算机中正在运行的程序的实例,具有独立的地址空间、资源和执行上下文。每个进程都有自己的内存空间、文件描述符和其他系统资源。

特点

  • 资源独立:每个进程有独立的内存空间,进程间的通信需要使用 IPC(进程间通信)机制,如管道、共享内存等。
  • 开销大:创建和销毁进程的开销相对较大,因为涉及内存分配和资源管理。

2. 线程(Thread)

定义
线程是进程中的一个执行单元,是程序执行的最小单位。一个进程可以包含多个线程,它们共享进程的地址空间和资源。

特点

  • 共享资源:同一进程中的线程共享进程的内存和资源,因此线程间的通信比进程间的通信更为高效。
  • 开销小:线程的创建和销毁比进程要轻量,因为不需要为每个线程分配独立的地址空间。

3. 协程(Coroutine)

定义
协程是一种用户级的轻量级线程,可以在程序中暂停和恢复执行。协程通常在同一线程中切换,由程序控制,而不是由操作系统调度。

特点

  • 高效:由于协程是由程序员控制的,切换开销相对较小,适合处理高并发场景。
  • 共享同一线程:协程通常在同一线程内运行,之间的切换非常快速,适合执行 I/O 密集型任务。

区别总结

特性进程线程协程
定义程序的独立实例进程内的执行单元用户级的轻量级线程
内存独立的内存空间共享进程内的内存共享线程内的内存
开销较低
调度由操作系统调度由操作系统调度由程序控制
通信复杂(需要 IPC)简单(共享内存)更简单(通过函数调用)
应用场景适合 CPU 密集型任务适合多任务并发处理适合高并发的 I/O 密集型任务

适用场景

  • 进程:适用于需要高度隔离的任务,比如服务器、桌面应用等。
  • 线程:适合需要共享资源的任务,如 GUI 应用程序的事件处理、网络服务等。
  • 协程:适合 I/O 密集型的应用,如网络爬虫、异步处理等,能够有效管理大量并发任务。

Go程的使用

go语言使用的是共享内存(与传统的共享模型不同的是,go语言与大多编程语言一样,允许加锁来保证线程安全)的并发模式,使用go关键字可以启动一个go程(先后顺序是随机的、可互换的),不同的go程之间可以通过通道来传输数据,使用锁或者sync包中的方法可以控制进程的顺序(有点类似于阶段内随机、总体分阶段进行)。

Goroutine

在go语言中,并发性是一种语言天然支持,简洁而容易实现的。

两种调用

实现一个“go程”,最常见的方法在正常的函数调用之前加上“go”关键字即可:

for i := 0; i < 5; i++ {wg.Add(1)//暂且忽略这一行go work(&wg)}

在go中,无法控制go程执行的先后顺序,也就是说这些go程的先后顺序是随机的。

此外还有一种方法是使用闭包函数的直接调用:

go func() { //使用go 直接将这个函数作为一个goroutine来运行defer fmt.Println("A defer")func() {defer fmt.Println("B defer")runtime.Goexit() //退出这个goroutine//return 退出内层匿名函数fmt.Println("A")}()}()

Go程的常见配套方法

前置defer()标记结束

func Run() {defer fmt.Println("子进程结束了")for i := 0; i < 10; i++ {fmt.Println("这是子进程的第", i, "个循环")time.Sleep(1 * time.Second)}
}

利用defer的特性,我们可以在go触发进程的函数结束之后,达到某种效果(比如sync.WaitGroup.Done()

time.Sleep(?* time.Second)收尾

goroutine可能的切换点

  • I/O,select
  • channel
  • 等待锁
  • 函数调用(有时)
  • runtime.Gosched()

Channel

channel是一个通道,是用来实现两个进程之间的通信的,本质上是一个队列的数据结构。

channel实现了goroutine两个进程之间的通信

chan2 := make(chan int, 10) make(chan Type, capacity)有缓冲通道
chan1 := make(chan int)     //make(chan Type)无缓冲通道

向通道中读写数据:

//向管道中写入数据
channel <- value 发送//从管道中读取数据
<- channel 接收并将其丢弃
x := <- channel 接收并赋值
x , ok := <- channel 接收并赋值,ok为false表示channel已关闭

两种通道的解释

下面两个是刘丹冰老师对于无缓冲通道和有缓冲通道的形象解释

无缓冲通道:

在这里插入图片描述

有缓冲通道:

在这里插入图片描述

实例:

func test1() {defer fmt.Println("主进程已经结束")//创建一个无缓冲channelc := make(chan int)go func() {defer fmt.Println("子进程已经结束")fmt.Println("正在进行")num := 666fmt.Println("子进程中数据的值为:", num)c <- num}()num := <-c //通过通道将子进程的数据捕捉到主进程fmt.Println("主进程捕捉到的子进程数据: ", num)
}

Lock

锁的使用,包含在sync包里面,分为互斥锁(Mutex)、读写锁(RWMutex)、等待组(WaitGroup)、一次性锁(Once)和条件变量(Cond)。这是为了解决在Go代码中可能会存在多个goroutine同时操作一个资源(临界区)以及这种情况下发生的竞态问题(数据竞态)。这篇文章只涉及前面两个,后续的在sync包中解释。

互斥锁(Mutex类型)

互斥锁只能被一个goroutine同时持有。如果另一个goroutine试图获取一个已被持有的互斥锁,它将被阻塞,直到持有锁的goroutine释放锁。使用互斥锁能够保证同一时间有且只有一个goroutine进入临界区,其他的goroutine则在等待锁;当互斥锁释放后,等待的goroutine才可以获取锁进入临界区,多个goroutine同时等待一个锁时,唤醒的策略是随机的。

案例:

package mainimport ("fmt""sync""time"
)var (sharedValue intmu          sync.Mutex // 创建一个互斥锁
)func increment(wg *sync.WaitGroup, id int) {defer wg.Done()          // 完成任务时调用 Done()for i := 0; i < 5; i++ { // 为了简化输出,将循环次数减少到5次mu.Lock() // 获取锁fmt.Printf("Go程编号: %d: 将共享变量 sharedValue 的值从 %d 增加\n", id, sharedValue)sharedValue++                      // 访问和修改共享变量time.Sleep(100 * time.Millisecond) // 模拟其他工作,增加延迟以便观察输出fmt.Printf("Go程编号: %d: 将共享变量 sharedValue 的值增加到 %d \n", id, sharedValue)mu.Unlock() // 释放锁}
}func main() {var wg sync.WaitGroupwg.Add(2)            // 添加两个 goroutine 的等待go increment(&wg, 1) // 创建第一个 goroutine,并传递ID 1go increment(&wg, 2) // 创建第二个 goroutine,并传递ID 2wg.Wait() // 等待所有 goroutine 完成fmt.Printf("最终的共享变量值: %d\n", sharedValue)
}

运行结果:

Go程编号: 2: 将共享变量 sharedValue 的值从 0 增加
Go程编号: 2: 将共享变量 sharedValue 的值增加到 1 
Go程编号: 2: 将共享变量 sharedValue 的值从 1 增加
Go程编号: 2: 将共享变量 sharedValue 的值增加到 2 
Go程编号: 1: 将共享变量 sharedValue 的值从 2 增加
Go程编号: 1: 将共享变量 sharedValue 的值增加到 3 
Go程编号: 1: 将共享变量 sharedValue 的值从 3 增加
Go程编号: 1: 将共享变量 sharedValue 的值增加到 4 
Go程编号: 2: 将共享变量 sharedValue 的值从 4 增加
Go程编号: 2: 将共享变量 sharedValue 的值增加到 5 
Go程编号: 2: 将共享变量 sharedValue 的值从 5 增加
Go程编号: 2: 将共享变量 sharedValue 的值增加到 6 
Go程编号: 1: 将共享变量 sharedValue 的值从 6 增加
Go程编号: 1: 将共享变量 sharedValue 的值增加到 7 
Go程编号: 1: 将共享变量 sharedValue 的值从 7 增加
Go程编号: 1: 将共享变量 sharedValue 的值增加到 8 
Go程编号: 2: 将共享变量 sharedValue 的值从 8 增加
Go程编号: 2: 将共享变量 sharedValue 的值增加到 9 
Go程编号: 1: 将共享变量 sharedValue 的值从 9 增加
Go程编号: 1: 将共享变量 sharedValue 的值增加到 10 
最终的共享变量值: 10

这个程序说明了,Lock()将go程锁住,这时候只有一个进程可以更改变量的值,直到这个进程结束后,才能有其他进程一起竞争这个变量值的使用。

锁的影响范围:
  • mu.Lock()mu.Unlock() 之间的代码:这些代码块中的所有操作都被保护。只有持有锁的 goroutine 可以执行这些操作。
  • mu.Unlock() 之后的代码:锁的释放意味着其他等待的 goroutine 现在可以获取锁并继续执行它们的操作。锁不再影响 mu.Unlock() 之后的代码块。

读写互斥锁(RWMutex类型

读写锁允许多个goroutine同时读取受保护的数据,但只允许一个goroutine同时写入受保护的数据。

每个进程都可以获得读锁,拿到之后都可以读。但是写锁只有一把,谁拿到谁写。所以很明显,读写锁非常适合读多写少的场景,如果读和写的操作差别不大,读写锁的优势就发挥不出来。

案例如下:

package mainimport ("fmt""sync""time"
)var (sharedValue intrwMutex     sync.RWMutex
)// 读操作
func read(id int) {rwMutex.RLock() // 获取读锁fmt.Printf("Goroutine %d: Reading sharedValue: %d\n", id, sharedValue)time.Sleep(100 * time.Millisecond) // 模拟读取操作rwMutex.RUnlock()                  // 释放读锁
}// 写操作
func write(id int, value int) {rwMutex.Lock() // 获取写锁fmt.Printf("Goroutine %d: Writing sharedValue from %d to %d\n", id, sharedValue, value)sharedValue = valuetime.Sleep(200 * time.Millisecond) // 模拟写操作rwMutex.Unlock()                   // 释放写锁
}func main() {var wg sync.WaitGroup// 启动多个读操作for i := 1; i <= 5; i++ {wg.Add(1)go func(id int) {defer wg.Done()read(id)}(i)}// 启动多个写操作for i := 1; i <= 3; i++ {wg.Add(1)go func(id int) {defer wg.Done()write(id, id*10)}(i)}// 再启动一些读操作for i := 6; i <= 10; i++ {wg.Add(1)go func(id int) {defer wg.Done()read(id)}(i)}wg.Wait()fmt.Printf("最终共享值为: %d\n", sharedValue)
}

运行结果:

Goroutine 1: Reading sharedValue: 0
Goroutine 2: Reading sharedValue: 0
Goroutine 2: Writing sharedValue from 0 to 20
Goroutine 8: Reading sharedValue: 20
Goroutine 9: Reading sharedValue: 20
Goroutine 4: Reading sharedValue: 20
Goroutine 3: Reading sharedValue: 20
Goroutine 6: Reading sharedValue: 20
Goroutine 7: Reading sharedValue: 20
Goroutine 5: Reading sharedValue: 20
Goroutine 10: Reading sharedValue: 20
Goroutine 1: Writing sharedValue from 20 to 10
Goroutine 3: Writing sharedValue from 10 to 30
最终共享值为: 30

从这里面我们可以看出来,读操作的进程是没有什么先后顺序的,完全随机的(比如89436这几个进程,完全是竞争的关系),而写操作之间也是相互竞争的。但是,我们不难发现,对于读取操作,他们使用的是读锁,因此这个竞争是随机的;但是写锁,很明显是有着先后顺序的(这点从前后值的变化就可以看出),即前面一个写进程结束之后,后面一个写进程才能继续。

后续的三个锁在sync包中有详解。


http://www.mrgr.cn/news/28956.html

相关文章:

  • TransmittableThreadLocal简单使用
  • UVA-211 多米诺效应 题解答案代码 算法竞赛入门经典第二版
  • 嵌入式DCMI摄像头功能调试方法
  • ChatGLM-6B部署到本地电脑
  • Chainlit集成Langchain并使用通义千问AI知识库高级检索(多重查询)网页对话应用教程
  • C++:析构函数
  • 全面掌握 Jest:从零开始的测试指南(下篇)
  • Python JSON
  • 分析和管理远程服务器方法
  • Netty笔记09-网络协议设计与解析
  • vue3 表单校验规则封装
  • 【docker学习笔记】docker概念和命令
  • 我的5周年创作纪念日,不忘初心,方得始终。
  • CI/CD持续集成和持续交付(git工具、gitlab代码仓库、jenkins)
  • Vue3项目开发——新闻发布管理系统(七)
  • Koa安装和应用
  • RocksDB系列一:基本概念
  • 超全网络安全面试题汇总(2024版)
  • list从0到1的突破
  • 精选评测!分享5款AI写论文最好用的软件排名