1. Go的并发机制

面对前来面试Java程序员的求职者的时候我一般都会问问并发编程,毕竟并发编程总是属于Java编程的高级部分。其实Java的并发编程已经被设计的很简单了,不过要想掌握并发编程也并不是一件很轻松的事情。

作为一个DBA,面对高并发的场景算是司空见惯的了,没有并发能力的数据库(对,我说的就是MyISAM),还不如玩具。

那么数据库并发的时候最重要的几个点是什么呢?

  • 锁,分为排他锁和共享锁,我认为这是个天才的设计;
  • 连接池,使用了连接池可以大大降低高并发时的负载;
  • 索引,索引能大幅提升查询效率,降低锁持有的时间。

其实说了这三样,还是锁最重要,锁保护了并发时出现竞态时的资源安全。实际上在Java编程的时候也总是说什么线程安全之类的概念,无非也就是对竞争状态下资源的保护。

Go作为一个现代的语言,其并发模型也是很简单的,这一点确实比Python好,很多人认为Python是没有并发编程的,这也不能算是全错,至少Python的并发能力和Java比起来还是差不少。

扯了这么多闲话,画一张图来说明一下Go的并发:

 

Go语言之初识Goroutine_时间片

 

  • G4到G7代表着goroutine,一旦创建出来就会被分配给一个逻辑处理器,这里记为P0;
  • 逻辑处理器P0被绑定到一个系统线程上;
  • 逻辑处理器是可以在代码里分配的。

这个模型看起来是还是怪怪的,因为我们知道计算机里有个很重要的概念叫做中断,大致意思是CPU每个时间片只能处理一件事,但是CPU可以分配时间片,即处理一会儿任务A,然后中断,分配一些时间片给任务B,因为切换时间太快,反应迟钝的人是反应不过来的,还以为计算机是并行处理的。

那么逻辑处理器要如何进行并发呢,这看起来还是不像并发的样子。事实上Go提供了这样的能力,不然还谈什么并发编程。

Go遇到需要阻塞的goroutine,就会分配一个新的线程,将这个goroutine和原来的线程分离,直到该阻塞线程有返回。这段时间里,逻辑处理器P0继续做队列里其他的事情。

2. 编码实现

下面来写一段代码展示如何实现并发:

package main

import (
    "fmt"
    "runtime"
    "sync"
)

var wg sync.WaitGroup

func main() {
    runtime.GOMAXPROCS(1)

    wg.Add(2)

    fmt.Println("Start........")

    go printPrime("A")
    go printPrime("B")

    fmt.Println("Waiting to finish")
    wg.Wait()
    fmt.Println("Done!")
}

func printPrime(prefix string) {
    defer wg.Done()

    next:
        for outer := 2; outer < 50000; outer++ {
            for inner := 2; inner < outer; inner++ {
                if outer % inner ==  {
                    continue next
                }
            }
            fmt.Printf("%s: %d\n", prefix, outer)
        }
        fmt.Printf("Finish %s\n", prefix)
}

这是一段找素数的代码,可以找到50000以内的素数并打印出来。下面来分析分析:

  • var wg sync.WaitGroup,顾名思义,这是等待组,后面会给这个值add 2,代表着要等待两个goroutine,这有点像Java的线程池;
  • runtime.GOMAXPROCS(1),分配一个逻辑处理器;
  • go printPrime("A"),创建一个goroutine,这里就能看出来goroutine是函数级别的。

写过Java并发的人都应该能反应过来,这段代码的打印结果一定是混乱的,A和B组会反复的交替出现,事实上手机游戏买卖地图也就是这样的。并发编程本来就是在互相争抢资源,包括处理器资源,谁先抢到了时间片,谁就先处理,其他人等待时间片。

不过在我的电脑上实验的时候发现,现在的电脑太快了,当我决定打印5000以内素数的时候,看起来就像是顺序执行的,只有将范围扩大到50000才能看出来效果:

 

Go语言之初识Goroutine_java_02

 

这里的并发实现,比Java简单多了,虽然Java已经很简单了,但是我还是更中意Go语言。

诚如我之前所说,数据库利用了锁来实现并发时的资源保护。这也就是说只要有并发编程就一定会遇到资源竞争的事情。比如下面一段代码, 这段代码被改造成了无数种形式来说明并发编程时需要注意的资源问题:

package main

import (
    "fmt"
    "runtime"
    "sync"
)

var (
    wg      sync.WaitGroup
    counter int
)

func main() {

    wg.Add(2)
    go printPrime(1)
    go printPrime(2)
    wg.Wait()
    fmt.Println(counter)

}

func printPrime(id int) {
    defer wg.Done()
    for count := 1; count < 3; count++ {
        value := counter
        runtime.Gosched()
        value++
        counter = value
    }
}

猜猜打印结果是多少?答案是不确定,反复进行资源的覆盖导致了结果的不确定。

这就是因为没有进行资源保护造成的,加把锁其实就解决这个问题了。

package main

import (
    "fmt"
    "runtime"
    "sync"
)

var (
    wg      sync.WaitGroup
    counter int
    mutex sync.Mutex
)

func main() {

    wg.Add(2)
    go printPrime(1)
    go printPrime(2)
    wg.Wait()
    fmt.Println(counter)

}

func printPrime(id int) {
    defer wg.Done()
    for count := 1; count < 3; count++ {
        mutex.Lock()
        {
            value := counter
            runtime.Gosched()
            value++
            counter = value
        }
        mutex.Unlock()
    }
}

将可能造成覆盖的区域锁住就可以了,这个时候就相当于给资源加上了一个X锁,即互斥锁,这种情况下,同一时刻只允许一个goroutine进入该区域。

我们也都知道,Java里有一种AtomicInteger类,是线程安全的原子类,Go也有类似的原子操作:

package main

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

var (
    wg      sync.WaitGroup
    counter int32
)

func main() {

    wg.Add(2)
    go printPrime(1)
    go printPrime(2)
    wg.Wait()
    fmt.Println(counter)

}

func printPrime(id int) {
    defer wg.Done()
    for count := 1; count < 3; count++ {
        atomic.AddInt32(&counter, 1)
        runtime.Gosched()
    }
}

这种方式也能写出线程安全的函数了。

3. 小结

不得不感叹一句,现在的语言真是好学啊,虽然说学精通需要代码量的训练和深入的思考,但是上手确实容易。

有了Go语言,连指针都变得不那么难以接受了。

参考资料:《Go IN ACTION》

顺便推荐一本提升程序员内力的好书:《码农翻身》。

喜欢或者帮助到了你,记得点个赞哦。