golang并发编程

在早期,CPU都是以单核的形式顺序执行机器指令。C语言、PHP正是这种顺序编程语言的代表,即所有的指令都是以串行的方式执行,在相同的时刻有且仅有一个CPU在顺序执行程序的指令。随着处理器技术的发展,单核时代以提升处理器频率来提高运行效率的方式遇到了瓶颈。单核CPU的发展的停滞,给多核CPU的发展带来了机遇。相应地,编程语言也开始逐步向并行化的方向发展。Go语言正是在多核和网络化的时代背景下诞生的原生支持并发的编程语言。

Goroutine

goroutine 是 Go 语言特有的并发体,是一种轻量级的线程,由go关键字启动。在真实的Go语言的实现中,goroutine 和系统线程也不是等价的。尽管两者的区别实际上只是一个量的区别,但正是这个量变引发了 Go 语言并发编程质的飞跃。

package main

import "fmt"

func main() {
    //并发版hello world
    go println("hello world")
}

每个系统级线程都会有一个固定大小的栈(一般默认可能是8MB),这个栈主要用来保存函数递归调用时参数和局部变量。固定了栈的大小导致了两个问题:一是对于很多只需要很小的栈空间的线程来说是一个巨大的浪费,二是对于少数需要巨大栈空间的线程来说又面临栈溢出的风险。相反,一个 goroutine 会以一个很小的栈启动(可能是2KB或4KB),当遇到当前栈空间不足时, goroutine 会根据需要动态地伸缩栈的大小。因为启动的代价很小,所以我们可以轻易地启动成千上万个 goroutine 。
Go的调度器使用了一些技术手段,可以在n个操作系统线程上多工调度m个 goroutine 。只有在当前 goroutine 发生阻塞时才会导致调度,同时发生在用户态,切换的代价要比系统线程低得多。运行时有一个 runtime.GOMAXPROCS 变量,用于控制当前运行正常非阻塞 goroutine 的系统线程数目。在Go语言中启动一个 goroutine 不仅和调用函数一样简单,而且 goroutine 之间调度代价也很低,这些因素极大地促进了并发编程的流行和发展。

Channel

在并发编程中,对共享资源的正确访问需要精确的控制,在目前的绝大多数语言中,都是通过加锁等线程同步方案来解决这一问题。而Go语言却另辟蹊径,它将共享的值通过Channel传递,数据竞争从设计层面上就被杜绝了。通过通道来传值是Go语言推荐的做法,虽然像引用计数这类简单的并发问题通过原子操作或互斥锁就能很好地实现,但是通过Channel来控制访问能够让你写出更简洁正确的程序。

创建通道

//非缓冲通道
ch1 := make(chan int)
//缓冲通道
ch2 := make(chan int, 1)

非缓冲通道必须确保有协程正在尝试读取当前通道,否则写操作就会阻塞直到有其它协程来从通道中读东西。

读写通道

//从通道读,
data, ok := <-ch1
data := <-ch1
//往通道写
ch2 <-data
//使用range读,通道没数据for就会阻塞,通道关闭就会退出for
for v := range ch1 {
    println(v)
}
//多路通道
for {
    select {
    case v := <-ch1:
        println(v)
    case v := <-ch2:
        println(v)
    }
}

通道满了,写操作就会阻塞,协程就会进入休眠,直到有其它协程读通道挪出了空间,协程才会被唤醒。通道空了,读操作就会阻塞,协程也会进入睡眠,直到有其它协程写通道装进了数据才会被唤醒。

//关闭通道
close(ch1)

读取一个已经关闭的通道会立即返回通道类型的「零值」,而写一个已经关闭的通道会抛异常。使用 for range 读取时用完要记得关闭通道,否则会阻塞。

同步控制

根据 Go 语言规范,main 函数退出时程序结束,不会等待任何后台线程。因为 goroutine 的执行和 main 函数的返回事件是并发的,谁都有可能先发生,所以什么时候打印,能否打印都是未知的。

sleep

func main() {
    go println("你好, 世界")
    time.Sleep(time.Second)
    //或者一个死循环
    for {}
}

不可靠,因为实际协程执行时间未知

互斥锁

func main() {
    var mu sync.Mutex
    mu.Lock()
    go func() {
        println("你好, 世界")
        mu.Unlock()
    }()
    mu.Lock()
}

主携程中第二次获取锁时阻塞

通道

func main() {
    ch := make(chan int, 1)
    go func() {
        println("你好, 世界")
        ch<-1
    }()
    <-ch
}

从ch取值,由于通道为空所以会阻塞直到有数据写入

原子等待组

func main() {
    var wg sync.WaitGroup
    wg.Add(10)

    for i := 1; i < 10; i++ {
        //wg.Add(1)
        go func(n int) {
            println("你好, ", n)
            wg.Done()   //wg.Add(-1)
        }(i)
    }
    //等待协程完成
    wg.Wait()
}

如果不把i作为参数传入闭包函数,闭包go协程里面引用的是变量i的地址,所有的go协程启动后等待调用,很可能在for循环完成之后才被调用,所以输出结果很多都是10

编程技巧

控制并发数

虽然启动一个携程代价很小,但是也不能无限制地创建携程,否则导致cpu占用过高

func main() {
    var limit = make(chan int, 3)
    for _, id := range ids {
        limit <- 1
        go func() {
            worker(id)
            <-limit
        }()
    }
    for {}
}

超时处理

当限制并发数的时候,如果有大量写通道,会造成通道阻塞过长

func main() {
    select {
    case id <- 1:
        println("success")
    case <- time.After(3 * time.Second):
        println("timeout")
    }
}

生产者消费者实例

例如在tcp编程中,一个 goroutine 用来读,一个 goroutine 用来写,读写 goroutine 间用通道传递消息

func main()  {
	listen, _ := net.Listen("tcp4", ":9001")
	defer listen.Close()
	for {
		conn, _ := listen.Accept()

		ch := make(chan string, 10)
		go read(conn, ch)
		go write(conn, ch)
	}
}

func write(conn net.Conn, ch <-chan string) {
	for msg := range ch {
		_, err := conn.Write([]byte(msg))
		if err != nil {
			break
		}
	}
}

func read(conn net.Conn, ch chan<- string) {
	for {
		msg := make([]byte, 1024)
		n, err := conn.Read(msg)
		if err != nil {
			break
		}
		ch <- string(msg[:n])
	}
}
posted @ 2020-05-13 18:41  luke44  阅读(495)  评论(2编辑  收藏  举报