Go 并发编程面试题

2023-12-13 12:00:41 浏览数 (1)

1. Mutex 几种状态

在 Go 语言的同步库中,sync.Mutex是用来提供互斥锁的基本同步原语。Mutex用于保护共享资源,在多个 goroutine 尝试同时访问相同资源时确保只有一个 goroutine 能够访问该资源,从而避免竞态条件。

在 Go 的互斥锁(Mutex)实现中,我们可以考虑几种“状态”或“场景”来描述 Mutex 的行为,但需要注意的是,这些状态不是通过 Mutex 结构体上的明确字段暴露的。互斥锁在内部状态实现可能因 Go 语言的不同版本而有所不同。截至目前知识截断日期前的实现,以下是一些可以用于描述互斥锁的状态或者行为:

  1. 未锁定(Unlocked) :这是默认状态,表示没有 goroutine 持有锁,在这种状态下,任何 goroutine 都可以通过调用Lock()方法来获取锁。
  2. 已锁定(Locked) :在一个 goroutine 获取到锁时,锁的状态就变成了 Locked。在这个状态下,其他任何尝试获取这个锁的 goroutine 都将阻塞,直到锁被原持有者通过调用Unlock()方法释放。
  3. 竞争状态(Contended) :当某个已处于 Locked 状态的 Mutex 被其他 goroutine 尝试获取时,这些 goroutine 将被进入等待队列,Mutex 就进入 Contended 状态。在Unlock调用时,处于等待队列中的某个(或某些)goroutine 将会被唤醒并尝试获取锁。
  4. 饥饿状态(Starvation) :当持续有新的 goroutine 尝试获取互斥锁时,长时间等待的 goroutine 可能会面临饥饿问题,即它们可能需要过长的时间才能成功获取到锁。Go 的 Mutex 设计尝试确保公平性,避免长时间等待的 goroutine 处于饥饿状态。
  5. 唤醒状态(Woken) :当一个 goroutine 调用Unlock()释放锁时,系统会从等待队列中唤醒一个或多个 goroutine。被唤醒的 goroutine 进入 Woken 状态,尝试获取锁。

除了sync.Mutex,Go 标准库还提供了其他同步原语,例如读写互斥锁(RWMutex),允许多个读操作同时进行,但写操作会互斥,即每次只有一个写操作或多个读操作可以持有锁。

值得注意的是,Go 的互斥锁实现可能因版本更新而发送变化,用于优化性能或实现细节。因此建议查看当前使用版本的源代码或官方文档来获取最准确的实现信息。

2. Mutex 正常模式和饥饿模式

在 Go 语言中,sync.Mutex的实现包含两种模式:正常模式和饥饿模式。这两种模式的目的是平衡低延迟和公平性。

  1. 正常模式:
  • 在正常模式下,当 Mutex被解锁时(调用Unlock),它将检查是否有等待的 goroutine。如果有,它会唤醒其中一个(通常是等待时间最长的一个)
  • 唤醒的 goroutine 不会立即获得锁。相反,它会和新到达的 goroutine 一起竞争锁。如果这个唤醒的 goroutine 没有在一定时间内(通常是 1ms)获得锁,所有新来的 goroutine 将会暂停竞争锁,直到唤醒的 goroutine 获取锁后竞争才会再次开启。这是一种优化,目的是减少锁的传递延迟。
  1. 饥饿模式:
  • 饥饿模式主要是为了解决在高竞争的情况下长时间等待的 goroutine 可能遇到的饥饿问题。
  • 当一个 goroutine 等待锁超过了一定的时间阈值(如 1ms),Mutex 会切换到饥饿模式。在饥饿模式下,锁的所有权将直接从解锁的 goroutine 交给等待队列中的下一个(即等待时间最长的那个)。
  • 在饥饿模式下,新来的 goroutine 不会尝试去抢锁,即使锁刚好是 unlock 状态,它们会加入等待队列的尾部。
  • 当一个处于饥饿模式的锁被持有的 goroutine 解释,并且下一个等待的 groutine:
    • 可以立即获取到锁;
    • 并且没有其他等待获得锁的 goroutine;
    • 或者这个 goroutine 是在锁变成饥饿模式前就已经在等待的;

则会将锁切换回正常模式。

这两种模式的设计提供了一个平衡点,既可以保证高竞争时的公平性(通过饥饿模式),又可以在竞争不是特别激烈时提供更优的性能(通过正常模式,因为有更少的线程上下文切换和锁的唤醒/等待)。

Go 语言团队不断优化sync.Mutex,以找到公平和效率之间的平衡点。有关 sync.Mutex 的更多权威信息,建议查看最新的 Go 语言源码和文档。

3. Mutex 允许自旋的条件

总结点

  • 锁已被占用,并且锁不处于饥饿模式
  • 积累的自旋次数小于最大自旋次数(active_spin=4)
  • CPU 核数大于 1
  • 有空闲的 P
  • 当前 goroutine 锁挂在的 P 下,本地待运行队列为空

详细了解

sync.Mutex在 Go 语言中采用了自旋的机制来改善锁的性能。当一个 goroutine 尝试获取一个已经被持有的锁时,它可能会进行“自旋”,即在一个循环中重复检查锁的状态,希望锁会很快被释放。自旋可以避免 goroutine 陷入睡眠状态,从而减少因为频繁的上下文切换带来的开销。然而,长时间的自旋会浪费 CPU 时间,尤其是在锁持有时间较长时。因此,自旋必须谨慎使用。

以下是在 Go 中sync.Mutex实现中可能会触发自旋的一些条件:

  1. 当前 CPU 的使用率:如果 CPU 使用率较低,系统可能认为有足够的资源来自旋,因为这样可能会更快地获得锁,并且不会过度影响其他进程。
  2. 锁被持有的时间:如果预计锁被持有的时间很短(比如锁的持有者只是执行了一个简单的操作),则自旋可能是合理的,因为等待时间可能比执行上下文切换所需要的时间还要短。
  3. 等待队列的长度:如果等待获得锁的 goroutines 数量较少,或没有 goroutine 在等待,自旋可能会被允许。在较长的等待队列情况下进行自旋会增加获得锁的等待时间,影响公平性。
  4. 饥饿模式:如果 Mutex 未处于饥饿模式,那么自旋是被考虑的,因为在饥饿模式下,所有获得锁的权力都优先交给等待队列中的 goroutines,自旋在这种模式下就不会发生。
  5. 是不是运行在多处理器上:在多处理器系统上运行时,自旋的机会更大,因为即使一个核心忙于自旋,其他核心仍然可以执行其他任务。
  6. 是否已经在自旋了:如果一个 goroutine 已经自旋并且未能获取锁,它可能会选择停止自旋,并让出其时间片,进入睡眠状态等待被唤醒。

需要注意的是,在实现细节方面,Go 的标准库不提供关于互斥锁内部行为的具体参数。自旋的逻辑和上述条件可能在 Go 不同的版本之间有所变化,以上内容主要基于 Go 语言当前和之前版本的实现。因此,如果需要最新和最准确的信息,查看当前版本源代码或相关官方文档是非常必要的。

4. RWMutex 实现

sync.RWMutex是 Go 语言中的一个同步原语,用于控制对某项资源的读写访问。它优化了读多写少的场景,允许多个 goroutine 同时读取资源,但写入时需要排他性访问。RWMutex具有以下特性:

  • 当没有写入者时,允许多个 goroutine 持有读锁(共享锁)
  • 写锁(排他锁)会阻止其他写锁和读锁的获取
  • 读取可以很快地连续进行,因为它们不需要改变锁的状态。但是,获取写锁需要等待所有的读锁和写锁释放。

RWMutex的基本方法包括:

  • Lock- 获取写锁,阻塞直到没有其他读锁或写锁。
  • Unlock- 释放写锁。
  • RLock- 获取读锁,可以与其他读锁并存,但会被写锁阻塞。
  • RUnlock- 释放读锁。

RWMutex的实现可以通过一个计数器和两个sync.Mutex来理解,其中一个用于读锁计数器,另一个用于写锁。在RWMutex内部,会有以下字段(注意,这是概念性描述,实际实现可能有所不同):

  • 一个读计数器,表示当前持有读锁的 goroutine 数量。
  • 一个写标志,表示是否有 goroutine 持有写锁。
  • 两个队列,分别管理等待读锁和写锁的 goroutine。
  • 一个互斥锁,用于修改以上字段的访问保护。

写操作的实现逻辑:

  1. 当 goroutine 请求写锁时,它首先增加等待写锁的数量。
  2. 如果存在活跃的读锁或一个活跃的写锁,它将等待。
  3. 一旦它时队列中的写一个写锁,并且没有活跃的读锁或写锁,它将获得写锁。
  4. 获得写锁后,减少等待写锁的数量。

读操作的逻辑:

  1. 当 goroutine 请求读锁时,如果有活跃的写锁或等待的写锁,则它将等待。
  2. 否则,增加读计数器。

释放锁:

  1. 当写锁被释放时,将检查是否有等待的读锁或写锁,然后适当地唤醒 goroutines。
  2. 读锁释放时,只是简单地减少计算器。如果这是最后一个读锁,并且有写锁等待,会唤醒写锁。

这种设计使得 RWMutex 在读取操作频繁而写入操作较少的场合中表现优异。然而,写锁的请求者可能会遇到饥饿问题,尤其是在高读负载的情况下。为了避免这种情况,Go 的实现中增加了一些附加逻辑来提供更多的公平性和减少饥饿的可能性。

5. RWMutex 注意事项

使用sync.RWMutex时,有几点需要特别注意,以确保高效、正确且死锁无忧的并发控制:

  1. 锁的升级和降级:Go 的sync.RWMutex并不支持直接从读锁升级到写锁,因为这可能导致死锁。如果两个 goroutine 都持有读锁并且都尝试升级到写锁,它们都会永远等待对方释放读锁。锁的降级从写锁到读锁是可能的,但这需要先释放写锁然后立即获取读锁,在这个过程中资源是无锁保护的,所以需要谨慎操作。
  2. 写锁饥饿RWMutex优先读操作,频繁的读锁请求可能会导致写锁请求等待到难以接受的程度。为了防止写锁饥饿,你需要确保写操作也有机会执行。
  3. 递归锁定:Go 的sync.RWMutex不是递归锁。不可以对同一个RWMutex重复加锁,即使是在同一个 goroutine 中。尝试这样做将导致死锁。
  4. 读锁释放顺序:与写锁不同,在释放读锁的时候没有严格要求 goroutine 以获取读锁的相反顺序来释放它们,任何持有读锁的 goroutine 都可以随时释放它。
  5. 避免长时间持有写锁:由于写锁阻塞所有其他读锁和写锁请求,长时间持有会影响系统性能。应该尽快完成写操作并释放锁。
  6. 公平性:Go 1.6 之后,RWMutex对写锁提供了一定程度的公平性。一旦有 goroutine 在等待写锁,后续的读锁请求会等待,直到写锁被服务,以避免饥饿。
  7. 复制 Mutex:在锁被使用的情况下复制sync.RWMutex可能导致不可预知的行为。在实例化后应使用指针来传递RWMutex
  8. 可重入性sync.RWMutex不是可重入的。在已被当前 goroutine 锁定的情况下,试图再次获取相同的锁将导致死锁。
  9. 锁与内存同步RWMutex不仅用于相互排斥,也在 goroutine 之间同步内存访问。当你释放一个锁时,你是在表达:“我已完成在共享资源上的修改,它们可以被其他 goroutine 安全读取了。”当你获得一个锁时,你是在表达:“我想查看最新的,安全的共享资源。”

遵守这些注意事项可以帮助开发者更安全地使用sync.RWMutex,避免常见的并发陷阱和同步问题。

6. Cond 是什么

在 Go 语言中,sync.Cond是一个条件变量,它可以让一系列的 goroutine 在满足特定条件下被唤醒。条件变量总是和一个锁(通常是sync.Mutexsync.RWMutex)结合使用,用来协调那些需要等待某个条件的 goroutine。

条件变量的主要方法有三个:

  1. Wait方法会阻塞当前的 goroutine,直到它被另外的 goroutine 在同一个 Cond 上调用SignalBroadcast唤醒。在调用Wait之前,goroutine 应该锁定与 Cond 关联的锁。Wait自动释放锁,并暂停执行 goroutine。在 SignalBroadcast唤醒等待的 goroutine 后,Wait 会在返回之前重新获得锁。
  2. Signal方法唤醒等待同一条件的 Cond 的变量上的一个 goroutine。如果没有 goroutine 在等待,它不会有任何效果。通常,goroutine 会在改变状态并影响条件之后调用Signal
  3. Broadcast方法唤醒所有在此 Cond 上等待的 goroutines。只要条件有变更,它就会调用 Broadcast。

使用sync.Cond最典型的例子是,你有一个处理流程需要其他操作完成才能进行,那么这些等地的 goroutine 就会等待一个或多个条件成立。sync.Cond可以用来实现其他同步机制,如栅栏(barrier)。

下面是sync.Cond的一个简单用法示例:

代码语言:javascript复制
type Queue strct {
    cond *sync.Cond
    buffer []interface{}
}

func NewQueue(sz int) *Queue {
    return &Queue{
        cond:   sync.NewCond(&sync.Mutex{}),
        buffer: make([]interface{}, 0, sz)
    }
}

func (q *Queue) Enqueue(item interface{}) {
    q.cond.L.Lock()
    q.buffer = append(q.buffer, item)
    q.cond.L.Unlock()
    q.cond.Signal()
}

func (q *Queue) Dequeue() interface{} {
    q.cond.L.Lock()
    for len(q.buffer) == 0 {
        q.cond.Wait()
    }
    item := q.buffer[0]
    q.buffer = q.buffer[1:]
    q.cond.L.Unlock()
    return item
}

在这个例子中,Enqueue操作会向队列添加一个元素并发送信号,而Dequeue操作在队列为空时会阻塞,直到有元素被添加进来。这是通过sync.Cond实现的。

总结来说,sync.Cond是一个协调等待特定条件并允许 goroutine 之间同步的原语,使得 goroutines 能够在资源变得可用时或某个条件发生改变时得到通知,并重新开始执行。

7. Broadcast 和 Signal 区别

在 Go 语言的 sync包中,Cond提供了两种方式来唤醒等待(阻塞)在条件变量上的 goroutines:SignalBroadcast。这两个方法的关键区别在于它们唤醒等待的 goroutines 的数量:

  1. Signal会唤醒在调用Wait方法等待的 goroutines 中的一个。如果多个 goroutines 在同一个Cond上等待,则只有一个(通常是等待最久的那个)会被Signal唤醒。如果没有 goroutines 在等待,则调用Signal不会有任何效果。
  2. Broadcast会唤醒在调用Wait方法等待的所有 goroutines。如果没有 goroutines 在等待,调用Broadcast同样不会有任何效果。

使用SignalBroadcast确保在状态发生变化时通知等待的 goroutines,goroutines 被唤醒后通常将再次检查条件是否满足,因为:

  • 在它们等待的时候条件可能已经改变。
  • Signal的情况下,可能有多个 goroutines 在等待,但只有一个会被唤醒,所以其他的必须继续等待。
  • Broadcast的情况下,所有的 goroutine 都会被唤醒,但是它们可能需要通过重新获取锁来串行化访问共享资源,这时可能发现条件已不满足,所以需要重新等待。

通常,选择使用Signal还是Broadcast取决于程序的需求。如果状态变化只对一个等待的 goroutine 有意义,那么使用Signal更有效率,但如果每次状态变化都可能对多个等待的 goroutine 有意义,或者你不确定有多少 goroutine 可能在等待,那么使用Broadcast是更安全的选择。

这种机制在多数情况下用于协调对共享资源的访问,以及协同工作流(比如生产者-消费者模型)这类情况。

8. Cond 中 Wait 使用

在 Go 语言中,sync.CondWait方法被用来挂起当前 goroutine,直到被SignalBroadcast方法唤醒。这常用于等待某个条件或状态的变更。使用Wait需要遵循一定的模式来确保程序的正确性和避免竞态条件。

以下是Wait方法正确使用的步骤:

  1. 创建 Cond:在使用Wait前,需要创建一个sync.Cond实例。sync.Cond需要与一个互斥锁(sync.Mutexsync.RWMutex)关联。
代码语言:javascript复制
var mutex sync.Mutex
cond := sync.NewCond(&mutex)
  1. 获取锁:在调用Wait方法之前,必须获取与Cond关联的锁。这是因为Wait会在开始时自动释放锁,并在结束时重新获取锁。
代码语言:javascript复制
mutex.Lock()
  1. 检查条件循环Wait应该在一个循环中调用,以防止虚假唤醒或条件在等待时变更。
代码语言:javascript复制
for !condition {
    cond.Wait()
}
  1. 释放锁:操作完共享资源并对状态进行了相应的修改之后,你应该释放锁以允许其他 goroutine 访问资源。
代码语言:javascript复制
mutex.Unlock()

下面是一个使用sync.Cond的例子:

代码语言:javascript复制
package main

import (
    "sync"
    "time"
)

var cond = sync.NewCond(&sync.Mutex{})
var ready bool

// 生产者
func produce() {
    time.Sleep(time.Second) // 模拟生产过程
    cond.L.Lock()    // 获取锁
    ready = true   // 更新条件变量
    cond.Signal()   // 发信号给等待的消费者
    cond.L.Unlock()   // 释放锁
}

// 消费者
func consume() {
    cond.L.Lock()   // 获取锁
    for !ready {   // 循环检查条件
        cond.Wait()   // 挂起,等待信号
    }
    // 处理ready条件
    cond.L.Unlock()   // 释放锁
}

func main() {
    go consume() // 启动消费者 goroutine
    produce()  // 在主 goroutine 里生产
}

在这个例子中,produce 函数更新了一个称为 ready 的条件,并发出信号以唤醒 consume 函数中等待的 goroutine。

值得注意的是,Wait 会在暂停 goroutine 之前释放锁,并在它返回时得到锁。这意味着其他 goroutine 有机会在调用者暂停期间获取锁,改变条件并发出信号。一旦条件变更,Wait 会返回,此时 goroutine 会再次获取锁。

正确使用 Cond 可以在多个 goroutine 需要等待特定条件变为真时协调它们,这在编写需要多个阶段或多个步骤协调执行的程序时非常有用。

9. WaitGroup 用法

sync.Group是 Go 语言标准库中的一个同步原语,它用于等待一组 goroutine 完成。WaitGroup提供了三个方法:Add(delta int)Done()以及Wait()

以下是如何在代码中正确使用WaitGroup:

  1. 初始化:通常,你会使用零值的WaitGroup,不需要显式初始化。
  2. 添加计数:在启动 goroutine 之前,使用Add方法来设置计数,代表需要等待的 goroutine 数量。这可以一次性完成,或者根据新创建的 goroutine 动态增加。
代码语言:javascript复制
var wg sync.WaitGroup
wg.Add(1) // 每启动一个goroutine前调用一次,数值为需要等地啊的 goroutine数
  1. 在 goroutine 中使用 Done:在每个 goroutine 的工作结束时,调用WaitGroupDone方法。这通常通过defer语句在 goroutine 入口处完成,Done方法将减少WaitGroup的计数。
代码语言:javascript复制
go func() {
    defer wg.Done() // 这将在函数返回前调用,减少 wg 计数
    // 执行一些操作...
}()
  1. 等待完成:使用Wait方法阻塞,直到WaitGroup的计数减到 0,即所有的 goroutine 都调用了 Done方法。
代码语言:javascript复制
wg.Wait() // 等待所有的 goroutine 完成

下面是一个使用WaitGroup的简单例子:

代码语言:javascript复制
package main

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

func main() {
    var wg sync.WaitGroup

    // 启动多个 goroutine
    for i := 0; i < 5; i   {
        wg.Add(1) // 为每个 goroutine 增加计数
        go func(i int){
            defer wg.Done() // 在退出 goroutine 前递减计数
            time.Sleep(2 * time.Second) // 模拟耗时操作
            fmt.Printf("Goroutine %d finishedn", i)
        }(i)
    }

    fmt.Println("Waiting for goroutines...")
    wg.Wait() // 等待所有 goroutine 调用 Done
    fmt.Println("All goroutines completed")
}

当使用 WaitGroup 时,需要确保不会发生计数器的泄漏。如果 Add 的调用数量和 Done 的调用数量不匹配,程序可能会在 Wait 处永远阻塞,或者出现负计数从而导致 panic 错误。

另外值得注意的是,WaitGroup 不应被拷贝,所以通常应当通过指针来传递它。在多个 goroutine 中传递 WaitGroup 时,需要特别小心。通常做法是将 WaitGroup 作为指针在 goroutine 间共享或传递。

10. WaitGroup 实现原理

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

在 Go 语言中,sync.WaitGroup 是一种同步原语,用于等待一组协程(goroutine)完成执行。虽然我不能提供精确的内部细节,因为它们可能在不同版本的 Go 中有所不同,但我可以概括地解释它大致的实现原理。

sync.WaitGroup的实现基于几个关键组件:

  1. 计数器(Counter)WaitGroup维护一个内部计数器,该计算器跟踪还有多少个 goroutine 需要等待完成。Add方法增加计算器的值,而Done方法减少计数器的值。
  2. 信号量(Samaphore) :Go 语言的sync包在内部使用了信号量(或类似的机制)来阻塞和唤醒在WaitGroup上等待的 goroutine。当WaitGroup的计数为 0 时,等待的 goroutine 会被唤醒。
  3. 原子操作(Atomic Operations)WaitGroup使用原子操作来增加或减少计数器。这些操作确保即使多个 goroutine 同时调用AddDone,内部状态也能保持一致。
  4. 互斥锁(Mutex) :部分实现中可能使用互斥锁来防止减少计数器时产生的竞态条件,尤其是在Wait方法内部检查计数器值时。

WaitGroup的主要方法工作机制如下:

  • Add(delta int):这个方法接收一个 int 类型的参数detla,用来设置计数器增加(delta>0)或者减少(delta<0)。通过原子操作来保证计数器的正确性。
  • Done():这个方式是Add(-1)的快捷调用,它减少计数器的值。当计数器的值为 0 时,所有的Wait被调用的 goroutine 将会被唤醒。
  • Wait():此方法会阻塞调用它的 goroutine,直到计数器变为 0。在内部,它可能会使用循环来检查计数器是否为 0,并在不为 0 的情况下使 goroutine 等待。这通常设计对信号量使用等待(Wait)操作。

在实现上,WaitGroup 通常不需要借助操作系统的资源,而是利用了 Go 运行时提供的原语和调度器,在用户空间内部实现等待/唤醒的机制。这使 WaitGroup 非常高效,因为它避免了系统调用带来的开销。

11. 什么是 sync.Once

sync.Once是 Go 语言中的一个同步原语,它保证一个函数在多个 goroutines 中只被执行一次。即使在并发的环境中,sync.Once也能确保指定函数的执行具有幂等性,这意味着无论调用多少次,函数的效果和执行了一次是一样的。

sync.Once含有一个布尔标记和一个互斥锁,其内部提供了一个方法DoDo方法接收一个没有参数和返回值的函数作为参数,并确保这个函数在全局范围内只执行一次,不管它被多少次调用或在多少个 goroutine 中调用。

使用sync.Once的时机通常是需要执行仅一次的初始化代码,特别是在有多个 goroutines 可以并发执行初始化代码的时候。

下面是sync.Once使用的一个基础示例:

代码语言:javascript复制
package main

import (
    "fmt"
    "sync"
)

var once sync.Once

func main() {
    for i:= 0; i < 5; i   {
        go func(i int){
            once.Do(func(){
                fmt.Println("Only once", i)
            })
        }(i)
    }
    // 等待所有goroutines完成并查看影响
    time.Sleep(time.Second)
}

在这个例子中,即使sync.Once被多个 goroutine 调用多次,传递给Do方法的函数只会被执行一次。无论哪个 goroutine 首先到达once.Do调用点,都会执行该函数,并且之后的调用会立即返回,即使它们有不同的函数参数也是如此。

sync.Once的实现确保了线程安全性,使得关联函数的执行在多个 goroutines 中只会发生一次,即使在面临复杂的并发情况也是如此。这是通过内部互斥锁实现的,当任何 goroutine 进入Do方法时,都会检查内部的布尔标记是否已设置;如果没有设置,执行函数,并将标记设置为true。该操作是原子的,以确保即使多个goroutine同时调用Do方法,函数也只会执行一次。

12. 什么操作叫做原子操作

原子操作(Atomic Operation)是计算机科学的一个术语,指的是在多线程程序中不可被中断的一个操作,这个操作要么全部执行完成,要么就是完全不执行,不会出现执行了一半的情况。这种操作无序互斥锁(mutexs)或其他同步原语来控制并发,因为它们保证在单个操作中就完成了所需的条件检查、更新等步骤。

在现代计算机架构中,原子操作通常是由机器指令层次直接支持的,例如 x86 架构的cmpxchg指令。在高级编程语言中,这些操作常常是通过特殊的库或语言构造来实现的,例如 Go 语言中的 sync/atomic包提供的功能。

举例说明,你可以使用原子操作来安全地递增一个共享计数器,而不必担心多个线程可能同时读写这个值:

代码语言:javascript复制
vat counter int32

func increment() {
    atomic.AddInt32(&counter, 1) // 原子地将 counter 的值加 1
}

在这个例子中,AddInt32是原子操作。多个线程可以同时调用increment函数,但是每次增加的操作都是相互独立的,每个操作看起来都像是在单线程环境中按顺序执行的一样。

原子操作在并发编程中非常重要,因为它们允许程序在不适用锁的情况下防止竞态条件。这可以帮助减少死锁的可能性,并可以提高程序在多核处理器上的性能。然而,它们并非适合所有情况,通常只用于关联共享资源的简单、独立的状态。更复杂的同步可能仍然需要使用互斥锁或其他同步机制。

13. 原子操作和锁的区别

原子操作和锁是两种常用的并发控制技术,尽管它们的目的一致——确保在并发环境中数据的一致性和线程安全,但是它们的工作方式有所不同:

  1. 原子操作(Atomic Operations):
  • 定义:原子操作是不可分割的操作,其执行过程不会被任何其他任务(比如线程,CPU 核心)打断。它们经常被用在实现线程安全的变量操作上。
  • 性能:通常执行得更快,因为它们往往是由 CPU 直接支持的简单操作。
  • 适用性:适合简单的场景,如增加计数器,修改布尔标志等。
  • 限制:原子操作对于复杂的数据结构或负责的事务不太适用,因为它们通常只能对单个数据单位(如一个变量)进行操作。
  1. 锁(Locks):
  • 定义:锁是一种同步机制,用于确保在给定时间只有一个线程可以执行临界区(critical section)的代码。
  • 性能:锁涉及到更多复杂的机制,如锁定、阻塞、唤醒等,所以一般来说比原子操作慢。
  • 适用性:适用于更复杂的操作和数据结构,当需要执行一系列需要完整执行的操作时,使用锁可以确保数据的完整性和一致性。
  • 限制:锁可能导致一些问题,如死锁,优先级反转,饥饿或过度的线程调度延迟等。

简而言之,原子操作通常用于简单的、单一的操作;而锁适用于顺序执行一系列操作的复杂情况。原子操作由于减少线程上下文切换和调度的开销,在适当的情境下可以提供更好的性能。而锁,则提供了一个更广泛应用的线程同步解决方案,但可能会带来更高的性能开销和设计挑战。

实际应用时,选择哪种机制取决于具体问题的需求。在一些高并发和对响应时间要求严格的应用中,优先选择原子操作可能会更好,但如果逻辑复杂,涉及到多个变量或者状态的综合卡量,则可能需要选择锁。有时,甚至需要组合使用原子操作和锁,以最佳方式解决并发问题。

14. 什么是 CAS

CAS 是比较并交换(Compare And Swap)的缩写,它是一种用于同步的原子指令,通常用于多线程编程中的锁和并发数据结构的实现。CAS 是实现无锁编程(lock-free programming)原语的基础.

CAS 操作涉及三个操作数:

  1. 内存位置的值(V):指内存中的当前值
  2. 预期原值(A):线程预测的内存位置的值,也就是期望值。
  3. 新值(B):如果内存位置的当前值与预期值匹配,将要写入的新值。

CAS 的工作原理是这样的:

  1. 它检查内存位置的当前值(V)是否与预期原值(A)相等。
  2. 如果相等,就将该内存位置更新为新值(B)。
  3. 如果不相等,操作失败;当前值不会改变。

在 CAS 操作中,整个读取旧值、比较值、写入新值的过程是原子的,意味着执行这一系列步骤的过程不会被其他线程打断。

在伪代码中,CAS 可以描述为:

代码语言:javascript复制
function CAS(V, A, B):
 if *V == A:
    *V = B
    return true
  else:
    return false

*V表示位置地址 V 的值。

CAS 提供了一个实现无锁数据结构所需的关键机制,因为它允许你检查和更新一个值,而不会因为其他线程的干扰而导致错误。CAS 经常被用来实现自旋锁(spinlock)和其他同步工具,如 Java 中的java.util.concurrent.atomic包下的类等。

一个实用的 CAS 操作可能需要在循环中不断重试,直到成功为止,以应对其他线程的并发修改。这个“线程自旋”可能导致所谓的活锁(livelock)问题,在活锁中,线程不断地重复,但无法继续向前推进,因为条件总是被并发的线程更改。

此外,这种方法也有 ABA 问题。即如果一个变量由 A 变为 B,然后又变回 A,CAS 会认为这个变量没有改变,但在这样的情况下,这可能是一种错误的假设。因此,在涉及无锁数据结构时,需要小心处理这种情况。

15. sync.Pool 有什么用

在 Go 语言中,sync.Pool是一个用于存储和复用临时对象的容器,以减少内存分配的开销。它可以提高应用程序在处理大量短命对象时的性能,特别是在并发环境下。

sync.Pool的主要作用和特点如下:

  1. 减少 GC 压力sync.Pool可以通过复用对象减少垃圾回收(GC)的压力。当创建很多声明周期短暂的对象时,如果不使用sync.Pool,这些对象将会在使用后变为垃圾,需要由 GC 来清理。频繁的垃圾回收会影响程序的性能。
  2. 提高性能:通过复用已经存在的对象而不是频繁创建新对象,sync.Pool可以显著提升性能,特别是在高并发的场景下。
  3. 保持实例sync.Pool维护一个池中对象实例的集合,每个 goroutine 都可以从中独立获取和返回对象,而不影响其他 goroutine。
  4. 自动缩放:在进行垃圾回收时,sync.Pool中的对象可能会被自动清理。这意味着长时间不使用的对象最终会被垃圾收集器回收,从而减少内存泄漏的风险。
  5. 局部性优化sync.Pool提高了数据的局部性,池中的对象通常分布在一起,减少了缓冲未命中的几率,进一步提升性能。

sync.Pool的使用一般在以下场景中最为有效:

  • 需要频繁创建和销毁的小对象。
  • 并发访问的对象缓存,比如在网页服务器中用于缓冲请求的上下文对象。

sync.Pool不适用于所有情况,特别是在以下情景中:

  • 创建和维护对象代价很高的场景。
  • 对象的大小非常大,可能导致池自身维护的成本超过收益。
  • 池内对象有可能导致显著的内存泄露。

以下是一个基本的sync.Pool使用示例:

代码语言:javascript复制
var pool = sync.Pool {
    New: func() interface{} {
      return new(bytes.Buffer)
    },
}

// 假设处理一些工作,需要临时的 Buffer 对象
func doWork() {
    buf := pool.Get().(*byte.Buffer) // 从池中获取一个 Buffer
    // 使用 buf 进行工作...
    buf.Reset()       // 清楚 Buffer,以便复用
    pool.Put(buf)      // g工作w完成后把 Buffer 放回 pool 中
}

在这个例子中,每当需要bytes.Buffer实例时,可以尝试从sync.Pool中获取,而不是每次需要时都创建一个新的实例。完成工作之后,对象会被重置以避免携带过往操作的状态,然后放回sync.Pool以供后续重用。这样可以减少内存分配的次数,从而提高性能。

0 人点赞