关于后端:通俗易懂剖析Go-Channel理解并发通信的核心机制

本文来自 Go待业训练营 小韬同学的投稿。

也强烈安利大家多写博客,不仅能倒逼本人学习总结,也能作为简历的加分项,进步求职面试的竞争力。

你想想看:面试官看到你简历中的博客主页有几十篇文章,几千粉丝是什么感觉。要比你空洞洞的写一句“酷爱技术”强太多啦!

注释

咱们在学习与应用Go语言的过程中,对channel并不生疏,channel是Go语言不同凡响的个性之一,也是十分重要的一环,深刻了解Channel,置信可能在应用的时候更加的得心应手。

一、Channel根本用法

1、channel类别

channel在类型上,能够分为两种:
双向channel:既能接管又能发送的channel
单向channel:只能发送或只能接管的channel,即单向channel能够为分为:
    + 只写channel
    + 只读channel

申明并初始化如下如下:

func main() {  
    // 申明并初始化  
    var ch chan string = make(chan string) // 双向channel  
    var readCh <-chan string = make(<-chan string) // 只读channel  
    var writeCh chan<- string = make(chan<- string) // 只写channel  
}  

上述定义中,<-示意单向的channel。如果箭头指向chan,就示意只写channel,能够往chan里边写入数据;如果箭头远离chan,则示意为只读channel,能够从chan读数据。

在定义channel时,能够定义任意类型的channel,因而也同样能够定义chan类型的channel。例如:

a := make(chan<- chan int)   // 定义类型为 chan int 的写channel  
b := make(chan<- <-chan int) // 定义类型为 <-chan int 的写channel  
c := make(<-chan <-chan int) // 定义类型为 <-chan int 的读channel  
d := make(chan (<-chan int)) // 定义类型为 (<-chan int) 的读channel  

channel未初始化时,其零值为nilnil 是 chan 的零值,是一种非凡的 chan,对值是 nil 的 chan 的发送接管调用者总是会阻塞。

func main() {  
    var ch chan string  
    fmt.Println(ch) // <nil>  
}  

通过make咱们能够初始化一个channel,并且能够设置其容量的大小,如下初始化了一个类型为string,其容量大小为512channel

var ch chan string = make(chan string, 512)  

当初始化定义了channel的容量,则这样的channel叫做buffered chan,即有缓冲channel。如果没有设置容量,channel的容量为0,这样的channel叫做unbuffered chan,即无缓冲channel

有缓冲channel中,如果channel中还有数据,则从这个channel接收数据时不会被阻塞。如果channel的容量还未满,那么向这个channel发送数据也不会被阻塞,反之则会被阻塞。

无缓冲channel则只有当读写操作都筹备好后,才不会阻塞,这也是unbuffered chan在应用过程中十分须要留神的一点,否则可能会呈现常见的bug。

channel的常见操作:

1. 发送数据

往channel发送一个数据应用ch <-

func main() {  
    var ch chan int = make(chan int, 512)  
    ch <- 2000  
}  

上述的ch能够是chan int类型,也能够是单向chan <-int

2. 接收数据

从channel接管一条数据能够应用<-ch

func main() {  
    var ch chan int = make(chan int, 512)  
    ch <- 2000 // 发送数据  
  
    data := <-ch // 接收数据  
    fmt.Println(data) // 2000  
}  

ch 类型是 chan T,也能够是单向<-chan T

在接收数据时,能够返回两个返回值。第一个返回值返回channel中的元素,第二个返回值为bool类型,示意是否胜利地从channel中读取到一个值。

如果第二个参数是false,则示意channel曾经被close而且channel中没有缓存的数据,这个时候第一个值返回的是零值。

func main() {  
    var ch chan int = make(chan int, 512)  
    ch <- 2000 // 发送数据  
  
    data1, ok1 := <-ch // 接收数据  
    fmt.Printf("data1 = %d, ok1 = %t\n", data1, ok1) // data1 = 2000, ok1 = true  
    close(ch)  // 敞开channel  
    data2, ok2 := <-ch  // 接收数据  
    fmt.Printf("data2 = %d, ok2 = %t", data2, ok2) // data2 = 0, ok2 = false  
}  

所以,如果从channel读取到一个零值,可能是发送操作真正发送的零值,也可能是closed敞开channel并且channel没有缓存元素产生的零值,这是须要留神判断的一个点。

3. 其余操作

Go内建的函数closecaplen都能够对chan类型进行操作。
close:敞开channel。
cap:返回channel的容量。
len:返回channel缓存中还未被取走的元素数量。

func main() {  
    var ch chan int = make(chan int, 512)  
    ch <- 100  
    ch <- 200  
    fmt.Println("ch len:", len(ch)) // ch len: 2  
    fmt.Println("ch cap:", cap(ch)) // ch cap: 512  
}  

发送操作接管操作能够作为select语句中的case clause,例如:

func main() {  
    var ch = make(chan int, 512)  
    for i := 0; i < 10; i++ {  
       select {  
       case ch <- i:  
       case v := <-ch:  
          fmt.Println(v)  
       }  
    }  
}  

for-range语句同样能够在chan中应用,例如:

func main() {  
    var ch = make(chan int, 512)  
    ch <- 100  
    ch <- 200  
    ch <- 300  
    for v := range ch {  
       fmt.Println(v)  
    }  
}  
  
// 执行后果  
100  
200  
300  

2、select介绍

在Go语言中,select语句用于监控一组case语句,依据特定的条件执行绝对应的case语句或default语句,与switch相似,但不同之处在于select语句中所有case中的表达式都必须是channel的发送或接管操作。select应用示例代码如下:

select {  
case <-ch1:  
    fmt.Println("ch1")  
case ch2 <- 1:  
    fmt.Println("ch2")  
}  

上述代码中,select关键字让以后goroutine同时期待ch1 的可读和ch2的可写,在满足任意一个case分支之前,select 会始终阻塞上来,直到其中的一个 channel 转为就绪状态时执行对应case分支的代码。如果多个channel同时就绪的话则随机抉择一个case执行。

当应用空select时,空的 select 语句会间接阻塞以后的goroutine,使得该goroutine进入无奈被唤醒的永恒休眠状态。空select,即select内不蕴含任何case

select{  
    
}  

另外当select语句内只有一个case分支时,如果该case分支不满足,那么以后select就变成了一个阻塞的channel读/写操作。

select {  
case <-ch1:  
    fmt.Println("ch1")  
}  

上述select中,当ch1可读时,会执行打印操作,反之则阻塞以后goroutine

select语句内蕴含default分支时,如果select内的所有case都不满足,则会执行default分支的逻辑,用于当其余case都不满足时执行一些默认操作。

select {  
case <-ch1:  
    fmt.Println("ch1")  
case ch2 <- 1:  
    fmt.Println("ch2")  
default:  
    fmt.Println("default")  
}  

上述代码中,当ch1可读或ch2可写时,会执行相应的打印操作,否则就执行default语句中的代码,相当于一个非阻塞的channel读取操作。

select的应用能够总结为:

select不存在任何的case且没有default分支:永恒阻塞以后 goroutine;
select只存在一个case且没有default分支:阻塞的发送/接管;
select存在多个case:随机抉择一个满足条件的case执行;
select存在default,其余case都不满足时:执行default语句中的代码;

二、Channel实现原理

从代码的角度分析channel的实现,可能让咱们更好的去应用channel

咱们能够从chan类型的数据结构、初始化以及三个操作发送、接管和敞开这几个方面来理解channel

1、chan数据结构

chan类型的数据结构定义位于runtime.hchan,其构造体定义如下:

type hchan struct {  
    qcount   uint           // total data in the queue  
    dataqsiz uint           // size of the circular queue  
    buf      unsafe.Pointer // points to an array of dataqsiz elements  
    elemsize uint16  
    closed   uint32  
    elemtype *_type // element type  
    sendx    uint   // send index  
    recvx    uint   // receive index  
    recvq    waitq  // list of recv waiters  
    sendq    waitq  // list of send waiters  
  
    // lock protects all fields in hchan, as well as several  
    // fields in sudogs blocked on this channel.  
    //  
    // Do not change another G's status while holding this lock  
    // (in particular, do not ready a G), as this can deadlock  
    // with stack shrinking.  
    lock mutex  
}  

解释一下上述各个字段的意义:

qcount:示意chan中曾经接管到的数据且还未被取走的元素个数。内建函数len能够返回这个字段的值。
datasiz:循环队列的大小。chan在实现上应用一个循环队列来寄存元素的个数,循环队列实用于生产者-消费者的场景。
buf:寄存元素的循环队列bufferbuf 字段是一个指向队列缓冲区的指针,即指向一个dataqsiz元素的数组。buf 字段是应用 unsafe.Pointer 类型来示意队列缓冲区的起始地址。unsafe.Pointer是一种非凡的指针类型,它能够用于指向任何类型的数据。因为队列缓冲区的类型是动态分配的,所以不能间接应用某个具体类型的指针来示意。
elemtypeelemsizeelemtype示意chan中元素的数据类型,elemsize示意其大小。当chan定义后,它的元素类型是固定的,即一般类型或者指针类型,因而元素大小也是固定的。
sendx:解决发送数据操作的指针在buf队列中的地位。当channel接管到了新的数据时,该指针就会加上elemsize,挪动到下一个地位。buf 的总大小是elemsize的整数倍且buf是一个循环列表。
recvx:解决接收数据操作的指针在buf队列中的地位。当从buf中取出数据,此指针会挪动到下一个地位。
recvq:当接管操作发现channel中没有数据可读时,会被则色,此时会被退出到recvq队列中。
sendq:当发送操作发现buf队列已满时,会被进行阻塞,此时会被退出到sendq队列中。

<p align=center><img src=”https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ddc1b911e2ac40b9ad73bff9647e4dc0~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=395&h=524&s=37043&e=png&b=fbf7f6″ alt=”image.png”  /></p>

2、chan初始化

channel在进行初始化时,Go编译器会依据是否传入容量的大小,来抉择调用makechan64,还是makechanmakechan64在实现上底层还是调用makechan来进行初始化,makechan64只是对size做了查看。

makechan函数依据chan的容量的大小和元素的类型不同,初始化不同的存储空间。省略一些查看代码,makechan函数的次要逻辑如下:

func makechan(t *chantype, size int) *hchan {  
    elem := t.elem  
      
    ...  
  
    mem, overflow := math.MulUintptr(elem.size, uintptr(size))  
      
    ...  
      
    var c *hchan  
    switch {  
    case mem == 0:  
       // 队列或元素大小为零,不用创立buf  
       c = (*hchan)(mallocgc(hchanSize, nil, true))  
       c.buf = c.raceaddr()  
    case elem.ptrdata == 0:  
       // 元素不蕴含指针,调配一块间断的内存给hchan数据结构和buf  
       // hchan数据结构前面紧接着就是buf,在一次调用中调配hchan和buf  
       c = (*hchan)(mallocgc(hchanSize+mem, nil, true))  
       c.buf = add(unsafe.Pointer(c), hchanSize)  
    default:  
       // 元素蕴含指针,独自调配buf  
       c = new(hchan)  
       c.buf = mallocgc(mem, elem, true)  
    }  
  
    // 记录元素大小、类型、容量  
    c.elemsize = uint16(elem.size)  
    c.elemtype = elem  
    c.dataqsiz = uint(size)  
    lockInit(&c.lock, lockRankHchan)  
      
    ...  
      
    return c  
}  

3、send发送操作

Go在编译发送数据给channel时,会把发送操作send转换成chansend1函数,而chansend1函数会调用chansend函数。

func chansend1(c *hchan, elem unsafe.Pointer) {  
    chansend(c, elem, true, getcallerpc())  
}  

咱们能够来分段剖析chansend函数的实现逻辑。

第一局部:

次要是对chan进行判断,判断chan是否为nil,若为nil,则判断是否须要将以后goroutine进行阻塞,阻塞通过gopark来对调用者goroutine park(阻塞休眠)。

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {  
    // 第一局部  
    if c == nil { // 判断chan是否为nil  
       if !block { // 判断是否须要阻塞以后goroutine  
          return false  
       }  
       // 调用这goroutine park,进行阻塞休眠  
       gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)  
       throw("unreachable")  
    }  
      
    ...  
}  

第二局部

第二局部的逻辑判断是当你往一个容量已满的chan实例发送数据,且不想以后调用的goroutine被阻塞时(chan未被敞开),那么解决的逻辑是间接返回。

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {  
    ...  
    // 第二局部  
    if !block && c.closed == 0 && full(c) {  
        return false  
    }  
    ...  
}  

第三局部

第三局部的逻辑判断是首先进行互斥锁加锁,而后判断以后chan是否敞开,如果chan曾经被close了,则开释互斥锁并panic,即对已敞开的chan发送数据会panic

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {  
    ...  
    // 第三局部  
    lock(&c.lock) // 开始加锁  
  
    if c.closed != 0 { // 判断channel是否敞开  
        unlock(&c.lock)  
        panic(plainError("send on closed channel"))  
    }  
    ...  
}  

第四局部

第四局部的逻辑次要是判断接管队列中是否有正在期待的接管方receiver。如果存在正在期待的receiver(阐明此时buf中没有缓存的数据),则将他从接管队列中弹出,间接将须要发送到channel的数据交给这个receiver,而无需放入到buf中,让发送操作速度更快一些。

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {  
    ...  
      
    // 第四局部  
    if sg := c.recvq.dequeue(); sg != nil {  
       // 找到了一个正在期待的接收者。咱们传递咱们想要发送的值  
       // 间接传递给receiver接收者,绕过channel buf缓存区(如果receiver有的话)  
       send(c, sg, ep, func() { unlock(&c.lock) }, 3)  
       return true  
    }  
  
    ...  
}  

第五局部

当期待队列中并没有正在期待的receiver,则阐明以后buf还没有满,此时将发送的数据放入到buf中。

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {  
    ...  
      
    // 第五局部  
    if c.qcount < c.dataqsiz { // 判断buf是否满了  
       // channel buf还有可用的空间. 将发送数据入buf循环队列.  
       qp := chanbuf(c, c.sendx)  
       if raceenabled {  
          racenotify(c, c.sendx, nil)  
       }  
       typedmemmove(c.elemtype, qp, ep)  
       c.sendx++  
       if c.sendx == c.dataqsiz {  
          c.sendx = 0  
       }  
       c.qcount++  
       unlock(&c.lock)  
       return true  
    }  
      
    ...  
}  

第六局部

当逻辑走到第六局部,阐明正在解决buf已满的状况。如果buf已满,则发送操作的goroutine就会退出到发送者的期待队列,直到被唤醒。当goroutine被唤醒时,数据或者被取走了,或者chan曾经被敞开了。

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {  
    ...  
    // 第六局部  
      
    // chansend1函数调用不会进入if块里,因为chansend1的block=true  
    if !block {  
       unlock(&c.lock)  
       return false  
    }  
      
    ...  
      
    c.sendq.enqueue(mysg) // 退出发送队列  
      
    ...  
      
    gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2) // 阻塞  
      
    ...  
}  

4、recv接管操作

channel中接收数据时,Go会将代码转换成chanrecv1函数。如果须要返回两个返回值,则会转换成chanrecv2chanrecv1函数和chanrecv2都会调用chanrecv函数。chanrecv1chanrecv2传入的 block参数的值是true,两种调用都是阻塞形式,因而在剖析chanrecv函数的实现时,能够不思考 block=false的状况。

// 从已编译代码中进入 <-c 的入口点  
func chanrecv1(c *hchan, elem unsafe.Pointer) {  
    chanrecv(c, elem, true)  
}  
  
func chanrecv2(c *hchan, elem unsafe.Pointer) (received bool) {  
    _, received = chanrecv(c, elem, true)  
    return  
}  

同样,省略一些查看类的代码,咱们也能够分段剖析chanrecv函数的逻辑。

第一局部

第一局部次要判断以后进行接管操作的chan实例是否为nil,若为nil,则从nil chan中接收数据的调用这goroutine会被阻塞。

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {  
    ...  
    // 第一局部  
    if c == nil { // 判断chan是否为nil  
       if !block { // 是否阻塞,默认为block=true  
          return  
       }  
       // 进行阻塞  
       gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)  
       throw("unreachable")  
    }  
    ...  
}  

第二局部
这一部分只有是思考block=falsec为空的状况,block=false的状况咱们能够不做思考。

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {  
    ...  
    // 查看未取得锁的失败非阻塞操作。  
    if !block && empty(c) {  
        ...  
    }  
    ...  
}  

第三局部

第三局部的逻辑为判断以后chan是否被敞开,若以后chan曾经被close了,并且缓存队列中没有缓冲的元素时,返回truefalse

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {  
  
    ...  
     
    lock(&c.lock) // 加锁,返回时开释锁  
      
    // 第三局部  
    if c.closed != 0 { // 当chan已被敞开时  
        if c.qcount == 0 { // 且 buf区 没有缓存的数据了  
              
            ...  
              
            unlock(&c.lock) // 解锁  
            if ep != nil {  
               typedmemclr(c.elemtype, ep)  
            }  
            return true, false  
        }  
    }   
    ...  
}  

第四局部

第四局部是解决通道未敞开且buf缓存队列已满的状况。只有当缓存队列已满时,才可能从发送期待队列获取到sender。若以后的chanunbufferchan,即无缓冲区channel时,则间接将sender的发送数据传递给receiver。否则就从缓存队列的头部读取一个元素值,并将获取的sender携带的值退出到buf循环队列的尾部。

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {  
    ...  
    if c.closed != 0 { // 当chan已被敞开时  
      
    } else { // 第四局部,通道未敞开  
       // 如果sendq队列中有期待发送的sender  
       if sg := c.sendq.dequeue(); sg != nil {  
          // 存在正在期待的sender,如果缓存区的容量为0则间接将发送方的值传递给接管方  
          // 反之,则从缓存队列的头部获取数据,并将获取的sender的发送值退出到缓存队列尾部  
          recv(c, sg, ep, func() { unlock(&c.lock) }, 3)  
          return true, true  
       }  
    }  
      
    ...  
}  

第五局部

第五局部的次要逻辑是解决发送队列中没有期待的senderbuf中有缓存的数据。该段逻辑与外出的互斥锁共用一把锁,因而不存在并发问题。当buf缓存区有缓存元素时,则取出该元素传递给receiver,同时挪动接管指针。

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {  
    ...  
      
    // 第五局部  
    if c.qcount > 0 { // 发送队列中没有期待的sender,且buf中有缓存数据  
        // 间接从缓存队列中获取数据  
        qp := chanbuf(c, c.recvx)  
        if raceenabled {  
           racenotify(c, c.recvx, nil)  
        }  
        if ep != nil {  
           typedmemmove(c.elemtype, ep, qp)  
        }  
        typedmemclr(c.elemtype, qp)  
        c.recvx++ // 挪动接管指针  
        if c.recvx == c.dataqsiz { // 指针若已到开端则进行重置(循环队列)  
           c.recvx = 0  
        }  
        c.qcount-- // 获取数据后,buf缓存区元素个数减一  
        unlock(&c.lock) // 解锁  
        return true, true  
    }  
  
    if !block { // block=true  
        unlock(&c.lock)  
        return false, false  
    }  
    ...  
}  

第六局部

第六局部的逻辑次要是解决buf缓存区中没有缓存数据的状况。当buf缓存区没有缓存数据时,那么以后的receiver就会被阻塞,直到它从sender中接管了数据,或者是chanclose,才会返回。

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {  
    ...  
    c.recvq.enqueue(mysg) // 将以后接管操作入接管队列  
      
    ...  
      
    // 进行阻塞,期待唤醒  
    gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)  
    ...  
}  

5、close敞开

close函数次要用于channel的敞开,Go编译器会替换成closechan函数的调用。省略一些查看下的代码后,closechan函数的次要逻辑如下:
+ 如果以后channil,则间接panic
+ 如果以后chan已敞开,再次close则间接panic
+ 如果chan不为nilchan也没有closed,就把期待队列中的 sender(writer)和 receiver(reader)从队列中全副移除并唤醒。

func closechan(c *hchan) {  
    if c == nil { // 若以后chan未nil,则间接panic  
       panic(plainError("close of nil channel"))  
    }  
  
    lock(&c.lock) // 加锁  
      
    if c.closed != 0 { // 若以后chan曾经敞开,则间接panic  
       unlock(&c.lock)  
       panic(plainError("close of closed channel"))  
    }  
      
    ...  
  
    c.closed = 1 // 设置以后channel的状态为已敞开  
  
    var glist gList  
  
    // 开释接管队列中所有的reader  
    for {  
       sg := c.recvq.dequeue()  
       if sg == nil {  
          break  
       }  
       if sg.elem != nil {  
          typedmemclr(c.elemtype, sg.elem)  
          sg.elem = nil  
       }  
       if sg.releasetime != 0 {  
          sg.releasetime = cputicks()  
       }  
       gp := sg.g  
       gp.param = unsafe.Pointer(sg)  
       sg.success = false  
       if raceenabled {  
          raceacquireg(gp, c.raceaddr())  
       }  
       glist.push(gp)  
    }  
  
    // 开释发送队列中所有的writer (它们会panic)  
    for {  
       sg := c.sendq.dequeue()  
       if sg == nil {  
          break  
       }  
       sg.elem = nil  
       if sg.releasetime != 0 {  
          sg.releasetime = cputicks()  
       }  
       gp := sg.g  
       gp.param = unsafe.Pointer(sg)  
       sg.success = false  
       if raceenabled {  
          raceacquireg(gp, c.raceaddr())  
       }  
       glist.push(gp)  
    }  
    unlock(&c.lock)  
  
    for !glist.empty() {  
       gp := glist.pop()  
       gp.schedlink = 0  
       goready(gp, 3)  
    }  
}  

三、总结

通过学习channel的根本应用,理解其操作背地的实现原理,能够帮忙咱们更好的应用channel,防止一些操作不当而导致的panic或者说是bug,让咱们在应用channel时可能更加的得心应手。

channel的值和状态有多种状况,而不同的操作(send、recv、close)又可能失去不同的后果,这是应用 channel 类型时须要常常留神的点,咱们能够将不同channel值下的不同操作进行一个总结,特地留神操作channel时会产生panic的状况,曾经可能会导致线程阻塞的状况,都是有可能导致死锁与goroutine透露的罪魁祸首。

| channel执行操作\channel状态 | channel为nil | channel buf为空                | channel buf已满               | channel buf未满且不为空       | channel已敞开       |
| ————————— | ———— | —————————— | —————————– | —————————– | ——————- |
receive接管操作           | 阻塞         | 阻塞                           | 读取数据                      | 读取数据                      | 返回buf中缓存的数据 |
send发送操作              | 阻塞         | 写入数据                       | 阻塞                          | 写入数据                      | panic           |
close敞开                 | panic    | 敞开channel,buf中没有缓存数据 | 敞开channel,保留已缓存的数据 | 敞开channel,保留已缓存的数据 | panic

又出问题啦

咱们又出问题啦!大厂Offer集锦!遥遥领先!

这些敌人赢麻了!

这是一个专一程序员升职加薪の常识星球

答疑解惑

须要「简历优化」、「就业辅导」、「职业规划」的敌人能够分割我。

加我微信:wangzhongyang1993

关注我的同名公众号:王中阳Go

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理