golang channel源码解析

go channel源码分析

不要通过共享内存来通信,而要通过通信来实现内存共享。
在这里插入图片描述

从本质上来看,计算机上线程和协程同步信息其实都是通过『共享内存』来进行的,因为无论是哪种通信模型,线程或者协程最终都会从内存中获取数据,所以更为准确的说法是『为什么我们使用发送消息的方式来同步信息,而不是多个线程或者协程直接共享内存?

抽象层级

发送消息和共享内存这两种方式其实是用来传递信息的不同方式,但是它们两者有着不同的抽象层级,发送消息是一种相对『高级』的抽象,但是不同语言在实现这一机制时也都会使用操作系统提供的锁机制来实现,共享内存这种最原始和最本质的信息传递方式就是使用锁这种并发机制实现的。

我们可以这么理解:更为高级和抽象的信息传递方式其实也只是对低抽象级别接口的组合和封装,Go 语言中的 Channel 就提供了 Goroutine 之间用于传递信息的方式,它在内部实现时就广泛用到了共享内存和锁,通过对两者进行的组合提供了更高级的同步机制。

在这里插入图片描述

耦合

使用发送消息的方式替代共享内存也能够帮助我们减少多个模块之间的耦合,假设我们使用共享内存的方式在多个 Goroutine 之间传递信息,每个 Goroutine 都可能是资源的生产者和消费者,它们需要在读取或者写入数据时先获取保护该资源的互斥锁。
在这里插入图片描述
然而我们使用发送消息的方式却可以将多个线程或者协程解耦,以前需要依赖同一个片内存的多个线程,现在可以成为消息的生产者和消费者,多个线程也不需要自己手动处理资源的获取和释放,其中 Go 语言实现的 CSP 机制通过引入 Channel 来解耦 Goroutine:

这种通过发送信息的解耦方式,尤其是 Go 语言实现的 CSP 模型其实与消息队列非常相似,我们引入 Channel 这一中间层让资源的生产者和消费者更加清晰,当我们需要增加新的生产者或者消费者时也只需要直接增加 Channel 的发送方和接收方。

线程竞争

Go 语言在实现上通过 Channel 保证被共享的变量不会同时被多个活跃的 Goroutine 访问,一旦某个消息被发送到了 Channel 中,我们就失去了当前消息的控制权,作为接受者的 Goroutine 在收到这条消息之后就可以根据该消息进行一些计算任务;从这个过程来看,消息在被发送前只由发送方进行访问,在发送之后仅可被唯一的接受者访问,所以从这个设计上来看我们就避免了线程竞争。
在这里插入图片描述

一、通道是什么?

通道是可以让一个goroutine发送特定值到另一个goroutine的通信机制

无缓冲通道:
img

也称同步通道,无缓冲通道上的发送操作将会阻塞,直到另一个goroutine在对应的通道上执行接收操作,此时传送完成,两个goroutine才能继续执行。反之亦然。

有缓冲通道:

img

缓冲通道有一个缓冲队列,队列的最大长度在创建的时候通过make的容量参数来设置。

缓冲通道上的发送操作在队列的尾部插入一个元素,接收操作从队列的头部移除一个元素。

如果通道满了,发送操作的goroutine会阻塞在通道的等待发送队列中,直到另一个goroutine接收数据。

如果通道为空,接收操作的goroutine会阻塞在通道的等待接收队列中,直到另一个goroutine发送数据。

二、通道源码剖析
1.channel的构造:

构造语句,make(chan int)会被golang编译器编译成runtime.makechan函数

函数原型:

func makechan(t *chantype, size int ) *hchan

其中,t *chantype即构造channel时传入的元素类型。size int即用户指定的channel缓冲区大小,不指定则为0。该函数的返回值是*hchan。hchan则是channel在golang中的内部实现

2.通道结构 hchan

在这里插入图片描述

type hchan struct {
    qcount   uint           // buffer中已放入的元素个数
    dataqsiz uint           // 用户构造channel时指定的buf大小
    buf      unsafe.Pointer // buffer
    elemsize uint16         // buffer中每个元素的大小
    closed   uint32         // channel是否关闭,== 0代表未closed
    elemtype *_type         // channel元素的类型信息
    sendx    uint           // buffer中已发送的索引位置 send index
    recvx    uint           // buffer中已接收的索引位置 receive index
    recvq    waitq          // 队列:等待接收的goroutine  list of recv waiters
    sendq    waitq          // 队列:等待发送的goroutine list of send waiters
    
    lock mutex              // 锁
}

通过分析hchan的属性,得知 buffer和waitq是两个重要的组件,hchan的所有行为都是围绕这两个组件进行

sudog:对当前运行的goroutine和待发送数据的封装,有一个前驱指针和后驱指针hchan的sendq和recvq是由sudog组成的双向链表。

3.channel中ring buffer的实现

环形缓冲区好处较多,非常适用于FIFO式的固定长度队列

在这里插入图片描述

hchan中有两个与buffer相关的变量:recvx和sendx。

  • sendx:表示buffer中可写的index
  • recvx:表示buffer中可读的index

从recvx到sendx之间的元素,表示已经正常放入buffer中的数据。

4. gopark()和goready()
  • gopark(): 用于协程的切换

    gopark函数做的事情分为两点:

    1. 解除当前goroutine与m的绑定关系,将当前goroutine的状态置为等待状态
    2. 调用一次schedule()函数,在局部调度器P发起一轮新的调度
//gopark()
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
    //如果阻塞原因是 Sleep
	if reason != waitReasonSleep {
		checkTimeouts() 
	}
	mp := acquirem()
	gp := mp.curg
	status := readgstatus(gp)
    //如果状态不是_Grunning
	if status != _Grunning && status != _Gscanrunning {
		throw("gopark: bad g status")
	}
    //记录g休眠的原因和上下文
	mp.waitlock = lock
	mp.waitunlockf = unlockf
	gp.waitreason = reason
	mp.waittraceev = traceEv
	mp.waittraceskip = traceskip
    //释放线程m
	releasem(mp)
    // mcall()作用:
    // 1.切换当前线程的堆栈从 g 的堆栈切换到g0的堆栈
    // 2.在g0的堆栈上执行新的函数 fn(g)
    // 3.保存当前协程的信息,当后续唤醒当前信息时恢复现场
	mcall(park_m)
}
//park_m()
func park_m(gp *g) {
    //获取g0
	_g_ := getg()
	if trace.enabled {
		traceGoPark(_g_.m.waittraceev, _g_.m.waittraceskip)
	}
    //更新gp的状态为_Gwaiting
	casgstatus(gp, _Grunning, _Gwaiting)
    //移除gp和m的绑定关系
	dropg()
    //进入等待状态前执行前置函数
	if fn := _g_.m.waitunlockf; fn != nil {
		ok := fn(gp, _g_.m.waitlock)//执行进入wait前的前置函数
		_g_.m.waitunlockf = nil
		_g_.m.waitlock = nil
        //如果waitunlockf函数执行失败,将gp重新置为_Grunnable状态,恢复
		if !ok {
			if trace.enabled {
				traceGoUnpark(gp, 2)
			}
			casgstatus(gp, _Gwaiting, _Grunnable)
			execute(gp, true) // Schedule it back, never returns.
		}
	}
    //发起一轮新的调度
	schedule()
}
  • goready(): 用于唤醒协程

    goready主要做的事情:唤醒某一个goroutine,并将该协程转换为runnable状态,并将其放入到P的local queue(本地队列),等待调度wating

func goready(gp *g, traceskip int) {
    //切换到g0的栈
	systemstack(func() {
		ready(gp, traceskip, true)
	})
}

func ready(gp *g, traceskip int, next bool) {
	if trace.enabled {
		traceGoUnpark(gp, traceskip)
	}
    //获取goroutine的运行状态
	status := readgstatus(gp)

    //获取g0
	_g_ := getg()
    //获取m
	mp := acquirem() // disable preemption because it can be holding p in a local var
	if status&^_Gscan != _Gwaiting {
		dumpgstatus(gp)
		throw("bad g->status in ready")
	}
	casgstatus(gp, _Gwaiting, _Grunnable)
    // 将g放入P的可运行队列中
	runqput(_g_.m.p.ptr(), gp, next)
    // 如果有空闲的p 并且没有正在spinning状态的m 则唤醒一个p
	if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 {
		wakep()
	}
	releasem(mp)
}
5.向通道中发送数据

​ c<-1 对应于runtime中的runtime.chansend函数

  • memmove() 进行数据的转移,本质上就是一个内存拷贝。

  • 发送数据到通道

/*
c:  通道指针
ep: 指向要发送数据的首地址
block:代表写入操作是否阻塞
*/
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
	//如果channel为空或者未初始化
    if c == nil {
        //如果block表示非阻塞,直接return
		if !block {
			return false
		}
        //如果block为阻塞,永久阻塞
		gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
		throw("unreachable")
	}

	if debugChan {
		print("chansend: chan=", c, "\n")
	}

	if raceenabled {
		racereadpc(c.raceaddr(), callerpc, funcPC(chansend))
	}
	
    //如果block为非阻塞
    //且channel未关闭
    //且(channel非缓冲队列且接收队列receiver为空)或者(channel为有缓冲队列且buf已满 )
    //直接return false
	if !block && c.closed == 0 && ((c.dataqsiz == 0 && c.recvq.first == nil) ||
		(c.dataqsiz > 0 && c.qcount == c.dataqsiz)) {
		return false
	}

	var t0 int64
	if blockprofilerate > 0 {
		t0 = cputicks()
	}
    //获取同步锁,保证线程安全
	lock(&c.lock)
	
    //如果通道已经关闭,写入数据产生panic
	if c.closed != 0 {
		unlock(&c.lock)
		panic(plainError("send on closed channel"))
	}
    
    //*******主要部分*******//
 
    //如果接收队列recv不为空,即有goroutine在接收队列中等待时
    //这里不用区分有缓冲和无缓冲channel
    //跳过缓冲区,直接将数据发送给等待的接收者goroutine
	if sg := c.recvq.dequeue(); sg != nil {
        //send函数
		send(c, sg, ep, func() { unlock(&c.lock) }, 3)
		return true
	}
	
    //如果接收队列recv为空
    //且缓冲区数据大小 < 通道的大小  (说明此时发送队列sendq一定为空)
	if c.qcount < c.dataqsiz {
	
        //直接将数据放入到缓冲区
		qp := chanbuf(c, c.sendx)
		if raceenabled {
			raceacquire(qp)
			racerelease(qp)
		}
        //数据转移:本质上是内存拷贝,ep处拷贝到qp处
		typedmemmove(c.elemtype, qp, ep)
		c.sendx++
		if c.sendx == c.dataqsiz {
			c.sendx = 0
		}
		c.qcount++
		unlock(&c.lock)
		return true
	}
	
	//如果以上条件不满足
	//即接收队列为空,且缓冲区buf数据量大小 == 通道大小
    //如果block为非阻塞,解锁并返回 false
	if !block {
		unlock(&c.lock)
		return false
	}

    //如果接收队列recv不为空
    //且缓冲队列已满
    //则将当前的goroutine加入到sendq队列
	gp := getg()
	mysg := acquireSudog()
	mysg.releasetime = 0
	if t0 != 0 {
		mysg.releasetime = -1
	}
	mysg.elem = ep
	mysg.waitlink = nil
	mysg.g = gp
	mysg.isSelect = false
	mysg.c = c
	gp.waiting = mysg
	gp.param = nil
    //将当前goroutine的sudog加入到sendq
	c.sendq.enqueue(mysg)
    //将当前goroutine休眠
	gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
	KeepAlive(ep)
	if mysg != gp.waiting {
		throw("G waiting list is corrupted")
	}
	gp.waiting = nil
	gp.activeStackChans = false
	if gp.param == nil {
		if c.closed == 0 {
			throw("chansend: spurious wakeup")
		}
		panic(plainError("send on closed channel"))
	}
	gp.param = nil
	if mysg.releasetime > 0 {
		blockevent(mysg.releasetime-t0, 2)
	}
	mysg.c = nil
	releaseSudog(mysg)
	return true
}

send函数

// send 函数处理向一个空的 channel 发送操作
//直接拷贝的作用:绕开了缓冲区,减少一次加锁操作,提高性能

// ep 指向被发送的元素,会被直接拷贝到接收的 goroutine
// 之后,接收的 goroutine 会被唤醒
// c 必须是空的(因为等待队列里有 goroutine,肯定是空的)
// ep 必须是非空,并且它指向堆或调用者的栈
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
    // 省略一些用不到的
    // ……
    // sg.elem 指向接收到的值存放的位置,如 val <- ch,指的就是 &val
    if sg.elem != nil {
        // 直接拷贝内存(从发送者到接收者)
        sendDirect(c.elemtype, sg, ep)
        sg.elem = nil
    }
    // sudog 上绑定的 goroutine
    gp := sg.g
    // 解锁
    unlockf()
    gp.param = unsafe.Pointer(sg)
    if sg.releasetime != 0 {
        sg.releasetime = cputicks()
    }
    // 唤醒接收的 goroutine. skip 和打印栈相关,暂时不理会
    goready(gp, skip+1)
}

简单来说,向通道中发送数据的整个流程如下:

1.检查recvq是否为空。如果不为空,则从recvq头部取一个goroutine,将数据发送过去,并唤醒对应的goroutine即可。

2.如果recv为空,且缓冲取未满,则将数据放入channel buffer缓冲区中。

3.如果buffer已满,则将要发送的数据和当前goroutine打包成sudog对象放入到sendq中,并将goroutine置为wating状态

注意: channel的整个发送过程和接收过程都使用了runtime.mutex进行加锁。runtime.mutex是runtime相关源码中常用的一个轻量级锁。


6.向通道中接收数据
/*
block:表示当channel无法返回数据时是否阻塞等待  比如: 当block为false并且channel中没有数据时,直接返回
*/
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {

    //*******前置场景*******//
    
	if debugChan {
		print("chanrecv: chan=", c, "\n")
	}
    //如果通道为空或未初始化
	if c == nil {
        //如果block为非阻塞
        //直接返回
		if !block {
			return
		}
        //如果block为阻塞,调用gopark()阻塞当前goroutine
		gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
		throw("unreachable")
	}
    //如果通道不为空或者已经初始化
    //如果block为非阻塞接收
    //且((通道为无缓冲通道且发送对列为空) 或者 (通道为有缓冲通道且缓冲区元素为0且通道未关闭))
    //直接return false false
	if !block && (c.dataqsiz == 0 && c.sendq.first == nil ||
		c.dataqsiz > 0 && atomic.Loaduint(&c.qcount) == 0) &&
		atomic.Load(&c.closed) == 0 {
		return
	}

	var t0 int64
	if blockprofilerate > 0 {
		t0 = cputicks()
	}
    //获取全局锁
	lock(&c.lock)
    
    //*******主要部分*******//
    
    //如果通道已经关闭
    //且缓冲区中无元素
    //直接返回true和false(非正常返回)
	if c.closed != 0 && c.qcount == 0 {
		if raceenabled {
			raceacquire(c.raceaddr())
		}
		unlock(&c.lock)
		if ep != nil {
            //返回空值
            //typedmemclr根据类型清理相应的地址的内存
			typedmemclr(c.elemtype, ep)
		}
		return true, false
	}

    //如果等待发送对列sendq不为空   (注意:此时的缓冲区一定是满的)
    //有可能是
    //1.非缓冲型channel 则直接进行内存拷贝
    //2.缓冲型channel,但buf满了, 则接收buf头部的元素,并将发送队列头goroutine的元素放到循环数组的尾部
    //返回true,true
	if sg := c.sendq.dequeue(); sg != nil {
		recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
		return true, true
	}
    //如果等待发送队列sendq为空
    //且通道非空  (注意: 一定是缓冲型channel)
    //则从通道中获取数据
	if c.qcount > 0 {
	    //从循环数组里找到要接收的元素
		qp := chanbuf(c, c.recvx)
		if raceenabled {
			raceacquire(qp)
			racerelease(qp)
		}
		//ep不为nil,说明未忽略要接收的值,即val<-ch,非<-ch
		if ep != nil {
            //内存拷贝
			typedmemmove(c.elemtype, ep, qp)
		}
		//清理循环数组recvx处的值
		typedmemclr(c.elemtype, qp)
		c.recvx++
		if c.recvx == c.dataqsiz {
			c.recvx = 0
		}
		c.qcount--
		unlock(&c.lock)
		return true, true
	}
    //如果等待发送队列sendq为空
    //且通道中元素个数为0,通道为空
    //且block为非阻塞
    //则直接返回false,false
	if !block {
		unlock(&c.lock)
		return false, false
	}

    //如果以上情况均不满足,即
    //等待发送队列sendq为空
    //且通道为空
    //且block为阻塞
    //则将该goroutine阻塞
	gp := getg()
	mysg := acquireSudog()
	mysg.releasetime = 0
	if t0 != 0 {
		mysg.releasetime = -1
	}
	//保存待接收变量的地址ep
	mysg.elem = ep
	mysg.waitlink = nil
	gp.waiting = mysg
	mysg.g = gp
	mysg.isSelect = false
	mysg.c = c
	gp.param = nil
	//goroutine加入等待接收队列
	c.recvq.enqueue(mysg)
	gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanRece    sg.elem = nil
ive, traceEvGoBlockRecv, 2)

	// 如果被唤醒了,继续执行扫尾工作
	if mysg != gp.waiting {
		throw("G waiting list is corrupted")
	}
	gp.waiting = nil
	gp.activeStackChans = false
	if mysg.releasetime > 0 {
		blockevent(mysg.releasetime-t0, 2)
	}
	closed := gp.param == nil
	gp.param = nil
	mysg.c = nil
	releaseSudog(mysg)
	return true, !closed
}
  • ep:接收数据变量对应的地址
  • sg:从sendq中取出的第一个sudog
  • typedmemmove(c.elemtype, ep, qp)表示buffer中的当前可读元素拷贝到接收变量的地址处。
  • typedmemmove(c.elemtype, qp, sg.elem)表示将sendq中goroutine等待发送的数据拷贝到buffer中。因为此后进行了recv++, 因此相当于把sendq中的数据放到了队尾。
func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
    // 如果是非缓冲型的 channel
    if c.dataqsiz == 0 {
        if raceenabled {
            racesync(c, sg)
        }
        // 未忽略接收的数据
        if ep != nil {
            // 直接拷贝数据,从 sender goroutine -> receiver goroutine
            recvDirect(c.elemtype, sg, ep)
        }
    } else {
        // 缓冲型的 channel,但 buf 已满。
        // 将循环数组 buf 队首的元素拷贝到接收数据的地址
        // 将发送者的数据入队。实际上这时 recvx 和 sendx 值相等
        // 找到接收游标
        qp := chanbuf(c, c.recvx)
      
        // 将接收游标处的数据拷贝给接收者
        if ep != nil {
            typedmemmove(c.elemtype, ep, qp)
        }
        // 将发送者数据拷贝到 buf
        typedmemmove(c.elemtype, qp, sg.elem)
        // 更新游标值
        c.recvx++
        if c.recvx == c.dataqsiz {
            c.recvx = 0
        }
        c.sendx = c.recvx
    }
    sg.elem = nil
    gp := sg.g
    // 解锁
    unlockf()
    gp.param = unsafe.Pointer(sg)
    if sg.releasetime != 0 {
        sg.releasetime = cputicks()
    }
    // 唤醒发送的 goroutine。需要等到调度器的光临
    goready(gp, skip+1)
}

简单来说,向通道中发送数据的整个流程如下:

1.检查sendq是否为空。如果不为空,则从sendq头部取一个goroutine,将缓冲区中队首的元素拷贝给接收变量,同时将sendq中的元素拷贝到队尾。

2.如果sendq为空,且缓冲取不为空,则直接从缓冲取队首获取元素。

3.如果缓冲区为空,则将当前goroutine打包成sudog对象放入到recvq中,并将goroutine置为wating状态

这里channel将buffer中队首的数据拷贝给了对应的接收变量,同时将sendq中的元素拷贝到了队尾,这样才可以做到数据的FIFO(先入先出)。

接下来可能有点绕,c.sendx = c.recvx, 这句话实际的作用相当于c.sendx = (c.sendx+1) % c.dataqsiz,因为此时buffer依然是满的,所以sendx == recvx是成立的。


7. 场景分析
a)通道操作情形A 【接收】

当一个协程R尝试从一个非零且尚未关闭的通道接收数据的时候,此协程R将首先尝试获取此通道的锁,成功之后将执行下列步骤,直到其中一个步骤的条件得到满足。

  • 如果通道的缓冲队列不为空接收数据协程队列必为空),协程R将从缓冲队列取出一个值。

    如果发送数据协程队列不为空,一个发送协程将从此队列中弹出,此协程欲发送的值将被推入缓冲队列。此发送协程将恢复至运行状态。 接收数据协程R继续运行,不会阻塞。
    在这里插入图片描述

  • 如果通道的缓冲队列为空,且发送数据协程队列不为空非缓冲通道),

    一个发送数据协程将从此队列中弹出,此协程欲发送的值将被接收数据协程R接收。此发送协程将恢复至运行状态。 接收数据协程R继续运行,不会阻塞。
    在这里插入图片描述

  • 如果 通道的缓冲队列发送数据协程队列均为空,此接收数据协程R将被推入接收数据协程队列,并进入阻塞状态。

    它以后可能会被另一个发送数据协程唤醒而恢复运行。
    在这里插入图片描述

b)通道操作情形B 【发送】

当一个协程S尝试向一个非零且尚未关闭的通道发送数据的时候,此协程S将首先尝试获取此通道的锁,成功之后将执行下列步骤,直到其中一个步骤的条件得到满足。

  • 如果通道的接收数据协程队列不为空缓冲队列必为空

    一个接收数据协程将从此队列中弹出,此协程将接收到发送协程S发送的值。此接收协程将恢复至运行状态。 发送数据协程S继续运行,不会阻塞。
    在这里插入图片描述

  • 如果 接收数据协程队列为空,如果缓冲队列未满发送数据协程队列必为空

    发送协程S欲发送的值将被推入缓冲队列,发送数据协程S继续运行,不会阻塞。
    在这里插入图片描述

  • 如果 接收数据协程队列为空,并且缓冲队列已满,此发送协程S将被推入发送数据协程队列,并进入阻塞状态。

    它以后可能会被另一个接收数据协程唤醒而恢复运行。
    在这里插入图片描述

c)通道操作情形C 【关闭】

当一个协程成功获取到一个非零且尚未关闭的通道的并且准备关闭此通道时,下面两步将依次执行:

  • 如果 通道的接收数据协程队列不为空缓冲队列必为空),此队列中的所有协程将被依个弹出,并且每个协程将接收到此通道的元素类型的一个零值,然后恢复至运行状态。
  • 如果 通道的发送数据协程队列不为空【注意】,队列中的所有协程将被依个弹出,并且每个协程中都将产生一个恐慌(因为向已关闭的通道发送数据)。

注意:当一个缓冲队列不为空的通道被关闭之后,它的缓冲队列不会被清空,其中的数据仍然可以被后续的数据接收操作所接收到。详见下面的对情形D的解释。

d)通道操作情形D

一个非零通道被关闭之后,此通道上的后续数据接收操作将永不会阻塞。 此通道的缓冲队列中存储数据仍然可以被接收出来。 伴随着这些接收出来的缓冲数据的第二个可选返回(类型不确定布尔)值仍然是true。 一旦此缓冲队列变为空,后续的数据接收操作将永不阻塞并且总会返回此通道的元素类型的零值和值为false的第二个可选返回结果。 上面已经提到了,一个接收操作的第二个可选返回(类型不确定布尔)结果表示一个接收到的值是否是在此通道被关闭之前发送的。 如果此返回值为false,则第一个返回值必然是一个此通道的元素类型的零值。

8. Summary

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值