【每日八股】Golang篇(三):关键字(下)

目录

  • [channel 的概念?](#channel 的概念?)
  • [channel 有哪些状态?](#channel 有哪些状态?)
  • [如何判断 channel 已关闭?](#如何判断 channel 已关闭?)
  • [channel 的底层实现原理?](#channel 的底层实现原理?)
  • [channel 发送数据和接收数据的过程?](#channel 发送数据和接收数据的过程?)
  • [channel 是否线程安全?](#channel 是否线程安全?)
  • [channel 如何实现线程安全?](#channel 如何实现线程安全?)
  • [channel 的应用场景?](#channel 的应用场景?)
  • [defer 的概述?](#defer 的概述?)
  • [defer 的使用场景?](#defer 的使用场景?)
  • [defer 的底层原理?](#defer 的底层原理?)
  • [defer 函数和 return 的执行顺序?](#defer 函数和 return 的执行顺序?)
  • [WaitGroup 使用的注意事项?](#WaitGroup 使用的注意事项?)
    • [补充:什么是 WaitGroup?](#补充:什么是 WaitGroup?)
    • 注意事项
    • 使用示例
    • [与 channel 对比](#与 channel 对比)

channel 的概念?

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

channel 有哪些状态?

  • nil:未初始化的状态,只进行了赋值,或手动赋值为 nil;
  • active:正常的 channel,可读可写;
  • closed:已关闭,channel 的值不是 nil。关闭状态的 channel 仍可以读,但不能写

    注意,上图当中的空 channel 指的是状态为 nil 的 channel,而不是活跃状态下没有数据的 channel。

如何判断 channel 已关闭?

go 复制代码
if v, ok := <- ch; !ok {
	fmt.Println("channel is already closed")
}

channel 的底层实现原理?

channel 有几个重要的字段:

  • buf 指向底层的循环数组,只有设置为有缓存的 channel 才会有 buf(在 make 一个 channel 的时候,指定 make 的第二个参数);
  • sendx 和 recvx 分别指向底层循环数组的发送和接收元素位置的索引
  • sendq 和 recvq 分别表示发送数据的被阻塞的 goroutine 和读取数据的 goroutine,二者都是双向链表结构;
  • sendq 和 recvq 是等待队列类型;
  • sudog 是对 goroutine 的封装;
go 复制代码
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 中发送数据的 goroutine 由于没有缓冲区,处于 waiting(即阻塞)状态,sendq 也是一个队列,如果此时有 goroutine 从 sendq 对头的 goroutine 取数据,那么取走数据之后,sendq 对头的 goroutine 将出列,并结束 waiting 状态】;
  • 如果 sendq 不为空,且有缓冲区,则说明缓冲区已满,此时从缓冲区首部读出数据,把 sendq 头部的 goroutine 数据写入到缓冲区尾部,并将 goroutine 唤醒,结束读取过程;
  • 如果 sendq 为空,缓冲区有数据,则直接从缓冲区取数据;
  • 如果 sendq 为空,且缓冲区没有数据或没有缓冲区,则当前的 goroutine 加入到 recvq,并进入 waiting 状态,等待输入数据到 channel 的 goroutine 将其唤醒。

注意事项

  • sendq 和 recvq 这些队列都是单个 channel 内部的成员,因此其中保存的 goroutine 都是要操作当前 channel 的 goroutine;
  • 对于没有缓冲区的 channel,当 goroutine A 向 channel 写入数据时,比如保证有另一个 goroutine B 读取,如果没有 B 读取,那么 A 将进入 sendq,状态变为 waiting,即阻塞地等待另一个 goroutine 读取数据;
  • 同理,对于没有缓冲区的 channel,goroutine B 直接从中读取数据将直接使 goroutine 进入 waiting 状态,直到有 goroutine A 向 channel 输入数据;
  • 理清楚缓冲区大小为 1 和 没有缓冲区的 channel 之间的区别:没有缓冲区的 channel 要求 goroutine 在读取时,必须有一个 goroutine 在阻塞地写入,否则读取的 goroutine 阻塞等待数据写入;反之,goroutine 写入没有缓冲区的 channel 时,必须有一个 goroutine 在阻塞地等待接收数据,否则写入的 goroutine 阻塞。对于缓冲区大小为 1 的 channel,写入的 goroutine 可以在 buffer 为空时直接写入,此时 buffer 满了,其它 goroutine 写入时将被阻塞;如果 buffer 为空,有 goroutine 从 buffer 读取数据也将被阻塞,而如果 buffer 满了,即其中有一个数据,那么读取的 goroutine 可以直接将数据读走。

channel 是否线程安全?

channel 是线程安全的。

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

channel 如何实现线程安全?

channel 的底层实现中,hchan 结构体使用 mutex 锁确保数据读写的按权。在对 hchan 中 buf 的数据进行入队和出队的操作时,必须先获取互斥锁,才能操作 channel 中的数据。

channel 的应用场景?

任务定时

go 复制代码
select {
	case <- time.After(time.Second)
}

time.After 返回一个 channel(<- chan time.Time),在指定的时间间隔后 ,该 channel 会收到一个时间值。time.After 常用于超时控制或延迟操作,特点是只能触发一次。

一个更高阶的例子如下:

go 复制代码
select {
case <-time.After(2 * time.Second):
    fmt.Println("2秒后执行")
case <-someOtherChannel:
    fmt.Println("其他通道先收到消息")
}

在该例中,如果 someOtherChannel 在 2 秒内没有收到消息,time.After 会在 2 秒后触发,执行相应的操作。

定时任务

go 复制代码
select {
	case <- time.Tick(time.Second)
}

time.Tick 返回一个 channel(<- chan time.Time),每隔指定的时间间隔 ,这个 channel 会收到一个时间值。time.Tick 的用途是定时任务或周期性操作time.Tick 的特点是会持续触发,每隔指定的时间间隔发送一次时间值到通道。

一个更高阶的例子如下:

go 复制代码
ticker := time.Tick(1 * time.Second)
for {
    select {
    case <-ticker:
        fmt.Println("每秒执行一次")
    case <-someOtherChannel:
        fmt.Println("其他通道收到消息")
        return
    }
}

在该例中,time.Tick 会每个 1 秒发送一次时间值到通道,直到 someOtherChannel 收到消息为止。

注意事项

在 Golang 当中,select 是单次执行的,如果没有 for 循环,那么 select 在单次执行之后会退出,继续执行 select 语句块之后的代码。

解耦生产者和消费者

基于 channel 可以将生产者和消费者解耦,生产者只需要向 channel 发送数据,而消费者只管从 channel 中读取数据。

以 Zinx 框架当中的 Connection 类型的读写分离模型为例,Connection 在 Start 之后,会分别通过 StartReader 和 StartWriter 两个 goroutine 分别开启从 TCP 连接中读取数据和向连接中发送数据的 goroutine。StartReader 在读取数据并进行业务处理之后,得到了业务数据,这个时候要回写到 conn 当中。非解耦的做法是直接在 StartReader 当中将数据写回到 conn 当中,而读写分离的逻辑是通过 channel 将业务处理的结果发送到 StartWriter 这个 goroutine,在这个 goroutine 中将数据回写到 conn 当中。

控制并发数

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

go 复制代码
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 逻辑:

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

注意事项

  • select 语句只能用于 channel 的读写操作;
  • select 中的 case 条件(非阻塞)是并发执行的,select 会选择先操作成功的那个 case 去执行,如果多个 case 同时成立,则随机选择一个执行,因此无法保证顺序;
  • 对于 case,如果存在 channel 为 nil 的情况,则该分支将被忽略,可以理解为从 select 中删除了这个 case;
  • 可以设置一个任务定时的 case,比如使用time.After,它通常会替代 default,即:如果在指定时间内没有任务执行,那么就执行time.After这个 case 对应的语句块,否则执行相应的成立的 case;
  • 空的 select{} 会引起 deadlock;
  • 对于 for loop 当中的 select{}可能会引起 CPU 占用过高的问题

defer 的概述?

defer 是 golang 提供的一种用于注册延迟调用的机制:defer 能够让函数或语句在当前函数执行完毕之后(包括 return 正常结束和 panic 导致的异常退出)进行调用。

defer 的特性:

  • 延迟调用:defer 在 main return 前调用,且 defer 必须置于函数内部;
  • LIFO:Last In First Out,后进先出,压栈式执行;
  • 作用域:如果一个 defer 处于某个匿名函数当中,那么会先调用这个匿名函数中的 defer。

defer 的使用场景?

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

并发处理

go 复制代码
var wg sync.WaitGroup

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

go 复制代码
	mu.RLock()
	defer mu.RUnlock()

资源释放

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

panic-recover

go 复制代码
defer func() {
	if v := recover(); v != nil {
		_ = fmt.Errorf("PANIC=%v", v)
	}
}()

defer 的底层原理?

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

go 复制代码
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关键字执行的时候填充。

defer 函数和 return 的执行顺序?

执行顺序如下:

首先,return 语句执行:

  • return 先计算返回值(如果有返回值的话),并将返回值存储到函数的返回变量中;
  • 如何返回值是命名返回值(named return value),return 会将值赋给命名变量;

之后,defer 函数链执行:

  • return 完成返回值计算后,defer 开始执行;
  • defer 可以访问和修改命名的返回值

最后,函数真正地返回:

  • defer 函数链执行完毕后,函数才真正地返回给调用者。

WaitGroup 使用的注意事项?

补充:什么是 WaitGroup?

在 Golang 当中,sync.WaitGroup 是一个用于等待一组 goroutine 完成执行的同步工具。它非常适合在需要等待多个并发任务完成后再继续执行的场景。

WaitGroup 的作用

  • goroutine 计数:通过计数器跟踪正在执行的 goroutine 数量;
  • 阻塞等待:主 goroutine 可以调用 Wait 方法阻塞,直到所有被跟踪的 goroutine 完成执行;
  • 动态增减:可以在运行时动态增减 goroutine 的计数;

WaitGroup 的核心方法

(1)Add(delta int)

  • 功能:增加或减少 WaitGroup 的计数器;
  • 参数 delta 可正(增加计数)可负(减少计数);
  • 通常在启动新的 goroutine 之前调用 Add(1)

(2)Done()

  • 功能:WaitGroup 的计数器减一;
  • 等价于Add(-1)
  • 常在 goroutine 完成时调用 Done()

(3)Wait()

  • 功能:阻塞当前 goroutine,直到 WaitGroup 的计数器变为 0;
  • 通常在 main goroutine 调用,阻塞地等待所有子 goroutine 完成。

注意事项

(1)计数器的初始值:

计数器的初始值必须为 0,如果是负数将触发 panic;

(2)AddDone 的调用顺序:

  • Add 必须在启动 goroutine 之前调用;
  • Done 必须在 goroutine 完成后调用,通常使用 defer 确保调用;

(3)WaitGroup 值传递:
WaitGroup 是值类型,传递时应使用指针(如 &wg),否则会导致计数器无法正确更新。

(4)避免竞争条件:

如果多个 goroutine 同时修改 WaitGroup 的计数器,可能会导致竞争条件。可以使用 sync.Mutexsync/atomic 包来保护计数器。

使用示例

go 复制代码
package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 3; i++ {
        wg.Add(1) // 增加计数器
        go worker(i, &wg)
    }

    wg.Wait() // 阻塞,直到计数器为 0
    fmt.Println("所有 goroutine 完成")
}

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // goroutine 完成时减少计数器

    fmt.Printf("Worker %d 开始工作\n", id)
    time.Sleep(time.Second * time.Duration(id)) // 模拟工作耗时
    fmt.Printf("Worker %d 完成工作\n", id)
}

与 channel 对比

  • WaitGroup:适合简单的等待场景,代码更简洁。
  • channel:适合需要 goroutine 之间通信的场景,功能更强大但代码更复杂。
相关推荐
woniu_maggie2 小时前
SAP DOI EXCEL&宏的使用
后端·excel
二两小咸鱼儿3 小时前
Java Demo - JUnit :Unit Test(Assert Methods)
java·后端·junit
字节源流3 小时前
【spring】配置类和整合Junit
java·后端·spring
PfCoder3 小时前
C#的判断语句总结
开发语言·c#·visual studio·winform
好看资源平台4 小时前
Java/Kotlin逆向基础与Smali语法精解
java·开发语言·kotlin
七七知享4 小时前
Go 语言编程全解析:Web 微服务与数据库十大专题深度精讲
数据库·web安全·网络安全·微服务·golang·web3·webkit
wjcroom4 小时前
数字投屏叫号器-发射端python窗口定制
开发语言·python
静候光阴4 小时前
python使用venv命令创建虚拟环境(ubuntu22)
linux·开发语言·python
LuckyLay4 小时前
Golang学习笔记_49——解释器模式
笔记·学习·设计模式·golang·解释器模式
Y1nhl4 小时前
力扣hot100_二叉树(4)_python版本
开发语言·pytorch·python·算法·leetcode·机器学习