Tag Archives: goroutine

再探 goroutine

之前写过一篇 go 语言并发机制 goroutine 初探,后来对 go 如何跟踪 syscall 调用返回产生了兴趣,研究后发现,之前的一些看法是错误的。

问题是这么发现的。

// par.go
package main

import (
"fmt";
"runtime"
"strconv"
"time"
)

func main() {
        runtime.GOMAXPROCS(2)
        ch := make(chan int)
        n := 1000
        for i := 0; i < n; i++ {
                task(strconv.Itoa(i), ch, 100)
        }
        fmt.Printf("begin\n")
        for i := 0; i < n; i++ {
                <-ch
        }
}

func task(name string, ch chan int, max int) {
        go func() {
                i:= 1
                for i <= max {
                        fmt.Printf("%s %d\n", name, i)
                        i++
                }
                ch <- 1
        }();
}

这么一个程序,运行 ./par | less ,然后查看 /proc/<pid>/tasks,或者用类似的 pstree -p <pid> 。原来指望只有很少的线程数。结果却是 1002 个。看起来似乎是实实在在地为每个 goroutine 启动了一个线程。然后又用 strace -f ./par 2>&1 | less 跟踪,也发现了大量的 clone 系统调用。也就是说,goroutine 并不是像我之前认为的,在 cgocall 或者 syscall 的时候进行自动切换,而是使用了线程。同时,这个线程数和 runtime.GOMAXPROCS 也没有直接关联。在这个情况下,虽然 runtime.GOMAXPROCS 设为了 2 ,但是最后照样用了 1000 多个线程。但是 strace -f ./par 直接运行,此时跟踪线程数,最多就只有几十个。看来和 less 也有关系。

在 golang 的邮件列表里提问了解到,goroutine 在遇到阻塞性的系统调用,比如 Read ,或者 cgo 调用,会启用一个线程来处理这些调用。想想也和结果对应上了。因为使用了 less ,在显示完一屏幕,并且管道缓冲也被填满后,fmt.Printf 底层对应的 Write 系统调用全部被阻塞。而由于每一个系统调用都需要一个线程来处理,于是就有了 1000 多个线程。由此看来,goroutine 也并不是之前想象中的那么神奇。同时邮件列表里也提到,对于 net 包,还是使用了异步 io 系统调用,因此在网络应用中并不会由于网络 io 速度慢造成阻塞而产生大量线程。看了 net 包的源代码下的 netfd(net/fd*.go) ,确实如此。

go 语言要避免大量线程产生的切换开销,用类似 coroutine 的方式,还是得结合异步 io 。但是目前只在网络 io 上实现了这点。对于其他的 io,比如文件系统,仍然会由于阻塞而产生线程。如果应用中需要使用文件 io,就得使用生产者消费者模式来减少线程数量,或者可以考虑利用 netfd 的代码来实现一个其他类型 io 的异步包装(当然功能上会有一些限制)。

go 语言并发机制 goroutine 初探

go 语言的一个很大的优势就是可以方便地编写并发程序。go 语言内置了 goroutine 机制。这是一种类似 coroutaine(协程) 的东西。但是又不完全相同。

比如这个例子:

package main
import (
    "fmt";
    "strconv"
)
func main() {
    ch := make(chan int)
    task("A", ch)
    task("B", ch)
    fmt.Printf("begin\n")
    <-ch
    <-ch
}
func task(name string, ch chan int) {
    go func() {
        i:= 1
        for {
            fmt.Printf("%s %d\n", name, i)
            i++
        }
        ch <- 1
    }();
}

运行以后,发现会 A B 两个 goroutine 会交替执行,并像传统的协程需要手动 schedule 。看起来很神奇。

稍稍改一下代码,把

fmt.Printf("%s %d\n", name, i)

改成

print(name + " " + strconv.Itoa(i) + "\n")

再看看。神奇的效果消失了,只有 A 被运行。

那么 fmt.Printf 和 print 有什么差别,导致了这个结果呢?

大致翻了一下 go 的代码,看出 go 语言在对 c lib 的包装上用了个 cgo 的方式。而在通过 cgo 调用 c 库的时候,会在调用时自动 schedule 切换走,在调用结束的时候再返回。这两个结果的差异就在于,fmt.Printf 是通过 cgo 包装的,而 print 则是原生实现的。所以在调用 fmt.Printf 的时候,就自动实现了调度。

传统的 coroutaine 在访问网络、数据库等 io 操作时仍然会阻塞,失去并发能力,所以通常需要结合异步 io 来实现。而现有的库如果本身未提供异步功能,就很难办,往往需要重新实现。而且,即使有异步 io 功能,也需要额外的开发,才能在表现上和以往顺序程序相同的方式。

go 语言的这种实现方式很好的解决了这个问题,可以充分利用现有的大量 c 库来包装。

同时,也还是可以使用 runtime.Gosched() 来手动调度。在运算量大的场景下,也还是必要的。

在使用 print 的例子里,如果使用 runtime.GOMAXPROCS(2),又可以重新并行起来。这时,两个 goroutine 是在两个独立的线程中运行的。这又是 goroutine 和协程的一个不同点。不过启用多个线程并不见得能让程序更快。因为跨线程的上下文切换代价也是很大的。在上面那个简单的例子里,还会让程序变得更慢。降低上下文切换开销也是协程的一个优势所在。


之后又对 goroutine 的实现研究了一下,发现了之前的一些错误。详见:再探-goroutine