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

Go语言并发编程:从理论到实践

并发是计算机科学领域中的一个核心概念,但对于不同的人来说,它可能意味着不同的东西。除了“并发”之外,你可能还听说过“异步”、“并行”或“多线程”等术语。一些人认为这些词是同义的,而另一些人则严格区分它们。如果我们要花整篇文章来讨论并发,那么首先明确我们所说的“并发”是什么将非常有益。

本文将深入探讨并发的概念、重要性、面临的挑战,以及Go语言如何通过其并发原语来简化并发编程。我们将结合代码示例(包含中文注释)来阐述这些概念,帮助你更好地理解并应用并发编程。

一、什么是并发?

当大多数人使用“并发”这个词时,他们通常指的是多个进程同时发生。通常也暗示这些进程大致在同一时间取得进展。为了更直观地理解这个概念,可以想象为人们。你现在正在阅读这篇文章,而世界上其他人也在同时过着他们的生活。他们与你是并发存在的。

并发在计算机科学中是一个广泛的话题,从这个定义中衍生出了各种主题:理论、并发建模的方法、逻辑正确性、实践问题,甚至包括理论物理学!在本文中,我们将主要关注在Go语言的背景下理解并发的实际问题,特别是:Go如何选择建模并发、这种模型会带来哪些问题,以及我们如何在这个模型中组合原语来解决问题。

二、并发的重要性

1. 摩尔定律的终结与多核处理器的崛起

1965年,戈登·摩尔(Gordon Moore)发表了一篇三页的论文,描述了电子市场向集成电路的整合,以及集成电路中组件数量每年翻一番的趋势。这一预测后来被称为“摩尔定律”。1975年,他将这一预测修正为集成电路中的组件数量每两年翻一番。这个预测基本上一直成立,直到2012年左右。

许多公司预见到摩尔定律预测的增长速度将放缓,开始研究提高计算能力的替代方法。正如俗话所说,需求是创新之母,于是多核处理器就这样诞生了。

虽然多核处理器看起来是解决摩尔定律限制的巧妙方法,但计算机科学家很快发现自己面对着另一条定律的限制:阿姆达尔定律(Amdahl’s Law),以计算机架构师吉恩·阿姆达尔(Gene Amdahl)的名字命名。

2. 阿姆达尔定律与并行化的局限

阿姆达尔定律描述了一种模型,用于预测通过并行方式实现问题的解决方案所能带来的性能提升。简单来说,它指出性能提升受到程序中必须以顺序方式编写的部分的限制。

举个例子,假设你正在编写一个主要基于GUI的程序:用户被呈现一个界面,点击一些按钮,然后发生一些操作。这个程序受到一个非常大的顺序部分的限制:人机交互。无论你为这个程序提供多少核,它总是受限于用户与界面交互的速度。

另一方面,考虑计算圆周率的数字。借助一类称为“流水线算法”(spigot algorithms)的算法,这个问题被称为“尴尬并行”(embarrassingly parallel),这实际上是一个技术术语,意味着它可以轻松地划分为并行任务。在这种情况下,通过为程序提供更多的核,可以获得显著的性能提升,你需要处理的只是如何组合和存储结果。

阿姆达尔定律帮助我们理解这两个问题之间的差异,并帮助我们决定并行化是否是解决系统性能问题的正确方法。

3. 云计算与Web规模

对于那些“尴尬并行”的问题,建议你将应用程序编写成可以水平扩展的方式。这意味着你可以运行程序的多个实例,在更多的CPU或机器上运行,从而提高系统的运行速度。

云计算的兴起使得这种水平扩展变得更加容易。云计算意味着一种新的规模和应用程序部署方法,以及水平扩展的方法。开发人员可以使用相对廉价的大量计算能力来解决大型问题。

但是,云计算也带来了许多新的挑战。资源的配置、机器实例之间的通信、结果的汇总和存储都成为需要解决的问题。但其中最困难的之一是如何对代码进行并发建模。并发带来的复杂性增加了程序的理解难度和容错难度。

三、为什么并发难以处理?

并发代码因其难以正确实现而臭名昭著。通常需要多次迭代才能使其按预期工作,即使如此,也常常会存在多年后才因某种时机变化(如更高的磁盘利用率、更多的用户登录系统等)而显现的bug。

幸运的是,所有人在处理并发代码时都会遇到相同的问题。正因为如此,计算机科学家们能够为这些常见问题贴上标签,使我们能够讨论它们是如何出现的、为什么会出现,以及如何解决它们。

1. 竞争条件(Race Conditions)

竞争条件发生在两个或多个操作必须以正确的顺序执行,但程序未保证这种顺序得到维护的情况下。

通常,这会以数据竞争(data race)的形式出现,其中一个并发操作试图读取一个变量,而在某个不确定的时间,另一个并发操作试图写入同一个变量。

示例:
var data int

go func() {
    data++
}()

if data == 0 {
    fmt.Printf("数值是 %v。\n", data)
}

在Go语言中,你可以使用go关键字来并发地运行一个函数。这会创建一个goroutine

在上述代码中,data++if data == 0都在尝试访问变量data,但并未保证它们的执行顺序。运行这段代码可能会有三种结果:

  • 什么都不打印data++if语句之前执行。
  • 打印“数值是 0。”if语句在data++之前执行。
  • 打印“数值是 1。”if data == 0data++之前执行,但data++fmt.Printf之前执行。

仅仅几行代码的错误,就能为你的程序引入巨大的不确定性。

解决方案:

为了避免竞争条件,需要确保操作的执行顺序,可以使用同步机制。例如,使用sync.WaitGroup等待goroutine执行完毕。

var data int
var wg sync.WaitGroup

wg.Add(1)
go func() {
    defer wg.Done()
    data++
}()

wg.Wait()

if data == 0 {
    fmt.Printf("数值是 %v。\n", data)
} else {
    fmt.Printf("数值是 %v。\n", data)
}

在这个示例中,我们使用wg.Wait()等待goroutine完成,从而确保data++if语句之前执行。

2. 原子性(Atomicity)

原子性意味着在操作的上下文中,它是不可分割和不可中断的。这意味着,操作要么全部完成,要么完全不发生。

示例:
i++

这看起来是一个原子操作,但实际上,它包含多个步骤:

  1. 读取i的值。
  2. i的值加1。
  3. 将新值写回i

虽然每个步骤本身可能是原子的,但组合在一起就不一定是原子的,取决于你的上下文。如果有多个并发操作访问i,那么这个增量操作就不是原子的。

解决方案:

为了确保操作的原子性,可以使用sync/atomic包提供的原子操作。

import "sync/atomic"

var i int32

atomic.AddInt32(&i, 1)

使用atomic.AddInt32可以确保对i的增量操作是原子的。

3. 内存访问同步

当多个并发进程需要访问共享资源时,就需要同步内存访问,以避免竞争条件和数据竞争。

示例:
var data int
var mutex sync.Mutex

go func() {
    mutex.Lock()
    data++
    mutex.Unlock()
}()

mutex.Lock()
if data == 0 {
    fmt.Printf("数值是 %v。\n", data)
} else {
    fmt.Printf("数值是 %v。\n", data)
}
mutex.Unlock()

在这个示例中,我们使用sync.Mutex来确保在同一时间只有一个goroutine可以访问data。这解决了数据竞争的问题。

4. 死锁(Deadlock)

死锁是指所有的并发进程都在等待彼此,导致程序永远无法继续执行。

示例:
type value struct {
    mu    sync.Mutex
    value int
}

var wg sync.WaitGroup

printSum := func(v1, v2 *value) {
    defer wg.Done()

    v1.mu.Lock()
    defer v1.mu.Unlock()

    time.Sleep(2 * time.Second)

    v2.mu.Lock()
    defer v2.mu.Unlock()

    fmt.Printf("sum=%v\n", v1.value+v2.value)
}

var a, b value
wg.Add(2)
go printSum(&a, &b)
go printSum(&b, &a)
wg.Wait()

运行这段代码,会出现如下错误:

fatal error: all goroutines are asleep - deadlock!

这是因为printSum函数中的两个goroutine相互等待对方释放锁,导致死锁。

解决方案:

为了避免死锁,可以确保所有的锁按照相同的顺序获取。例如,始终先锁v1,再锁v2

printSum := func(v1, v2 *value) {
    defer wg.Done()

    if v1 == v2 {
        v1.mu.Lock()
        defer v1.mu.Unlock()
    } else if v1.value < v2.value {
        v1.mu.Lock()
        defer v1.mu.Unlock()
        v2.mu.Lock()
        defer v2.mu.Unlock()
    } else {
        v2.mu.Lock()
        defer v2.mu.Unlock()
        v1.mu.Lock()
        defer v1.mu.Unlock()
    }

    fmt.Printf("sum=%v\n", v1.value+v2.value)
}

通过确保锁的获取顺序一致,可以避免死锁的发生。

5. 活锁(Livelock)

活锁指的是程序虽然在积极地执行并发操作,但这些操作并没有推进程序的状态。

示例:

假设两个人在走廊相遇,都试图让对方先走。他们不断地左右移动,始终无法通过。

var (
    left   int32
    right  int32
    wg     sync.WaitGroup
    cadence = sync.NewCond(&sync.Mutex{})
)

go func() {
    for range time.Tick(1 * time.Millisecond) {
        cadence.Broadcast()
    }
}()

takeStep := func() {
    cadence.L.Lock()
    cadence.Wait()
    cadence.L.Unlock()
}

tryDir := func(dirName string, dir *int32, out *bytes.Buffer) bool {
    fmt.Fprintf(out, " %v", dirName)
    atomic.AddInt32(dir, 1)
    takeStep()

    if atomic.LoadInt32(dir) == 1 {
        fmt.Fprint(out, ". 成功!")
        return true
    }
    takeStep()
    atomic.AddInt32(dir, -1)
    return false
}

walk := func(walking *sync.WaitGroup, name string) {
    var out bytes.Buffer
    defer func() { fmt.Println(out.String()) }()
    defer walking.Done()
    fmt.Fprintf(&out, "%v 正在尝试通过:", name)
    for i := 0; i < 5; i++ {
        if tryLeft(&out) || tryRight(&out) {
            return
        }
    }
    fmt.Fprintf(&out, "\n%v 放弃了!", name)
}

tryLeft := func(out *bytes.Buffer) bool {
    return tryDir("左边", &left, out)
}

tryRight := func(out *bytes.Buffer) bool {
    return tryDir("右边", &right, out)
}

wg.Add(2)
go walk(&wg, "小明")
go walk(&wg, "小红")
wg.Wait()

运行结果可能是:

小明 正在尝试通过: 左边 右边 左边 右边 左边 右边 左边 右边 左边 右边
小明 放弃了!
小红 正在尝试通过: 左边 右边 左边 右边 左边 右边 左边 右边 左边 右边
小红 放弃了!

两个人不断尝试移动,但始终无法通过,形成了活锁。

解决方案:

为了避免活锁,可以引入协调机制或随机性。例如,可以让其中一方等待一段随机的时间再尝试移动,或者引入一个协调者来指挥双方的动作。

6. 饿死(Starvation)

饿死是指并发进程无法获取执行工作所需的所有资源,导致无法完成任务。

示例:
var wg sync.WaitGroup
var sharedLock sync.Mutex
const runtime = 1 * time.Second

greedyWorker := func() {
    defer wg.Done()
    var count int
    for begin := time.Now(); time.Since(begin) <= runtime; {
        sharedLock.Lock()
        time.Sleep(3 * time.Nanosecond)
        sharedLock.Unlock()
        count++
    }
    fmt.Printf("贪婪的工作者执行了 %v 次工作循环\n", count)
}

politeWorker := func() {
    defer wg.Done()
    var count int
    for begin := time.Now(); time.Since(begin) <= runtime; {
        sharedLock.Lock()
        time.Sleep(1 * time.Nanosecond)
        sharedLock.Unlock()

        sharedLock.Lock()
        time.Sleep(1 * time.Nanosecond)
        sharedLock.Unlock()

        sharedLock.Lock()
        time.Sleep(1 * time.Nanosecond)
        sharedLock.Unlock()

        count++
    }
    fmt.Printf("礼貌的工作者执行了 %v 次工作循环\n", count)
}

wg.Add(2)
go greedyWorker()
go politeWorker()
wg.Wait()

运行结果:

礼貌的工作者执行了 289777 次工作循环
贪婪的工作者执行了 471287 次工作循环

贪婪的工作者一次持有锁的时间更长,导致礼貌的工作者被“饿死”,无法高效地执行任务。

解决方案:

为了避免饿死,可以调整锁的粒度,或者使用更公平的锁机制,如sync.RWMutex或其他同步原语。

7. 并发安全性判断

编写并发代码的一个主要挑战是判断代码是否并发安全。这需要仔细考虑函数如何使用并发,以及如何确保代码的正确性。

示例:
// CalculatePi 计算从起始位置到结束位置的圆周率数字。
func CalculatePi(begin, end int64, pi *Pi)

这个函数可能引发以下问题:

  • 如何使用这个函数进行并发计算?
  • 是否需要自己处理并发和同步?
  • pi的访问是否需要同步?
解决方案:

在代码中添加详细的注释,明确说明并发和同步的细节。

// CalculatePi 计算从起始位置到结束位置的圆周率数字。
// 内部会创建多个并发的goroutine来递归调用CalculatePi。
// 对pi的写入同步由Pi结构体内部处理,调用者无需关心同步问题。
func CalculatePi(begin, end int64, pi *Pi)

通过清晰的注释,帮助使用者理解函数的并发行为和同步机制,避免误用。

四、Go语言如何简化并发编程

Go语言通过其并发原语,使得并发编程更加简单和安全。以下是Go语言的并发特性如何帮助开发者的:

1. 轻量级的goroutine

Go语言的goroutine是由Go运行时管理的轻量级线程。与传统的操作系统线程相比,goroutine的创建和销毁成本非常低。这使得开发者可以方便地在程序中创建大量的并发任务。

示例:
func main() {
    for i := 0; i < 1000; i++ {
        go func(i int) {
            fmt.Printf("Goroutine %d\n", i)
        }(i)
    }
    time.Sleep(1 * time.Second)
}

在这个示例中,我们创建了1000个goroutine,每个goroutine都打印自己的编号。

2. 通道(Channel)

Go语言提供了通道(channel)来实现goroutine之间的通信。通道是一种类型安全的管道,用于在goroutine之间传递数据。

示例:
func main() {
    ch := make(chan int)

    go func() {
        ch <- 42 // 发送数据到通道
    }()

    value := <-ch // 从通道接收数据
    fmt.Println("接收到的值:", value)
}

3. 不需要手动管理内存

Go语言的垃圾回收器(GC)使得开发者不必手动管理内存。这在并发编程中尤为重要,因为手动管理内存在并发环境下非常容易出错。

4. 简洁的并发模型

Go语言的并发模型基于CSP(Communicating Sequential Processes,通信顺序进程)。这种模型强调通过通信而不是共享内存来进行并发编程,减少了竞争条件和死锁的可能性。

示例:使用通道实现生产者-消费者模式
func main() {
    ch := make(chan int, 10) // 创建一个缓冲区大小为10的通道
    var wg sync.WaitGroup

    // 生产者
    wg.Add(1)
    go func() {
        defer wg.Done()
        for i := 0; i < 10; i++ {
            ch <- i
            fmt.Println("生产者生产了:", i)
        }
        close(ch) // 关闭通道
    }()

    // 消费者
    wg.Add(1)
    go func() {
        defer wg.Done()
        for value := range ch {
            fmt.Println("消费者消费了:", value)
        }
    }()

    wg.Wait()
}

5. 更好的错误处理

Go语言鼓励显式的错误处理,这在并发编程中尤为重要。通过在函数中返回错误,调用者可以更好地判断并处理可能发生的错误。

示例:
func process(data int) error {
    if data < 0 {
        return fmt.Errorf("数据不能为负数")
    }
    // 处理数据
    return nil
}

func main() {
    var wg sync.WaitGroup
    for i := -5; i < 5; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            if err := process(i); err != nil {
                fmt.Println("错误:", err)
            } else {
                fmt.Println("成功处理数据:", i)
            }
        }(i)
    }
    wg.Wait()
}

五、总结

并发编程虽然充满挑战,但Go语言通过其简单而强大的并发原语,使得编写并发程序变得更加容易。通过goroutine、通道以及内置的同步机制,开发者可以更安全、更高效地编写并发代码。同时,Go语言的并发模型也鼓励通过通信而非共享内存来进行并发,降低了竞争条件和死锁的风险。

希望通过本文的讲解和示例,能够帮助你更好地理解并掌握Go语言的并发编程,为你的开发工作带来便利和效率。


http://www.kler.cn/a/315607.html

相关文章:

  • 数字IC后端实现之Innovus specifyCellEdgeSpacing和ICC2 set_placement_spacing_rule的应用
  • 【PowerHarmony】电鸿蒙学习记录-编写helloworld!
  • 前端面试笔试(二)
  • mysql 配置文件 my.cnf 增加 lower_case_table_names = 1 服务启动不了的原因
  • Zookeeper的安装与使用
  • Oracle ADB 导入 BANK_GRAPH 的学习数据
  • QT widgets 窗口缩放,自适应窗口大小进行布局
  • 【鸿蒙OH-v5.0源码分析之 Linux Kernel 部分】003 - vmlinux.lds 链接脚本文件源码分析
  • 第k个排列 - 华为OD统一考试(E卷)
  • 跟着问题学12——GRU详解
  • Lucene详解介绍以及底层原理说明
  • 如何在Linux Centos7系统中挂载群晖共享文件夹
  • 心理辅导平台的构建:Spring Boot技术解析
  • 深度学习-从零基础快速入门到项目实践,这本书上市了!!!
  • 828华为云征文|部署知识库问答系统 MaxKB
  • 【文献阅读】基于原型的自适应方法增强未见到的构音障碍者的语音识别
  • 分布式消息中间件kafka
  • Google深度学习的图像生成大模型Imagen
  • Java接口和抽象类的区别
  • calibre-web报错:File type isn‘t allowed to be uploaded to this server
  • Ubuntu20.04配置NVIDIA+CUDA12.2+CUDNN【附所有下载资源】【亲测有效】【非常详细】
  • 设计模式-依赖注入
  • Mac剪贴板历史全记录!
  • 单片机的信号线都需要差分布放吗?
  • turtle实现贪吃蛇小游戏
  • 【鼠标滚轮专用芯片】KTH57913D 霍尔位置传感器