Go中为什么不建议用锁?

Go语言中是不建议用锁,而是用通道Channel来代替(不要通过共享内存来通信,而通过通信来共享内存),当然锁也是可以用,锁是防止同一时刻多个goroutine操作同一个资源;

GO语言中,要传递某个数据给另一个goroutine(协程),可以把这个数据封装成一个对象,然后把这个对象的指针传入某个channel中,另外一个goroutine从这个channel中读出这个指针,并处理其指向的内存对象。GO从语言层面保证同一个时间只有一个goroutine能够访问channel里面的数据,为开发者提供了一种优雅简单的工具,所以GO的做法就是使用channel来通信,通过通信来传递内存数据,使得内存数据在不同的goroutine中传递,而不是使用共享内存来通信。

加锁操作通常通过 sync 包中的 Mutex 类型来实现。Mutex(互斥锁)是一种最基本的锁机制,用于保护共享资源,确保在同一时间只有一个 goroutine 可以访问共享资源,从而避免数据竞争和并发问题。

加锁的场景,通过合理地使用锁,可以确保并发程序的正确性和稳定性,避免出现数据竞争和其他并发问题。

  1. 共享数据的读写保护: 当多个 goroutine 需要同时读写共享的数据时,为了保证数据的一致性和正确性,需要使用锁来对共享数据进行读写保护。通过加锁操作,可以确保在同一时间只有一个 goroutine 可以对共享数据进行写操作,避免出现数据竞争和并发问题。
  2. 临界区保护: 当某个代码块需要被多个 goroutine 同时访问时,为了避免多个 goroutine 同时进入临界区而导致的问题,可以使用锁来对临界区进行保护。通过在临界区的入口处加锁,在出口处解锁,可以确保在同一时间只有一个 goroutine 可以执行临界区的代码。
  3. 资源的同步访问: 在某些场景下,多个 goroutine 需要对某个资源进行同步访问,例如在并发编程中常见的信号量、互斥锁等同步机制。通过使用锁来控制资源的访问,可以保证多个 goroutine 之间的操作是有序的,避免出现数据不一致或其他并发问题。
  4. 并发数据结构的实现: 在实现并发安全的数据结构时,如并发安全的队列、栈、哈希表等,通常需要使用锁来对数据结构进行加锁保护,以确保在并发环境中的安全访问。通过使用锁来控制并发访问,可以实现高效并发的数据结构操作。
  5. 避免竞态条件: 竞态条件是指当多个 goroutine 同时访问共享资源时,由于执行顺序的不确定性而导致的程序行为不确定的情况。为了避免竞态条件,可以使用锁来对共享资源进行加锁保护,确保每次操作的原子性和一致性。

我们来认识几种加锁和不加锁的使用;

1、读写互斥锁

应用场景

适用于读多写少的场景下,才能提高程序的执行效率.

特点

  1. 读的goroutine来了获取的是读锁,后续的goroutine能读不能写
  2. 写的goroutine来了获取的是写锁,后续的goroutine不管是读还是写都要等待获取锁

使用

go 复制代码
package main

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

func main() {
	var mu sync.Mutex // 定义一个互斥锁

	var counter int

	// 启动多个 goroutine 并发地增加计数器的值
	for i := 0; i < 5; i++ {
		go func() {
			for j := 0; j < 1000; j++ {
				// 在访问共享资源之前先加锁
				mu.Lock()
				counter++
				// 完成对共享资源的访问后释放锁
				mu.Unlock()
			}
		}()
	}

	// 等待所有 goroutine 完成
	time.Sleep(time.Second)

	// 打印最终计数器的值
	fmt.Println("Final Counter:", counter)
}

# 说明
var rwLock sync.RWMutex
rwLock.RLock() // 获取读锁
rwLock.RUnlock() // 释放读写

rwLock.Lock() // 获取写锁
rwLock.Unlock() // 释放写锁

以下介绍不需要用户额外加锁的并发操作

2、等待组

应用场景

sync.Waitgroup是一种同步原语,

用来等groutine执行完再继续,是一个结构体.是值类型.给函数传参数的时候要传指针.

  • 控制程序的并发流程
  • 监控程序执行完成状态
  • 等待一组 goroutine 完成任务
  • 资源等待和释放

特点

  • WaitGroup 是线程安全的,内部使用原子操作,无需额外加锁。

使用

go 复制代码
package main

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

func main() {
	var wg sync.WaitGroup

	for i := 0; i < 3; i++ {
		wg.Add(1) // 添加一个 goroutine 到计数器
		go func(id int) {
			defer wg.Done() // goroutine 完成任务后减少计数器
			fmt.Printf("Goroutine %d starting\n", id)
			time.Sleep(time.Second) // 模拟任务执行
			fmt.Printf("Goroutine %d done\n", id)
		}(i)
	}

	fmt.Println("Main goroutine waiting for other goroutines to finish...")
	wg.Wait() // 等待所有 goroutine 完成任务
	fmt.Println("All goroutines finished.")
}

# 说明
wg.Add(1) // 起几个goroutine就加几个计数
wg.Done() // 在goroutine对应的函数中,函数要结束的时候表示goroutine完成,计数器-1
wg.Wait() // 阻塞,等待所有的goroutine都结束

2、Sync.Once

使用场景

某些函数只需要执行一次的时候,就可以使用sync.Once

比如 blog加载图片那个例子

go 复制代码
var once sync.Once

once.Do() // 接受一个没有参数也没有返回值的函数,如有需要可以使用闭包

特点

  • 用于执行某个函数且确保只执行一次,通常用于初始化操作

使用

package main

import (
	"fmt"
	"sync"
)

func main() {
	var once sync.Once

	// 定义一个初始化函数,只会被执行一次
	initialize := func() {
		fmt.Println("Initializing...")
	}

	// 开启多个 goroutine 同时调用初始化函数
	for i := 0; i < 3; i++ {
		go func() {
			once.Do(initialize) // 使用 sync.Once 确保初始化函数只被执行一次
		}()
	}

	fmt.Println("Main goroutine waiting...")
}

3、sync.Map

使用场景

  • 缓存系统: 在缓存系统中,sync.Map 可以用来存储缓存数据,以供多个 goroutine 并发访问。它可以在不需要额外的锁机制的情况下提供并发安全的缓存存储和访问,从而提高缓存系统的性能和并发能力。
  • 全局状态管理: 在需要跨多个 goroutine 共享状态的应用程序中,sync.Map 可以用来管理全局状态。例如,一个 Web 服务器中可以使用 sync.Map 来存储用户的会话状态或其他全局状态信息。
  • 动态配置管理: 在一些需要动态加载和更新配置信息的应用程序中,sync.Map 可以用来存储配置信息,并提供并发安全的访问和更新接口。这样可以保证在配置更新的过程中不会出现数据竞争或其他并发问题。
  • 任务调度器: 在任务调度器中,sync.Map 可以用来存储任务的执行状态或其他相关信息。多个 goroutine 可以并发地读取和更新任务状态,而无需额外的锁机制,从而提高任务调度器的并发能力和性能。
  • 分布式系统中的局部缓存: 在分布式系统中,每个节点可能需要维护一个局部缓存来存储部分数据,sync.Map 可以作为局部缓存的实现。每个节点的局部缓存可以独立地进行读写操作,而无需与其他节点进行同步,从而提高系统的响应速度和吞吐量。

特点

sync.Map 是 Go 语言标准库 sync 包中提供的一种并发安全的键值对映射类型。与普通的 map 不同,sync.Map 在并发访问时不需要额外的锁机制,因此在并发场景下具有更好的性能。

使用

是一个开箱即用(不需要make初始化)的并发安全的map,

go 复制代码
package main

import (
	"fmt"
	"sync"
)

func main() {
	var m sync.Map

	// 使用 Store 方法向 sync.Map 中存储键值对
	m.Store("key1", "value1")
	m.Store("key2", "value2")
	m.Store("key3", "value3")

	// 使用 Load 方法从 sync.Map 中加载键对应的值
	if value, ok := m.Load("key1"); ok {
		fmt.Println("Value for key1:", value)
	} else {
		fmt.Println("Key1 not found")
	}

	// 使用 Range 方法遍历 sync.Map 中的所有键值对
	fmt.Println("All key-value pairs:")
	m.Range(func(key, value interface{}) bool {
		fmt.Println("Key:", key, "Value:", value)
		return true // 返回 true 继续遍历,返回 false 中止遍历
	})

	// 使用 Delete 方法从 sync.Map 中删除键值对
	m.Delete("key2")

	// 检查是否包含某个键
	fmt.Println("Contains key3?", m.Load("key3"))

	// 清空 sync.Map
	m.Range(func(key, value interface{}) bool {
		m.Delete(key)
		return true
	})
}


# 说明
// Map[key] = value // 原生map
syncMap.Store(key, value)
syncMap.Load(key)
syncMap.LoadOrStore()
syncMap.Delete()
syncMap.Range()

4、原子操作

Go语言内置了一些针对内置的基本数据类型的一些并发安全的操作;使用场景想用就用

特点

  1. 原子性: 原子操作是不可分割的,要么完全执行成功,要么完全不执行。在执行原子操作期间,不会被中断,也不会被其他 goroutine 所干扰。
  2. 并发安全: 原子操作是并发安全的,可以在多个 goroutine 并发访问时保证数据的一致性和正确性。即使多个 goroutine 同时对共享数据执行原子操作,也不会出现竞态条件(race condition)或数据竞争问题。
  3. 性能高效: 原子操作通常使用底层硬件的原子指令来实现,因此性能较高。相比于加锁机制,原子操作不需要额外的锁和同步机制,可以更快地完成操作。
  4. 适用范围广泛: 原子操作可以用于对各种类型的数据进行操作,如整型、指针等。它们可以在不同的并发场景下使用,如计数器递增、比较并交换、加载、存储等操作。
  5. 简单易用: Go 语言标准库中提供了一系列原子操作函数,使用起来非常简单直观。通过调用这些函数,开发者可以轻松地在并发程序中实现原子操作,而无需过多考虑并发安全性的问题。

使用

go 复制代码
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var counter int64 // 使用 int64 类型的计数器

	var wg sync.WaitGroup
	const numGoroutines = 10
	wg.Add(numGoroutines)

	// 多个 goroutine 并发地对计数器进行增加操作
	for i := 0; i < numGoroutines; i++ {
		go func() {
			for j := 0; j < 1000; j++ {
				atomic.AddInt64(&counter, 1) // 使用原子的增加操作
			}
			wg.Done()
		}()
	}

	wg.Wait() // 等待所有 goroutine 完成

	fmt.Println("Final Counter:", counter)
}

5、Channel

使用场景

  • goroutine 通信: Channel 是 goroutine 之间进行通信的主要方式。通过 channel,不同的 goroutine 可以安全地共享数据、进行同步操作,从而实现并发编程中的任务协作。
  • 工作池: 可以使用 channel 来实现工作池模式,将任务发送到一个任务队列中,由固定数量的 worker goroutine 来处理任务。通过 channel,可以很方便地控制 worker goroutine 的数量和任务的调度。
  • 事件通知: 可以使用 channel 来实现事件通知机制,一个 goroutine 可以向 channel 中发送事件,而其他 goroutine 可以通过监听 channel 来获取事件并进行相应的处理。
  • 计算结果收集: 在并发计算中,可以使用 channel 来收集各个 goroutine 计算得到的结果,并在所有结果都就绪后进行汇总或其他操作。
  • 超时控制: 可以使用 channel 来实现超时控制机制,例如通过 time.After 函数返回的 channel 来实现某个操作的超时判断。

特点

  • 安全性: Channel 是并发安全的,多个 goroutine 可以同时对一个 channel 进行读写操作,而不会发生数据竞争或其他并发问题。这是因为 channel 内部实现了同步机制,能够确保数据传递的安全性。
  • 阻塞操作: 当向一个已满的 channel 发送数据时,发送操作会阻塞直到有其他 goroutine 从该 channel 中接收数据;当从一个空的 channel 接收数据时,接收操作会阻塞直到有其他 goroutine 向该 channel 发送数据。这种阻塞操作使得 goroutine 之间的通信更加简洁和可靠。
  • 单向传输: Channel 支持单向传输,即可以指定 channel 只能用于发送数据或只能用于接收数据。这样可以在一定程度上增强代码的可读性和安全性。
  • 关闭通知: 可以通过关闭 channel 来向接收方通知数据流的结束。接收方可以通过检查 channel 的关闭状态来判断是否还有数据需要处理。
  • 引用类型: Channel 是引用类型,可以像其他引用类型一样进行传递、赋值和比较。这使得在函数间传递 channel 变得非常方便。

使用

go 复制代码
package main

import (
	"fmt"
	"time"
)

func sender(ch chan<- int) {
	for i := 0; i < 5; i++ {
		ch <- i // 向通道发送数据
		time.Sleep(time.Second)
	}
	close(ch) // 关闭通道
}

func receiver(ch <-chan int) {
	for num := range ch { // 从通道接收数据,直到通道关闭
		fmt.Println("Received:", num)
	}
}

func main() {
	ch := make(chan int) // 创建一个整型通道

	go sender(ch)   // 启动发送数据的 goroutine
	go receiver(ch) // 启动接收数据的 goroutine

	time.Sleep(6 * time.Second) // 等待一段时间,确保 goroutine 有足够的时间执行
}
相关推荐
XiaoLeisj1 小时前
【JavaEE初阶 — 多线程】单例模式 & 指令重排序问题
java·开发语言·java-ee
励志成为嵌入式工程师2 小时前
c语言简单编程练习9
c语言·开发语言·算法·vim
捕鲸叉3 小时前
创建线程时传递参数给线程
开发语言·c++·算法
A charmer3 小时前
【C++】vector 类深度解析:探索动态数组的奥秘
开发语言·c++·算法
Peter_chq3 小时前
【操作系统】基于环形队列的生产消费模型
linux·c语言·开发语言·c++·后端
记录成长java4 小时前
ServletContext,Cookie,HttpSession的使用
java·开发语言·servlet
前端青山4 小时前
Node.js-增强 API 安全性和性能优化
开发语言·前端·javascript·性能优化·前端框架·node.js
睡觉谁叫~~~5 小时前
一文解秘Rust如何与Java互操作
java·开发语言·后端·rust
音徽编程5 小时前
Rust异步运行时框架tokio保姆级教程
开发语言·网络·rust
观音山保我别报错5 小时前
C语言扫雷小游戏
c语言·开发语言·算法