Goroutine详解

第五章 Goroutine

5.1 进程、线程、协程

  1. 进程
  • 进程是程序一次动态执行的过程,是程序运行的基本单位。
  • 每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。
  • 进程占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、页表、文件句柄等)比较大,但相对比较稳定安全。协程切换和协程切换
  1. 线程
  • 线程又叫做轻量级进程,是CPU调度的最小单位。
  • 线程从属于进程,是程序的实际执行者。一个进程至少包含一个主线程,也可以有更多的子线程。
  • 多个线程共享所属进程的资源,同时线程也拥有自己的专属资源。
  • 程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。

image

  1. 协程
  • 协程是一种用户态的轻量级线程,协程的调度完全由用户控制。
  • 一个线程可以拥有多个协程,协程不是被操作系统内核所管理,而完全是由程序所控制。
  • 与其让操作系统调度,不如我自己来,这就是协程。

image

5.2 并行与并发的区别

并发并行相似但又是两个不同的概念,并行是指两个或者多个时间在同一时刻发生,就好比如多个程序同时运行。而并发是指两个或者多个时间在同一时间间隔内发生。

在多道程序环境下,并发性是指在一段时间内宏观上有多个程序在同时运行,但在处理系统中,每一时刻却仅能运行一个程序,故微观上这些程序只能是分时地交替执行。若是在计算机系统中有多个处理器则这些可以并发执行的程序便可分配到多个处理机上,实现并行执行,利用每个处理机来处理一个可并发执行的程序,这样,多个程序便可以同时执行。

并发:指应用能够交替执行不同的任务,其实并发有点类似于多线程的原理,多线程并非是同时执行多个任务,如果你开两个线程执行,就是在你几乎不可能察觉到的速度不断去切换这两个任务,以达到“同时执行效果”,其实并不是的,只是计算机的速度太快,我们无法察觉到而已。就类似于,吃一口饭喝一口水,以正常速度来看,完全能够看的出来是在依次地完成吃饭喝水,当把这个过程以n倍速度执行后看起来就像同时在完成吃饭和喝水。

并行:指应用能够同时执行不同的任务。例:吃饭的时候可以边吃饭边打电话,这两件事情可以同时执行。

两者区别:一个是交替执行,一个是同时执行。

5.3 操作系统的发展

早期的单进程操作系统:

image

单进程时代的两个问题:

  1. 单一执行流程、计算机只能一个任务一个任务地处理。
  2. 进程阻塞所带来的CPU浪费时间。

在单进程时代因为只有一个CPU,那能不能宏观上执行多个任务呢?为此出现了多线程/多进程操作系统。

image

多线程/多进程解决了阻塞问题,但又面临了新的问题:切换线程会浪费时间,进程/线程数量越多,切换成本就越大,也就越浪费,会造成高消耗调度CPU。且进程和线程所占用的内存很高,会造成高内存占用,比如在一个32位的操作系统中,进程在虚拟内存空间中占4GB左右,线程占用内存4MB左右。

image

5.4 线程的划分

一个线程在操作系统中被分为两个部分:用户空间(用户态)和内核空间(内核态)。用户空间表示上层开发中写业务逻辑调接口的部分,内核空间表示操作系统底层,包括分配物理内存资源、分配磁盘资源等。

image

内核线程(也称作线程thread)和用户线程(也称作协程co-routine)是绑定的,内核线程单独整理硬件部分的事物,用户线程保证用户层面的业务并发效果,且此时在CPU视野里只有内核空间的thread。

image

5.5 线程协程三种工作模式

N:1工作模式:一个内核线程通过协程调度器绑定多个协程来挂载任务。

好处:解决了高消耗调度CPU的问题,此时CPU视野里只有一个线程,无需切换线程。

弊端:若一个协程阻塞,则会影响下一个协程的进行。

image

1:1工作模式:此时不会出现阻塞问题,但此时就回到了之前的线程级别,想要达到并发效果,那么就需要切换内核空间的线程,还是存在高消耗调度CPU的问题。

image

M:N工作模式:多核CPU绑定多个线程再通过协程调度器绑定多个协程,这样优化协程调度器就显得尤为重要了。

image

5.6 Golang对协程的处理

在go语言中的协程(co-routine)叫Goroutine,且一个Goroutine的内存就占几KB,不像进程是GB级,线程是MB级别。小内存的Goroutine让它有着灵活调度的优点,使得可以有着大量的Goroutine存在,且可以常切换

image

5.6.1 GMP模型

G表示一个goroutine协程;M表示一个内核线程;P表示processor,其用作处理Goroutine协程,processor包含了每一个goroutine的资源,如果想运行一个goroutine,实际上应先获取P。

image

每个线程上的P中保存了goroutine的资源,P数量不是固定的,可以通过宏GOMAXPROCS来设置P的个数,程序当前最高能并行的goroutine的数量就是GOMAXPROCS设置P的个数。

全局队列用来存放空闲的G,创建出一个新的G优先会放在某个P的本地队列中,若满了则会放在全局队列中。

image

5.7 goroutine

【注】匿名函数的调用:

func(){
    //函数内容
}()//这里加一个小括号意为调用这个匿名函数,如果没有小括号就没有调用函数。可以这样想,调用函数的方式是函数名+小括号,所以这里要加小括号,若这个匿名函数有形参,那么最后的这个小括号要加上要传入函数的参数

goroutine的定义:

  • 任何函数只需加上go就能送给调度器运行。
  • 不需要在定义时区分是否为异步函数。
  • 调度器在合适的点进行切换。
  • 使用go run -race file_name.go执行数据访问冲突检测命令

普通方的调用和多线程示意图:

image

5.7.1 使用go关键字将函数送给协程调度器运行

package main

func main() {
   go func() {
      //函数内容
   }()
}

或:

package main

func function() {
   //函数内容
}

func main() {
   go function()
}

但更多是采用第一种将函数匿名的方式。

5.7.2 go关键字例子

  1. main是主goroutine,newTask是从goroutine
package main

import (
   "fmt"
   "time"
)

func newTask() {
   i := 0
   for {
      i++
      fmt.Printf("new Goroutine : i = %d\n", i)
      time.Sleep(1 * time.Second) //睡眠函数Sleep(),在显示每个循环过程的时候停顿一段时间
   }
}

//main是主goroutine,newTask是从goroutine
func main() {
   //创建一个goroutine去执行newTask()
   go newTask()

   i := 0

   for {
      i++
      fmt.Printf("main Goroutine : i = %d\n", i)
      time.Sleep(1 * time.Second)
   }
}

控制台输出:

main Goroutine : i = 1
new Goroutine : i = 1
new Goroutine : i = 2
main Goroutine : i = 2
main Goroutine : i = 3
new Goroutine : i = 3
new Goroutine : i = 4
main Goroutine : i = 4
main Goroutine : i = 5
new Goroutine : i = 5
new Goroutine : i = 6
main Goroutine : i = 6
main Goroutine : i = 7
new Goroutine : i = 7
new Goroutine : i = 8
main Goroutine : i = 8
main Goroutine : i = 9
new Goroutine : i = 9
......
......

此外,主go程结束所有子go程都会结束:

package main

import (
   "fmt"
   "time"
)

func newTask() {
   i := 0
   for {
      i++
      fmt.Printf("new Goroutine : i = %d\n", i)
      time.Sleep(1 * time.Second)
   }
}

func main() {
   go newTask()

   fmt.Println("main goroutine exit")
}

控制台输出:

main goroutine exit
  1. 1000个程序并发打印

【注】go语言的协程会在io操作时进行协程的切换(因为io操作会有等待的过程)

package main

import (
   "fmt"
   "time"
)

func main() {
   for i := 0; i < 1000; i++ {
      go func(i int) { //这里为了安全将外部的i传入了func中
         for {
            fmt.Printf("hello from goroutine %d\n", i)//io操作会进行协程的切换
         }
      }(i)
   }
   time.Sleep(time.Millisecond) //main一执行完毕,所有的goroutine都会退出,此句话是为了不让main那么快的退出
}

5.7.3 runtime.Goexit()

可以使用runtime.Goexit()退出当前goroutine。

Goexit终止调用它的go程。其它go程不会受影响。Goexit会在终止该go程前执行所有defer的函数。

package main

import (
   "fmt"
   "runtime"
   "time"
)

func main() {
   go func() {
      defer fmt.Println("A.defer")

      func() {
         defer fmt.Println("B.defer")
         //用runtime.Goexit()退出当前goroutine
         runtime.Goexit()
         fmt.Println("B")
      }()

      fmt.Println("A")
   }()

   for {
      time.Sleep(1 * time.Second)
   }
}

控制台输出:

B.defer
A.defer

5.7.4 goroutine可能切换的情况

下方只是参考,不能保证切换,不能保证在其他地方不切换

  1. go语言的协程会在I/O操作时进行协程的切换(因为I/O操作会有等待的过程)、select
  2. channel
  3. 等待锁
  4. 函数调用(有时会切换,由调度器决定)
  5. 手动交出控制器:runtime.Gosched()

runtime.Gosched()手动切换go程举例:

package main

import (
   "fmt"
   "runtime"
   "time"
)

func main() {
   var a [10]int
   for i := 0; i < 10; i++ {
      go func(i int) {
         for {
            a[i]++
            runtime.Gosched()
            //go程中没有io操作,进入第一个go程时就无法切换其他go程,所以需要手动给出切换go程的控制权
            //Gosched可以交出控制权让并发的别的go程也有机会运行,但一般很少使用。
            //Gosched使当前go程放弃处理器,以让其它go程运行。它不会挂起当前go程,因此当前go程未来会恢复执行。
         }
      }(i)
   }
   time.Sleep(time.Millisecond)
   fmt.Println(a)
}
posted @   雪碧锅仔饭  阅读(2995)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 地球OL攻略 —— 某应届生求职总结
· 提示词工程——AI应用必不可少的技术
· 字符编码:从基础到乱码解决
· SpringCloud带你走进微服务的世界
点击右上角即可分享
微信分享提示