Golang面试题三(并发编程)

目录

1.Mutex有几种状态

未锁定(Unlocked):

锁定(Locked):

饥饿(Starvation):

2.Mutex的模式

正常模式(Normal Mode)

饥饿模式(Starvation Mode)

3.Mutex 自旋锁

4.RWMutex 实现原理

1. 双重锁机制

2. 读锁(RLock)操作

3. 写锁(Lock)操作

4. 读锁(RUnlock)和写锁(Unlock)操作

5. 饥饿保护

总结

5.RWMutex 注意事项

读写分离:

避免死锁:

避免读写锁升级:

适时释放锁:

避免过度使用写锁:

监控锁竞争:

并发安全的初始化:

并发安全的结构体嵌入:

6.sync.Cond 是什么,有什么作用,使用示例

是什么

作用

示例

7.Broadcast 和 Signal 区别

8.sync.Cond 中 Wait

基本行为

9.WaitGroup 用法

基本用法

初始化

增加计数wg.Add()

启动 Goroutine,调用 Done()

等待所有 Goroutine 完成,调用 Wait() 方法阻塞

10.WaitGroup 底层原理

结构体

noCopy

state atomic.Uint64

 sema uint32

11.什么是 sync.Once

作用

数据结构

 工作原理总结

12.什么是原子操作

13.什么是CAS

14.原子操作和锁的区别

15.sync.Pool 有什么用


1.Mutex有几种状态

Go 语言中的 sync.Mutex(互斥锁)是一种用于保护共享资源免受并发访问的同步原语。Mutex 有以下几种状态:

  1. 未锁定(Unlocked)

    • 初始状态或刚刚解锁后的状态,表示没有任何 Goroutine 正在持有锁。
    • 在此状态下,多个 Goroutine 可以同时尝试获取锁,只有一个 Goroutine 能成功获得锁并进入锁定状态。
  2. 锁定(Locked)

    • 当一个 Goroutine 成功调用 Mutex.Lock() 方法后,Mutex 进入锁定状态。
    • 在此状态下,该 Goroutine 拥有对受保护资源的独占访问权,其他尝试获取锁的 Goroutine 将被阻塞,直到锁被解锁。
    • 锁定状态下的 Mutex 只能由持有锁的 Goroutine 解锁,即通过调用 Mutex.Unlock() 方法。
  3. 饥饿(Starvation)

    • 在某些情况下,如果有多个 Goroutine 长时间争抢同一把锁,且每次都是同一个 Goroutine 获得锁,可能导致其他 Goroutine 长时间等待而得不到锁,这种情况被称为“锁饥饿”。
    • Go 语言的 sync.Mutex 实现包含了一种饥饿模式(starvation mode),当检测到可能存在锁饥饿现象时,会切换到公平调度模式,优先让等待时间最长的 Goroutine 获取锁,以缓解饥饿问题。
    • 饥饿模式是 Mutex 内部的一种特殊状态,对应用程序而言是透明的,但会影响锁的调度策略。

需要注意的是,上述状态是逻辑上的描述,实际的 sync.Mutex 结构体内部并不直接存储这些状态标识,而是通过特定的标志位和算法来实现这些状态的管理。例如,通过一个整型变量(通常称为 state 或类似名称)的低位来表示锁定状态,高位可能用于存储饥饿状态和其他内部信息。

总结来说,Go 语言 sync.Mutex 主要有三种状态:未锁定、锁定和饥饿。这些状态反映了互斥锁在不同时间段对共享资源的控制情况以及对并发 Goroutine 访问的管理状态。通过正确使用 Lock() 和 Unlock() 方法,可以确保在多线程环境下对共享资源的访问是有序且互斥的,避免数据竞争和同步问题。当遇到锁饥饿问题时,Go 语言的 Mutex 实现会自动切换到公平调度模式,尽可能减少长时间等待的 Goroutine 的等待时间。

2.Mutex的模式

Go 语言中的 sync.Mutex(互斥锁)有两种模式:

  1. 正常模式(Normal Mode)

    在正常模式下,当多个 Goroutine 同时尝试获取 Mutex 时,它们会被安排在一个隐式的等待队列中。尽管 Go 语言文档和源码注释提到 Mutex 尝试遵循“先进先出”(FIFO)原则来决定哪个 Goroutine 最终获得锁,但实际上,这种“FIFO”并非严格意义上的队列顺序,而是近似的。这是因为锁的争用发生在操作系统层面,实际的调度可能受到操作系统的调度策略和硬件影响,新到来的 Goroutine 仍有可能“插队”先获得锁。因此,正常模式下的 Mutex 被认为是非公平锁。

    正常模式适用于大多数短时、轻量级的锁竞争场景,其设计倾向于在多数情况下提供较好的性能,允许新到来的 Goroutine 快速获得锁,从而减少整体的锁等待时间。

  2. 饥饿模式(Starvation Mode)

    当 Mutex 发现存在锁饥饿现象(即某个 Goroutine 长时间未能获得锁)时,会自动切换到饥饿模式。在饥饿模式下,Mutex 会优先让等待时间最长的 Goroutine 获得锁,以缓解饥饿问题。这种模式更接近公平锁,即等待时间最长的 Goroutine 有更高的概率先获得锁,从而减少了长时间等待的 Goroutine 被持续忽略的可能性。

    饥饿模式主要适用于存在长时、重度锁竞争,或者需要避免特定 Goroutine 长期等待锁的场景,有助于避免锁饥饿问题,保证等待时间较长的 Goroutine 能够获得锁,从而改善系统的响应时间和公平性。

    触发条件:当一个 Goroutine 在连续多次尝试获取锁的过程中始终未能成功,并且其等待时间超过一定阈值(目前为大约 1 毫秒)时,Mutex 会判断可能存在锁饥饿,进而切换到饥饿模式。

    退出条件:当一个 Goroutine 在饥饿模式下成功获取并释放锁后,Mutex 通常会回到正常模式。这是因为一旦长期等待的 Goroutine 获得了锁,锁饥饿问题被认为暂时得到了缓解。

总结来说,Go 语言 sync.Mutex 提供了正常模式和饥饿模式两种操作模式。正常模式在一般情况下提供较好的性能,但可能造成锁饥饿;而饥饿模式则在检测到锁饥饿时介入,优先让等待时间最长的 Goroutine 获得锁,以保证公平性和响应时间。这两种模式的切换由 Mutex 内部自动管理,对开发者来说是透明的。在编写并发程序时,应合理设计锁的使用,避免过度竞争和长时间等待,以充分利用 Mutex 的正常模式。当确实需要避免锁饥饿时,Go 语言的 Mutex 实现能够自动调整到饥饿模式,确保系统的稳定性和公平性。

3.Mutex 自旋锁

在 Go 语言中,sync.Mutex(互斥锁)的实现并不直接支持自旋(spin),即在无法获得锁时,goroutine 不会持续地循环检查锁状态,而是会主动进入休眠状态,等待被唤醒。这是与某些其他编程语言(如 C++、Java 等)中的自旋锁有所区别的地方。

Go 语言的 sync.Mutex 在遇到锁竞争时,goroutine 会经历以下过程:

  1. 尝试获取锁:goroutine 首先尝试通过原子操作直接获取锁。如果此时锁未被其他 goroutine 持有,goroutine 可以立即获得锁并继续执行。

  2. 阻塞等待:如果锁已被其他 goroutine 持有,goroutine 不会进行自旋,而是直接调用 Go 运行时的系统调用(如 runtime.futex 等)将自己置于休眠状态,并加入到锁的等待队列中。此时,goroutine 不再消耗 CPU 时间,等待被操作系统的调度器唤醒。

  3. 被唤醒并重新尝试获取锁:当持有锁的 goroutine 释放锁时,会唤醒等待队列中的一个或多个 goroutine。被唤醒的 goroutine 重新尝试获取锁,如果此时锁仍未被其他 goroutine 持有,goroutine 就能成功获得锁并继续执行。

综上所述,Go 语言标准库中的 sync.Mutex 不允许自旋。goroutine 在无法立即获得锁时,会立即进入休眠状态,而不是持续循环检查锁状态。这样做可以避免在锁竞争激烈时,大量 goroutine 无谓地消耗 CPU 资源,有助于提升系统的整体效率和资源利用率。

如果你需要实现类似自旋锁的功能,可以使用 Go 语言标准库中的 sync/atomic 包提供的原子操作,或者使用第三方库提供的自旋锁实现。但请注意,自旋锁在某些特定场景(如持有锁的代码执行时间非常短,且预期锁竞争不严重)下可以提高效率,但滥用自旋锁可能导致 CPU 使用率过高、能源浪费以及系统响应性下降等问题,应谨慎使用。在大多数情况下,Go 语言推荐使用其内置的同步原语(如 sync.Mutexsync.RWMutexsync.WaitGroup 等)来管理并发。

package main

import (
	"runtime"
	"sync/atomic"
	"time"
)

type SpinLock uint32

// Lock 尝试获取锁,如果锁已经被持有,则会自旋等待直到锁释放
func (sl *SpinLock) Lock() {
	for !atomic.CompareAndSwapUint32((*uint32)(sl), 0, 1) {
		runtime.Gosched() // 不要占满整个CPU,让出时间片
	}
}

// Unlock 释放锁
func (sl *SpinLock) Unlock() {
	atomic.StoreUint32((*uint32)(sl), 0)
}

// NewSpinLock 创建一个自旋锁
func NewSpinLock() *SpinLock {
	return new(SpinLock)
}

func main() {
	lock := NewSpinLock()
	lock.Lock()
	// 临界区
	time.Sleep(1 * time.Second) // 模拟临界区操作
	lock.Unlock()
}

4.RWMutex 实现原理

sync.RWMutex(读写互斥锁)是 Go 语言标准库提供的一个并发控制工具,它允许多个读取者同时访问受保护的资源,但同一时刻只允许一个写入者访问。RWMutex 的实现原理基于以下几点:

1. 双重锁机制

RWMutex 内部包含两个锁:一个用于写操作的互斥锁(Mutex),一个用于读操作的计数器(readerCount)。写锁(Mutex)确保同一时刻只有一个写入者,而读锁(readerCount)记录当前持有读锁的 Goroutine 数量。

2. 读锁(RLock)操作

当 Goroutine 调用 RLock() 方法获取读锁时,执行以下步骤:

  • 递增读者计数:使用原子操作(如 atomic.AddInt32)递增 readerCount,表示一个新读取者加入。

  • 检查写锁状态:如果此时没有写入者持有锁(即写锁未被锁定),读取者可以直接返回,开始读取操作。

  • 等待写锁释放:如果有写入者正在持有锁,读取者会被阻塞,加入到等待队列中,直到写锁被释放。

3. 写锁(Lock)操作

当 Goroutine 调用 Lock() 方法获取写锁时,执行以下步骤:

  • 获取写锁:尝试锁定写锁(Mutex),如果此时没有其他写入者或读取者持有锁,写入者可以直接获得锁,开始写入操作。

  • 等待读锁释放:如果有读取者正在持有锁,写入者会被阻塞,等待所有读取者释放其读锁。当最后一个读取者释放读锁时,readerCount 会降为 0,写入者可以继续执行。

  • 阻塞其他读写者:一旦写入者获得锁,所有后续的读取者和写入者都会被阻塞,直到写入者完成写入操作并释放锁。

4. 读锁(RUnlock)和写锁(Unlock)操作

  • 释放读锁:调用 RUnlock() 方法时,使用原子操作递减 readerCount,表示一个读取者完成读取并释放锁。

  • 释放写锁:调用 Unlock() 方法时,直接释放写锁(Mutex),同时唤醒等待队列中的下一个读取者或写入者。

5. 饥饿保护

如同 sync.MutexRWMutex 实现也包含了饥饿模式的逻辑。当检测到存在长时间等待的写入者时,会切换到饥饿模式,优先让等待时间最长的写入者获得锁,防止读取者过度霸占锁而导致写入者饥饿。

总结

sync.RWMutex 的实现原理主要基于双重锁机制,通过一个互斥锁(Mutex)控制写操作的独占性,一个计数器(readerCount)管理读取者的并发访问。读取者可以共享读锁,写入者则独占写锁,同时写入者会阻塞所有读取者。读写锁的获取和释放操作均通过原子操作保证线程安全,同时包含饥饿保护机制,确保在高并发场景下既能高效利用资源,又能避免特定 Goroutine 长时间等待锁。

5.RWMutex 注意事项

使用 Go 语言中的 sync.RWMutex(读写互斥锁)时,应注意以下事项以确保正确、高效地使用读写锁,并避免潜在的并发问题:

  1. 读写分离

    • 读操作:多个 Goroutine 可以同时进行读操作。当一个 Goroutine 持有读锁时,其他 Goroutine 仍可以获取读锁进行读取。读锁不会阻塞其他读锁的获取。
    • 写操作:任何时候只能有一个 Goroutine 持有写锁。当一个 Goroutine 持有写锁时,其他 Goroutine(包括读操作)都无法获取任何锁,直到写锁被释放。
  2. 避免死锁

    • 避免循环依赖:确保 Goroutines 在获取锁时不会形成循环等待。例如,一个 Goroutine 持有读锁时不应尝试获取写锁,否则可能造成死锁。
    • 合理顺序:如果多个锁需要按特定顺序获取,应遵守该顺序。例如,先获取写锁再获取读锁,或者先获取读锁再获取写锁,但不应在持有写锁时尝试获取读锁,反之亦然。
  3. 避免读写锁升级

    • 避免升级读锁为写锁sync.RWMutex 不支持锁升级,即一个 Goroutine 在持有读锁时不能直接升级为写锁。如果需要进行写操作,应先释放读锁,然后再获取写锁。
  4. 适时释放锁

    • 在完成读写操作后立即释放锁:确保在读取或修改共享数据后及时调用 RUnlock() 或 Unlock() 方法释放锁,避免长时间持有锁导致其他 Goroutines 阻塞。
    • 在 defer 语句中释放锁:为了确保锁总能被正确释放,推荐在获取锁的同一作用域内使用 defer 语句安排锁的释放。
  5. 避免过度使用写锁

    • 合理评估操作性质:如果一个操作只是读取数据,不需要修改,应使用读锁而非写锁。过度使用写锁会阻塞其他读操作,降低系统的并发性能。
    • 批量操作:如果一系列操作可以视为一个原子的读或写操作,应尽量一次性获取相应类型的锁,避免在操作过程中反复切换锁类型。
  6. 监控锁竞争

    • 检测锁等待时间:通过性能分析工具(如 pprof)监控锁的等待时间,如果发现等待时间过长,可能存在锁竞争问题,需要优化锁的使用或数据结构设计。
  7. 并发安全的初始化

    • 在使用前初始化:确保 sync.RWMutex 变量在任何 Goroutine 访问之前已经被正确初始化。未初始化的锁可能导致数据竞争和程序崩溃。
  8. 并发安全的结构体嵌入

    • 避免嵌入锁的结构体被复制:如果一个结构体中嵌入了 sync.RWMutex,并且该结构体被复制(如通过赋值或作为函数参数传递),复制后的结构体会带有独立的锁,可能导致数据不一致。应确保通过指针传递和操作包含锁的结构体。

总结来说,使用 sync.RWMutex 时应明确区分读写操作,避免死锁和锁升级,及时释放锁,合理使用读锁以提高并发性,并密切关注锁的竞争情况以进行性能调优。同时,要注意锁的初始化、结构体嵌入的并发安全问题。遵循这些注意事项,可以确保在多 Goroutine 环境下正确、高效地使用读写锁来保护共享资源。

6.sync.Cond 是什么,有什么作用,使用示例

是什么

sync.Cond 是 Go 语言中用于实现基于条件的同步的一种工具,它与互斥锁配合使用,使得 Goroutines 能够在满足特定条件时进行协作。通过 Wait()Signal() 和 Broadcast() 方法,Goroutines 可以在条件不满足时挂起等待,条件满足时被唤醒继续执行,有效地避免了忙等待,提高了并发环境下的资源利用率和程序性能。在实际编程中,条件变量常用于解决生产者-消费者、任务队列、事件通知等场景的同步问题。

作用

sync.Cond 的主要作用如下:

  1. 条件等待:当某个共享状态的条件不满足时,Goroutine 可以调用 Cond.Wait() 方法挂起自身,进入等待状态,直到其他 Goroutine 修改了共享状态,使得条件变为满足。

  2. 条件通知:当某个 Goroutine 修改了共享状态,使得某个等待条件变为满足时,可以调用 Cond.Signal() 或 Cond.Broadcast() 方法,通知一个或所有等待该条件的 Goroutine,使它们从等待状态恢复并继续执行。

示例

下面是一个使用 sync.Cond 的简单示例,模拟了一个生产者-消费者模型,其中消费者等待队列非空时才消费数据:

package main

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

var done = false

// 读函数
func read(name string, c *sync.Cond) {
	//关联锁
	c.L.Lock()
	//先让读函数等待通知
	for !done {
		fmt.Printf("【%s】 来了,等在门外\n", name)
		c.Wait()
	}
	fmt.Printf("【%s】 入账 ====》\n", name)
	c.L.Unlock()
}

// 写函数
func write(name string, c *sync.Cond) {
	c.L.Lock()
	fmt.Printf("【%s】入帐,作战计划制定中~~~~~ \n", name)
	time.Sleep(2 * time.Second)
	done = true
	c.L.Unlock()
	time.Sleep(2 * time.Second)
	fmt.Printf("======== 作战计划制定完毕 ========\n【%s】 大家进来开会吧! \n", name)
	c.Broadcast()
}

func main() {
	cond := sync.NewCond(&sync.Mutex{})
	fmt.Println("========= 玄德公 升帐了 =============")
	for _, name := range []string{"关羽", "张飞", "赵云"} {
		go read(name, cond)
	}
	time.Sleep(time.Second * 1)
	go write("孔明", cond)

	time.Sleep(time.Second * 10)
}

7.Broadcast 和 Signal 区别

sync.Cond 的 Broadcast() 和 Signal() 方法的主要区别在于唤醒等待者数量的不同:

  • Broadcast() 唤醒所有等待的 Goroutines。
  • Signal() 只唤醒一个等待的 Goroutine。

选择使用哪种方法取决于具体的应用场景和同步需求。如果需要通知所有等待者或发生了全局状态变化,使用 Broadcast() 更合适;如果只需要唤醒一个等待者或每次只有一个等待者的需求得到满足,使用 Signal() 更合适。在实际编程中,应根据实际的并发逻辑和资源管理需求,合理选择使用 Broadcast() 或 Signal(),以确保正确的并发行为和高效的资源利用。

8.sync.Cond 中 Wait

sync.Cond 中的 Wait() 方法用于让当前 Goroutine 暂停执行并进入等待状态,直到收到 Signal() 或 Broadcast() 的通知。以下是关于 Wait() 方法的详细说明:

基本行为

  • 释放锁:在调用 Wait() 之前,必须已经获得了与 Cond 相关联的底层互斥锁(由创建 Cond 时传入的 Locker 提供)。调用 Wait() 时,会自动释放底层互斥锁,使得其他 Goroutine 有机会获得锁并修改共享状态。

  • 进入等待状态:释放锁后,当前 Goroutine 会进入等待状态,挂起执行,直到收到 Signal() 或 Broadcast() 的通知。在此期间,当前 Goroutine 不会消耗 CPU 资源,而是等待被其他 Goroutine 唤醒。

  • 重新获取锁:当收到 Signal() 或 Broadcast() 的通知后,当前 Goroutine 会重新尝试获取底层互斥锁。一旦获取到锁,Wait() 调用会立即返回,使得当前 Goroutine 继续执行后续逻辑。

9.WaitGroup 用法

sync.WaitGroup 用于等待一组 Goroutine 完成其任务。使用时,首先初始化 WaitGroup,在启动每个 Goroutine 前调用 Add() 增加计数,Goroutine 结束时调用 Done() 通知完成,最后在需要等待所有 Goroutine 完成的地方调用 Wait()。使用过程中应注意避免计数器溢出、重复调用 Done()、确保 Done() 被调用,以及避免在 Wait() 后面的代码中修改共享状态。遵循这些规则,可以有效地使用 sync.WaitGroup 实现 Goroutine 的同步等待。

基本用法

初始化

创建一个 sync.WaitGroup 变量。

var wg sync.WaitGroup

增加计数wg.Add()

在启动 Goroutine 之前,调用 Add() 方法增加计数,参数表示要启动的 Goroutine 数量。

wg.Add(3) // 假设要启动3个Goroutine

启动 Goroutine,调用 Done()

启动 Goroutine,执行所需任务。在 Goroutine 结束时,调用 Done() 方法通知 WaitGroup 任务已完成。

for i := 0; i < 3; i++ {
    go func(i int) {
        defer wg.Done() // 任务完成后通知WaitGroup
        // 执行任务代码
    }(i)
}

等待所有 Goroutine 完成,调用 Wait() 方法阻塞

在主线程或其他需要等待所有 Goroutine 完成的地方,调用 Wait() 方法阻塞,直到所有 Goroutine 调用 Done() 方法,计数器归零。

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

后续操作Wait() 返回后,所有 Goroutine 已经完成任务,可以进行后续操作。

10.WaitGroup 底层原理

结构体

// WaitGroup 等待一组 Goroutine 完成。
// 主 Goroutine 调用 Add 方法设置要等待的 Goroutine 数量,
// 然后每个 Goroutine 运行并在完成后调用 Done 方法。
// 同时,可以使用 Wait 方法阻塞,直到所有 Goroutine 完成。
//
// WaitGroup 在第一次使用后不能被复制。
//
// 根据 Go 内存模型的术语,Done 调用“同步于”任何它解除阻塞的 Wait 调用的返回。
type WaitGroup struct {
	noCopy noCopy
	state atomic.Uint64 // 高 32 位是计数器, 低 32 位是等待者数量(后文解释)。
	sema  uint32
}

noCopy

首先是 noCopy,这个东西是为了告诉编译器,WaitGroup 结构体对象不可复制,即 wg2 := wg 是非法的。之所以禁止复制,是为了防止可能发生的死锁。但实际上如果我们对 WaitGroup 对象进行复制后,至少在 1.20 版本下,Go 的编译器只是发出警告,没有阻止编译过程,我们依然可以编译成功。 

state atomic.Uint64

state 是 WaitGroup 的核心,它是一个无符号的 64 位整型,并且用的是 atomic 包中的 Uint64,所以 state 本身是线程安全的。至于 atomic.Uint64 为什么能保证线程安全,因为它使用了 CompareAndSwap(CAS) 操作,而这个操作依赖于 CPU 提供的原子性指令,是 CPU 级的原子操作。

state 的高 32 位是计数器(counter),低 32 位是等待者数量(waiters)。其中计数器其实就是 Add(int) 数量的总和,譬如 Add(1) 后再 Add(2),那么这个计数器就是 1 + 2 = 3;而等待数量就是现在有多少 goroutine 在执行 Wait() 等待 WaitGroup 被释放。

 sema uint32

信号量

原理

  1. WaitGroup 主要维护了 2 个计数器,一个是请求计数器 v,一个是等待计数器 w,二者组成一个 64bit 的值,请求计数器占高 32bit,等待计数器占低32bit。
  2. 每次 Add 执行,请求计数器 v 加 1, Done 方法执行, 等待计数器减 1, v 为 0 时通过信号量唤醒 Wait()。

参考:Golang WaitGroup 底层原理及源码解析_Golang_脚本之家 

11.什么是 sync.Once

作用

sync.Once 是一种用于确保一段代码在整个程序生命周期中仅被执行一次的同步工具。通过封装待执行的代码为一个回调函数,并通过 Once.Do(callback) 调用,可以确保即使在并发环境下,该回调函数也能安全、高效地仅被执行一次。sync.Once 常用于实现单例模式、延迟初始化、一次性设置全局状态等场景

数据结构

type Once struct {
    done uint32
    m    Mutex
}

 工作原理总结

sync.Once 结构体包含一个无符号整型变量 done 和一个互斥锁 mdone 作为布尔标记,用于记录动作是否已执行。当值为 0 时表示动作未执行,非 0 值表示已执行。m 互斥锁用于保护 done 标记的访问和更新,确保在并发环境下对 done 的操作是线程安全的。

  1. 使用原子操作 atomic.LoadUint32 无锁地检查 done 标记,快速判断动作是否已执行。
  2. 若动作未执行,尝试获取互斥锁 m,确保后续操作的线程安全。
  3. 在持有锁的情况下进行双重检查,确认动作是否仍然未执行。如果未执行,则执行动作并更新 done 标记为非 0。
  4. 释放互斥锁,允许其他 Goroutine 重新尝试执行动作。

12.什么是原子操作

原子操作(Atomic Operation)是指在计算机科学中,一个操作在执行过程中不会被其他操作(包括来自其他线程、进程或处理器核心的干扰)中断,能够以不可分割的单位从开始到结束完整地执行完毕。换句话说,原子操作在并发环境中具有如下特征:

  1. 不可分割性(Atomicity):原子操作被视为一个单一的、不可分割的操作单元,它要么全部完成,要么完全不执行。在执行过程中不会被其他并发活动“切割”或干扰,始终保持操作的完整性。

  2. 并发安全性(Thread Safety):由于原子操作不会被中断,因此它在多线程或多进程环境下是安全的。即使多个线程同时访问同一数据,也不会出现数据竞争(Data Race)或竞态条件(Race Condition),确保了数据的一致性和正确性。

  3. 原子性保障(Hardware Support):原子操作通常由硬件(如处理器指令集)或操作系统提供的低级别机制来保证。这些机制确保在多核处理器系统中,即使多个核心同时尝试对同一数据进行操作,原子操作也能正确无误地执行。

原子操作在并发编程中尤为重要,尤其是在多线程环境(如使用 Go 语言的 Goroutines)中,用于同步对共享数据的访问。常见的原子操作包括:

  • 读取-修改-写入(Read-Modify-Write, RMW)操作:如原子计数(如 atomic.AddInt32)、原子比较并交换(Compare-and-Swap, CAS)、原子交换(Swap)等。

  • 简单的读取和写入:对某些特定类型(如整数)的简单读取和写入操作,也可以是原子的,这依赖于硬件和编程语言的支持。

原子操作常用于构建无锁数据结构、实现无锁算法、管理并发资源的访问控制等,能够有效避免传统锁机制带来的开销和复杂性,提高并发程序的性能和可伸缩性。在 Go 语言中,标准库 sync/atomic 包提供了对各种原子操作的支持。使用原子操作时,需要谨慎考虑操作的语义和并发场景,以确保程序的正确性和性能。

13.什么是CAS

在 Go 语言中,CAS(Compare-and-Swap,比较并交换)是一种原子操作,用于在并发环境中安全地更新共享变量的值。CAS 操作包含三个主要部分:

  1. 比较(Compare):首先检查内存中某个位置(通常是变量地址)的实际值是否等于期望值。

  2. 交换(Swap):如果实际值等于期望值,则将该位置的新值替换为给定的新值。这个替换操作是原子的,即在替换过程中不会被其他并发操作中断。

  3. 返回结果:CAS 操作返回一个布尔值,表示比较和交换是否成功。如果实际值等于期望值且成功进行了交换,返回 true;否则(实际值不等于期望值或交换失败),返回 false

在 Go 语言的标准库 sync/atomic 包中,提供了多种类型的 CAS 操作函数,如 CompareAndSwapInt32CompareAndSwapInt64CompareAndSwapPointer 等,分别对应不同的数据类型。这些函数通常接受三个参数:一个指向目标变量的指针、一个期望值(旧值)和一个新值。

以下是一个使用 atomic.CompareAndSwapInt32 的简单示例:

package main

import (
	"fmt"
	"sync/atomic"
)

var counter int32 = 0

func increment() bool {
	oldValue := atomic.LoadInt32(&counter) // 获取当前值
	newValue := oldValue + 1
	return atomic.CompareAndSwapInt32(&counter, oldValue, newValue) // 比较并尝试更新
}

func main() {
	if increment() {
		fmt.Println("Counter incremented successfully.")
	} else {
		fmt.Println("Counter failed to increment.")
	}
}

14.原子操作和锁的区别

原子操作和锁都是并发编程中用于确保数据一致性和线程安全的手段,但它们在实现原理、使用方式和适用场景等方面存在一些区别:

原子操作和锁的主要区别在于:

  1. 实现原理:原子操作依赖于硬件级别的支持,无需上下文切换;锁则通常基于操作系统提供的同步原语实现,可能涉及上下文切换。
  2. 轻重程度:原子操作更轻量级,无阻塞;锁相对较重,可能阻塞线程。
  3. 适用范围:原子操作适用于对简单数据类型的原子更新,无锁;锁适用于更广泛的同步场景,包括复杂的同步逻辑和数据结构。
  4. 风险与复杂性:原子操作不易引发死锁,但可能需要处理ABA问题和循环重试;锁需要妥善处理死锁风险,但提供了更丰富的同步语义和控制。

在实际编程中,应根据具体需求选择使用原子操作还是锁。对于简单的整数计数、标志位更新等场景,原子操作通常更为高效;而对于复杂的共享数据结构、需要协调多个操作的同步逻辑,使用锁往往更为合适。有时,二者也会结合使用,原子操作作为底层构建块,用于实现更高级的同步原语(如锁)。

15.sync.Pool 有什么用

sync.Pool 是 Go 语言标准库 sync 包提供的一个对象池(Object Pool)类型,用于重用临时对象,以减少内存分配和垃圾回收(GC)的压力。以下是 sync.Pool 的主要用途和优势:

  1. 减少内存分配:创建新对象通常需要分配内存,这是一个相对昂贵的操作,尤其是在高并发场景下频繁创建小对象时。sync.Pool 提供了一个缓存,可以将不再使用的对象暂存起来,当需要创建新对象时,优先从池中获取已存在的对象,避免了不必要的内存分配。

  2. 降低 GC 负荷:Go 语言的垃圾回收器(GC)会对不再使用的对象进行回收。频繁创建和销毁小对象会增加 GC 的工作负担,可能导致程序暂停时间变长,影响性能。sync.Pool 通过复用对象,减少了需要被 GC 回收的对象数量,从而降低了 GC 的压力,提升了程序的整体性能。

  3. 缓存热点数据:对于经常被创建和销毁的热点数据(如解析结果、计算中间结果等),使用 sync.Pool 可以避免每次都需要重新计算或解析,进一步提升程序效率。

  4. 控制资源使用:对于某些稀缺资源(如数据库连接、网络连接、文件句柄等),通过 sync.Pool 可以有效地管理和复用这些资源,避免资源耗尽或过度分配。

  5. 避免频繁初始化:对于构造成本较高的对象,如大型缓冲区、复杂的结构体等,通过 sync.Pool 可以避免每次使用时都进行完整的初始化,节省了 CPU 资源。

package main

import (
	"bytes"
	"sync"
)

var bufferPool = sync.Pool{
	New: func() interface{} {
		return new(bytes.Buffer)
	},
}

func getBuffer() *bytes.Buffer {
	buf := bufferPool.Get().(*bytes.Buffer)
	buf.Reset() // 清空缓冲区以便重用
	return buf
}

func releaseBuffer(buf *bytes.Buffer) {
	bufferPool.Put(buf)
}

func processData(data []byte) {
	buf := getBuffer()
	defer releaseBuffer(buf)

	buf.Write(data)
	// 使用 buf 进行处理...
}

func main() {
	processData([]byte("Hello, World!"))
}

在这个示例中,创建了一个 sync.Pool 用于缓存 bytes.Buffer 对象。每当需要一个缓冲区时,调用 getBuffer() 从池中获取一个已存在的缓冲区(如果池中有可用的),并清空它以备重用。处理完数据后,通过 releaseBuffer() 将缓冲区放回池中,供后续使用。这样,程序在处理数据时就不必每次都创建新的 bytes.Buffer,从而节省了内存分配和 GC 的开销。

注意事项

虽然 sync.Pool 有诸多优点,但也需注意以下几点:

  • 对象生命周期不确定:放入 sync.Pool 的对象可能在任何时候被垃圾回收器清理掉。因此,不应在对象中存储重要数据或长时间保持对这些对象的引用。

  • 适合临时对象sync.Pool 主要用于重用短期存活的临时对象。对于长期存在的对象或具有复杂生命周期的对象,直接使用常规的内存分配和管理方式可能更为合适。

  • 性能优化辅助工具sync.Pool 是一种性能优化手段,不是必需的。在使用前应进行性能测试,确保它确实能带来性能提升。

综上所述,sync.Pool 是一种用于重用临时对象、减少内存分配和垃圾回收压力的有效工具,适用于优化高并发场景下小对象的创建和销毁、缓存热点数据、控制资源使用和避免频繁初始化等场景。使用时需注意对象的生命周期和适用范围,将其作为一种辅助性能优化手段,而非解决所有问题的万能钥匙。

  • 45
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值