zoukankan      html  css  js  c++  java
  • hello world 的并发实现


    本篇文章将介绍 hello world 的并发实现,其中涉及到的知识有:

    • 并发与并行
    • GPM 调度系统

    并发与并行

    并发不是并行。并发是同时管理很多事情,这些事情可能只做了一半就被暂停做别的事情了。而并行是同时做很多事情,让不同的代码段同时在不同的物理处理器上执行。
    在很多情况下,并发要比并行好,它符合 Go 语言的涉及哲学: 使用较少的资源做更多的事情。

    Go 的 GPM 调度系统

    GPM 是 Go 自己实现的一套调度系统,区别于操作系统层面的线程调度系统。

    • G 是 Goroutine 的缩写,goroutine 相当于操作系统的进程控制块,它是一个独立的工作单元。
    • P(Processor) 是一个抽象的概念,并不是真正的 CPU,它管理着一组 goroutine 队列(暂停占用较长 CPU 时间的 goroutine,运行等待的 goroutine 等),当管理的 goroutine 队列都执行完则从全局队列里取任务,如果全局队列也没有任务,则去其它 P 的队列里抢任务。
    • M(Machine) 是 Go 运行时(runtime) 对操作系统内核线程的虚拟,M 与内核线程是一一映射的关系(M 和 P 一般也是一一对应),一个 goroutine 最终要调度到 M 上执行。

    1. hello world 的并发实现

    package main
    
    import (
    	"fmt"
    	"runtime"
        "sync"
    )
    
    var wg sync.WaitGroup
    
    func say_hello(value interface{}) {
    	defer wg.Done()
    	fmt.Printf("%v", value)
    }
    
    func common_say_hello() {
    	wg.Add(5)
    	go say_hello("w")
    	go say_hello("o")
    	go say_hello("r")
    	go say_hello("l")
    	go say_hello("d")
    }
    
    func main() {
    	runtime.GOMAXPROCS(1)
    	common_say_hello()
    	wg.Wait()
    }
    

    代码介绍:

    • runtime 包的 GOMAXPROCS 函数允许程序更改调度器可以使用的逻辑处理器数量,逻辑处理器和操作系统线程是一一绑定的关系。这里仅使用 1 个逻辑处理器处理并发运行的 goroutine。
    • 实现 goroutine 很简单只需要在函数名前加 go 即可让该函数独立于其它函数运行,Go 会将其视为一个独立的工作单元,这个单元会被调度到可用的逻辑处理器上执行。
    • 使用 sync 包结构体 WaitGroup 的 Add/Wait/Done 方法来等待 goroutine 的完成。如果不加等待, main 函数会在 goroutine 运行前终止。

    代码运行结果如下:

    dworl
    

    为什么 d 会打印在最前面而 worl 则依次打印呢?
    <<Go 语言实战>> 给出的解释是“第一个 goroutine 完成所有显示需要花的时间很短,以至于调度器切换到第二个 goroutine之前就完成了所有任务”。那么,这里的第一个 goroutine 是 “go say_hello("d")” 吗?第二个,第三个 goroutine.. 又是哪个呢?调度器根据怎么的顺序来调度 goroutine 呢?这些问题留给我们后续解答,有知道的朋友还请不吝赐教,感谢。

    上面的代码限定了逻辑处理器的数量为 1,所以这里其实实现的是并发而不是并行。当设置逻辑处理器的数量大于 1 时,即实现了并行也实现了并发。更改逻辑处理器数量为 3,查看程序运行情况:

    dorlw
    dowlr
    ldorw
    

    执行了三次每次打印的输出都不一样。

    那么是不是到这里就结束了呢?没有。有一点需要说明的是: 一个正在运行的 goroutine 可以被停止并重新调度。如果 goroutine 长时间占用逻辑处理器,调度器会停止该 goroutine,并给其它 goroutine 运行的机会。
    基于上述分析,更改 hello world 代码,使每个 goroutine 占用较长的逻辑处理器时间,查看 goroutine 是否被调度器切换,代码如下:

    func multi_hello(prefix string) {
    	defer wg.Done()
    
    next:
    	for outer := 2; outer < 5000; outer++ {
    		for inter := 2; inter < outer; inter++ {
    			if outer%inter == 0 {
    				continue next
    			}
    		}
    		fmt.Println("say %s: %d times", prefix, outer)
    	}
    }
    
    func crazy_say_hello() {
    	wg.Add(5)
    	go multi_hello("w")
    	go multi_hello("o")
    	go multi_hello("r")
    	go multi_hello("l")
    	go multi_hello("d")
    }
    
    func main() {
    	runtime.GOMAXPROCS(1)
    	crazy_say_hello()
    	wg.Wait()
    }
    

    查看代码执行结果:

    say r: 4327 times
    say r: 4337 times
    say r: 4339 times
    say w: 4493 times
    say w: 4507 times
    say w: 4513 times
    ...
    say w: 4999 times
    say r: 4349 times
    say r: 4357 times
    ...
    

    这里仅截取部分执行结果。可以看到,执行 r goroutine 第 4349 次的时候调度器切换 “r goroutine” 到 “w goroutine” ,然后执行 w goroutine 4999 次的时候调度再切换回 “r goroutine”。

    上述 hello world 的 goroutine 均不涉及对共享资源的访问,因此它们能和谐共存,互不干扰。如果涉及到共享资源的访问,goroutine 将变得相当“野蛮”也即出现相互竞争访问共享资源的状态,这种情况称为“竞争”状态。

    2. 竞争状态的 goroutine

    进一步改写 hello world 程序如下:

    var helloTimes int32
    
    func cal_hello_num(prefix string) {
    	defer wg.Done()
    
    	value := helloTimes
    	runtime.Gosched()
    
    	value++
    	helloTimes = value
    	fmt.Printf("say %s: %d times
    ", prefix, helloTimes)
    }
    
    func num_say_hello() {
    	wg.Add(5)
    	go cal_hello_num("w")
    	go cal_hello_num("o")
    	go cal_hello_num("r")
    	go cal_hello_num("l")
    	go cal_hello_num("d")
    }
    
    func main() {
    	runtime.GOMAXPROCS(1)
    	num_say_hello()
    	wg.Wait()
    }
    

    为方便说明这里将逻辑处理器的数量设为 1,同时引入 runtime 包的 Gosched 函数,该函数会将当前 goroutine 从线程退出,并放回到逻辑处理器的队列中。程序执行结果如下:

    say d: 1 times
    say w: 1 times
    say o: 1 times
    say r: 1 times
    say l: 1 times
    

    多次执行,每个 goroutine 打印结果均为 1,为什么呢?
    分析上述代码,每个 goroutine 都会覆盖另一个 goroutine 的工作(竞争状态因此存在)。每个 goroutine 均创造了变量 helloTimes 的副本 value,当 goroutine 切换时,每个 goroutine 会将自己维护的 value 赋值给 helloTimes,导致 helloTimes 的值一直是 1。

    那么,如果每个 goroutine 都不创造变量的副本是否这种竞争状态就消失了呢?
    进一步改写程序如下:

    改写版本1

    func cal_hello_num(prefix string) {
    	defer wg.Done()
    
    	helloTimes++
    	runtime.Gosched()
    	fmt.Printf("say %s: %d times
    ", prefix, helloTimes)
    }
    
    // 运行结果
    say d: 5 times
    say w: 5 times
    say o: 5 times
    say r: 5 times
    say l: 5 times
    

    改写版本 2

    func cal_hello_num(prefix string) {
    	defer wg.Done()
    
    	runtime.Gosched()
    	helloTimes++
    	fmt.Printf("say %s: %d times
    ", prefix, helloTimes)
    }
    
    // 运行结果
    say d: 1 times
    say w: 2 times
    say o: 3 times
    say r: 4 times
    say l: 5 times
    

    版本 1 和版本 2 移动了 helloTimes++ 相对于 GoSched 的位置,却得到了完全不同的结果。
    其实不难理解,因为 helloTimes 是全局变量,每个 goroutine 都维护这个变量。所以,在版本一中每个 goroutine 切换之前都会对全局变量 helloTimes 加 1,加 1 完成后,程序依次打印“最终值” 5。而版本二 goroutine 在切换之后对全局变量加 1,其效果相当于每个 goroutine 按顺序依次执行全局变量的自增操作。

    多个 goroutine 访问共享资源极易出现“幺蛾子”,在程序中可以通过锁住共享资源的方式来避免竞争状态的出现。

    3. 锁住共享资源

    通过原子函数,互斥锁锁住共享资源,实现 goroutine 对共享资源的顺序访问。

    3.1 原子函数

    import (
    	"fmt"
    	"runtime"
    	"sync"
    	"sync/atomic"
    )
    
    func cal_hello_num(prefix string) {
    	defer wg.Done()
    
    	atomic.AddInt32(&helloTimes, 1)
    	fmt.Printf("say %s: %d times
    ", prefix, helloTimes)
    }
    

    使用 atomic 包导入原子函数 AddInt32 实现变量 helloTimes 的自增操作。执行结果如下:

    say d: 1 times
    say w: 2 times
    say o: 3 times
    say r: 4 times
    say l: 5 times
    

    3.2 互斥锁

    使用互斥锁防止竞争状态的发生。互斥锁会在代码上创建临界区,保证同一时间只有一个 goroutine 可以访问执行临界区代码。代码如下:

    var (
    	wg    sync.WaitGroup
    	mutex sync.Mutex
    )
    
    func cal_hello_num(prefix string) {
    	defer wg.Done()
    	mutex.Lock() 
    
    	value := helloTimes
    	runtime.Gosched()
    	value++
    	helloTimes = value
    	fmt.Printf("say %s: %d times
    ", prefix, helloTimes)
    
    	mutex.Unlock() 
    }
    

    有一点要注意的是: 使用 Gosched 强制 goroutine 退出当前线程后,调度器会再次分配这个 goroutine 继续运行临界区代码。程序执行结果如下:

    say d: 1 times
    say w: 2 times
    say o: 3 times
    say r: 4 times
    say l: 5 times
    

    再次强调 value 的位置是很关键的,如果对 value := helloTimes 不加锁,每个 goroutine 还是会保留各自的副本,起不到防止竞争状态的作用。代码及执行结果如下所示:

    func cal_hello_num(prefix string) {
    	defer wg.Done()
    
    	value := helloTimes
    	mutex.Lock()
    	runtime.Gosched()
    	value++
    	helloTimes = value
    	fmt.Printf("say %s: %d times
    ", prefix, helloTimes)
    	mutex.Unlock()
    }
    
    // 执行结果
    say d: 1 times
    say w: 1 times
    say o: 1 times
    say r: 1 times
    say l: 1 times
    

    当然,除了原子函数和互斥锁防止竞争状态外,还可以使用 channel 通道,channel 通过发送和接收需要共享的资源,实现共享资源在 goroutine 之间的同步。下节将介绍 Go 的 channel 类型以及如何避免掉入 channel 的坑。

    芝兰生于空谷,不以无人而不芳。
  • 相关阅读:
    第十六周博客总结
    第十五周博客总结
    自学第六次博客(动作事件的处理)
    第十四周博客总结
    自学的第五篇博客
    自学电脑游戏第四天(Swing)
    c++面向对象程序设计第四章课后习题
    SQL注入
    VirtualBox+Vagrant环境配置
    测试
  • 原文地址:https://www.cnblogs.com/xingzheanan/p/14660707.html
Copyright © 2011-2022 走看看