概念

下面简单介绍一下,进程,线程,锁,协程,通道,并发,并行的概念。

进程

一个程序一般会开一个进程(像phpfpm这种是开多进程的,Nodejs还可以使用多进程间通信),一个进程可以开N多线程。

线程

线程在单核计算机上是抢占执行的,也就是说同一时间单核计算机上一个进程内只有一个线程列队(线程池),但可以有多条线程在线程池,它们等待时机执行,但是同一时间段运行着的只有一个线程。这样做的好处就是异步执行,比如一些耗时的任务可以先放入线程池,等其余的线程不忙碌的时候再拿出来执行。而多核计算机则可以有多个线程池,把不同的线程池分配到不同的核心上,但是同样的一个核心上的每个线程池同时只有一个线程在运行,所以多核心上同时运行的线程数量就等于核心的数量。

协程

这里的协程直接使用goroutine概念,跟coroutine无关

线程池与线程,线程与协程的关系类似于RabbitMQ的异步消息列队与列队任务的关系

所以每个线程又可以有非常多的协程,协程在线程上成列队排序,就跟每个内核的线程池一样,并且随机执行,当某个线程上一个协程在执行过程中卡死的时候go会自动把此线程上其余协程分配到空闲的线程上执行。

通道与锁

一个线程池的线程列队是随机执行的,这会导致共享的数据无法确定。比如一个线程列队共享一个数据count,但是其中一部分线程设置count值,另一部分线程又需要读取count值,而我们又无法确定哪些线程先执行哪些后执行,这时候在一个设置count的线程里会导致count值被随意覆盖,而读取的线程里无法获得准确值。所以我们就需要线程锁来控制线程的执行顺序,以及锁定共享数据了,通过这种共享内存的方法虽然解决了问题,但也造成了不必要的风险,比如锁住后忘记解锁会导致整个程序崩溃。因为一个线程上的协程也是随机抢占(就是不自觉排队,看能力插队)执行的,这就导致了与线程同样的问题-数据不准确。

go直接放弃操作线程,只操作协程,并且使用简单易用的通道来解决这个问题。通道的目标就是在一部分协程间建立起一座数据通信的桥梁,使各个协程可以关联,从而通过执行顺序来控制共享数据。

并发并行

多个任务在同一时间片执行是为并行,多个任务在同一时间异步抢占执行是为并发。由于一个核心同时只能有一个线程运行,一个线程同时只有一个协程处于运行状态,所以协程的并行需要线程的并行,线程的并行需要多核心。所以go并行的首要条件就是多核心计算机。其中并行的每个核心其实包含了并发的协程。但是重要的是,对于单核计算机来说,协程也远比线程轻量,协程的切换和控制更加方便,所以即使单核计算机上goroutine的性能也是无可比拟的。

线程设置

Go默认设置的线程数等于执行go程序的计算机的核心数,但是这个数字是可以读取也可以修改的。如下:

当GOMAXPROCS方法没有参数或参数<=0时为读取线程数

// 获取核心数
fmt.Println(runtime.NumCPU())
// 读取当前线程数
fmt.Println(runtime.GOMAXPROCS(0))
// 设置线程数
runtime.GOMAXPROCS(16)

协程优势

类似PHP(swoole等除外)这种语言是最传统的,每次执行一个单位需要过一遍整个进程。而像Java,Node(新版本),Python等编程语言对并发的处理一般使用多线程的方式解决,而一个线程是非常重量的单位,一个进程开的线程数是非常有限的,开多了很容易造成程序假死。而go协程(区别于python,kotlin等coroutines)是非常轻量的单位,一个进程就可以管理上百万个协程,所以这就使得go的并发量特别高,进一步是go程序的执行性能非常优秀。协程主要用于API请求,消息推送,聊天会话等,每次请求开一个协程,开个几十万都不是问题。

协程

初步

go的入口函数main是所有协程的父协程

Go语言里使用协程非常简单,只要在调用一个函数的时候在其前面加上go关键字即可。比如:

package main

import (
    "time"
    "fmt"
)

func outputName(name string)  {
    fmt.Println(name)
}

func main() {
    go outputName("Jack")
    go outputName("Rose")
    go outputName("Lily")
  // 保证其它的协程执行完毕后(1秒的时间足够执行三次输出)再继续执行主协程
    time.Sleep(time.Second)
    fmt.Println("do in main goroutine")
}

结果:

每次执行输出的结果都是不一样的除了最后一条,因为输出人名的是放到线程里异步抢占执行的协程

Jack
Lily
Rose
do in main goroutine

协程也可以使用立即执行的匿名函数,与JS里的用法类似,比如:

立即执行的意思,是立即加入到协程列队,而不是同步当场执行

func main() {
  go func() {
    fmt.Println("Jack")
  }()
  // 保证其它的协程执行完毕后(1秒的时间足够执行完毕)再继续执行主协程
    time.Sleep(time.Second)
    fmt.Println("do in main goroutine")
}

为了不会让因子协程执行错误而导致主协程甚至整个程序挂掉,可以在有风险的子协程函数内加入一个defer关键字的异常处理,并在异常处理的时候使用recover()函数恢复异常状态为正常状态,这样可以确保在子协程执行遇到错误的时候立即结束这个协程而不至于冒泡到主协程

func outputName(name string)  {
    defer func() {
    if err := recover(); err != nil {
      // log error
    }
  }()
}

通道

通道(channels)是协程间的数据通信机制。可以用通道对协程进行执行顺序和数据的控制。

创建通道

通道是引用类型数据,使用make关键字创建,如下:

ch1 := make(chan 类型,缓冲数量) // 类型可以是int,string等基础类型,也可以是结构体指针或接口等引用类型 

无缓冲通道

无缓冲通道就是创建通道的时候把缓冲数量参数的值设置为<=0的数值或者不设置。一个无缓冲通道要求发送goroutine和接受gorouine同时就绪才能执行。如果发送者没有准备好,则接受者会一直处于阻塞状态,反之亦然。

阻塞是某个协程处于等待数据的状态而无法向下执行

package main

import (
    "fmt"
    "math/rand"
    "time"
)

var groups = []string{
    "jack", "rose", "Lily", "jobs", "tim", "jordan",
}

func init() {
    // 设置一个取货随机数的基准为unix时间戳
    rand.Seed(time.Now().UnixNano())
}

func main() {
    // 设置一个判断是否全歼敌军的通道变量
    alldied := make(chan bool)
    go func() {
        for {
            // 随机判断是否击杀
            killed := rand.Intn(2) != 0
            if !killed {
                fmt.Println("本轮没有击杀敌人")
            } else {
                // 如果击杀则pop最后一个敌人
                groups = groups[0 : len(groups)-1]
                fmt.Printf("击杀1人,剩余%d个敌人\n", len(groups))
            }
            // 全部歼灭则发送通道值通知父routine并跳出循环
            if len(groups) <= 0 {
                alldied <- true
                break
            }
            // 每轮射击后休息1秒
            time.Sleep(time.Second)
        }

    }()
    // 阻塞到全部击杀
    <-alldied
    fmt.Println("敌人全部被歼灭")
}

执行结果如下

本轮没有击杀敌人
击杀1人,剩余5个敌人
击杀1人,剩余4个敌人
本轮没有击杀敌人
击杀1人,剩余3个敌人
本轮没有击杀敌人
击杀1人,剩余2个敌人
击杀1人,剩余1个敌人
本轮没有击杀敌人
击杀1人,剩余0个敌人
敌人全部被歼灭

缓冲通道