返回

Golang基础知识

概述

进程、线程、协程的区别?掌握

进程:进程是每一次程序动态执行的过程,是程序运行的基本单位。进程占据独立的内存,有内存地址,有自己的堆,上级挂靠操作系统,操作系统以进程为单位分配资源(如CPU时间片、内存等),进程是资源分配的最小单位。

线程:线程又叫做轻量级进程,是CPU调度的最小单元。线程从属于进程,是程序的实际执行者,一个进程至少包含一个主线程,也可以有多个子线程。线程会共享所属进程的资源,同时线程也有自己的独占资源。线程切换和线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。

协程:协程是一种用户态的轻量级线程,协程的调度完全由用户控制。一个线程可以有多个协程,协程不是被操作系统内核所管理,而是由程序所控制。

区别

  • 拥有资源:进程是拥有资源的最小单位,线程不拥有资源,但是可以访问隶属进程的资源。进程所维护的是程序所包含的资源(静态资源), 如:地址空间,打开的文件句柄集,文件系统状态,信号处理handler等;线程所维护的运行相关的资源(动态资源),如:运行栈,调度相关的控制信息,待处理的信号集等
  • 并发性:不仅进程可以并发执行,同一进程的多个线程也可以并发执行。
  • 系统开销:在创建或撤消进程时,由于系统都要为之分配和回收资源,导致系统的开销明显大于创建或撤消线程时的开销。但是进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个进程死掉就等于所有的线程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些
  • 协程和线程:协程避免了无意义的调度,由此可以提高性能,但是用户调度过程中可能存在风险。

goroutine相比线程的优势?掌握

协程拥有极高的执行效率,子程序切换不是线程切换而是由程序自身控制,所以没有线程切换的开销。和多线程比,线程的数量越多,协程的性能优势就越明显。

协程不需要多线程的锁机制,因为只有一个线程,所以不存在同时写变量的冲突。在协程中控制共享资源不加锁,只需要判断状态就可以,执行效率比多线程要高。

20241022补充说明:由于线程是CPU调度的基本单位,所以单线程中的多个协程并非真正的并行执行,只是用时间片轮转模拟并行,因此不会有两个协程同时对一个变量进行写操作

go与Java的区别?掌握

  1. 运行:go是静态编译语言;Java是基于类的面向对象语言,Java应用程序在JVM上运行。
  2. 函数重载:go上不允许函数重载,必须具有方法和函数的唯一名称;java允许函数重载。
  3. 多态:Java默认允许多态,而go没有。
  4. 路由配置:go语言使用HTTP协议进行路由配置;java使用Akka.routing进行路由配置。
  5. 继承:go的继承通过匿名组合完成,基类以Struct的方式定义,子类只需要把基类作为成员放在子类的定义中,支持多继承;Java的继承通过extends关键字完成,不支持多继承。

20241022补充:静态编译语言是指在编译过程中,所有的类型信息都在编译阶段确定,并且程序在运行之前需要经过编译器生成可执行文件。与动态语言相对,静态编译语言的特点是能够在编译时捕获类型错误,并且通常会生成更高效的机器代码,适合性能要求较高的应用。

  • 静态编译语言(如 C、C++、Go、Rust):
    • 源代码在编译阶段被编译器直接转换为机器代码(可执行文件),这意味着编译后的程序与特定的操作系统和硬件架构密切相关。
    • 编译完成后,程序在运行时不需要依赖于任何额外的运行环境。
  • Java
    • Java 源代码首先被编译成字节码(.class 文件),字节码是一种中间格式,不是直接的机器代码。
    • 字节码需要在 Java 虚拟机(JVM)上运行,JVM 会将字节码解释或即时编译(JIT)为机器代码。
    • 这种设计使得 Java 程序可以在不同的平台上运行,只要有相应的 JVM

多态是面向对象编程中的一个重要概念,它允许不同类型的对象通过统一的接口进行操作。通过多态性,程序可以在运行时决定调用哪个方法,从而提高代码的灵活性和可扩展性。在 Go 语言中,多态通常通过接口实现。

Java 和 Go 都支持多态,但实现的方式和基础的设计理念不同。Java 是一个面向对象的语言,强调类的继承,而 Go 则强调接口和组合,采用了一种更灵活、更简洁的方式来实现多态。这使得 Go 在某些场景下的多态性更具优势,但同时也可能使得使用者需要更加小心地管理接口的设计和使用。

多继承是面向对象编程中的一种特性,允许一个类从多个父类(超类)继承属性和方法

go语言中是如何实现继承的?掌握

在go中没有extends关键字,所以go并没有原生级别的继承支持。本质上,Go使用组合来代替继承:

1
2
3
4
5
6
7
8
9
type Person struct {
	Name string
	Age  int
}

type Student struct {
	Person // 组合
	School string
}

组合:Go 使用结构体(struct)组合的方式来实现功能复用,而不是通过类继承。可以在一个结构体中嵌入另一个结构体,从而获得其方法

for遍历多次执行goroutine会存在什么问题?掌握

在 Go 中,使用 for 循环创建多个 goroutine 时,如果不正确地处理循环变量 i,可能会导致所有 goroutine 访问到相同的 i 值,造成随机输出。这是因为 for 循环在很短的时间内就完成了,而 goroutine 的调度和初始化需要时间,导致所有 goroutine 可能在访问 i 时都得到相同的值。

示例代码:问题展示,以下代码展示了这个问题:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func main() {
    for i := 0; i < 10; i++ {
        go func() {
            // 这里直接使用循环变量 i,会导致访问到最后一个 i 值
            fmt.Println(i)
        }()
    }

    // 等待一些时间,让 goroutine 完成
    time.Sleep(time.Second)
}

问题分析

  1. 值拷贝for 循环中的 i 是一个共享的变量。当 goroutine 被创建时,i 的值并没有被复制到闭包中,而是直接引用了这个变量。因此,当所有 goroutine 启动时,i 可能已经被更新为循环结束后的值(在这个例子中是 10)。
  2. 时间延迟:goroutine 的启动和调度并不是瞬时完成的,而是在上下文中处理的,这意味着在创建 goroutine 之后,for 循环可能会很快执行完,导致所有 goroutine 同时访问 i 的结果都是最终的值。

解决方法:要解决这个问题,可以使用闭包并将当前的 i 作为参数传递给 goroutine。这样每个 goroutine 都会有自己的 i 副本,确保它们访问到各自的值。

以下是修复后的代码示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func main() {
    for i := 0; i < 10; i++ {
        // 使用闭包将 i 的值传递给 goroutine
        go func(i int) {
            fmt.Println(i)
        }(i) // 将 i 作为参数传入
    }

    // 等待一些时间,让 goroutine 完成
    time.Sleep(time.Second)
}

for并发读取文件

程序会panic:too many open files

解决的方法:通过带缓冲的channelsync.waitgroup控制协程并发量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func main() {
    const maxConcurrentGoroutines = 5  // 设置最大并发 goroutine 数量
    var wg sync.WaitGroup

    // 创建一个带缓冲的 channel,用于限制并发数量
    semaphore := make(chan struct{}, maxConcurrentGoroutines)

    for i := 0; i < 20; i++ {
        wg.Add(1)

        // 获取一个信号量 向 semaphore 发送一个空结构体,以表示正在使用一个 goroutine
        semaphore <- struct{}{}

        go func(i int) {
            defer wg.Done()
            defer func() { <-semaphore }()  // 释放信号量

            // 模拟工作,例如打开文件或其他 I/O 操作
            fmt.Printf("Goroutine %d is working\n", i)
            time.Sleep(time.Second) // 模拟耗时操作
        }(i)
    }

    wg.Wait() // 等待所有 goroutine 完成
    fmt.Println("All goroutines finished")
}

init函数是什么时候执行的?掌握

特点

  • init函数先于main函数自动执行,不能被其他函数调用。
  • init函数没有输入参数、返回值。
  • 每个包可以有多个init函数,包的每个源文件也可以有多个init函数。
  • go没有明确定义同一个包的init执行顺序,编程时程序不能依赖这个执行顺序。
  • 不同包的init函数按照包导入的依赖关系决定执行顺序。

作用

  • 初始化变量
  • 程序运行前的注册
  • 实现sync.Once功能

20241022:sync.Once 是 Go 语言标准库中 sync 包提供的一种用于确保某个操作仅执行一次的结构。这在多线程环境中非常有用,特别是在需要进行一次性初始化时,比如初始化单例模式、配置设置等。

  1. 确保某个操作只执行一次:不论有多少个 goroutine 调用这个操作,sync.Once 确保操作只会执行一次。
  2. 并发安全sync.Once 是并发安全的,适合在多个 goroutine 中共享。

执行顺序

go程序初始化先于main函数执行,由runtime进行初始化,初始化顺序如下:

  • 初始化导入的包,包的初始化顺序并不是按导入顺序执行的,runtime需要解析包依赖关系,没有依赖的包最先初始化
  • 初始化包作用域的变量,runtime解析变量依赖关系,没有依赖的变量最先初始化
  • 执行包的init函数

最终初始化顺序:导入的包 -> 变量初始化 -> init() -> main()

关键字

make和new区别?重要

make:make能够分配并初始化类型所需的内存空间和结构,返回引用类型的本身;make具有使用范围的局限性,仅支持 channel、map、slice三种类型;make函数会对三种类型的内部数据结构(长度、容量等)赋值。

new:new能够分配类型所需的内存空间,返回指针引用(指向内存的指针);new可被替代,能够通过字面值快速初始化。

struct能不能比较?掌握

不同类型的结构体,如果成员变量类型、变量名和顺序都相同,而且结构体没有不可比较字段时,那么进行显式类型转换后就可以比较,反之不能比较。

同类型的struct分为两种情况:

  • struct的所有成员都是可以比较的,则该strcut的不同实例可以比较
  • struct中含有不可比较的成员,则该struct不可以比较

当需要比较两个struct内容时,最好使用reflect.DeepEqual方法进行比较,无论什么类型均可满足比较要求。

不可比较的类型

  • slice,因为slice是引用类型,除非和nil比较
  • map,和slice同理,如果要比较两个map只能通过循环遍历实现
  • 函数类型,不能比较

为什么slice之间不能直接比较?了解

因为slice的元素是间接引用的,一个slice甚至可以包含自身,slice的变量实际是一个指针,使用==其实在判断地址。

slice的底层实现?重要

切片的底层是一个结构体,对应三个参数,一个是unsafe.Pointer指针,指向一个具体的底层数组,一个是cap,切片的容量,一个是len,切片的长度。

因为切片是基于数组实现,所以它的底层的内存是连续分配的,效率非常高,可以通过索引获得数据。切片本身并不是动态数组或者数组指针,而是设定相关属性,将数据读写操作限定在指定的区域内。切片本身是一个只读对象,其工作机制类似数组指针的一种封装。

如果make函数初始化了一个太大的切片,该切片就会逃逸到堆区;如果分配了一个比较小的切片,就会被分配到栈区,切片大小的临界值默认为64KB,因此make([]int64, 1023)make([]int64, 1024)是完全不同的内存布局。

1
2
3
4
5
type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

20241023

堆区和栈区是两种不同的内存分配区域,各自有不同的特点和用途:

栈区(Stack)

  • 分配方式:栈是由系统自动管理的内存区域,内存分配和释放非常快,通常采用后进先出(LIFO)的方式。
  • 大小限制:栈的大小通常较小,受到操作系统的限制,超过一定大小会导致栈溢出(stack overflow)。
  • 生命周期:栈中分配的内存会在函数调用结束时自动释放,变量的生命周期与其所在的函数相同。
  • 使用场景:适合存储局部变量、函数参数和返回值等。

堆区(Heap)

  • 分配方式:堆是由程序员手动管理的内存区域,通过内存分配函数(如 mallocnew)进行分配,释放则需调用相应的释放函数(如 freedelete)。
  • 大小限制:堆的大小通常仅受限于可用内存,允许分配更大的数据结构。
  • 生命周期:堆中分配的内存需要手动管理,使用后必须显式释放,否则会造成内存泄漏。
  • 使用场景:适合存储动态分配的对象和数据结构,如切片、映射和自定义类型的实例等。

总结

  • 性能:栈的内存分配和释放速度比堆快。
  • 内存管理:栈由系统自动管理,堆需要程序员手动管理。
  • 使用场景:栈用于局部变量,堆用于动态数据。

slice和数组的区别?重要

切片是指针类型,数组是值类型

传递数组是通过拷贝的方式,传递切片是通过传递引用的方式。

数组的长度固定,而切片可以进行动态扩容

数组是一组内存空间连续的数据,一旦初始化长度大小就不会再改变,切片的长度可以进行扩展,当切片底层的数组容量不够时,切片会创建新的底层数组。

切片比数组多一个属性容量(cap)

slice的扩容机制?重要

扩容主要分为两个过程:第一步是分配新的内存空间,第二步是将原有切片内容进行复制。分配新空间时候需要估计大致容量,然后再确定容量。

根据该切片当前容量选择不同的策略:

  • 如果期望容量大于当前容量的两倍,就会使用期望容量
  • 如果当前切片的长度小于 1024,容量就会翻倍
  • 如果当前切片的长达大于 1024,每次扩容 25% 的容量,直到新容量大于期望容量
    • 在进行循环1.25倍计算时,最终容量计算值发生溢出,即超过了int的最大范围,则最终容量就是新申请的容量

对于切片的扩容

  • 当切片比较小的,采用较大的扩容倍速进行扩容,避免频繁扩容,从而减少内存分配的次数和数据拷贝的代价
  • 当切片较大的时,采用较小的扩容倍速,主要避免空间浪费

slice是线程安全的吗?了解

不是。slice底层结构并没有使用加锁等方式,不支持并发读写,所以并不是线程安全的,使用多个goroutine对类型为slice的变量进行操作,每次输出的值大概率都不会一样,与预期值不一致; slice在并发执行中不会报错,但是数据会丢失。

实现线程安全的方法

互斥锁,读写锁,原子操作,sync.oncesync.atomicchannel

slice之间怎么进行比较?掌握

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func equal(x, y []int) bool {
    if len(x) != len(y) {
        return false
    }
    for i := range x {
        if x[i] != y[i] {
            return false
        }
    }
    return true 
}

map之间如何进行比较?了解

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func equal(x, y map[string]int) bool {
    if len(x) != len(y) {
        return false
    }
    for k, xv := range x {
        // 判x字典中的该key是否在y字典中没有或value不一样
        if yv, ok := y[k]; !ok || yv != xv {
            return false
        }
    }
    return true 
}

map如何实现顺序读取?了解

map不能顺序读取,是因为他是无序的,想要有序读取,需要把key变为有序,所以可以把key放入切片,对切片进行排序,遍历切片,通过key取值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
    "fmt"
    "sort"
)

func main() {
    var m = map[string]int{
        "hello":         0,
        "morning":       1,
        "keke":          2,
        "jame":   		 3,
    }
    var keys []string
    for k := range m {
        keys = append(keys, k)
    }
    sort.Strings(keys)
    for _, k := range keys {
        fmt.Println("Key:", k, "Value:", m[k])
    }
}

map的底层数据结构?掌握

结构:底层是由hmapbmap两个结构体实现,map在实际存储键值对结构用到了数组和链表。之所以高效,是因为其结合了顺序存储数组和链式存储(链表)两种存储结构。数组是map的主干,在数组下有一个类型为链表的元素。

hmap

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type hmap struct {
    count     int    //元素的个数
    flags     uint8  //状态标志
    B         uint8  //可以最多容纳 6.5 * 2 ^ B 个元素,6.5为装载因子
    noverflow uint16 //溢出的个数
    hash0     uint32 //哈希种子

    buckets    unsafe.Pointer //指向一个桶数组
    oldbuckets unsafe.Pointer //指向一个旧桶数组,用于扩容
    nevacuate  uintptr        //搬迁进度,小于nevacuate的已经搬迁
    overflow *[2]*[]*bmap     //指向溢出桶的指针
}

hmapmap的最外层的一个数据结构,包括了map的各种基础信息、如大小、bucketbuckets这个参数它存储的是指向buckets数组的一个指针。

bmap

1
2
3
4
5
6
7
8
9
type bmap struct {
    //元素hash值的高8位代表它在桶中的位置,如果tophash[0] < minTopHash,表示这个桶的搬迁状态
    tophash [bucketCnt]uint8
    //接下来是8个key、8个value,但是我们不能直接看到;为了优化对齐,go采用了key放在一起,value放在一起的存储方式,
    keys     [8]keytype   //key单独存储
	values   [8]valuetype //value单独存储
	pad      uintptr
	overflow uintptr	  //指向溢出桶的指针
}

bucket(桶),每一个bucket最多放8keyvalue,最后由一个overflow字段指向下一个bmapkeyvalueoverflow字段都不显示定义,而是通过maptype计算偏移获取的。

高位哈希和低位哈希

哈希函数会将传入的key值进行哈希运算,得到一个唯一的值。前半部分就叫做高位哈希值,后半部分就叫做低位哈希值高位哈希值是用来确定当前的bucket有没有所存储的数据的;低位哈希值是用来确定,当前的数据存在了哪个bucket

bucket是如何工作的?了解

buckettophash存储高八位哈希以加快索引。把高八位存储起来,不用完整比较key就能过滤掉不符合的key,加快查询速度。当一个哈希值的高8位和存储的高8位相符合,再去比较完整的key值,进而取出value。当超过8个元素需要存入某个bucket时,hmap会拓展该bucket

存储的keyvalue底层排列方式是:key全部放在一起,value全部放在一起。当key大于128字节时,bucketkeyvalue字段存储的是指针,指向实际内容。这样排列好处是在keyvalue的长度不同的时候,可以消除padding带来的空间浪费

bucket还存储的溢出时指向的下一个bucket的指针。如果超过就重新创建一个bucket挂在原bucket上,持续挂接形成链表。

map的查找过程?了解

查找或操作map时,首先key经过hash函数生成hash值,通过哈希值的低8位来判断当前数据属于哪个bucket,找到bucket以后,通过哈希值的高八位与bucket存储的高位哈希值循环比对,如果相同就比较刚才找到的底层数组的key值,如果key相同,取出value。如果高八位hash值在此bucket没有,或者有,但是key不相同,就去链表中下一个溢出bucket中查找,直到查找到链表的末尾。

map的扩容过程?了解

bmap扩容的加载因数达到6.5(元素个数/bucket),bmap就会进行扩容,将原来bucket数组数量扩充一倍,产生一个新的bucket数组。这样bmap中的oldbuckets属性指向的就是旧bucket数组。

map的扩容不会立马全部复制,而是渐进式扩容,即首先开辟2倍的内存空间,创建一个新的bucket数组。只有当访问原来旧的bucket数组时,才会将旧的bucket拷贝到新的bucket数组,进行渐进式的扩容。当然旧的数据不会删除,而是去掉引用,等待gc回收。

负载因子

加载因数6.5,这个是经过测试才得出的合理的一个阈值。因为,加载因子越小,空间利用率就小,加载因子越大,产生冲突的几率就大。所以6.5是一个平衡的值。

如何实现一个线程安全的map?

三种方式实现:

  • 加读写锁
  • 分片加锁
  • sync.Map

加读写锁、分片加锁,这两种方案都比较常用,后者的性能更好,因为它可以降低锁的粒度,提高访问此 map 对象的吞吐。前者并发性能虽然不如后者,但是加锁的方式更加简单。sync.Map 是 Go 1.9 增加的一个线程安全的 map ,虽然是官方标准,但反而是不常用的,原因是 map 要解决的场景很难描述,很多时候程序员在做抉择是否该用它,不过在一些特殊场景会使用 sync.Map:

  • 场景一:只会增长的缓存系统,一个 key 值写入一次而被读很多次
  • 场景二:多个 goroutine 为不相交的键读、写和重写键值对

使用场景:https://golang.org/pkg/sync/#Mapopen in new window

加读写锁,扩展 map 来实现线程安全,支持并发读写。使用读写锁 RWMutex,是为了读写性能的考虑。对 map 对象的操作,无非就是常见的增删改查和遍历。可以将查询和遍历看作读操作,增加、修改和删除看作写操作。

大量并发读写的情况下,锁的竞争会很激烈,导致性能降低。如何解决这个问题?

尽量减少锁的粒度和锁的持有时间,减少锁的粒度,常用方法就是分片Shard,将一把锁分成几把锁,每个锁控制一个分片。

channel的概念?重要

channel又称为管道,用于数据传递或数据共享,其本质是一个先进先出的队列,使用goroutine + channel进行数据通讯简单高效,同时也线程安全,多个goroutine可同时修改一个channel,不需要加锁。

channel有哪些状态?重要

nil:未初始化的状态,只进行了声明,或者手动赋值为nil。

active:正常的channel,可读或者可写。

closed:已关闭,channel的值不是nil关闭的状态的channel仍然可以读值(取值),但不能写值(会报panic: send on closed channel)nil状态的channel是不能close(panic: close of nil channel)的。如果关闭后的 channel 没有数据可读取时,将得到零值,即对应类型的默认值。

操作 空channel 已关闭channel 活跃中的channel
close(ch) panic panic 成功关闭
ch<- v 永远阻塞 panic 成功发送或阻塞
v,ok = <-ch 永远阻塞 不阻塞 成功接收或阻塞

如何判断channel已经关闭?重要

1
2
3
if v, ok := <-ch; !ok {
	fmt.Println("channel 已关闭,读取不到数据")
}

channel的底层实现原理?了解

channel有几个重要的字段:

  • buf指向一个底层的循环数组,只有设置为有缓存的channel才会有buf
  • sendx和recvx分别指向底层循环数组的发送和接收元素位置的索引
  • sendq和recvq分别表示发送数据的被阻塞的goroutine和读取数据的goroutine,这两个都是一个双向链表结构
  • sendq和recvq 的结构为等待队列类型,sudog是对goroutine的一种封装
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type hchan struct {
    qcount   uint           // channel中的元素个数
    dataqsiz uint           // channel中循环队列的长度
    buf      unsafe.Pointer // channel缓冲区数据指针
    elemsize uint16            // buffer中每个元素的大小
    closed   uint32            // channel是否已经关闭,0未关闭
    elemtype *_type // channel中的元素的类型
    sendx    uint   // channel发送操作处理到的位置
    recvx    uint   // channel接收操作处理到的位置
    recvq    waitq  // 等待接收的sudog(sudog为封装了goroutine和数据的结构)队列由于缓冲区空间不足而阻塞的goroutine列表
    sendq    waitq  // 等待发送的sudog队列,由于缓冲区空间不足而阻塞的goroutine列表

    lock mutex   // 一个轻量级锁
}

channel发送数据和接收数据的过程?了解

channel发送数据过程:

  • 检查 recvq 是否为空,如果不为空,则从 recvq 头部取一个 goroutine,将数据发送过去,并唤醒对应的 goroutine
  • 如果 recvq 为空,则将数据放入到 buffer 中
  • 如果 buffer 已满,则将要发送的数据和当前 goroutine 打包成 sudog 对象放入到 sendq中。并将当前 goroutine 置为 waiting 状态

channel接收数据过程:

  • 检查sendq是否为空,如果不为空,且没有缓冲区,则从sendq头部取一个goroutine,将数据读取出来,并唤醒对应的goroutine,结束读取过程
  • 如果sendq不为空,且有缓冲区,则说明缓冲区已满,则从缓冲区中首部读出数据,把sendq头部的goroutine数据写入缓冲区尾部,并将goroutine唤醒,结束读取过程
  • 如果sendq为空,缓冲区有数据,则直接从缓冲区读取数据,结束读取过程
  • 如果sendq为空,且缓冲区没数据,则只能将当前的goroutine加入到recvq,并进入waiting状态,等待被写goroutine唤醒

channel是否线程安全的?掌握

channel是线程安全的。

不同协程通过channel进行通信,本身的使用场景就是多线程,为了保证数据的一致性必须实现线程安全。

channel如何实现线程安全的?掌握

channel的底层实现中, hchan结构体中采用Mutex锁来保证数据读写安全。在对循环数组buf中的数据进行入队和出队操作时,必须先获取互斥锁,才能操作channel数据。

channel的应用场景?掌握

任务定时

1
2
3
select {
    case <-time.After(time.Second)
}

定时任务

1
2
3
select {
    case <- time.Tick(time.Second)
}

解耦生产者和消费者

可以将生产者和消费者解耦出来,生产者只需要往channel发送数据,而消费者只管从channel中获取数据。

控制并发数

以爬虫为例,比如需要爬取1w条数据,需要并发爬取以提高效率,但并发量又不能过大,可以通过channel来控制并发规模,比如同时支持5个并发任务:

1
2
3
4
5
6
7
8
ch := make(chan int, 5)
for _, url := range urls {
  go func() {
    ch <- 1
    worker(url)
    <- ch
  }
}

select的用途?重要

select可以理解为是在语言层面实现了和I/O多路复用相似的功能:监听多个描述符的读/写等事件,一旦某个描述符就绪(一般是读或者写事件发生了),就能够将发生的事件通知给关心的应用程序去处理该事件。

golang的select机制是:监听多个channel,每一个case是一个事件,可以是读事件也可以是写事件,随机选择一个执行。可以设置default,它的作用是当监听的多个事件都阻塞住就会执行default的逻辑。

1
2
3
4
5
6
7
8
select {
    case <-ch1:
        // 如果从 ch1 信道成功接收数据,则执行该分支代码
    case ch2 <- 1:
        // 如果成功向 ch2 信道成功发送数据,则执行该分支代码
    default:
        // 如果上面都没有成功,则进入 default 分支处理流程
}

提示

  • select语句只能用于信道的读写操作
  • select中的case条件(非阻塞)是并发执行的,select会选择先操作成功的那个case条件去执行,如果多个同时返回,则随机选择一个执行,此时将无法保证执行顺序
  • 对于case条件语句中,如果存在信道值为nil的读写操作,则该分支将被忽略,可以理解为从select语句中删除了这个case语句
  • 如果有超时条件语句,判断逻辑为如果在这个时间段内一直没有满足条件的case,则执行这个超时case。如果此段时间内出现了可操作的case,则直接执行这个case。一般用超时语句代替了default语句
  • 对于空的select{},会引起死锁
  • 对于for中的select{}, 可能会引起cpu占用过高的问题

defer的概述?重要

defer是go语言提供的一种用于注册延迟调用的机制:让函数或者语句在当前函数执行完毕(包括return正常结束或者panic导致的异常结束)之后进行调用。defer具有以下特性:

  • 延迟调用:defer在main函数return之前调用,且defer必须置于函数内部
  • LIFO:后进先出,压栈式执行
  • 作用域:defer只和defer所在函数绑定在一起,作用域也只在这个函数,如果defer处于匿名函数中,会先调用匿名函数中的defer

defer的使用场景?重要

defer关键字通常通常出现在一些成对出现的操作中,比如创建关闭链接、加锁解锁、打开关闭文件等操作。defer在一些资源回收的场景很有用。

并发处理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var wg sync.WaitGroup

for i := 0; i < 2; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        // 程序逻辑
    }()
}
wg.Wait()

锁场景

1
2
mu.RLock()
defer mu.RUnlock()

资源释放

1
2
3
4
5
6
7
// new 一个客户端 client;
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
    log.Fatal(err)
}
// 释放该 client ,也就是说该 client 的声明周期就只在该函数中;
defer cli.Close()

panic-recover

1
2
3
4
5
defer func() {
    if v := recover(); v != nil {
        _ = fmt.Errorf("PANIC=%v", v)
    }
}()

defer的底层原理?掌握

defer的数据结构如下:主要由siz属性,标识返回值的内存和大小、heap属性,标识是在栈上分配还是在堆上分配、sp是栈指针、pc程序计数器、fn是传入的函数地址、link是defer链表。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type _defer struct {
	siz     int32 // 参数和返回值的内存大小
	started bool
	heap    bool    // 区分该结构是在栈上分配的,还是堆上分配的
	sp        uintptr  // sp 计数器值,栈指针;
	pc        uintptr  // pc 计数器值,程序计数器;
	fn        *funcval // defer 传入的函数地址,也就是延后执行的函数;
	_panic    *_panic  // panic that is running defer
	link      *_defer   // 链表
}

link属性将defer串成一个链表,表头是挂载在goroutine的_defer属性。defer结构只是一个头结构,后面跟着延迟函数的参数和返回值空间,内存在defer关键字执行的时候填充。

对于go语言版本1.13之前defer关键字处理被分为deferproc和deferreturn两个过程,对应着回调注册函数过程和执行注册函数链的过程。在go1.13起带来了deferprocStatck,也是用来注册回调函数,但是不同的是,deferproc是在堆上分配内存结构,deferprocStack是在栈上分配struct的结构,栈上的分配是远快于堆上的分配,所以性能得到了提升。

当 defer 外层出现显式(for)或者隐式(goto)的时候,将会导致 struct _defer 结构体分配在堆上。

deferprocStack

当defer结构体在栈上分配时,调用deferprocStack之前编译器就已经把defer结构体初始化好了,heap属性设置为false,保存上下文,把 caller 函数的 rsp,pc(rip) 寄存器的值保存到 _defer结构体,并且将defer结构体挂载到goroutine链表中去。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 进到这个函数之前,就已经在栈上分配好了内存结构(编译器分配的,rsp 往下伸展即可)
func deferprocStack(d *_defer) {
	gp := getg()

	// siz 和 fn 在进到这个函数之前已经赋值;
	d.started = false
	// 标名是栈的内存
	d.heap = false
	// 获取到 caller 函数的 rsp 寄存器值,并赋值到 _defer 结构 sp 字段中;
	d.sp = getcallersp()
	// 获取到 caller 函数的 pc (rip) 寄存器值,并赋值到 _defer 结构 pc 字段中;
	// 回忆起函数调用的原理,就知道 caller 的压栈的 pc 值就是 deferprocStack 的下一行指令;
	d.pc = getcallerpc()

	// 把这个 _defer 结构作为一个节点,挂到 goroutine 的链表中;
	*(*uintptr)(unsafe.Pointer(&d._panic)) = 0
	*(*uintptr)(unsafe.Pointer(&d.link)) = uintptr(unsafe.Pointer(gp._defer))
	*(*uintptr)(unsafe.Pointer(&gp._defer)) = uintptr(unsafe.Pointer(d))
	// 注意,特殊的返回,不会触发延迟调用的函数
	return0()
}

deferproc

当defer结构体在堆上分配时,结构体在函数里面初始化,调用newdefer分配结构体,并且去缓冲池中查找,如果有就直接调用,否则使用mallocgc从堆上分配内存,deferproc 接受入参 siz,fn ,这两个参数分别标识延迟函数的参数和返回值的内存大小,延迟函数地址,保存上下文,把 caller 函数的 rsp,pc(rip) 寄存器的值保存到 _defer 结构体,defer作为一个节点挂接到链表。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func deferproc(siz int32, fn *funcval) { // arguments of fn follow fn
	// 获取 caller 函数的 rsp 寄存器值
	sp := getcallersp()
	argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn)
	// 获取 caller 函数的 pc(rip) 寄存器值
	callerpc := getcallerpc()

	// 分配 struct _defer 内存结构
	d := newdefer(siz)
	if d._panic != nil {
		throw("deferproc: d.panic != nil after newdefer")
	}
	// _defer 结构体初始化
	d.fn = fn
	d.pc = callerpc
	d.sp = sp
	switch siz {
	case 0:
		// Do nothing.
	case sys.PtrSize:
		*(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp))
	default:
		memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz))
	}
	// 注意,特殊的返回,不会触发延迟调用的函数
	return0()
}

deferreturn

遍历defer链表从前往后执行,执行一个就取出一个,直到链表为空。jmpdefer 负责跳转到延迟回调函数执行指令,执行结束之后,跳转回 deferreturn里执行。_defer.sp 的值可以用来判断哪些是当前 caller 函数注册的,这样就能保证只执行自己函数注册的延迟回调函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func deferreturn(arg0 uintptr) {
	gp := getg()
	// 获取到最前的 _defer 节点
	d := gp._defer
	// 函数递归终止条件(d 链表遍历完成)
	if d == nil {
		return
	}
	// 获取 caller 函数的 rsp 寄存器值
	sp := getcallersp()
	if d.sp != sp {
		// 如果 _defer.sp 和 caller 的 sp 值不一致,那么直接返回;
		// 因为,就说明这个 _defer 结构不是在该 caller 函数注册的  
		return
	}

	switch d.siz {
	case 0:
		// Do nothing.
	case sys.PtrSize:
		*(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d))
	default:
		memmove(unsafe.Pointer(&arg0), deferArgs(d), uintptr(d.siz))
	}
	// 获取到延迟回调函数地址
	fn := d.fn
	d.fn = nil
	// 把当前 _defer 节点从链表中摘除
	gp._defer = d.link
	// 释放 _defer 内存(主要是堆上才会需要处理,栈上的随着函数执行完,栈收缩就回收了)
	freedefer(d)
	// 执行延迟回调函数
	jmpdefer(fn, uintptr(unsafe.Pointer(&arg0)))
}

参数传入

采用预计算参数,_defer作为头部信息,延迟回调函数和返回值在头部之后放置,参数是在defer执行的时候计算好了,而非函数执行时设置好。

defer函数和return的执行顺序?了解

go 的一行函数返回 return语句对应非原子操作的多行汇编指令,包括 返回值设置 和 ret 指令执行。其中 ret 汇编指令的内容是两个,指令 pc 寄存器恢复为 rsp 栈顶保存的地址,rsp 往上缩减,rsp+0x8。defer 的函数链调用是在设置了 result parameters 之后,但是在运行指令上下文返回到 caller 函数之前。所以过程如下:

  • 设置返回值
  • 执行 defered 链表
  • ret 指令跳转到 caller 函数

WaitGroup使用的注意事项?

  • Add一个负数:如果计数器的值小于0会直接panic。
  • Add在Wait之后调用:比如一些子协程开头调用 Add 结束调用 Wait ,这些 Wait 无法阻塞子协程。正确做法是在开启子协程之前先 Add 特定的值。
  • 未置为0就重用:WaitGroup可以完成一次编排任务,计数值降为0后可以继续被其他任务所用,但是不要在还没使用完的时候就用于其他任务,这样由于带着计数值,很可能出问题。
  • 复制waitgroup:WaitGroup有nocopy字段,不能被复制。也意味着 WaitGroup 不能作为函数的参数

GMP

GMP模型?重要

G(goroutine)

go 语言中的协程 goroutine 的缩写,相当于操作系统中的进程控制块。其中存着 goroutine 的运行时栈信息,CPU 的一些寄存器的值以及执行的函数指令等。sched字段保存了 goroutine 的上下文。goroutine 切换的时候不同于线程有 OS 来负责这部分数据,而是由一个 gobuf 结构体来保存。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type g struct {
  stack       stack   		// 描述真实的栈内存,包括上下界

  m              *m     	// 当前的 m
  sched          gobuf   	// goroutine 切换时,用于保存 g 的上下文      
  param          unsafe.Pointer // 用于传递参数,睡眠时其他 goroutine 可以设置 param,唤醒时该goroutine可以获取
  atomicstatus   uint32
  stackLock      uint32 
  goid           int64  	// goroutine 的 ID
  waitsince      int64 		// g 被阻塞的大体时间
  lockedm        *m     	// G 被锁定只在这个 m 上运行
}

gobuf 保存了当前的栈指针,计数器,还有 g 自身,这里记录自身 g 的指针的目的是为了能快速的访问到 goroutine 中的信息。gobuf 的结构如下:

1
2
3
4
5
6
7
8
9
type gobuf struct {
    sp   uintptr
    pc   uintptr
    g    guintptr
    ctxt unsafe.Pointer
    ret  sys.Uintreg
    lr   uintptr
    bp   uintptr // for goEXPERIMENT=framepointer
}

M(Machine)

M代表一个操作系统的主线程,对内核级线程的封装,数量对应真实的 CPU 数。一个 M 直接关联一个 os 内核线程,用于执行 G。M 会优先从关联的 P 的本地队列中直接获取待执行的 G。M 保存了 M 自身使用的栈信息、当前正在 M上执行的 G 信息、与之绑定的 P 信息。

结构体 M 中,curg代表结构体M当前绑定的结构体 G ;g0 是带有调度栈的 goroutine,普通的 goroutine 的栈是在堆上分配的可增长的栈,但是 g0 的栈是 M 对应的线程的栈。与调度相关的代码,会先切换到该 goroutine 的栈中再执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type m struct {
    g0      *g     				// 带有调度栈的goroutine

    gsignal       *g         	// 处理信号的goroutine
    tls           [6]uintptr 	// thread-local storage
    mstartfn      func()
    curg          *g       		// 当前运行的goroutine
    caughtsig     guintptr 
    p             puintptr 		// 关联p和执行的go代码
    nextp         puintptr
    id            int32
    mallocing     int32 		// 状态

    spinning      bool 			// m是否out of work
    blocked       bool 			// m是否被阻塞
    inwb          bool 			// m是否在执行写屏蔽

    printlock     int8
    incgo         bool
    fastrand      uint32
    ncgocall      uint64      	// cgo调用的总数
    ncgo          int32       	// 当前cgo调用的数目
    park          note
    alllink       *m 			// 用于链接allm
    schedlink     muintptr
    mcache        *mcache 		// 当前m的内存缓存
    lockedg       *g 			// 锁定g在当前m上执行,而不会切换到其他m
    createstack   [32]uintptr 	// thread创建的栈
}

P(Processor)

Processor 代表了 M 所需的上下文环境,代表 M 运行 G 所需要的资源。是处理用户级代码逻辑的处理器,可以将其看作一个局部调度器使 go 代码在一个线程上跑。当 P 有任务时,就需要创建或者唤醒一个系统线程来执行它队列里的任务,所以 P 和 M 是相互绑定的。P 可以根据实际情况开启协程去工作,它包含了运行 goroutine 的资源,如果线程想运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
type p struct {
    lock mutex

    id          int32
    status      uint32 		// 状态,可以为pidle/prunning/...
    link        puintptr
    schedtick   uint32     // 每调度一次加1
    syscalltick uint32     // 每一次系统调用加1
    sysmontick  sysmontick 
    m           muintptr   // 回链到关联的m
    mcache      *mcache
    racectx     uintptr

    goidcache    uint64 	// goroutine的ID的缓存
    goidcacheend uint64

    // 可运行的goroutine的队列
    runqhead uint32
    runqtail uint32
    runq     [256]guintptr

    runnext guintptr 		// 下一个运行的g

    sudogcache []*sudog
    sudogbuf   [128]*sudog

    palloc persistentAlloc // per-P to avoid mutex

    pad [sys.CacheLineSize]byte
}

GMP的调度流程?重要

img

  • 每个 P 有个局部队列,局部队列保存待执行的goroutine(流程 2),当 M 绑定的 P 的的局部队列已经满了之后就会把 goroutine 放到全局队列(流程 2-1)
  • 每个 P 和一个 M 绑定,M 是真正的执行 P 中 goroutine 的实体(流程 3), M 从绑定的 P 中的局部队列获取 G 来执行
  • 当 M 绑定的 P 的局部队列为空时,M 会从全局队列获取到本地队列来执行 G (流程 3.1),当从全局队列中没有获取到可执行的 G 时候,M 会从其他 P 的局部队列中偷取 G 来执行(流程 3.2),这种从其他 P 偷的方式称为 work stealing
  • 当 G 因系统调用(syscall)阻塞时会阻塞 M,此时 P 会和 M 解绑即 hand off,并寻找新的 idle 的 M,若没有 idle 的 M 就会新建一个 M(流程 5.1)
  • 当 G 因 channel 或者 network I/O 阻塞时,不会阻塞 M,M 会寻找其他 runnable 的 G;当阻塞的 G 恢复后会重新进入 runnable 进入 P 队列等待执行(流程 5.3)

P和M的个数?

  • P: 由启动时环境变量 $GOMAXPROCS 或者是由 runtime的方法GOMAXPROCS()决定。这意味着在程序执行的任意时刻都只有$GOMAXPROCS个goroutine在同时运行。
  • M:
    • Go 语言本身的限制:Go 程序启动时,会设置 M 的最大数量,默认 10000,但是内核很难支持这么多的线程数,所以这个限制可以忽略。
    • runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量。
    • 一个 M 阻塞了,会创建新的 M。

M 与 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是 1,也有可能会创建很多个 M 出来。

P和M何时会被创建?

P: 在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P。

M: 没有足够的 M 来关联 P 并运行其中的可运行的 G 时创建。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。

goroutine创建流程?重要

在调用go func()的时候,会调用runtime.newproc来创建一个goroutine,这个goroutine会新建一个自己的栈空间,同时在G的sched中维护栈地址与程序计数器这些信息(备注:这些数据在goroutine被调度的时候会被用到。准确的说该goroutine在放弃cpu之后,下一次在重新获取cpu的时候,这些信息会被重新加载到cpu的寄存器中。)

创建好的这个goroutine会被放到它所对应的内核线程M所使用的上下文P中的run_queue中,等待调度器来决定何时取出该goroutine并执行,通常调度是按时间顺序被调度的,这个队列是一个先进先出的队列。

goroutine什么时候会被挂起?重要

  • waitReasonChanReceiveNilChan:对未初始化的 channel 进行读操作
  • waitReasonChanSendNilChan:对未初始化的 channel 进行写操作
  • 在 main goroutine 发生 panic 时,会触发
  • 在调用关键字 select 时会触发
  • 在调用关键字 select 时,若一个 case 都没有,会直接触发
  • 在 channel 进行读操作,会触发
  • 在 channel 进行写操作,会触发
  • sleep 行为,会触发
  • IO 阻塞等待时,例如:网络请求等
  • 在垃圾回收时,主要场景是 GC 标记终止和标记阶段时触发
  • GC 清扫阶段中的结束行为,会触发
  • 信号量处理结束时,会触发

同时启动了一万个goroutine,会如何调度?掌握

一万个G会按照P的设定个数,尽量平均地分配到每个P的本地队列中。如果所有本地队列都满了,那么剩余的G则会分配到GMP的全局队列上。接下来便开始执行GMP模型的调度策略:

  • 本地队列轮转:每个P维护着一个包含G的队列,不考虑G进入系统调用或IO操作的情况下,P周期性的将G调度到M中执行,执行一小段时间,将上下文保存下来,然后将G放到队列尾部,然后从队首中重新取出一个G进行调度。
  • 系统调用:P的个数默认等于CPU核数,每个M必须持有一个P才可以执行G,一般情况下M的个数会略大于P的个数,这多出来的M将会在G产生系统调用时发挥作用。当该G即将进入系统调用时,对应的M由于陷入系统调用而进被阻塞,将释放P,进而某个空闲的M1获取P,继续执行P队列中剩下的G。
  • 工作量窃取:多个P中维护的G队列有可能是不均衡的,当某个P已经将G全部执行完,然后去查询全局队列,全局队列中也没有新的G,而另一个M中队列中还有很多G待运行。此时,空闲的P会将其他P中的G偷取一部分过来,一般每次偷取一半。

goroutine内存泄漏和处理?

原因

Goroutine 是轻量级线程,需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息,而这些内存在目前版本的 Go 中是不会被释放的。因此,如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象。造成泄露的大多数原因有以下三种:

  • Goroutine 内正在进行 channel/mutex 等读写操作,但由于逻辑问题,某些情况下会被一直阻塞。
  • Goroutine 内的业务逻辑进入死循环,资源一直无法释放。
  • Goroutine 内的业务逻辑进入长时间等待,有不断新增的 Goroutine 进入等待。

解决方法

  • 使用channel
    • 1、使用channel接收业务完成的通知
    • 2、业务执行阻塞超过设定的超时时间,就会触发超时退出
  • 使用pprof排查
    • pprof是由 Go 官方提供的可用于收集程序运行时报告的工具,其中包含 CPU、内存等信息。当然,也可以获取运行时 goroutine 堆栈信息。

垃圾回收

golang的垃圾回收?重要

golang GC 算法使用的是无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法。

三色标记法将对象分为三类,并用不同的颜色相称:

  • 白色对象(可能死亡):未被回收器访问到的对象。在回收开始阶段,所有对象均为白色,当回收结束后,白色对象均不可达
  • 灰色对象(波面):已被回收器访问到的对象,但回收器需要对其中的一个或多个指针进行扫描,因为他们可能还指向白色对象
  • 黑色对象(确定存活):已被回收器访问到的对象,其中所有字段都已被扫描,黑色对象中任何一个指针都不可能直接指向白色对象

标记过程如下:

  • 第一步:起初所有的对象都是白色的
  • 第二步:从根对象出发扫描所有可达对象,标记为灰色,放入待处理队列
  • 第三步:从待处理队列中取出灰色对象,将其引用的对象标记为灰色并放入待处理队列中,自身标记为黑色
  • 重复第三步,直到待处理队列为空,此时白色对象即为不可达的“垃圾”,回收白色对象

写屏障?了解

当标记和程序是并发执行的,这就会造成一个问题。在标记过程中,有新的引用产生,可能会导致误清扫。清扫开始前,标记为黑色的对象引用了一个新申请的对象,它肯定是白色的,而黑色对象不会被再次扫描,那么这个白色对象无法被扫描变成灰色、黑色,它就会最终被清扫。golang 采用了写屏障,作用就是为了避免这类误清扫问题,写屏障即在内存写操作前,维护一个约束,从而确保清扫开始前,黑色的对象不能引用白色对象。gc一旦开始,无论是创建对象还是对象的引用改变,都会先变为灰色。

垃圾回收的触发条件?重要

  • 系统触发:运行时自行根据内置的条件,检查、发现到则进行 GC 处理,维护整个应用程序的可用性
  • 系统监控:当超过两分钟没有产生任何GC时,强制触发 GC
  • 步调(Pacing)算法,其核心思想是控制内存增长的比例,当前内存分配达到一定比例则触发
  • 触发:开发者在业务代码中自行调用 runtime.GC 方法来触发 GC

基础语法

  1. =:= 的区别?
  • := 声明+赋值
  • = 仅赋值
1
2
3
4
var foo int
foo = 10
// 等价于
foo := 10
  1. 指针的作用?

指针用来保存变量的地址。

例如

1
2
3
var x =  5
var p *int = &x
fmt.Printf("x = %d",  *p) // x 可以用 *p 访问
  • * 运算符,也称为解引用运算符,用于访问地址中的值。
  • &运算符,也称为地址运算符,用于返回变量的地址。
  1. Go 允许多个返回值吗?

允许

1
2
3
4
5
6
7
8
func swap(x, y string) (string, string) {
   return y, x
}

func main() {
   a, b := swap("A", "B")
   fmt.Println(a, b) // B A
}
  1. Go 有异常类型吗?

Go 没有异常类型,只有错误类型(Error),通常使用返回值来表示异常状态。

1
2
3
4
f, err := os.Open("test.txt")
if err != nil {
    log.Fatal(err)
}
  1. 什么是协程(Goroutine)

Goroutine 是与其他函数或方法同时运行的函数或方法。 Goroutines 可以被认为是轻量级的线程。 与线程相比,创建 Goroutine 的开销很小。 Go应用程序同时运行数千个 Goroutine 是非常常见的做法。

  1. 如何高效地拼接字符串

Go 语言中,字符串是只读的,也就意味着每次修改操作都会创建一个新的字符串。如果需要拼接多次,应使用 strings.Builder,最小化内存拷贝次数。

1
2
3
4
5
var str strings.Builder
for i := 0; i < 1000; i++ {
    str.WriteString("a")
}
fmt.Println(str.String())
  1. 什么是 rune 类型

ASCII 码只需要 7 bit 就可以完整地表示,但只能表示英文字母在内的128个字符,为了表示世界上大部分的文字系统,发明了 Unicode, 它是ASCII的超集,包含世界上书写系统中存在的所有字符,并为每个代码分配一个标准编号(称为Unicode CodePoint),在 Go 语言中称之为 rune,是 int32 类型的别名。

Go 语言中,字符串的底层表示是 byte (8 bit) 序列,而非 rune (32 bit) 序列。例如下面的例子中 使用 UTF-8 编码后各占 3 个 byte,因此 len("Go语言") 等于 8,当然我们也可以将字符串转换为 rune 序列。

1
2
fmt.Println(len("Go语言")) // 8
fmt.Println(len([]rune("Go语言"))) // 4
  1. 如何判断 map 中是否包含某个 key ?
1
2
3
if val, ok := dict["foo"]; ok {
    //do something here
}

dict["foo"] 有 2 个返回值,val 和 ok,如果 ok 等于 true,则说明 dict 包含 key "foo",val 将被赋予 "foo" 对应的值。

  1. Go 支持默认参数或可选参数吗?

Go 语言不支持可选参数(python 支持),也不支持方法重载(java支持)。

  1. defer 的执行顺序
  • 多个 defer 语句,遵从后进先出(Last In First Out,LIFO)的原则,最后声明的 defer 语句,最先得到执行。
  • defer 在 return 语句之后执行,但在函数退出之前,defer 可以修改返回值。

例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func test() int {
	i := 0
	defer func() {
		fmt.Println("defer1")
	}()
	defer func() {
		i += 1
		fmt.Println("defer2")
	}()
	return i
}

func main() {
	fmt.Println("return", test())
}
// defer2
// defer1
// return 0

这个例子中,可以看到 defer 的执行顺序:后进先出。但是返回值并没有被修改,这是由于 Go 的返回机制决定的,执行 return 语句后,Go 会创建一个临时变量保存返回值,因此,defer 语句修改了局部变量 i,并没有修改返回值。那如果是有名的返回值呢?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func test() (i int) {
	i = 0
	defer func() {
		i += 1
		fmt.Println("defer2")
	}()
	return i
}

func main() {
	fmt.Println("return", test())
}
// defer2
// return 1

这个例子中,返回值被修改了。对于有名返回值的函数,执行 return 语句时,并不会再创建临时变量保存,因此,defer 语句修改了 i,即对返回值产生了影响。

  1. 如何交换 2 个变量的值?
1
2
3
a, b := "A", "B"
a, b = b, a
fmt.Println(a, b) // B A
  1. Go 语言 tag 的用处?

tag 可以理解为 struct 字段的注解,可以用来定义字段的一个或多个属性。框架/工具可以通过反射获取到某个字段定义的属性,采取相应的处理方式。tag 丰富了代码的语义,增强了灵活性。

例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package main

import "fmt"
import "encoding/json"

type Stu struct {
	Name string `json:"stu_name"`
	ID   string `json:"stu_id"`
	Age  int    `json:"-"`
}

func main() {
	buf, _ := json.Marshal(Stu{"Tom", "t001", 18})
	fmt.Printf("%s\n", buf)
}

这个例子使用 tag 定义了结构体字段与 json 字段的转换关系,Name -> stu_name, ID -> stu_id,忽略 Age 字段。很方便地实现了 Go 结构体与不同规范的 json 文本之间的转换。

  1. 如何判断 2 个字符串切片(slice) 是相等的?

go 语言中可以使用反射 reflect.DeepEqual(a, b) 判断 a、b 两个切片是否相等,但是通常不推荐这么做,使用反射非常影响性能。

通常采用的方式如下,遍历比较切片中的每一个元素(注意处理越界的情况)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func StringSliceEqualBCE(a, b []string) bool {
    if len(a) != len(b) {
        return false
    }

    if (a == nil) != (b == nil) {
        return false
    }

    b = b[:len(a)]
    for i, v := range a {
        if v != b[i] {
            return false
        }
    }

    return true
}
  1. 字符串打印时,%v%+v 的区别

%v%+v 都可以用来打印 struct 的值,区别在于 %v 仅打印各个字段的值,%+v 还会打印各个字段的名称。

1
2
3
4
5
6
7
8
type Stu struct {
    Name string
}

func main() {
    fmt.Printf("%v\n", Stu{"Tom"}) // {Tom}
    fmt.Printf("%+v\n", Stu{"Tom"}) // {Name:Tom}
}

但如果结构体定义了 String() 方法,%v%+v 都会调用 String() 覆盖默认值。

  1. Go 语言中如何表示枚举值(enums)

通常使用常量(const) 来表示枚举值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type StuType int32

const (
	Type1 StuType = iota
	Type2
	Type3
	Type4
)

func main() {
	fmt.Println(Type1, Type2, Type3, Type4) // 0, 1, 2, 3
}

参考 What is an idiomatic way of representing enums in Go? - StackOverflow

  1. 空 struct{} 的用途

使用空结构体 struct{} 可以节省内存,一般作为占位符使用,表明这里并不需要一个值。

1
fmt.Println(unsafe.Sizeof(struct{}{})) // 0

比如使用 map 表示集合时,只关注 key,value 可以使用 struct{} 作为占位符。如果使用其他类型作为占位符,例如 int,bool,不仅浪费了内存,而且容易引起歧义。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type Set map[string]struct{}

func main() {
	set := make(Set)

	for _, item := range []string{"A", "A", "B", "C"} {
		set[item] = struct{}{}
	}
	fmt.Println(len(set)) // 3
	if _, ok := set["A"]; ok {
		fmt.Println("A exists") // A exists
	}
}

再比如,使用信道(channel)控制并发时,我们只是需要一个信号,但并不需要传递值,这个时候,也可以使用 struct{} 代替。

1
2
3
4
5
6
7
8
9
func main() {
	ch := make(chan struct{}, 1)
	go func() {
		<-ch
		// do something
	}()
	ch <- struct{}{}
	// ...
}

再比如,声明只包含方法的结构体。

1
2
3
4
5
6
7
8
9
type Lamp struct{}

func (l Lamp) On() {
        println("On")

}
func (l Lamp) Off() {
        println("Off")
}

实现原理

  1. init() 函数是什么时候执行的?

init() 函数是 Go 程序初始化的一部分。Go 程序初始化先于 main 函数,由 runtime 初始化每个导入的包,初始化顺序不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。

每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的 init() 函数。同一个包,甚至是同一个源文件可以有多个 init() 函数。init() 函数没有入参和返回值,不能被其他函数调用,同一个包内多个 init() 函数的执行顺序不作保证。

一句话总结: import –> const –> var –> init() –> main()

示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import "fmt"

func init()  {
    fmt.Println("init1:", a)
}

func init()  {
    fmt.Println("init2:", a)
}

var a = 10
const b = 100

func main() {
    fmt.Println("main:", a)
}
// 执行结果
// init1: 10
// init2: 10
// main: 10
  1. Go 语言的局部变量分配在栈上还是堆上?

由编译器决定。Go 语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析(escape analysis),当发现变量的作用域没有超出函数范围,就可以在栈上,反之则必须分配在堆上。

1
2
3
4
5
6
7
8
9
func foo() *int {
	v := 11
	return &v
}

func main() {
	m := foo()
	println(*m) // 11
}

foo() 函数中,如果 v 分配在栈上,foo 函数返回时,&v 就不存在了,但是这段函数是能够正常运行的。Go 编译器发现 v 的引用脱离了 foo 的作用域,会将其分配在堆上。因此,main 函数中仍能够正常访问该值。

  1. 2 个 interface 可以比较吗?

Go 语言中,interface 的内部实现包含了 2 个字段,类型 T 和 值 V,interface 可以使用 ==!= 比较。2 个 interface 相等有以下 2 种情况

  1. 两个 interface 均等于 nil(此时 V 和 T 都处于 unset 状态)
  2. 类型 T 相同,且对应的值 V 相等。

看下面的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type Stu struct {
	Name string
}

type StuInt interface{}

func main() {
	var stu1, stu2 StuInt = &Stu{"Tom"}, &Stu{"Tom"}
	var stu3, stu4 StuInt = Stu{"Tom"}, Stu{"Tom"}
	fmt.Println(stu1 == stu2) // false
	fmt.Println(stu3 == stu4) // true
}

stu1stu2 对应的类型是 *Stu,值是 Stu 结构体的地址,两个地址不同,因此结果为 false。 stu3stu4 对应的类型是 Stu,值是 Stu 结构体,且各字段相等,因此结果为 true。

  1. 两个 nil 可能不相等吗?

可能。

接口(interface) 是对非接口值(例如指针,struct等)的封装,内部实现包含 2 个字段,类型 T 和 值 V。一个接口等于 nil,当且仅当 T 和 V 处于 unset 状态(T=nil,V is unset)。

  • 两个接口值比较时,会先比较 T,再比较 V。
  • 接口值与非接口值比较时,会先将非接口值尝试转换为接口值,再比较。
1
2
3
4
5
6
7
func main() {
	var p *int = nil
	var i interface{} = p
	fmt.Println(i == p) // true
	fmt.Println(p == nil) // true
	fmt.Println(i == nil) // false
}

上面这个例子中,将一个 nil 非接口值 p 赋值给接口 i,此时,i 的内部字段为(T=*int, V=nil),i 与 p 作比较时,将 p 转换为接口后再比较,因此 i == p,p 与 nil 比较,直接比较值,所以 p == nil

但是当 i 与 nil 比较时,会将 nil 转换为接口 (T=nil, V=nil),与i (T=*int, V=nil) 不相等,因此 i != nil。因此 V 为 nil ,但 T 不为 nil 的接口不等于 nil。

  1. 简述 Go 语言GC(垃圾回收)的工作原理

最常见的垃圾回收算法有标记清除(Mark-Sweep) 和引用计数(Reference Count),Go 语言采用的是标记清除算法。并在此基础上使用了三色标记法和写屏障技术,提高了效率。

标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:

  • 标记阶段 — 从根对象出发查找并标记堆中所有存活的对象;
  • 清除阶段 — 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表。

标记清除算法的一大问题是在标记期间,需要暂停程序(Stop the world,STW),标记结束之后,用户程序才可以继续执行。为了能够异步执行,减少 STW 的时间,Go 语言采用了三色标记法。

三色标记算法将程序中的对象分成白色、黑色和灰色三类。

  • 白色:不确定对象。
  • 灰色:存活对象,子对象待处理。
  • 黑色:存活对象。

标记开始时,所有对象加入白色集合(这一步需 STW )。首先将根对象标记为灰色,加入灰色集合,垃圾搜集器取出一个灰色对象,将其标记为黑色,并将其指向的对象标记为灰色,加入灰色集合。重复这个过程,直到灰色集合为空为止,标记阶段结束。那么白色对象为需要清理的对象,而黑色对象均为根可达的对象,不能被清理。

三色标记法因为多了一个白色的状态来存放不确定对象,所以后续的标记阶段可以并发地执行。当然并发执行的代价是可能会造成一些遗漏,因为那些早先被标记为黑色的对象可能目前已经是不可达的了。所以三色标记法是一个 false negative(假阴性)的算法。

三色标记法并发执行仍存在一个问题,即在 GC 过程中,对象指针发生了改变。比如下面的例子:

1
A (黑) -> B (灰) -> C (白) -> D (白)

正常情况下,D 对象最终会被标记为黑色,不应被回收。但在标记和用户程序并发执行过程中,用户程序删除了 C 对 D 的引用,而 A 获得了 D 的引用。标记继续进行,D 就没有机会被标记为黑色了(A 已经处理过,这一轮不会再被处理)。

1
2
3
A (黑) -> B (灰) -> C (白) 
  ↓
 D (白)

为了解决这个问题,Go 使用了内存屏障技术,它是在用户程序读取对象、创建新对象以及更新对象指针时执行的一段代码,类似于一个钩子。垃圾收集器使用了写屏障(Write Barrier)技术,当对象新增或更新时,会将其着色为灰色。这样即使与用户程序并发执行,对象的引用发生改变时,垃圾收集器也能正确处理了。

一次完整的 GC 分为四个阶段:

  • 1)标记准备(Mark Setup,需 STW),打开写屏障(Write Barrier)

  • 2)使用三色标记法标记(Marking, 并发)

  • 3)标记结束(Mark Termination,需 STW),关闭写屏障。

  • 4)清理(Sweeping, 并发)

  • 参考 fullstack

  1. 函数返回局部变量的指针是否安全?

这在 Go 中是安全的,Go 编译器将会对每个局部变量进行逃逸分析。如果发现局部变量的作用域超出该函数,则不会将内存分配在栈上,而是分配在堆上。

  1. 非接口的任意类型 T() 都能够调用 *T 的方法吗?反过来呢?
  • 一个T类型的值可以调用为*T类型声明的方法,但是仅当此T的值是可寻址(addressable) 的情况下。编译器在调用指针属主方法前,会自动取此T值的地址。因为不是任何T值都是可寻址的,所以并非任何T值都能够调用为类型*T声明的方法。
  • 反过来,一个*T类型的值可以调用为类型T声明的方法,这是因为解引用指针总是合法的。事实上,你可以认为对于每一个为类型 T 声明的方法,编译器都会为类型*T自动隐式声明一个同名和同签名的方法。

哪些值是不可寻址的呢?

  • 字符串中的字节;
  • map 对象中的元素(slice 对象中的元素是可寻址的,slice的底层是数组);
  • 常量;
  • 包级别的函数等。

举一个例子,定义类型 T,并为类型 *T 声明一个方法 hello(),变量 t1 可以调用该方法,但是常量 t2 调用该方法时,会产生编译错误。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type T string

func (t *T) hello() {
	fmt.Println("hello")
}

func main() {
	var t1 T = "ABC"
	t1.hello() // hello
	const t2 T = "ABC"
	t2.hello() // error: cannot call pointer method on t
}

代码输出

常量与变量

  1. 下列代码的输出是:
1
2
3
4
5
6
7
8
9
func main() {
	const (
		a, b = "golang", 100
		d, e
		f bool = true
		g
	)
	fmt.Println(d, e, g)
}

golang 100 true

在同一个 const group 中,如果常量定义与前一行的定义一致,则可以省略类型和值。编译时,会按照前一行的定义自动补全。即等价于

1
2
3
4
5
6
7
8
9
func main() {
	const (
		a, b = "golang", 100
		d, e = "golang", 100
		f bool = true
		g bool = true
	)
	fmt.Println(d, e, g)
}
  1. 下列代码的输出是:
1
2
3
4
5
6
7
8
func main() {
	const N = 100
	var x int = N

	const M int32 = 100
	var y int = M
	fmt.Println(x, y)
}

编译失败:cannot use M (type int32) as type int in assignment

Go 语言中,常量分为无类型常量和有类型常量两种,const N = 100,属于无类型常量,赋值给其他变量时,如果字面量能够转换为对应类型的变量,则赋值成功,例如,var x int = N。但是对于有类型的常量 const M int32 = 100,赋值给其他变量时,需要类型匹配才能成功,所以显示地类型转换:

1
var y int = int(M)
  1. 下列代码的输出是:
1
2
3
4
5
func main() {
	var a int8 = -1
	var b int8 = -128 / a
	fmt.Println(b)
}

-128

int8 能表示的数字的范围是 [-2^7, 2^7-1],即 [-128, 127]。-128 是无类型常量,转换为 int8,再除以变量 -1,结果为 128,常量除以变量,结果是一个变量。变量转换时允许溢出,符号位变为1,转为补码后恰好等于 -128。

对于有符号整型,最高位是是符号位,计算机用补码表示负数。补码 = 原码取反加一。

例如:

1
2
3
4
5
6
7
8
9
-1 :  11111111
00000001(原码)    11111110(取反)    11111111(加一)
-128:    
10000000(原码)    01111111(取反)    10000000(加一)

-1 + 1 = 0
11111111 + 00000001 = 00000000(最高位溢出省略)
-128 + 127 = -1
10000000 + 01111111 = 11111111
  1. 下列代码的输出是:
1
2
3
4
5
func main() {
	const a int8 = -1
	var b int8 = -128 / a
	fmt.Println(b)
}

编译失败:constant 128 overflows int8

-128 和 a 都是常量,在编译时求值,-128 / a = 128,两个常量相除,结果也是一个常量,常量类型转换时不允许溢出,因而编译失败。

作用域

  1. 下列代码的输出是:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
	var err error
	if err == nil {
		err := fmt.Errorf("err")
		fmt.Println(1, err)
	}
	if err != nil {
		fmt.Println(2, err)
	}
}

1 err

:= 表示声明并赋值,= 表示仅赋值。

变量的作用域是大括号,因此在第一个 if 语句 if err == nil 内部重新声明且赋值了与外部变量同名的局部变量 err。对该局部变量的赋值不会影响到外部的 err。因此第二个 if 语句 if err != nil 不成立。所以只打印了 1 err

defer 延迟调用

  1. 下列代码的输出是:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type T struct{}

func (t T) f(n int) T {
	fmt.Print(n)
	return t
}

func main() {
	var t T
	defer t.f(1).f(2)
	fmt.Print(3)
}

132

defer 延迟调用时,需要保存函数指针和参数,因此链式调用的情况下,除了最后一个函数/方法外的函数/方法都会在调用时直接执行。也就是说 t.f(1) 直接执行,然后执行 fmt.Print(3),最后函数返回时再执行 .f(2),因此输出是 132。

  1. 下列代码的输出是:
1
2
3
4
5
6
7
8
func f(n int) {
	defer fmt.Println(n)
	n += 100
}

func main() {
	f(1)
}

1

打印 1 而不是 101。defer 语句执行时,会将需要延迟调用的函数和参数保存起来,也就是说,执行到 defer 时,参数 n(此时等于1) 已经被保存了。因此后面对 n 的改动并不会影响延迟函数调用的结果。

  1. 下列代码的输出是:
1
2
3
4
5
6
7
func main() {
	n := 1
	defer func() {
		fmt.Println(n)
	}()
	n += 100
}

101

匿名函数没有通过传参的方式将 n 传入,因此匿名函数内的 n 和函数外部的 n 是同一个,延迟执行时,已经被改变为 101。

  1. 下列代码的输出是:
1
2
3
4
5
6
7
8
func main() {
	n := 1
	if n == 1 {
		defer fmt.Println(n)
		n += 100
	}
	fmt.Println(n)
}

101

1

先打印 101,再打印 1。defer 的作用域是函数,而不是代码块,因此 if 语句退出时,defer 不会执行,而是等 101 打印后,整个函数返回时,才会执行。

并发编程

  1. 无缓冲的 channel 和 有缓冲的 channel 的区别?

对于无缓冲的 channel,发送方将阻塞该信道,直到接收方从该信道接收到数据为止,而接收方也将阻塞该信道,直到发送方将数据发送到该信道中为止。

对于有缓存的 channel,发送方在没有空插槽(缓冲区使用完)的情况下阻塞,而接收方在信道为空的情况下阻塞。

例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func main() {
	st := time.Now()
	ch := make(chan bool)
	go func ()  {
		time.Sleep(time.Second * 2)
		<-ch
	}()
	ch <- true  // 无缓冲,发送方阻塞直到接收方接收到数据。
	fmt.Printf("cost %.1f s\n", time.Now().Sub(st).Seconds())
	time.Sleep(time.Second * 5)
}
func main() {
	st := time.Now()
	ch := make(chan bool, 2)
	go func ()  {
		time.Sleep(time.Second * 2)
		<-ch
	}()
	ch <- true
	ch <- true // 缓冲区为 2,发送方不阻塞,继续往下执行
	fmt.Printf("cost %.1f s\n", time.Now().Sub(st).Seconds()) // cost 0.0 s
	ch <- true // 缓冲区使用完,发送方阻塞,2s 后接收方接收到数据,释放一个插槽,继续往下执行
	fmt.Printf("cost %.1f s\n", time.Now().Sub(st).Seconds()) // cost 2.0 s
	time.Sleep(time.Second * 5)
}
  1. 什么是协程泄露(Goroutine Leak)?

协程泄露是指协程创建后,长时间得不到释放,并且还在不断地创建新的协程,最终导致内存耗尽,程序崩溃。常见的导致协程泄露的场景有以下几种:

  • 缺少接收器,导致发送阻塞

这个例子中,每执行一次 query,则启动1000个协程向信道 ch 发送数字 0,但只接收了一次,导致 999 个协程被阻塞,不能退出。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func query() int {
    ch := make(chan int)
    for i := 0; i < 1000; i++ {
        go func() { ch <- 0 }()
    }
    return <-ch
}

func main() {
    for i := 0; i < 4; i++ {
        query()
        fmt.Printf("goroutines: %d\n", runtime.NumGoroutine())
    }
}
// goroutines: 1001
// goroutines: 2000
// goroutines: 2999
// goroutines: 3998
  • 缺少发送器,导致接收阻塞

那同样的,如果启动 1000 个协程接收信道的信息,但信道并不会发送那么多次的信息,也会导致接收协程被阻塞,不能退出。

  • 死锁(dead lock)

两个或两个以上的协程在执行过程中,由于竞争资源或者由于彼此通信而造成阻塞,这种情况下,也会导致协程被阻塞,不能退出。

  • 无限循环(infinite loops)

这个例子中,为了避免网络等问题,采用了无限重试的方式,发送 HTTP 请求,直到获取到数据。那如果 HTTP 服务宕机,永远不可达,导致协程不能退出,发生泄漏。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func request(url string, wg *sync.WaitGroup) {
    i := 0
    for {
        if _, err := http.Get(url); err == nil {
            // write to db
            break
        }
        i++
        if i >= 3 {
            break
        }
        time.Sleep(time.Second)
    }
    wg.Done()
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go request(fmt.Sprintf("https://127.0.0.1:8080/%d", i), &wg)
    }
    wg.Wait()
}
  1. Go 可以限制运行时操作系统线程的数量吗?

The GOMAXPROCS variable limits the number of operating system threads that can execute user-level Go code simultaneously. There is no limit to the number of threads that can be blocked in system calls on behalf of Go code; those do not count against the GOMAXPROCS limit.

可以使用环境变量 GOMAXPROCSruntime.GOMAXPROCS(num int) 设置,例如:

1
runtime.GOMAXPROCS(1) // 限制同时执行Go代码的操作系统线程数为 1

从官方文档的解释可以看到,GOMAXPROCS 限制的是同时执行用户态 Go 代码的操作系统线程的数量,但是对于被系统调用阻塞的线程数量是没有限制的。GOMAXPROCS 的默认值等于 CPU 的逻辑核数,同一时间,一个核只能绑定一个线程,然后运行被调度的协程。因此对于 CPU 密集型的任务,若该值过大,例如设置为 CPU 逻辑核数的 2 倍,会增加线程切换的开销,降低性能。对于 I/O 密集型应用,适当地调大该值,可以提高 I/O 吞吐率。

最后更新于 Nov 20, 2024 16:41 UTC
Built with Hugo
Theme Stack designed by Jimmy