Go线程模型&异步编程的能力

1.文章目录

  • Go概述
  • Go语言线程模型
  • goroutine与channel初探实践
  • Go实现异步编程与JDK的对比

2.Go概述

  • 传统的编程模型,JAVA,C++,Python实现并发编程时,多线程之间需要通过共享内存(JAVA堆上的共享变量)来通信;为了保证线程安全,多线程共享的数据结构需要使用锁来保护,多线程访问共享数据需要锁竞争,获得锁才可以获取共享数据;
  • Go提供了低级并发支持锁,互斥锁,读写锁,条件变量等;Go推荐使用channel和goroutine独特的结构化并发方式。
  • JAVA中的线程模型是一个操作系统内核线程对应一个new Thread创建的线程,由于操作系统的线程是有限的,所以限制了创建线程的个数;另外,线程阻塞时,线程要用用户态切换到内核态执行,开销很大;
  • Go线程模型时一个操作系统线程对应多个goroutine,用户可以创建的goroutine只受内存大小的限制,上下文切换都在用户态,很快,一台机器可以创建百万个goroutine;

3.Go线程模型

  • 线程的并发执行是操作系统来进行调度的,操作系统一般在内核提供堆线程的支持;我们用高级语言编写时创建的线程时用户线程,用户线程和内核线程的关系是什么呢?

一对一模型

  • 这种线程模型用户线程和内核线程一对一,程序从程序入口启动后,操作系统就创建了一个进程。这个main函数所在的线程就是主线程
  • main函数中创建一个线程其实就对应操作系统中创建一个内核线程;
  • 优点:线程可以实现真正意义上的并行,一个线程阻塞其他线程不会有影响
  • 缺点:一般操作系统会限制内核的线程个数,所以用户线程也会限制;用户线程执行系统调用时会涉及线程用户态与内核态的切换;

          

多对一模型

  • 多个用户线程对应一个内核线程,同时同一个用户线程只能对应一个内核线程,这个时候统一内核的多个用户线程上下文切换是由用户态运行时线程库做的;
  • 优点:上下文切换在用户态,速度很快,开销很小;可以创建的用户线程数量很多,只手内存大小的限制;
  • 缺点:多个用户线程对应一个内核线程,有一个用户线程阻塞,该内核线程的其他用户线程也不能运行;不能很好的利用CPU进行并发;

           

多对多模型

  • 结合一对一和一对多的优点,让大量的用户线程对应少数几个内核线程;
  • 同时每个内核线程对应多个用户线程,每个用户线程对应多个内核线程,当一个用户线程阻塞时,其对应的其他用户线程切换到其他内核线程运行;所以可以充分使用CPU的效能;对用户线程没有个数限制;

                   

Go线程模型

     

  • Go的goroutine可以认为是轻量级的用户线程。Go的线程模型包含3个概念:内核线程,goroutine和逻辑处理器。
  • Go中每个逻辑处理器会绑定到一个内核线程上,每个逻辑处理器有一个本地队列,用来存放Go运行时分配的goroutine,操作系统调度线程在CPU上运行,在Go中是运行时goroutine在逻辑处理器上运行;
  • Go的两级调度:一级是操作系统的调度系统,该调度系统调度逻辑处理器占用CPU的运行时间;一级是Go的运行时调度系统调度某个goroutine在逻辑器上的运行;
  • 使用Go语言创建的goroutine会被放进Go的运行时调度器全局运行队列中,然后Go运行时调度器把全局队列中的goroutine分配给不同的逻辑处理器,然后被分配的goroutine进入逻辑处理器的运行队列,待分配到时间片就可以运行;
  • 防止goroutine出现饥饿现象,逻辑处理器会分时处理多个goroutine,不是独占到结束;
  • 一个goroutine阻塞OS线程,列如等待输入,OS线程对应的逻辑处理器会把其他的goroutine迁移到其他的OS线程;
  • 其他:可以使用debug.SetMaxThreads(num)设置最大内核线程数,默认10000个;也可以runtime.GOMAXPROCS设置逻辑处理器个数;

4.goroutine与channel实践

    在Go中,使用go 跟上一个函数就创建一个goroutine,每个goroutine可以任务是一个轻量级线程,占用更少的堆栈内存,并且可以自己在运行时动态增加/回收;

package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func main() {
	// 函数退出时会执行
	defer fmt.Println("---main goroutine over---")
    
	wg.Add(1)
	go func() {
		fmt.Println("goroutine hi")
		wg.Done()
	}()

	fmt.Println("--wait sub goroutine over--")
    // 阻塞等待goroutine结束
	wg.Wait()
	fmt.Println("---sub goroutine over---")
}
  • 上述代码,go func()运行一个匿名函数,我们也可以定义一个函数然后go 函数名,实现和上述代码一样的功能;
package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func printMsg()  {
	fmt.Println("goroutine hi")
	wg.Done()
}
func main() {
	// 函数退出时会执行
	defer fmt.Println("---main goroutine over---")

	wg.Add(1)
	go printMsg()
	fmt.Println("--wait sub goroutine over--")
	wg.Wait()
	fmt.Println("---sub goroutine over---")
}

注意点:

  • Go整个进程的生命周期与main函数所在的goroutine一致,main函数所在的goroutine结束,整个进程就结束了,不管是否还有其他goroutine在运行;
  • JDK中我们知道线程退出的条件是没有非Deamon线程,因此会等待非Deamon线程运行完;
package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func printMsg() {
	fmt.Println("goroutine hi")
	wg.Done()
	// 循环打印
	for {
		fmt.Println("yeyeye")
	}
}
func main() {
	// 函数退出时会执行
	defer fmt.Println("---main goroutine over---")

	wg.Add(1)
	go printMsg()
	fmt.Println("--wait sub goroutine over--")
	wg.Wait()
	fmt.Println("---sub goroutine over---")
}
  • 上述代码,尽管循环打印没有结束,还是会退出线程;

channel通知goroutine退出

package main

import (
	"fmt"
	"time"
)

func main() {
	// 函数退出时会执行
	defer fmt.Println("---main goroutine over---")

	quit := make(chan struct{})
	go func() {
        // 监听机制
		select {
        监听quit有数据
		case <-quit:
			fmt.Println("sub goroutine over")
			return
		default:
			for {
				time.Sleep(1 * time.Second)
				fmt.Println("hihi")
			}
		}
	}()
	// do something
	time.Sleep(5 * time.Second)
	fmt.Println("--stop sub goroutine over--")
    // 本质quit中写入零值,子goroutine监听到就会退出
	close(quit)

	time.Sleep(10 * time.Second)
}

Channel概述

  • channel通道,可以任务是一个并发安全的队列,生产者放入元素,消费者获取元素;
  • 从大小来看,通道分为有缓冲通道,无缓冲通道(最多只有一个);从方向来看,如果通道只允许写元素声明为var ch chan <- int,只允许取声明为var ch <- chan int接受通道;既可以取也可以收var ch chan int;
package main

func main() {
	// 创建一个无缓冲通道,两种方式
	ch1 := make(chan int)
	/*var ch1 chan int = make(chan int)
	// 创建有缓冲通道
	ch2 := make(chan int, 10) // 缓冲数为10
	var ch2 chan int  = make(chan int, 10)*/

	// 通道写入数据
	ch1 <- 1 // 写入

	ch1 <- 2     // 此时因为ch1内的数据没有被消费,会阻塞*/
	<-ch1        // 读取元素
	num := <-ch1 // 读取元素到变量num,此时会因为没有元素被阻塞

}
  • 到通道满了,添加元素会阻塞;当通道没有数据,获取数据也会阻塞;

生产者消费者模型

package main

import "fmt"

// ch只读取,wg只写入
func printer(ch <-chan int, wg chan<- int) {
	// 打印通道元素,没有则阻塞
	for i := range ch {
		fmt.Println(i)
	}
	// 写入元素
	wg <- 1
	// 关闭通道,只可以读通道元素不能再写
	close(wg)
}
func main() {
	// 创建缓冲通道
	ch := make(chan int, 10)
	// 创建同步用的无缓冲通道
	wg := make(chan int)

	go printer(ch, wg)
    // 写入元素,激活激活的goroutine
	for i := 1; i < 100; i++ {
		ch <- i
	}
	// 关闭协程,不能写,只能读
	close(ch)

	fmt.Println("wait sub goroutine over")
    // 当printer goroutine没有运行完,会一直阻塞,等待wg中有元素
	<-wg
	fmt.Println("main goroutine over")
}

5.Go实现异步编程&JDK对比

  • 我们使用管道实现异步编程以及回压;
  • 我们在JDK-CompletableFuture中,一个异步任务称为计算节点;基于Go实现类似功能;

计算节点一:异步将整数列表发送到通道,关闭通道返回;

// gen函数,nums表示多个int类型数据,多参数  返回一个只读取的通道, 不会阻塞
func gen(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		// 写入所有数据,然后关闭通道,写入一个之后等待消费者消费后,才可以再次写入
		for _, n := range nums {
			out <- n
		}
		// close 后不能写入,其他goroutine读取out的数据完之后就会退出不在阻塞
		close(out)
	}()
	return out
}

计算节点2:将通道数据读取,异步返回保存读取数据的平方数通道

func sq(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		//异步的从in获取数据,开始是阻塞的,遍历写入out,当gen调用close(out)后就会不在阻塞等待in的数据
		for n := range in {
			out <- n * n
		}
		// 通知main goroutine的for 通道数据写入完,读取完就可以了,不用阻塞了
		close(out)
	}()
	return out
}

测试:

package main

import (
	"fmt"
)

// gen函数,nums表示多个int类型数据,多参数  返回一个只读取的通道, 不会阻塞
func gen(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		// 写入所有数据,然后关闭通道,写入一个之后等待消费者消费后,才可以再次写入
		for _, n := range nums {
			out <- n
		}
		// close 后不能写入,其他goroutine读取out的数据完之后就会退出不在阻塞
		close(out)
	}()
	return out
}

func sq(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		//异步的从in获取数据,开始是阻塞的,遍历写入out,当gen调用close(out)后就会不在阻塞等待in的数据
		for n := range in {
			out <- n * n
		}
		// 通知main goroutine的for 通道数据写入完,读取完就可以了,不用阻塞了
		close(out)
	}()
	return out
}
func main() {
	c := gen(2, 3)
	out := sq(c)
    
	// 类似于JAVA CompleteFuture.thenApply ..等
	for i := range out{
		fmt.Println(i)
	}
}

         

同样的sq函数输入,输出类似,可以多次调用该节点

func main() {
	c := gen(2, 3)
	out := sq(c)
	out = sq(out)
	// 类似于JAVA CompleteFuture.thenApply ..等
	for i := range out{
		fmt.Println(i)
	}
}

异步回压操作:类似CompletableFuture.thenCombine:将两个节点的结果,作为参数作为下一个节点的入参

func merge(cs ...<-chan int) <-chan int {
	var wg sync.WaitGroup
	// 输出通道
	out := make(chan int)

	// 函数:将输入通道元素写入元素的输入通道
	outPut := func(c <-chan int) {
		for n := range c {
			out <- n
		}
		wg.Done()
	}
	// cs 数量的信号量
	wg.Add(len(cs))

	for _, c := range cs {
		go outPut(c)
	}

	// 等待输入通道所有数据全部写入输出通道
	go func() {
		wg.Wait()
		close(out)
	}()
	return out
}

测试:

func main() {
	c := gen(2, 3)
	// 由于两个sq是并发的,可能是4先写入meger通道也可能是9
	out := sq(c)
	out1 := sq(c)

	// 4,9 / 9,4
	for i := range merge(out, out1) {
		fmt.Println(i)
	}
}
  • 借助Go的并发原语与goroutine通信,完成异步常见模型

6.总结

  • 通过Go的线程模型的优势,与对并发的支持,以及异步模型的实践,Go为什么会被推举为并发世界的宠儿,简单,易用;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章