概述
进程、线程、协程的区别?掌握
进程:进程是每一次程序动态执行的过程,是程序运行的基本单位。进程占据独立的内存,有内存地址,有自己的堆,上级挂靠操作系统,操作系统以进程为单位分配资源(如CPU时间片、内存等),进程是资源分配的最小单位。
线程:线程又叫做轻量级进程,是CPU调度的最小单元。线程从属于进程,是程序的实际执行者,一个进程至少包含一个主线程,也可以有多个子线程。线程会共享所属进程的资源,同时线程也有自己的独占资源。线程切换和线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。
协程:协程是一种用户态的轻量级线程,协程的调度完全由用户控制。一个线程可以有多个协程,协程不是被操作系统内核所管理,而是由程序所控制。
区别
- 拥有资源:进程是拥有资源的最小单位,线程不拥有资源,但是可以访问隶属进程的资源。进程所维护的是程序所包含的资源(静态资源), 如:地址空间,打开的文件句柄集,文件系统状态,信号处理handler等;线程所维护的运行相关的资源(动态资源),如:运行栈,调度相关的控制信息,待处理的信号集等。
- 并发性:不仅进程可以并发执行,同一进程的多个线程也可以并发执行。
- 系统开销:在创建或撤消进程时,由于系统都要为之分配和回收资源,导致系统的开销明显大于创建或撤消线程时的开销。但是进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个进程死掉就等于所有的线程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。
- 协程和线程:协程避免了无意义的调度,由此可以提高性能,但是用户调度过程中可能存在风险。
goroutine相比线程的优势?掌握
协程拥有极高的执行效率,子程序切换不是线程切换而是由程序自身控制,所以没有线程切换的开销。和多线程比,线程的数量越多,协程的性能优势就越明显。
协程不需要多线程的锁机制,因为只有一个线程,所以不存在同时写变量的冲突。在协程中控制共享资源不加锁,只需要判断状态就可以,执行效率比多线程要高。
20241022补充说明:由于线程是CPU调度的基本单位,所以单线程中的多个协程并非真正的并行执行,只是用时间片轮转模拟并行,因此不会有两个协程同时对一个变量进行写操作
go与Java的区别?掌握
- 运行:go是静态编译语言;Java是基于类的面向对象语言,Java应用程序在JVM上运行。
- 函数重载:go上不允许函数重载,必须具有方法和函数的唯一名称;java允许函数重载。
- 多态:Java默认允许多态,而go没有。
- 路由配置:go语言使用HTTP协议进行路由配置;java使用Akka.routing进行路由配置。
- 继承: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使用组合来代替继承:
|
|
组合:Go 使用结构体(struct)组合的方式来实现功能复用,而不是通过类继承。可以在一个结构体中嵌入另一个结构体,从而获得其方法
for遍历多次执行goroutine会存在什么问题?掌握
在 Go 中,使用 for
循环创建多个 goroutine 时,如果不正确地处理循环变量 i
,可能会导致所有 goroutine 访问到相同的 i
值,造成随机输出。这是因为 for
循环在很短的时间内就完成了,而 goroutine 的调度和初始化需要时间,导致所有 goroutine 可能在访问 i
时都得到相同的值。
示例代码:问题展示,以下代码展示了这个问题:
|
|
问题分析
- 值拷贝:
for
循环中的i
是一个共享的变量。当 goroutine 被创建时,i
的值并没有被复制到闭包中,而是直接引用了这个变量。因此,当所有 goroutine 启动时,i
可能已经被更新为循环结束后的值(在这个例子中是 10)。 - 时间延迟:goroutine 的启动和调度并不是瞬时完成的,而是在上下文中处理的,这意味着在创建 goroutine 之后,
for
循环可能会很快执行完,导致所有 goroutine 同时访问i
的结果都是最终的值。
解决方法:要解决这个问题,可以使用闭包并将当前的 i
作为参数传递给 goroutine。这样每个 goroutine 都会有自己的 i
副本,确保它们访问到各自的值。
以下是修复后的代码示例:
|
|
for并发读取文件
程序会panic:too many open files
解决的方法:通过带缓冲的channel
和sync.waitgroup
控制协程并发量。
|
|
init函数是什么时候执行的?掌握
特点:
init
函数先于main
函数自动执行,不能被其他函数调用。init
函数没有输入参数、返回值。- 每个包可以有多个
init
函数,包的每个源文件也可以有多个init
函数。 - go没有明确定义同一个包的
init
执行顺序,编程时程序不能依赖这个执行顺序。 - 不同包的
init
函数按照包导入的依赖关系决定执行顺序。
作用:
- 初始化变量
- 程序运行前的注册
- 实现
sync.Once
功能
20241022:
sync.Once
是 Go 语言标准库中sync
包提供的一种用于确保某个操作仅执行一次的结构。这在多线程环境中非常有用,特别是在需要进行一次性初始化时,比如初始化单例模式、配置设置等。
- 确保某个操作只执行一次:不论有多少个 goroutine 调用这个操作,
sync.Once
确保操作只会执行一次。- 并发安全:
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)
是完全不同的内存布局。
|
|
20241023
堆区和栈区是两种不同的内存分配区域,各自有不同的特点和用途:
栈区(Stack)
- 分配方式:栈是由系统自动管理的内存区域,内存分配和释放非常快,通常采用后进先出(LIFO)的方式。
- 大小限制:栈的大小通常较小,受到操作系统的限制,超过一定大小会导致栈溢出(stack overflow)。
- 生命周期:栈中分配的内存会在函数调用结束时自动释放,变量的生命周期与其所在的函数相同。
- 使用场景:适合存储局部变量、函数参数和返回值等。
堆区(Heap)
- 分配方式:堆是由程序员手动管理的内存区域,通过内存分配函数(如
malloc
或new
)进行分配,释放则需调用相应的释放函数(如free
或delete
)。- 大小限制:堆的大小通常仅受限于可用内存,允许分配更大的数据结构。
- 生命周期:堆中分配的内存需要手动管理,使用后必须显式释放,否则会造成内存泄漏。
- 使用场景:适合存储动态分配的对象和数据结构,如切片、映射和自定义类型的实例等。
总结
- 性能:栈的内存分配和释放速度比堆快。
- 内存管理:栈由系统自动管理,堆需要程序员手动管理。
- 使用场景:栈用于局部变量,堆用于动态数据。
slice和数组的区别?重要
切片是指针类型,数组是值类型
传递数组是通过拷贝的方式,传递切片是通过传递引用的方式。
数组的长度固定,而切片可以进行动态扩容
数组是一组内存空间连续的数据,一旦初始化长度大小就不会再改变,切片的长度可以进行扩展,当切片底层的数组容量不够时,切片会创建新的底层数组。
切片比数组多一个属性容量(cap)
slice的扩容机制?重要
扩容主要分为两个过程:第一步是分配新的内存空间,第二步是将原有切片内容进行复制。分配新空间时候需要估计大致容量,然后再确定容量。
根据该切片当前容量选择不同的策略:
- 如果期望容量大于当前容量的两倍,就会使用期望容量
- 如果当前切片的长度小于 1024,容量就会翻倍
- 如果当前切片的长达大于 1024,每次扩容 25% 的容量,直到新容量大于期望容量
- 在进行循环1.25倍计算时,最终容量计算值发生溢出,即超过了int的最大范围,则最终容量就是新申请的容量
对于切片的扩容
- 当切片比较小的,采用较大的扩容倍速进行扩容,避免频繁扩容,从而减少内存分配的次数和数据拷贝的代价
- 当切片较大的时,采用较小的扩容倍速,主要避免空间浪费
slice是线程安全的吗?了解
不是。slice底层结构并没有使用加锁等方式,不支持并发读写,所以并不是线程安全的,使用多个goroutine对类型为slice的变量进行操作,每次输出的值大概率都不会一样,与预期值不一致; slice在并发执行中不会报错,但是数据会丢失。
实现线程安全的方法:
互斥锁,读写锁,原子操作,sync.once
,sync.atomic
,channel
slice之间怎么进行比较?掌握
|
|
map之间如何进行比较?了解
|
|
map如何实现顺序读取?了解
map不能顺序读取,是因为他是无序的,想要有序读取,需要把key变为有序,所以可以把key放入切片,对切片进行排序,遍历切片,通过key取值。
|
|
map的底层数据结构?掌握
结构:底层是由hmap
和bmap
两个结构体实现,map
在实际存储键值对结构用到了数组和链表。之所以高效,是因为其结合了顺序存储数组和链式存储(链表)两种存储结构。数组是map
的主干,在数组下有一个类型为链表的元素。
hmap:
|
|
hmap
是map
的最外层的一个数据结构,包括了map
的各种基础信息、如大小、bucket
。buckets
这个参数它存储的是指向buckets
数组的一个指针。
bmap:
|
|
bucket
(桶),每一个bucket
最多放8
个key
和value
,最后由一个overflow
字段指向下一个bmap
,key
、value
、overflow
字段都不显示定义,而是通过maptype
计算偏移获取的。
高位哈希和低位哈希
哈希函数会将传入的key
值进行哈希运算,得到一个唯一的值。前半部分就叫做高位哈希值,后半部分就叫做低位哈希值。高位哈希值是用来确定当前的bucket
有没有所存储的数据的;低位哈希值是用来确定,当前的数据存在了哪个bucket
bucket是如何工作的?了解
bucket
的tophash
存储高八位哈希以加快索引。把高八位存储起来,不用完整比较key
就能过滤掉不符合的key
,加快查询速度。当一个哈希值的高8位和存储的高8位相符合,再去比较完整的key
值,进而取出value
。当超过8个元素需要存入某个bucket
时,hmap
会拓展该bucket
。
存储的key
和value
底层排列方式是:key
全部放在一起,value
全部放在一起。当key
大于128字节时,bucket
的key
和value
字段存储的是指针,指向实际内容。这样排列好处是在key
和value
的长度不同的时候,可以消除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已经关闭?重要
|
|
channel的底层实现原理?了解
channel有几个重要的字段:
- buf指向一个底层的循环数组,只有设置为有缓存的channel才会有buf
- sendx和recvx分别指向底层循环数组的发送和接收元素位置的索引
- sendq和recvq分别表示发送数据的被阻塞的goroutine和读取数据的goroutine,这两个都是一个双向链表结构
- sendq和recvq 的结构为等待队列类型,sudog是对goroutine的一种封装
|
|
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的应用场景?掌握
任务定时
|
|
定时任务
|
|
解耦生产者和消费者
可以将生产者和消费者解耦出来,生产者只需要往channel发送数据,而消费者只管从channel中获取数据。
控制并发数
以爬虫为例,比如需要爬取1w条数据,需要并发爬取以提高效率,但并发量又不能过大,可以通过channel来控制并发规模,比如同时支持5个并发任务:
|
|
select的用途?重要
select可以理解为是在语言层面实现了和I/O多路复用相似的功能:监听多个描述符的读/写等事件,一旦某个描述符就绪(一般是读或者写事件发生了),就能够将发生的事件通知给关心的应用程序去处理该事件。
golang的select机制是:监听多个channel,每一个case是一个事件,可以是读事件也可以是写事件,随机选择一个执行。可以设置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在一些资源回收的场景很有用。
并发处理
|
|
锁场景
|
|
资源释放
|
|
panic-recover
|
|
defer的底层原理?掌握
defer的数据结构如下:主要由siz属性,标识返回值的内存和大小、heap属性,标识是在栈上分配还是在堆上分配、sp是栈指针、pc程序计数器、fn是传入的函数地址、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链表中去。
|
|
deferproc
当defer结构体在堆上分配时,结构体在函数里面初始化,调用newdefer分配结构体,并且去缓冲池中查找,如果有就直接调用,否则使用mallocgc从堆上分配内存,deferproc 接受入参 siz,fn ,这两个参数分别标识延迟函数的参数和返回值的内存大小,延迟函数地址,保存上下文,把 caller 函数的 rsp,pc(rip) 寄存器的值保存到 _defer 结构体,defer作为一个节点挂接到链表。
|
|
deferreturn
遍历defer链表从前往后执行,执行一个就取出一个,直到链表为空。jmpdefer 负责跳转到延迟回调函数执行指令,执行结束之后,跳转回 deferreturn里执行。_defer.sp 的值可以用来判断哪些是当前 caller 函数注册的,这样就能保证只执行自己函数注册的延迟回调函数。
|
|
参数传入
采用预计算参数,_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 结构体来保存。
|
|
gobuf 保存了当前的栈指针,计数器,还有 g 自身,这里记录自身 g 的指针的目的是为了能快速的访问到 goroutine 中的信息。gobuf 的结构如下:
|
|
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 的栈中再执行。
|
|
P(Processor)
Processor 代表了 M 所需的上下文环境,代表 M 运行 G 所需要的资源。是处理用户级代码逻辑的处理器,可以将其看作一个局部调度器使 go 代码在一个线程上跑。当 P 有任务时,就需要创建或者唤醒一个系统线程来执行它队列里的任务,所以 P 和 M 是相互绑定的。P 可以根据实际情况开启协程去工作,它包含了运行 goroutine 的资源,如果线程想运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。
|
|
GMP的调度流程?重要
- 每个 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
基础语法
=
和:=
的区别?
:=
声明+赋值=
仅赋值
|
|
- 指针的作用?
指针用来保存变量的地址。
例如
|
|
*
运算符,也称为解引用运算符,用于访问地址中的值。&
运算符,也称为地址运算符,用于返回变量的地址。
- Go 允许多个返回值吗?
允许
|
|
- Go 有异常类型吗?
Go 没有异常类型,只有错误类型(Error),通常使用返回值来表示异常状态。
|
|
- 什么是协程(Goroutine)
Goroutine 是与其他函数或方法同时运行的函数或方法。 Goroutines 可以被认为是轻量级的线程。 与线程相比,创建 Goroutine 的开销很小。 Go应用程序同时运行数千个 Goroutine 是非常常见的做法。
- 如何高效地拼接字符串
Go 语言中,字符串是只读的,也就意味着每次修改操作都会创建一个新的字符串。如果需要拼接多次,应使用 strings.Builder
,最小化内存拷贝次数。
|
|
- 什么是 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 序列。
|
|
- 如何判断 map 中是否包含某个 key ?
|
|
dict["foo"]
有 2 个返回值,val 和 ok,如果 ok 等于 true
,则说明 dict 包含 key "foo"
,val 将被赋予 "foo"
对应的值。
- Go 支持默认参数或可选参数吗?
Go 语言不支持可选参数(python 支持),也不支持方法重载(java支持)。
- defer 的执行顺序
- 多个 defer 语句,遵从后进先出(Last In First Out,LIFO)的原则,最后声明的 defer 语句,最先得到执行。
- defer 在 return 语句之后执行,但在函数退出之前,defer 可以修改返回值。
例如:
|
|
这个例子中,可以看到 defer 的执行顺序:后进先出。但是返回值并没有被修改,这是由于 Go 的返回机制决定的,执行 return 语句后,Go 会创建一个临时变量保存返回值,因此,defer 语句修改了局部变量 i,并没有修改返回值。那如果是有名的返回值呢?
|
|
这个例子中,返回值被修改了。对于有名返回值的函数,执行 return 语句时,并不会再创建临时变量保存,因此,defer 语句修改了 i,即对返回值产生了影响。
- 如何交换 2 个变量的值?
|
|
- Go 语言 tag 的用处?
tag 可以理解为 struct 字段的注解,可以用来定义字段的一个或多个属性。框架/工具可以通过反射获取到某个字段定义的属性,采取相应的处理方式。tag 丰富了代码的语义,增强了灵活性。
例如:
|
|
这个例子使用 tag 定义了结构体字段与 json 字段的转换关系,Name -> stu_name
, ID -> stu_id
,忽略 Age 字段。很方便地实现了 Go 结构体与不同规范的 json 文本之间的转换。
- 如何判断 2 个字符串切片(slice) 是相等的?
go 语言中可以使用反射 reflect.DeepEqual(a, b)
判断 a、b 两个切片是否相等,但是通常不推荐这么做,使用反射非常影响性能。
通常采用的方式如下,遍历比较切片中的每一个元素(注意处理越界的情况)。
|
|
- 字符串打印时,
%v
和%+v
的区别
%v
和 %+v
都可以用来打印 struct 的值,区别在于 %v
仅打印各个字段的值,%+v
还会打印各个字段的名称。
|
|
但如果结构体定义了 String()
方法,%v
和 %+v
都会调用 String()
覆盖默认值。
- Go 语言中如何表示枚举值(enums)
通常使用常量(const) 来表示枚举值。
|
|
参考 What is an idiomatic way of representing enums in Go? - StackOverflow
- 空 struct{} 的用途
使用空结构体 struct{} 可以节省内存,一般作为占位符使用,表明这里并不需要一个值。
|
|
比如使用 map 表示集合时,只关注 key,value 可以使用 struct{} 作为占位符。如果使用其他类型作为占位符,例如 int,bool,不仅浪费了内存,而且容易引起歧义。
|
|
再比如,使用信道(channel)控制并发时,我们只是需要一个信号,但并不需要传递值,这个时候,也可以使用 struct{} 代替。
|
|
再比如,声明只包含方法的结构体。
|
|
实现原理
- init() 函数是什么时候执行的?
init()
函数是 Go 程序初始化的一部分。Go 程序初始化先于 main 函数,由 runtime 初始化每个导入的包,初始化顺序不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。
每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的 init()
函数。同一个包,甚至是同一个源文件可以有多个 init()
函数。init()
函数没有入参和返回值,不能被其他函数调用,同一个包内多个 init()
函数的执行顺序不作保证。
一句话总结: import –> const –> var –> init()
–> main()
示例:
|
|
- Go 语言的局部变量分配在栈上还是堆上?
由编译器决定。Go 语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析(escape analysis),当发现变量的作用域没有超出函数范围,就可以在栈上,反之则必须分配在堆上。
|
|
foo()
函数中,如果 v 分配在栈上,foo 函数返回时,&v
就不存在了,但是这段函数是能够正常运行的。Go 编译器发现 v 的引用脱离了 foo 的作用域,会将其分配在堆上。因此,main 函数中仍能够正常访问该值。
- 2 个 interface 可以比较吗?
Go 语言中,interface 的内部实现包含了 2 个字段,类型 T
和 值 V
,interface 可以使用 ==
或 !=
比较。2 个 interface 相等有以下 2 种情况
- 两个 interface 均等于 nil(此时 V 和 T 都处于 unset 状态)
- 类型 T 相同,且对应的值 V 相等。
看下面的例子:
|
|
stu1
和 stu2
对应的类型是 *Stu
,值是 Stu 结构体的地址,两个地址不同,因此结果为 false。
stu3
和 stu4
对应的类型是 Stu
,值是 Stu 结构体,且各字段相等,因此结果为 true。
- 两个 nil 可能不相等吗?
可能。
接口(interface) 是对非接口值(例如指针,struct等)的封装,内部实现包含 2 个字段,类型 T
和 值 V
。一个接口等于 nil,当且仅当 T 和 V 处于 unset 状态(T=nil,V is unset)。
- 两个接口值比较时,会先比较 T,再比较 V。
- 接口值与非接口值比较时,会先将非接口值尝试转换为接口值,再比较。
|
|
上面这个例子中,将一个 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。
- 简述 Go 语言GC(垃圾回收)的工作原理
最常见的垃圾回收算法有标记清除(Mark-Sweep) 和引用计数(Reference Count),Go 语言采用的是标记清除算法。并在此基础上使用了三色标记法和写屏障技术,提高了效率。
标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:
- 标记阶段 — 从根对象出发查找并标记堆中所有存活的对象;
- 清除阶段 — 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表。
标记清除算法的一大问题是在标记期间,需要暂停程序(Stop the world,STW),标记结束之后,用户程序才可以继续执行。为了能够异步执行,减少 STW 的时间,Go 语言采用了三色标记法。
三色标记算法将程序中的对象分成白色、黑色和灰色三类。
- 白色:不确定对象。
- 灰色:存活对象,子对象待处理。
- 黑色:存活对象。
标记开始时,所有对象加入白色集合(这一步需 STW )。首先将根对象标记为灰色,加入灰色集合,垃圾搜集器取出一个灰色对象,将其标记为黑色,并将其指向的对象标记为灰色,加入灰色集合。重复这个过程,直到灰色集合为空为止,标记阶段结束。那么白色对象为需要清理的对象,而黑色对象均为根可达的对象,不能被清理。
三色标记法因为多了一个白色的状态来存放不确定对象,所以后续的标记阶段可以并发地执行。当然并发执行的代价是可能会造成一些遗漏,因为那些早先被标记为黑色的对象可能目前已经是不可达的了。所以三色标记法是一个 false negative(假阴性)的算法。
三色标记法并发执行仍存在一个问题,即在 GC 过程中,对象指针发生了改变。比如下面的例子:
|
|
正常情况下,D 对象最终会被标记为黑色,不应被回收。但在标记和用户程序并发执行过程中,用户程序删除了 C 对 D 的引用,而 A 获得了 D 的引用。标记继续进行,D 就没有机会被标记为黑色了(A 已经处理过,这一轮不会再被处理)。
|
|
为了解决这个问题,Go 使用了内存屏障技术,它是在用户程序读取对象、创建新对象以及更新对象指针时执行的一段代码,类似于一个钩子。垃圾收集器使用了写屏障(Write Barrier)技术,当对象新增或更新时,会将其着色为灰色。这样即使与用户程序并发执行,对象的引用发生改变时,垃圾收集器也能正确处理了。
一次完整的 GC 分为四个阶段:
-
1)标记准备(Mark Setup,需 STW),打开写屏障(Write Barrier)
-
2)使用三色标记法标记(Marking, 并发)
-
3)标记结束(Mark Termination,需 STW),关闭写屏障。
-
4)清理(Sweeping, 并发)
-
参考 fullstack
- 函数返回局部变量的指针是否安全?
这在 Go 中是安全的,Go 编译器将会对每个局部变量进行逃逸分析。如果发现局部变量的作用域超出该函数,则不会将内存分配在栈上,而是分配在堆上。
- 非接口的任意类型 T() 都能够调用
*T
的方法吗?反过来呢?
- 一个T类型的值可以调用为
*T
类型声明的方法,但是仅当此T的值是可寻址(addressable) 的情况下。编译器在调用指针属主方法前,会自动取此T值的地址。因为不是任何T值都是可寻址的,所以并非任何T值都能够调用为类型*T
声明的方法。 - 反过来,一个
*T
类型的值可以调用为类型T声明的方法,这是因为解引用指针总是合法的。事实上,你可以认为对于每一个为类型 T 声明的方法,编译器都会为类型*T
自动隐式声明一个同名和同签名的方法。
哪些值是不可寻址的呢?
- 字符串中的字节;
- map 对象中的元素(slice 对象中的元素是可寻址的,slice的底层是数组);
- 常量;
- 包级别的函数等。
举一个例子,定义类型 T,并为类型 *T
声明一个方法 hello()
,变量 t1 可以调用该方法,但是常量 t2 调用该方法时,会产生编译错误。
|
|
代码输出
常量与变量
- 下列代码的输出是:
|
|
golang 100 true
在同一个 const group 中,如果常量定义与前一行的定义一致,则可以省略类型和值。编译时,会按照前一行的定义自动补全。即等价于
|
|
- 下列代码的输出是:
|
|
编译失败:cannot use M (type int32) as type int in assignment
Go 语言中,常量分为无类型常量和有类型常量两种,const N = 100
,属于无类型常量,赋值给其他变量时,如果字面量能够转换为对应类型的变量,则赋值成功,例如,var x int = N
。但是对于有类型的常量 const M int32 = 100
,赋值给其他变量时,需要类型匹配才能成功,所以显示地类型转换:
|
|
- 下列代码的输出是:
|
|
-128
int8 能表示的数字的范围是 [-2^7, 2^7-1],即 [-128, 127]。-128 是无类型常量,转换为 int8,再除以变量 -1,结果为 128,常量除以变量,结果是一个变量。变量转换时允许溢出,符号位变为1,转为补码后恰好等于 -128。
对于有符号整型,最高位是是符号位,计算机用补码表示负数。补码 = 原码取反加一。
例如:
|
|
- 下列代码的输出是:
|
|
编译失败:constant 128 overflows int8
-128 和 a 都是常量,在编译时求值,-128 / a = 128,两个常量相除,结果也是一个常量,常量类型转换时不允许溢出,因而编译失败。
作用域
- 下列代码的输出是:
|
|
1 err
:=
表示声明并赋值,=
表示仅赋值。
变量的作用域是大括号,因此在第一个 if 语句 if err == nil
内部重新声明且赋值了与外部变量同名的局部变量 err。对该局部变量的赋值不会影响到外部的 err。因此第二个 if 语句 if err != nil
不成立。所以只打印了 1 err
defer 延迟调用
- 下列代码的输出是:
|
|
132
defer 延迟调用时,需要保存函数指针和参数,因此链式调用的情况下,除了最后一个函数/方法外的函数/方法都会在调用时直接执行。也就是说 t.f(1)
直接执行,然后执行 fmt.Print(3)
,最后函数返回时再执行 .f(2)
,因此输出是 132。
- 下列代码的输出是:
|
|
1
打印 1 而不是 101。defer 语句执行时,会将需要延迟调用的函数和参数保存起来,也就是说,执行到 defer 时,参数 n(此时等于1) 已经被保存了。因此后面对 n 的改动并不会影响延迟函数调用的结果。
- 下列代码的输出是:
|
|
101
匿名函数没有通过传参的方式将 n 传入,因此匿名函数内的 n 和函数外部的 n 是同一个,延迟执行时,已经被改变为 101。
- 下列代码的输出是:
|
|
101
1
先打印 101,再打印 1。defer 的作用域是函数,而不是代码块,因此 if 语句退出时,defer 不会执行,而是等 101 打印后,整个函数返回时,才会执行。
并发编程
- 无缓冲的 channel 和 有缓冲的 channel 的区别?
对于无缓冲的 channel,发送方将阻塞该信道,直到接收方从该信道接收到数据为止,而接收方也将阻塞该信道,直到发送方将数据发送到该信道中为止。
对于有缓存的 channel,发送方在没有空插槽(缓冲区使用完)的情况下阻塞,而接收方在信道为空的情况下阻塞。
例如:
|
|
- 什么是协程泄露(Goroutine Leak)?
协程泄露是指协程创建后,长时间得不到释放,并且还在不断地创建新的协程,最终导致内存耗尽,程序崩溃。常见的导致协程泄露的场景有以下几种:
- 缺少接收器,导致发送阻塞
这个例子中,每执行一次 query,则启动1000个协程向信道 ch 发送数字 0,但只接收了一次,导致 999 个协程被阻塞,不能退出。
|
|
- 缺少发送器,导致接收阻塞
那同样的,如果启动 1000 个协程接收信道的信息,但信道并不会发送那么多次的信息,也会导致接收协程被阻塞,不能退出。
- 死锁(dead lock)
两个或两个以上的协程在执行过程中,由于竞争资源或者由于彼此通信而造成阻塞,这种情况下,也会导致协程被阻塞,不能退出。
- 无限循环(infinite loops)
这个例子中,为了避免网络等问题,采用了无限重试的方式,发送 HTTP 请求,直到获取到数据。那如果 HTTP 服务宕机,永远不可达,导致协程不能退出,发生泄漏。
|
|
- 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.
可以使用环境变量 GOMAXPROCS
或 runtime.GOMAXPROCS(num int)
设置,例如:
|
|
从官方文档的解释可以看到,GOMAXPROCS
限制的是同时执行用户态 Go 代码的操作系统线程的数量,但是对于被系统调用阻塞的线程数量是没有限制的。GOMAXPROCS
的默认值等于 CPU 的逻辑核数,同一时间,一个核只能绑定一个线程,然后运行被调度的协程。因此对于 CPU 密集型的任务,若该值过大,例如设置为 CPU 逻辑核数的 2 倍,会增加线程切换的开销,降低性能。对于 I/O 密集型应用,适当地调大该值,可以提高 I/O 吞吐率。