再探 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 的异步包装(当然功能上会有一些限制)。

6 thoughts on “再探 goroutine

  1. Hong Ruiqi August 2, 2012 / 5:03 pm

    调用 fmt.Printf 是一定会建立新的进程的(前提:有goroutine等待运行)。go 运行时自身无法判断 Printf 会立即完成还是会阻塞一段时间,所以只能默认大多数的情况,Printf 会阻塞。所以在当前线程阻塞在Printf之前,会建立新线程执行其它goroutine。
    Go 的思想应该是线程复用,从而避免线程不停创建销毁的开销。系统调用时必然会阻塞线程,只是时间长短。如果时间长,又要并行,就必须要有新的线程建立。Syscall 里的 Read/Write 都是会产生新线程的。减少线程数量,可以使用操作系统的机制(Epoll等),使得有数据读写时才去产生系统调用,使得阻塞时间降低到最小,线程可以很快被复用,降低了总的线程数量。这也是net库所做的事。
    net库在读写的时候同样使用了Syscall Read/Write,所以并不能避免线程的产生。假设当前有N个线程,其中N-1个都阻塞了,第N个线程调用了Read方法,并且有数据等待被读,此时若有goroutine在等待运行,仍然会有N+1进程被创建,只是很快读取完数据,转为空闲状态。
    less会使用更多线程,我觉得是由于less使得输出变慢,系统调用阻塞时间变长,线程复用率低。假设每个Printf都可以很快完成,那么后面的Printf就可以复用前面所建立的线程,使总线程数减少。但是如果每个线程都长时间阻塞在Printf,后执行的Printf将由于没有空闲线程可用,建立新的线程用于阻塞。(因为goroutine是运行时一起建立的,一个紧接一个执行。所以Printf需求空闲线程的频率会很高,如果Printf不能在很短时间完成,就会有大量线程创建)
    GOMAXPROCS 只是指定同时在CPU上跑(执行用户代码)的最大线程数量,与程序所使用的线程数关系不大。
    以上是我的理解,可能有不对的地方,请指正。

    • 神仙 August 2, 2012 / 9:30 pm

      嗯,说得比我还清楚一些。less 是使所有的 Write 完全阻塞了,1000 个 goroutine 里的 Printf 都没法完成,所以每一个线程都没法回收。

  2. perfgeeks September 26, 2012 / 5:25 pm

    可以通过channel限制goroutinue跑的数量

    func working(bc chan int) {
    //do something
    <- bc //拿走一个
    }

    func maner() {
    conNum := 10;
    files := loadFiles("*.csv")
    bc := make(chan int, conNum)
    for _, f := range files {
    bc <- 1
    go working(bc)
    }
    }

  3. jhiter December 10, 2012 / 12:48 am

    这是我对goroutine调度的一点理解,此外,使用cgo进行调用的时候应该会切换到M的g0这个goroutine(使用线程原生的堆栈)上去,因为每一个M是一个线程,而每一个M只有一个g0,所以使用cgo调用到c程序时会占用一个线程

  4. hit9 November 17, 2013 / 9:31 pm

    一楼的见解值得收藏

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s