标签 调度 下的文章

也谈并发与并行

在一般人的眼中,“并行”就是并行,即你干你的,我干我的,两个“并行”的执行过程可能是两条毫无瓜葛的平行线,也可能是有交叉,但瞬即分开的两条线。不 过在程序员的世界里,有关“并行”的概念却有两个单词:Concurrency和Parallelism,对应的比较主流的中文翻译为并发 (Concurrency)和并行(Parallelism)。

之前一直使用C、Python进行Coding,对Concrrency和Parallelism的异同并不十分关心,也未求甚解。但switch to golang后,尤其是学习2012年Rob Pike的一个talk slide:“Concurrency is not Parallelism(译作:并发不是并行)"后,感觉之前对于“并行”的理解还未到火候。

golang的Author们对文档还是非常看重的。按照目前golang的age来说,其文档的充分性相对于其他语言已经是相对较好的了。golang 的 author们还时不时放出一些blog、talk和slide,以帮助大家编写出more idiomatic的golang程序。Rob Pike的“并发不是并行”就是golang官方站点上的一个talk slide(中文版在这里 )。

Rob Pike是Golang大神,这里先列出他在talk中对于并发与并行的学术阐释和理解:

【Concurrency并发】
Programming as the composition of independently executing processes. (Processes in the general sense, not Linux processes. Famously hard to define.)
将相互独立的执行过程综合到一起的编程技术。(这里是指通常意义上的执行过程,而不是Linux进程。很难定义。)

Concurrency is about dealing with lots of things at once.
并发是指同时处理很多事情。

Concurrency is about structure.
并发关乎结构。

Concurrency provides a way to structure a solution to solve a problem that may (but not necessarily) be parallelizable.
并发提供了一种方式让我们能够设计一种方案将问题(非必须的)并行的解决。

Concurrency is a way to structure a program by breaking it into pieces that can be executed independently.
并发是一种将一个程序分解成小片段独立执行的程序设计方法。

【Parallelism并行】
Programming as the simultaneous execution of (possibly related) computations.
同时执行(通常是相关的)计算任务的编程技术。

Parallelism is about doing lots of things at once.
并行是指同时能完成很多事情。

Parallelism is about execution.
并行关乎执行。

【小结】
They are Not the same, but related.
它们不相同,但相关。

怎么样?看上上面的论述是不是一头雾水啊。Rob Pike也觉得这些概念以及描述过于抽象,于是给了一个具体的“地鼠推车运书”的例子,不过当你看完这个例子后,可能会变得更加糊涂,至少我有这种感觉-地鼠凌乱综合症^_^。这是因为这个例子隐含的结合了Go语言goroutine调度的三个概念:P(虚拟processor上下文)、M(内核线程)和G(Goroutine对象)。如果仅仅从理解并行和并发的差异来说,我们可以抛开go语言,用生活中的例子感觉更适合些。

下面我们就来一个例子来说说明一下并发与并行,从一个程序的设计演进角度来阐述。

问题:说的是一个Gopher早起后的生活,Gopher早起后,有三个任务(或者称为三件事情)要完成:洗漱、早餐、着装。我们来设计一个程序,帮助Gopher高效正确的完成这三件事。

如果你是程序员,要完成这个场景,你可能会这么设计你的程序:

program1:

最简单的思路:这个gopher一件一件事情去完成:

main:
    call 洗漱
    call 早餐
    call 着装

这里我们把Gopher看做是一颗cpu,它按程序逻辑,顺序执行洗漱、早餐和着装三件事。即如下图那样:

现在我们玩个克隆游戏,我们clone出一个与这个Gopher一模一样的Gopher,且两个gopher之间存在着某种超宇宙联系,一个Gopher行为的结果都能反应到另外一个gopher上。我们让这两个Gopher一起来做这三件事情,看看是否能够提速。

遗憾的是,两个Gopher都要从洗漱做起。一个Gopher占用了卫生间开始洗漱,另外一个Gopher只能等着,而没法去做早餐或是着装。当那个 Gopher完成洗漱,后面的这个Gopher由于超联系也同步完成了洗漱,进入下一个环节:早餐。过程还是一样的,只能一个Gopher在餐厅准备早 餐。也就是说这两个Gopher没有一起做事,而是一个做,一个赋闲。因此我们看到两个Gopher并没有加快事情完成的步伐,从过程上来看,即便有更多 的Gopher,也依旧无法提速。我们需要对程序做些改造。

注:首尾相连的红线的总长度 = 完成时间。

program2:

main:
    pthread_create(洗漱)
    pthread_create(早餐)
    pthread_create(着装)

    waitAll

Gopher来执行一遍新程序。由于建立了三个逻辑执行体,因此Gopher在三个执行体间切换,从Gopher的角度去看,Gopher的执行路径如下图:


Program2-1

Gopher不再像上面Program1那样顺序执行了,而是在三个活动间切换,但总时长依旧没有下降。

为了验证该程序在多Gopher下是否有效率提升,我们再玩一次克隆游戏,这次clone出另外两个Gopher,三个Gopher一起来执行该程序,一个可能的执行路径见下图:


Program2-2

每个Gopher绑定一个逻辑执行体,整体完成的总时长下降为原来的三分之一。这次三个Gopher都没有赋闲,真正做到你干你的,我干我的,一起做。

program3:

虽然在program2中,多个Gopher一起工作提升了效率,但那是极限么,还能提高么?我们试想一下三个活动:洗漱、早餐和着装的难易不同,耗时不 同。一个可能的结果是Gopher1完成了洗漱,但Gopher2才准备了一半早餐,Gopher3刚选完上衣。这时Gopher1便开始空闲,无法帮助 Gopher2和Gopher3继续提高效率。我们再试试重新组合一下要完成的任务,让每个Gopher都能执行不同的活动环节。

main:
        c chan job
        for i = 0; i < 3; i++  {
            go gopherworker(c)
        }

        for j := range jobs {
            c <- j
        }
        … …

gopherworker(c chan job):
      for {
         select {
         case <-c:
         … …
      }

以下是一个可能的执行路径图:

到了这里,不知道你是否通过上面程序演进的过程悟道些什么,例子里我通篇没有提到并发或并行。

但从例子可以看出,并发和并行是两个阶段的事情。并发在程序的设计和实现阶段,并行在程序的执行阶段。

在Program1之前,我们只有问题,并无方案。

Program1方案让我们可以解决问题,但从Program1的执行结果来看,Program1并不能并行执行。原因是在设计和实现阶段程序就是按照顺序思路进行的,这就好比底子没打好,在平房的地基上永远不能盖50层的大楼。

Program2-1方案的执行结果与Program1相同,但Program2在设计和实现阶段采用的理念却与Program1完全不同,如果说 Program1打的是平房的地基,那么Program2打的就是大厦的地基,虽然Program2-1上依旧盖的是平房(单Gopher执行)。但 Program2-2显然就是在这样的地基上盖的摩天大楼了(多Gopher执行)。Program2的结构使得Program2在多Gopher下提升 了效率,实现了运行时并行。

Program3更进一步,在设计和实现阶段就本着充分高效的利用多个Gopher的理念,并最终实现了执行阶段的并行。

因此我们在编程语言层面更多谈并发,Golang对外宣传时永远用的是支持并发,而不是支持并行。设计实现阶段好比打地基,不同水准的地基决定了你在这个地基上面是只能盖平房,还是盖高层,还是能盖摩天大楼。

我们再回过头来重温Rob Pike大神关于两者的阐述:“并发关乎结构,并行关乎执行”,是不是感觉意味深长啊,大神就是大神,一句话就能抓住本质。

go 1.5之前默认情况下,Go程序都是不能并行的,因为Go将GOMAXPROCS默认设置为1,这样你仅仅能利用一个内核线程。Go 1.5及以后GOMAXPROCS被默认设置为所运行机器的CPU核数,如果你的机器是多核的,你的Go程序就有可能在运行期是并行的,前提是你在设计程 序时就充分运用了并发的设计理念,否则就会像Program1那样,即便有1w颗CPU,你也只能利用上一颗。

近期遇到的3个Golang代码问题

这两周来业余时间都在用Golang写代码,现在处于这样一个状态:除了脚本,就是Golang了。反正能用golang实现的,都用golang写。

Golang语言相对成熟了,但真正写起来,还是要注意一些“坑”的,下面是这周遇到的三个问题,这里分享出来,希望能对遇到同样问题的童鞋有所帮助。

一、误用定时器,狂占CPU

golang中有一个通过channel实现timeout或tick timer的非常idiomatic的方法,代码如下:

func worker(start chan bool) {
        for {
                timeout := time.After(30 * time.Second)
                 select {
                         // … do some stuff
                         case <- timeout:
                                 return
                 }
        }
}

func worker(start chan bool) {
        for {
                heartbeat := time.Tick(30 * time.Second)
                 select {
                         // … do some stuff
                         case <- heartbeat:
                                 return
                 }
        }
}

没错,就像上面这两个例子,如果你单独执行它们,你不会发现任何问题,但是当你将这样的代码放到一个7 * 24小时的Service中,并且timeout间隔或heartbeat间隔为更短时间,比如1s时,问题就出现了。

我的程序最初就是用上面的代码实现了一个timewheel,通过放置在一个单独goroutine中的定时器检测timewheel是否有到期的 timer。程序跑在后台运行的很好,直到有一天晚上我无意中执行了一下top,我发现这个service居然站用了40%多的CPU负荷。最初我怀疑是 不是代码中有死循环,但仔细巡查一遍代码后,没有发现死循环的痕迹,算法逻辑也没问题。

于是重启了一下这个service,发现cpu占用降了下来。出去去了趟卫生间,回来继续用top观察,不好,这个service占用了1%的CPU,再 过一会升到2%,观察一段时间后,发现这个service对cpu的占用率随着时间的推移而增加。gdb attach了相应的进程号,stack多是go runtime的调度。

再次回到代码,发现可能存在问题的只有这里的tick。我的tick间隔是1s。这样每1s都会创建一个runtime timer,而通过runtime的源码来看,这些timer都扔给了runtime调度(一个heap)。时间长了,就会有超多的timer需要 runtime调度,不耗CPU才怪。

于是做了如下修改:

func worker(start chan bool) {
        heartbeat := time.Tick(1 * time.Second)
         for {
               
                 select {
                         // … do some stuff
                         case <- heartbeat:
                                 return
                 }
        }
}

重新编译执行service,观察了一天,cpu再也没有升高过。

二、小心list.List的Delete逻辑

其实这是一个在哪种语言中都会遇到的初级问题,这里只是给大家提个醒罢了。不多说了,上代码:

从一个list.List中删除一个element,一般逻辑是:

l := list.New()
… …
for e := l.Front(); e != nil; e = e.Next() {
        if e.Value.(int) == someValue {
                l.Remove(e)
                return or break
        }
}

但是如果list里有重复元素,且代码要遍历整个list删除某个值为somevalue的元素呢?上面的一般方法是由逻辑缺陷的,例子:

func foo(i int) {
        l := list.New()
        for i := 0; i < 9; i++ {
                l.PushBack(i)
        }
        l.PushBack(6)

        for e := l.Front(); e != nil; e = e.Next() {
                fmt.Print(e.Value.(int))
        }

        for e := l.Front(); e != nil; e = e.Next() {
                if e.Value.(int) == i {
                        l.Remove(e)
                }
        }

        fmt.Printf("\n")
        for e := l.Front(); e != nil; e = e.Next() {
                fmt.Print(e.Value.(int))
        }
        fmt.Printf("\n")
}

func main() {
        foo(6)
}

该程序试图删除list中的所有值为6的element,但执行结果却是:

go run testlist.go
0123456786
012345786

list中尾部的那个6没有被删除,程序似乎在删除完第一个6之后就不再继续循环了。事实也是这样:

当l.Remove(e)执行后,e.Next()被置为了nil,这样循环条件不再满足,循环终止。

为此,对于这样的程序,下面的方法才是正确的:

func main() {
        bar(6)
}

func bar(i int) {
        l := list.New()
        for i := 0; i < 9; i++ {
                l.PushBack(i)
        }
        l.PushBack(6)

        for e := l.Front(); e != nil; e = e.Next() {
                fmt.Print(e.Value.(int))
        }

        var next *list.Element
        for e := l.Front(); e != nil; {
                if e.Value.(int) == i {
                        next = e.Next()
                        l.Remove(e)
                        e = next
                } else {
                        e = e.Next()
                }
        }

        fmt.Printf("\n")
        for e := l.Front(); e != nil; e = e.Next() {
                fmt.Print(e.Value.(int))
        }
        fmt.Printf("\n")
}

执行结果:
$ go run testlist.go
0123456786
01234578

三、要给template起个正确的名字

编写一个Web程序,需要用到html/template。

… …
t := template.New("My Reporter")
t, err = t.ParseFiles("views/report.html")
if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        return
}

t.Execute(w, UserInfo{xx: XX})

结果一执行却crash了:

[martini] PANIC: runtime error: invalid memory address or nil pointer dereference
/usr/local/go/src/runtime/panic.go:387 (0×16418)
/usr/local/go/src/runtime/panic.go:42 (0x1573e)
/usr/local/go/src/runtime/sigpanic_unix.go:26 (0x1bb50)
/usr/local/go/src/html/template/template.go:59 (0x7ed64)
/usr/local/go/src/html/template/template.go:75 (0x7ef0d)
/Users/tony/Test/GoToolsProjects/src/git.oschina.net/bigwhite/web/app.go:104 (0x2db0)
    reportHandler: t.Execute(w, UserInfo{xx: XXX})

问题在t.Execute这行,单独把template代码摘出来放在一个测试代码中:

//testtmpl.go
type UserInfo struct {
        Name string
}

func main() {
        t := template.New("My Reporter")
        t, err := t.ParseFiles("views/report.html")
        if err != nil {
                fmt.Println("parse error")
                return
        }

        err = t.Execute(os.Stdout, UserInfo{Name: "tonybai"})
        if err != nil {
                fmt.Println("exec error", err)
        }
        return
}

执行结果:
go run testtmpl.go
exec error template: My Reporter: "My Reporter" is an incomplete or empty template; defined templates are: "report.html"

看起来似乎template对象与模板名字对不上导致的错误啊。修改一下:

t := template.New("report.html")

执行结果:

<html>
<head>
</head>
<body>
    Hello, tonybai
</body>
</html>

这回对了,看来template的名字在与ParseFiles一起使用时不是随意取的,务必要与模板文件名字相同。

ParseFiles支持解析多个文件,如果是传入多个文件该咋办?godoc说了,template名字与第一个文件名相同即可。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats