Golang快速复习指南QuickReview——goroutine

Posted randyfield

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Golang快速复习指南QuickReview——goroutine相关的知识,希望对你有一定的参考价值。

goroutineGolang特有,类似于线程,但是线程是由操作系统进行调度管理,而goroutine是由Golang运行时进行调度管理的用户态的线程。

1.C#的线程操作

1.1 创建线程

 static void Main(string[] args)
 {
     Thread thread = new Thread(Count);
     thread.IsBackground = true;
     thread.Start();
     for (int i = 0; i < 10; i++)
         Console.Write("x
");
 }
 static void Count()
 {
     for (int i = 0; i < 100; i++)
     {
         Console.WriteLine(i); ;
     }
 }

1.2 向线程传参

Thread构造函数有两个参数ParameterizedThreadStartThreadStart

public delegate void ParameterizedThreadStart(object? obj);
public delegate void ThreadStart();

没错,一个是无参委托,,一个是有参委托且参数类型为object,因此我们用以创建线程的方法参数需为object类型

static void Main(string[] args)
{
    Thread thread = new Thread(Count);
    thread.IsBackground = true;
    thread.Start(100);
    for (int i = 0; i < 10; i++)
        Console.Write("x
");
}


static void Count(object times)
{
    int count = (int)times;
    for (int i = 0; i < 100; i++)
    {
        Console.WriteLine(i); ;
    }
}

当然简单的还是直接使用lambda表达式

static void Main(string[] args)
{
    Thread thread = new Thread(()=>{
        Count(100);
    });
    thread.IsBackground = true;
    thread.Start();
    for (int i = 0; i < 10; i++)
        Console.Write("x
");
}

static void Count(object times)
{
    int count = (int)times;
    for (int i = 0; i < 100; i++)
    {
        Console.WriteLine(i); ;
    }
}

注意:使用Lambda表达式可以很简单的给Thread传递参数,但是线程开始后,可能会不小心修改了被捕获的变量,这要多加注意。比如循环体中,最好创建一个临时变量。

1.3 线程安全与锁

从单例模式来看线程安全:

class Student
{
    private static Student _instance =new Student();
    private Student()
    {
    }
    static Student GetInstance()
    {
        return _instance;
    }
}
  • 单例模式,我们的本意是始终保持类实例化一次然后保证内存中只有一个实例。上述代码中在类被加载时,就完成静态私有变量的初始化,不管需要与否,都会实例化,这个被称为饿汉模式的单例模式。这样虽然没有线程安全问题,但是这个类如果不使用,就不需要实例化。然后便有了下面的写法:需要时实例化
class Student
{
    private static Student _instance;
    private Student()
    {
    }
    static Student GetInstance()
    {
        if (_instance == null) 
                        _instance = new Student();
        return _instance;
    }
}
  • 调用时判断静态私有变量是否为空,然后再给。这个其实就有一个线程安全的问题:多线程调用GetInstance(),当同时多个线程执行时,条件_instance == null可能会同时都满足。这样_instance就完成了多次实例化赋值操作,就引出了我们的锁Lock
private static Student _instance;
private static readonly object locker = new object();
private Student()
{

}
static Student GetInstance()
{
    lock (locker)
    {
        if (_instance == null)
            _instance = new Student();
    }

    return _instance;
}
  • 第一个线程运行,就会加锁Lock
  • 第二个线程运行,首先检测到locker对象为"加锁"状态(是否还有线程在lock内,未执行完成),该线程就会阻塞等待第一个线程解锁
  • 第一个线程执行完lock体内代码,解锁,第二个线程才会继续执行

上面看似已经完美了,但是多线程情况下,每次都要经历,检测(是否阻塞),上锁,解锁,其实是很影响性能,我们本来的目的是返回单一实例即可。我们在检测locker对象是否加锁之前,如果实例已经存在,那么后续工作是没必要做的。所以就有了下面的 双重检验

private static Student _instance;
private static readonly object locker = new object();
private Student()
{

}
static Student GetInstance()
{
    if (_instance == null)
    {
        lock (locker)
        {
            if (_instance == null)
                _instance = new Student();
        }
    }

    return _instance;
}

1.4 Task

线程有两种工作类型:

  • CPU-Bound计算密集型,花费大部分时间执行CPU密集型工作的操作,这种工作类型永远也不会让线程处在等待状态。

  • IO-BoundI/O密集型,花费大部分时间等待某事发生的操作,一直等待着,导致线程进入等待状态的工作类型。比如通过http请求对资源的访问。

对于IO-Bound的操作,时间花费主要是在I/O上,而在CPU上几乎是没有花费时间。对于此,更推荐使用Task编写异步代码,而对于CPU-BoundIO-Bound的异步代码不是我们本篇的重点,博主将大概介绍一下Task的优势:

  • 不再干等:: 以HttpClient使用异步代码请求GetStringAsync为例,这显然是一个I/O-Bound,最终会对操作系统本地网络库进行调用,系统API调用,比如发起请求的socket,但是这个时间长短并不是由代码决定,他取决硬件,操作系统,网络情况。控制权会返回给调用者,我们可以做其他操作,这让系统能处理更多的工作而不是等待 I/O 调用结束。直到await去获得请求结果。

  • 让调用者不再干等: 对于CPU-Bound,没有办法避免将一个线程用于计算,因为这毕竟是一个计算密集型的工作。但是使用Task的异步代码(asyncawait)不仅可以与后台线程交互,还可以让调用者继续响应(可以并发执行其他操作)。同上,直到遇到await时,异步方法都会让步于调用方。

2.Golang的goroutine

2.1 启动goroutine

Golang中启动一个goroutine没有C#的线程那么麻烦,只需要在调用方法的前面加上关键字go.

func main(){
    go Count(100)
}
func Count(times int) {
	for i := 0; i < times; i++ {
		fmt.Printf("%v
", i)
	}
}

2.2 goroutine的同步

在C#中的任务(Task)可以使用Task.WhenAll来等待Task对象完成。Golang中比较原始,像个花名册一样登记造册:

  • 启动 - 计数+1
  • 执行完毕 - 计数-1
  • 完成

要使用sync包:

package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func main() {
    
    //登记造册
	wg.Add(2)
	go Count(100)
	go Count(100)
    
    //等待所有登记的goroutine都结束
	wg.Wait()
	fmt.Println("执行完成")
}

func Count(times int) {
    
    //执行完成
	defer wg.Done()
	for i := 0; i < times; i++ {
		fmt.Printf("%v
", i)
	}
}

2.3 channel通道

一个goroutine发送特定值到另一个goroutine的通信机制就是channel

2.3.1 channel声明

channel是引用类型

var 变量 chan 元素类型

chan 元素类型structinterface一样,就是一种类型。后面的元素类型限定了通道具体存储类型。

2.3.2 channel初始化

声明后的channel是空值nil,需要初始化才能使用。

var ch chan int
ch=make(chan int,5) //5为设定的缓冲大小,可选参数

2.3.3 channel操作

操作三板斧:

  • send - 发送 ch<-100

值指向通道

  • receive - 接收 value:=<-ch
i, ok := <-ch1 // 通道关闭后再取值ok=false

//或者
for i := range ch1 { // 通道关闭后会退出for range循环
    fmt.Println(i)
}

通道指向变量

  • close - 关闭 close(ch)

2.3.3 缓冲与无缓冲

ch1:=make(chan int,5) //5为设定的缓冲大小,可选参数
ch2:=make(chan int)

无缓冲的通道,无缓冲的通道只有在接收值的时候才能发送值。

  • 只往通道传值,不从通道接收,就会出现deadlock
  • 只从通道接收,不往淘到发送,也会发生阻塞

使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道也被称为同步通道

有缓冲的通道,可以有效缓解无缓冲通道的尴尬,但是通道装满,上面的尴尬依然存在。

2.3.4 单向通道

限制通道在函数中只能发送或只能接收,单向通道粉墨登场,单向通道的使用是在函数的参数中,也没有引入新的关键字,只是简单的改变的箭头的位置:

chan<- int 只写不读
<-chan int 只读不写

函数传参及任何赋值操作中可以将双向通道转换为单向通道,反之,不行。

2.3.5 多路复用

package main

import "fmt"

func main() {
	ch := make(chan int, 1)
	for i := 0; i < 10; i++ {
		select {
		case x := <-ch:
			fmt.Printf("第%v次,x := <-ch,从通道中读取值%v", i+1, x)
			fmt.Println()
		case ch <- i:
			fmt.Printf("第%v次,执行ch<-i", i+1)
			fmt.Println()
		}
	}
}

第1次,执行ch<-i
第2次,x := <-ch,从通道中读取值0
第3次,执行ch<-i
第4次,x := <-ch,从通道中读取值2
第5次,执行ch<-i
第6次,x := <-ch,从通道中读取值4
第7次,执行ch<-i
第8次,x := <-ch,从通道中读取值6
第9次,执行ch<-i
第10次,x := <-ch,从通道中读取值8

Select多路复用的规则:

  • 可处理一个或多个channel的发送/接收操作。
  • 如果多个case同时满足,select会随机选择一个。
  • 对于没有caseselect{}会一直等待,可用于阻塞main函数。

2.5 并发安全与锁

goroutine是通过channel通道进行通信。不会出现并发安全问题。但是,实际上还是不能完全避免操作公共资源的情况,如果多个goroutine同时操作这些公共资源,可能就会发生并发安全问题,跟C#的线程一样,锁的出现就是为了解决这个问题:

2.5.1 互斥锁

互斥锁,这个就跟C#的锁是一样,一个goroutine访问,另外一个就这能等待互斥锁的释放。同样需要sync包:

sync.Mutex

var lock sync.Mutex

lock.Lock()//加锁

//操作公共资源

lock.Unlock()//解锁

2.5.2 读写互斥锁

互斥锁是完全互斥的,如果是读多写少,大部分goroutine都在读,少量的goroutine在写,这时并发读是没必要加锁的。使用时,依然需要sync包:

sync.RWMutex

读写锁分为两种:

  • 读锁
  • 写锁。
import (
	"fmt"
	"sync"
)

var (
	lock   sync.Mutex
	rwlock sync.RWMutex
)

rwlock.Lock() // 加写锁
//效果等同于互斥锁
rwlock.Unlock() // 解写锁

rwlock.RLock()  //加读锁
//可读不可写
rwlock.RUnlock() //解读锁

2.6* sync.Once

goroutine的同步我们使用过sync.WaitGroup

  • Add(count int) 计数器累加,在调用goroutine外部执行,由开发人员指定
  • Done() 计数器-1,在goroutine内部执行
  • Wait() 阻塞 直至计数器为0

除此之外还有一个sync.Once,顾名思义,一次,只执行一次。

func (o *Once) Do(f func()) {}

var handleOnce sync.Once
handleOnce.Do(函数)

sync.Once其实内部包含一个互斥锁和一个布尔值,互斥锁保证布尔值和数据的安全,而布尔值用来记录初始化是否完成。这样设计就能保证初始化操作的时候是并发安全的并且初始化操作也不会被执行多次。

type Once struct {
	// done indicates whether the action has been performed.
	// It is first in the struct because it is used in the hot path.
	// The hot path is inlined at every call site.
	// Placing done first allows more compact instructions on some architectures (amd64/x86),
	// and fewer instructions (to calculate offset) on other architectures.
	done uint32
	m    Mutex
}

2.7* sync.Map

Golang的map不是并发安全的。sync包中提供了一个开箱即用的并发安全版map–sync.Map

开箱即用表示不用像内置的map一样使用make函数初始化就能直接使用。同时sync.Map内置了诸如StoreLoadLoadOrStoreDeleteRange等操作方法。

var m = sync.Map{}
m.Store("四川", "成都")
m.Store("成都", "高新区")
m.Store("高新区", "应龙南一路")
m.Range(func(key interface{}, value interface{}) bool {
    fmt.Printf("k=:%v,v:=%v
", key, value)
    return true
})
fmt.Println()
v, ok := m.Load("成都")
if ok {
    fmt.Println(v)
}
fmt.Println()
value, loaded := m.LoadOrStore("陕西", "西安")
fmt.Println(value)
fmt.Println(loaded)
m.Range(func(key interface{}, value interface{}) bool {
    fmt.Printf("k=:%v,v:=%v
", key, value)
    return true
})
fmt.Println()

//存在就加载,不存在就添加
value1, loaded1 := m.LoadOrStore("四川", "成都")
fmt.Println(value1)
fmt.Println(loaded1)
m.Range(func(key interface{}, value interface{}) bool {
    fmt.Printf("k=:%v,v:=%v
", key, value)
    return true
})
fmt.Println()

//加载并删除 key存在
value2, loaded2 := m.LoadAndDelete("四川")
fmt.Println(value2)
fmt.Println(loaded2)
m.Range(func(key interface{}, value interface{}) bool {
    fmt.Printf("k=:%v,v:=%v
", key, value)
    return true
})
fmt.Println()

//加载并删除 key 不存在
value3, loaded3 := m.LoadAndDelete("北京")
fmt.Println(value3)
fmt.Println(loaded3)
m.Range(func(key interface{}, value interface{}) bool {
    fmt.Printf("k=:%v,v:=%v
", key, value)
    return true
})
fmt.Println()

m.Delete("成都")  //内部是调用的LoadAndDelete
m.Range(func(key interface{}, value interface{}) bool {
    fmt.Printf("k=:%v,v:=%v
", key, value)
    return true
})
fmt.Println()
k=:四川,v:=成都
k=:成都,v:=高新区
k=:高新区,v:=应龙南一路

高新区

西安
false
k=:四川,v:=成都
k=:成都,v:=高新区
k=:高新区,v:=应龙南一路
k=:陕西,v:=西安

成都
true
k=:四川,v:=成都
k=:成都,v:=高新区
k=:高新区,v:=应龙南一路
k=:陕西,v:=西安

成都
true
k=:陕西,v:=西安
k=:成都,v:=高新区
k=:高新区,v:=应龙南一路

<nil>
false
k=:成都,v:=高新区
k=:高新区,v:=应龙南一路
k=:陕西,v:=西安

k=:高新区,v:=应龙南一路
k=:陕西,v:=西安

2.8 单例模式

综上,sync.Once其实内部包含一个互斥锁和一个布尔值,这个布尔值就相当于C#单例模式下的双重检验的第一个判断。所以在golang中可以利用sync.Once实现单例模式:

package singleton

import (
    "sync"
)

type singleton struct {}

var instance *singleton
var once sync.Once

func GetInstance() *singleton {
    once.Do(func() {
        instance = &singleton{}
    })
    return instance
}

2.9* 原子操作

代码中的加锁操作因为涉及内核态的上下文切换会比较耗时、代价比较高。针对基本数据类型我们还可以使用原子操作来保证并发安全。Golang语言中原子操作由内置的标准库sync/atomic提供。由于场景较少,就不做介绍,详细操作请自行查阅学习。

再次强调:这个系列并不是教程,如果想系统的学习,博主可推荐学习资源。

以上是关于Golang快速复习指南QuickReview——goroutine的主要内容,如果未能解决你的问题,请参考以下文章

Golang快速复习指南QuickReview——socket

JAVA开发者的Golang快速指南

golang 快速入门让Golang kafka驱动程序发布到“测试”主题,这些主题是从快速入门指南创建的http://kafka.apache.org/docum

0- Golang 修炼指南

Golang系列之快速入门教程

golang 项目实战简明指南