Golang原理剖析(channel源码分析)

文章目录

channel是什么​

顾名思义,channel就是一个通信管道,被设计用于实现goroutine之间的通信​ ​

Go语言尊崇的设计思想是:以通信的方式来共享内存,而不是通过共享内存来实现通信,channel就是这一思想的体现

channel的数据结构​

channel的功能比较复杂,所以就不会是几个字节就能实现的,所以需要一个复杂的struct来承接channel的作用,也就是下文的hchan结构体​ ​

要注意channel是直接分配到堆上的 ,因为channel从设计理念上看,就是用于goroutine之间的通信,作用域和生命周期不会被限制在一个函数中​ ​

runtime.mutex和sync.Mutex的区别

hchan中的字段

runtime.hchan的类型定义在源码 src/runtime/chan.go中:

go 复制代码
type hchan struct {
	qcount   uint           // channel 环形数组中元素的数量
	dataqsiz uint           // channel 环形数组的容量
	buf      unsafe.Pointer // 指向channel 环形数组的一个指针
	elemsize uint16         // 元素所占的字节数
	closed   uint32         // 是否关闭
	timer    *timer // timer feeding this chan
	elemtype *_type         // 元素类型
	sendx    uint           // send index 下一次写的位置
	recvx    uint           // receive index 下一次读的位置
	recvq    waitq          // list of recv waiters 读等待队列
	sendq    waitq          // list of send waiters 写等待队列
	bubble   *synctestBubble

	// 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          // runtime.mutex,保证channel并发安全
}

对于channel,我们可以将数据缓存到其中,所以有一个buf数组,用来缓冲数据,又因为channel可以同时提供读写功能,所以我们有sendx 和 recvx分别指向下一次写和下一次读的位置,buf、sendx、recvx 就构成了一个环形数组,每次读或写超过最后一个下标,就会回到下标0处。

用qcount表示buf中元素数量,用dataqsiz表示buf的容量。

因为channel设计是用在多个goroutine之间的通信上的,所以需要一把mutex来保护读、写、关闭操作的并发安全【在并发/编程语境里,"同步/异步"最核心的区别就是发起一个操作后,要不要等它完成。】

因为channel提供一个读和写等待队列(recvq和sendq)来帮助goroutine在未完成读写操作后,可以被阻塞挂起,然后等待channel通信来临时,再被唤醒调度

下面我们看一下recvq 和 sendq的结构,也就是waitq结构体,可以把waitq看作是一个链表构成的队列

waitq结构体

go 复制代码
type waitq struct {
	first *sudog // sudog队列的队头指针
	last  *sudog // sudog队列的队尾指针
}

sudog结构体

介绍一下sudog这个结构体,sudog可以看作是对阻塞挂起的g的一个封装,用多个sudog来构成等待队列

当 goroutine 在不可立即完成的 channel 操作上需要阻塞(例如:向已满的缓冲 channel 发送、从空的缓冲 channel 接收,或无缓冲 channel 缺少配对方)时,运行时会将该 goroutine 关联成一个 sudog,挂到对应等待队列(sendq 或 recvq)上并 park。随后当另一侧操作到来使其能配对完成时,通常会唤醒队列中的一个 goroutine;而在 close(ch) 时,会唤醒相关等待者(接收者通常是全部)。

下面看一下sudog结构(只留下主要字段):

go 复制代码
type sudog struct {
	g *g // 绑定的goroutine

	next *sudog // 前后指针
	prev *sudog
	elem unsafe.Pointer // 存储元素的容器

	isSelect bool // 标识是不是因为select操作封装的sudog

	// 为true,表示这个sudog是因为channel 通信唤醒的
	// 为false,表示这个sudog是因为channel close唤醒的
	success bool
	c       *hchan // 绑定的channel
}

这里关注下elem字段,elem作为收发数据的容器

当向channel发送数据时,elem代表将要写进channel的元素地址

当从channel读取数据时,elem代表要从channel中读取的元素地址

channel操作

channel初始化

Go语言中,我们只能通过make函数来初始化一个channelruntime会调用runtime.makechan函数来完成channel的初始化工作

源码位于src/runtime/chan.go中:

go 复制代码
func makechan(t *chantype, size int) *hchan {
	// channel 元素类型
	elem := t.Elem

	// compiler checks this but be safe.
	if elem.Size_ >= 1<<16 {
		throw("makechan: invalid channel element type")
	}
	if hchanSize%maxAlign != 0 || elem.Align_ > maxAlign {
		throw("makechan: bad alignment")
	}

	mem, overflow := math.MulUintptr(elem.Size_, uintptr(size))
	if overflow || mem > maxAlloc-hchanSize || size < 0 {
		panic(plainError("makechan: size out of range"))
	}

	// Hchan does not contain pointers interesting for GC when elements stored in buf do not contain pointers.
	// buf points into the same allocation, elemtype is persistent.
	// SudoG's are referenced from their owning thread so they can't be collected.
	// TODO(dvyukov,rlh): Rethink when collector can move allocated objects.
	var c *hchan
	switch {
	case mem == 0:
		// channel无缓冲 or 元素大小为0,只需要分配一个hchan
		// Queue or element size is zero.
		c = (*hchan)(mallocgc(hchanSize, nil, true))
		// Race detector uses this location for synchronization.
		c.buf = c.raceaddr()
	case !elem.Pointers():
		// channel 元素不包含指针,hchan和buf 一起分配
		// Elements do not contain pointers.
		// Allocate hchan and buf in one call.
		c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
		c.buf = add(unsafe.Pointer(c), hchanSize)
	default:
		// channel 元素包含指针,hchan和buf 分开分配
        // 因为 申请的span 为scan 和 noscan,无法一起分配
		// Elements contain pointers.
		c = new(hchan)
		c.buf = mallocgc(mem, elem, true)
	}

	// channel 的一些初始化
	c.elemsize = uint16(elem.Size_)
	c.elemtype = elem
	c.dataqsiz = uint(size)
	if b := getg().bubble; b != nil {
		c.bubble = b
	}
	lockInit(&c.lock, lockRankHchan)

	if debugChan {
		print("makechan: chan=", c, "; elemsize=", elem.Size_, "; dataqsiz=", size, "\n")
	}
	return c
}

makechan函数有两个参数 t *chantype, size int,第一个参数代表要创建的channel的元素类型,而第二个参数代表通道环形缓冲的容量大小

可以看出为channel开辟内存分为三种情况:

  1. channel无缓冲 or 元素大小为0(存储的元素都是空结构体的情况):只需要分配hchan本身结构体大小的内存
  2. 有缓冲区buf,但元素不包含指针:hchan和buf 一起分配(hchan 和缓冲区会被一次性连续分配,buf 指向 hchan 之后)
  3. 有缓冲区buf,且元素包含指针类型:hchan和buf 分开分配 【new(T) 等价于按 T 的类型信息 去分配堆对象。hchan 结构体里确实有指针字段(例如 buf unsafe.Pointer、elemtype *type、等待队列指针等),runtime 一般不会为这种"运行时拼出来的结构体 + 动态长度数组"生成一份新的类型/位图来交给 GC(成本和复杂度都很高),所以选择 分开分配:
    hchan 按 hchan 的类型扫描,buf 按 elem 的类型扫描。因此它属于 scan object,GC 需要扫描它的指针字段。为了让 GC 能从 hchan.buf 找到并扫描缓冲区里的指针,hchan 就必须是 scan(至少要扫描到 buf 这个指针)

channel写入

下面是往channel中写入一个数据的例子

go 复制代码
ch := make(chan int)
ch <- 1      // 往管道里写入1

底层其实就是调用了 runtime.chansend 函数,源码如下:

go 复制代码
/*
 * generic single channel send/recv
 * If block is not nil,
 * then the protocol will not
 * sleep but return if it could
 * not complete.
 *
 * sleep can wake up with g.param == nil
 * when a channel involved in the sleep has
 * been closed.  it is easiest to loop and re-run
 * the operation; we'll see that it's now closed.
 */
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
	// c: 对应的hchan指针
	// ep: 被发送到channel的变量的地址
	// block: 发送操作是否阻塞(代表如果send不能立即完成的话,是否阻塞)

	// 判断channel是否为nil
	if c == nil {
		// 如果是非阻塞类型,直接返回false
		if !block {
			return false
		}

		// 是阻塞类型(永久性挂起)
		gopark(nil, nil, waitReasonChanSendNilChan, traceBlockForever, 2)
		throw("unreachable")
	}

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

	if raceenabled {
		racereadpc(c.raceaddr(), callerpc, abi.FuncPCABIInternal(chansend))
	}

	if c.bubble != nil && getg().bubble != c.bubble {
		fatal("send on synctest channel from outside bubble")
	}

	// Fast path: check for failed non-blocking operation without acquiring the lock.
	//
	// After observing that the channel is not closed, we observe that the channel is
	// not ready for sending. Each of these observations is a single word-sized read
	// (first c.closed and second full()).
	// Because a closed channel cannot transition from 'ready for sending' to
	// 'not ready for sending', even if the channel is closed between the two observations,
	// they imply a moment between the two when the channel was both not yet closed
	// and not ready for sending. We behave as if we observed the channel at that moment,
	// and report that the send cannot proceed.
	//
	// It is okay if the reads are reordered here: if we observe that the channel is not
	// ready for sending and then observe that it is not closed, that implies that the
	// channel wasn't closed during the first observation. However, nothing here
	// guarantees forward progress. We rely on the side effects of lock release in
	// chanrecv() and closechan() to update this thread's view of c.closed and full().
	// 非阻塞类型channel and channel没关闭 and channel满了
	if !block && c.closed == 0 && full(c) {
		return false
	}

	var t0 int64
	if blockprofilerate > 0 {
		t0 = cputicks()
	}

	// 加锁
	lock(&c.lock)

	// channel关闭了,执行写操作,触发panic
	if c.closed != 0 {
		unlock(&c.lock)
		panic(plainError("send on closed channel"))
	}

	// 尝试从读等待队列中取出一个goroutine
	if sg := c.recvq.dequeue(); sg != nil {
		// Found a waiting receiver. We pass the value we want to send
		// directly to the receiver, bypassing the channel buffer (if any).
		// 读等待队列有goroutine,将写入数据直接交给对应goroutine
		send(c, sg, ep, func() { unlock(&c.lock) }, 3)
		return true
	}

	// 如果环形数组还有容量可以写入
	if c.qcount < c.dataqsiz {
		// Space is available in the channel buffer. Enqueue the element to send.
		// 通过sendx 找到写入位置的地址
		qp := chanbuf(c, c.sendx)
		if raceenabled {
			racenotify(c, c.sendx, nil)
		}

		// 将ep中的数据写入到qp中
		typedmemmove(c.elemtype, qp, ep)
		c.sendx++

		// 如果sendx == dataqsiz,因为是缓冲数组,如果将snedx置为0
		if c.sendx == c.dataqsiz {
			c.sendx = 0
		}

		// channel 中的元素数量+1
		c.qcount++
		unlock(&c.lock)
		return true
	}

	// 非阻塞类型的写操作走到这一步,不管有没有写入到channel中,都不需要阻塞,直接return
	if !block {
		unlock(&c.lock)
		return false
	}

	// Block on the channel. Some receiver will complete our operation for us.
	gp := getg()

	// 取出一个sudog结构
	mysg := acquireSudog()
	mysg.releasetime = 0
	if t0 != 0 {
		mysg.releasetime = -1
	}
	// No stack splits between assigning elem and enqueuing mysg
	// on gp.waiting where copystack can find it.
	// 设置对应状态
	mysg.elem = ep
	mysg.waitlink = nil

	// 绑定goroutine
	mysg.g = gp
	mysg.isSelect = false

	// 绑定channel
	mysg.c = c
	gp.waiting = mysg
	gp.param = nil

	// 进入写等待队列
	c.sendq.enqueue(mysg)
	// Signal to anyone trying to shrink our stack that we're about
	// to park on a channel. The window between when this G's status
	// changes and when we set gp.activeStackChans is not safe for
	// stack shrinking.
	gp.parkingOnChan.Store(true)
	reason := waitReasonChanSend
	if c.bubble != nil {
		reason = waitReasonSynctestChanSend
	}

	// gopark操作
	gopark(chanparkcommit, unsafe.Pointer(&c.lock), reason, traceBlockChanSend, 2)
	// Ensure the value being sent is kept alive until the
	// receiver copies it out. The sudog has a pointer to the
	// stack object, but sudogs aren't considered as roots of the
	// stack tracer.
	KeepAlive(ep)

	// someone woke us up.
	if mysg != gp.waiting {
		throw("G waiting list is corrupted")
	}
	gp.waiting = nil
	gp.activeStackChans = false
	closed := !mysg.success
	gp.param = nil
	if mysg.releasetime > 0 {
		blockevent(mysg.releasetime-t0, 2)
	}
	mysg.c = nil
	releaseSudog(mysg)
	if closed {
		if c.closed == 0 {
			throw("chansend: spurious wakeup")
		}
		panic(plainError("send on closed channel"))
	}
	return true
}

往channel发送数据会出现三种情况

case1:channel中有读等待goroutine

go 复制代码
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
	// .......
	// 加锁
	lock(&c.lock)

	// 尝试从读等待队列中取出一个goroutine
	if sg := c.recvq.dequeue(); sg != nil {
		// 读等待队列有goroutine,将写入数据直接交给对应goroutine
		send(c, sg, ep, func() { unlock(&c.lock) }, 3)
		return true
	}

	// .......
}

// send processes a send operation on an empty channel c.
// The value ep sent by the sender is copied to the receiver sg.
// The receiver is then woken up to go on its merry way.
// Channel c must be empty and locked.  send unlocks c with unlockf.
// sg must already be dequeued from c.
// ep must be non-nil and point to the heap or the caller's stack.
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
	if c.bubble != nil && getg().bubble != c.bubble {
		unlockf()
		fatal("send on synctest channel from outside bubble")
	}
	if raceenabled {
		if c.dataqsiz == 0 {
			racesync(c, sg)
		} else {
			// Pretend we go through the buffer, even though
			// we copy directly. Note that we need to increment
			// the head/tail locations only when raceenabled.
			racenotify(c, c.recvx, nil)
			racenotify(c, c.recvx, sg)
			c.recvx++
			if c.recvx == c.dataqsiz {
				c.recvx = 0
			}
			c.sendx = c.recvx // c.sendx = (c.sendx+1) % c.dataqsiz
		}
	}

	// 将ep 复制到 sg对应的elem上
	if sg.elem != nil {
		sendDirect(c.elemtype, sg, ep)
		sg.elem = nil
	}
	gp := sg.g

	// 释放锁(因为写操作已经完成了)
	unlockf()
	gp.param = unsafe.Pointer(sg)
	sg.success = true
	if sg.releasetime != 0 {
		sg.releasetime = cputicks()
	}
	// 唤醒goroutine
	goready(gp, skip+1)
}

// Sends and receives on unbuffered or empty-buffered channels are the
// only operations where one running goroutine writes to the stack of
// another running goroutine. The GC assumes that stack writes only
// happen when the goroutine is running and are only done by that
// goroutine. Using a write barrier is sufficient to make up for
// violating that assumption, but the write barrier has to work.
// typedmemmove will call bulkBarrierPreWrite, but the target bytes
// are not in the heap, so that will not help. We arrange to call
// memmove and typeBitsBulkBarrier instead.

func sendDirect(t *_type, sg *sudog, src unsafe.Pointer) {
	// src is on our stack, dst is a slot on another stack.

	// Once we read sg.elem out of sg, it will no longer
	// be updated if the destination's stack gets copied (shrunk).
	// So make sure that no preemption points can happen between read & use.
	dst := sg.elem
	typeBitsBulkBarrier(t, uintptr(dst), uintptr(src), t.Size_)
	// No need for cgo write barrier checks because dst is always
	// Go memory.
	// 将src复制到dst(复制channel对应的类型值)
	memmove(dst, src, t.Size_)
}

// goready should be an internal detail,
// but widely used packages access it using linkname.
// Notable members of the hall of shame include:
//   - gvisor.dev/gvisor
//   - github.com/sagernet/gvisor
//
// Do not remove or change the type signature.
// See go.dev/issue/67401.
//
//go:linkname goready
func goready(gp *g, traceskip int) {
	systemstack(func() {
		ready(gp, traceskip, true)
	})
}

// Mark gp ready to run.
func ready(gp *g, traceskip int, next bool) {
	status := readgstatus(gp)

	// Mark runnable.
	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")
	}

	// status is Gwaiting or Gscanwaiting, make Grunnable and put on runq
	trace := traceAcquire()
	// 状态修改成 待执行
	casgstatus(gp, _Gwaiting, _Grunnable)
	if trace.ok() {
		trace.GoUnpark(gp, traceskip)
		traceRelease(trace)
	}
	// 放入到gmp模型中,重新得到调度
	runqput(mp.p.ptr(), gp, next)
	wakep()
	releasem(mp)
}
  • 先拿锁

  • 从recvq(读等待队列)里面弹出队列头部的sudog,进入send流程

  • 将要写入的数据拷贝得到这个sudog对应的elem数据容器上

  • 释放锁

  • 唤醒sudog绑定的goroutine(也就是将这个goroutine重新放入到gmp模型中,等待调度)【他会放入当前P(执行唤醒操作的P)的本地队列。 如果当前P的本地队列已满,或者有其他更优的调度策略(比如有空闲的P),它可能会被放入其他P的本地队列。 如果所有P的本地队列都满了,或者在某些特定情况下(例如,长时间等待后被唤醒的goroutine,或者通过runtime.Gosched()主动让出的goroutine),它才可能会被放入全局运行队列】

case2:channel中没有读等待goroutine,并且环形缓冲数组里面有剩余空间

go 复制代码
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
	// .......

	// 加锁
	lock(&c.lock)

	// 如果环形数组还有容量可以写入
	if c.qcount < c.dataqsiz {
		// 通过sendx 找到写入位置的地址
		qp := chanbuf(c, c.sendx)
		if raceenabled {
			racenotify(c, c.sendx, nil)
		}

		// 将ep中的数据写入到qp中
		typedmemmove(c.elemtype, qp, ep)
		c.sendx++
		// 如果sendx == dataqsiz,因为是缓冲数组,如果将snedx置为0
		if c.sendx == c.dataqsiz {
			c.sendx = 0
		}

		// channel 中的元素数量+1
		c.qcount++
		unlock(&c.lock)
		return true
	}

	// ...............
}


// chanbuf(c, i) is pointer to the i'th slot in the buffer.
//
// chanbuf should be an internal detail,
// but widely used packages access it using linkname.
// Notable members of the hall of shame include:
//   - github.com/fjl/memsize
//
// Do not remove or change the type signature.
// See go.dev/issue/67401.
//
//go:linkname chanbuf
func chanbuf(c *hchan, i uint) unsafe.Pointer {
	return add(c.buf, uintptr(i)*uintptr(c.elemsize))
}
  • 先拿锁

  • 将数据写入到 sendx指向的位置中

  • sendx++, qcount++

  • 释放锁

case3:channel中没有读等待goroutine,并且无剩余空间存放数据

go 复制代码
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
	// ......
	gp := getg()
	// 取出一个sudog结构
	mysg := acquireSudog()
	// 将ep存入到elem中
	mysg.elem = ep
	// 绑定goroutine
	mysg.g = gp
	// 绑定channel
	mysg.c = c
	// 进入写等待队列队尾
	c.sendq.enqueue(mysg)

	// gopark操作
	gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)

	// 处理状态
	gp.waiting = nil
	gp.activeStackChans = false
	closed := !mysg.success
	gp.param = nil
	mysg.c = nil

	// 回收sudog
	releaseSudog(mysg)
	return true
}

// Puts the current goroutine into a waiting state and calls unlockf on the
// system stack.
//
// If unlockf returns false, the goroutine is resumed.
//
// unlockf must not access this G's stack, as it may be moved between
// the call to gopark and the call to unlockf.
//
// Note that because unlockf is called after putting the G into a waiting
// state, the G may have already been readied by the time unlockf is called
// unless there is external synchronization preventing the G from being
// readied. If unlockf returns false, it must guarantee that the G cannot be
// externally readied.
//
// Reason explains why the goroutine has been parked. It is displayed in stack
// traces and heap dumps. Reasons should be unique and descriptive. Do not
// re-use reasons, add new ones.
//
// gopark should be an internal detail,
// but widely used packages access it using linkname.
// Notable members of the hall of shame include:
//   - gvisor.dev/gvisor
//   - github.com/sagernet/gvisor
//
// Do not remove or change the type signature.
// See go.dev/issue/67401.
//
//go:linkname gopark
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceReason traceBlockReason, traceskip int) {
	if reason != waitReasonSleep {
		checkTimeouts() // timeouts may expire while two goroutines keep the scheduler busy
	}
	mp := acquirem()
	gp := mp.curg
	status := readgstatus(gp)

	// gopark 只能从running或者scanrunning状态进入
	if status != _Grunning && status != _Gscanrunning {
		throw("gopark: bad g status")
	}
	mp.waitlock = lock
	mp.waitunlockf = unlockf
	gp.waitreason = reason
	mp.waitTraceBlockReason = traceReason
	mp.waitTraceSkip = traceskip
	releasem(mp)
	// can't do anything that might move the G between Ms here.
	// 去系统栈空间(g0)挂起当前M上的g
	mcall(park_m)
}

// park continuation on g0.
func park_m(gp *g) {
	mp := getg().m

	trace := traceAcquire()

	// If g is in a synctest group, we don't want to let the group
	// become idle until after the waitunlockf (if any) has confirmed
	// that the park is happening.
	// We need to record gp.bubble here, since waitunlockf can change it.
	bubble := gp.bubble
	if bubble != nil {
		bubble.incActive()
	}

	if trace.ok() {
		// Trace the event before the transition. It may take a
		// stack trace, but we won't own the stack after the
		// transition anymore.
		trace.GoPark(mp.waitTraceBlockReason, mp.waitTraceSkip)
	}
	// N.B. Not using casGToWaiting here because the waitreason is
	// set by park_m's caller.
	casgstatus(gp, _Grunning, _Gwaiting)
	if trace.ok() {
		traceRelease(trace)
	}
	// 解绑当前G
	dropg()

	if fn := mp.waitunlockf; fn != nil {
		ok := fn(gp, mp.waitlock)
		mp.waitunlockf = nil
		mp.waitlock = nil
		if !ok {
			trace := traceAcquire()
			casgstatus(gp, _Gwaiting, _Grunnable)
			if bubble != nil {
				bubble.decActive()
			}
			if trace.ok() {
				trace.GoUnpark(gp, 2)
				traceRelease(trace)
			}
			execute(gp, true) // Schedule it back, never returns.
		}
	}

	if bubble != nil {
		bubble.decActive()
	}
	// 新一轮调度使命
	schedule()
}
  • 锁保护步骤同样有的

  • 获取一个sudog结构,绑定对应的channel,goroutine,还有ep指针

  • 将sudog放入channel的写等待队列(sendq)

  • runtime.gopark(挂起当前goroutine,可以看作是解绑当前g(协程)和m(线程),然后开启下一轮调度)【gopark就是把goroutine的可执行状态改成待执行(等待一些条件就位),触发和恢复也是由runtime来调度的】

特殊case

第一种特殊情况:写入的channel为nil

go 复制代码
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
	// c:对应的hchan指针
	// ep:被发送到channel的变量的地址
	// block:发送操作是否阻塞(代表如果send不能立即完成的话,是否阻塞)

	// 判断channel是否为nil
	if c == nil {
		// 如果是非阻塞类型,直接返回false
		if !block {
			return false
		}

		// 是阻塞类型(永久性挂起)
		gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
		throw("unreachable")
	}

	// ............
}
  • 当channel为nil的时候,对channel进行写操作,会导致当前goroutine永久性挂起如果当前goroutine是main goroutine的话,还会导致整个程序退出

第二种特殊情况:channel已经关闭,还想进行写操作

go 复制代码
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {

	// 加锁
	lock(&c.lock)
	// channel关闭了,执行写操作,触发panic
	if c.closed != 0 {
		unlock(&c.lock)
		panic(plainError("send on closed channel"))
	}

	// ............
}
  • 当channel已经被关闭,再向channel写数据,会出现panic

channel读取

从channel读取数据的编码形式如下:

go 复制代码
ch := make(chan, int)
v := <- ch          // 直接读取
v, ok <- ch         // ok判断读取的是否有效

底层其实就是调用了 runtime.chanrecv 函数,源码如下:

ep 代表一个指针,它指向一个外部的变量地址。在代码中,这个变量用于存储从 Go channel 中读取出来的数据。具体来说,ep 是用来存放 channel 接收到的值的地址。

go 复制代码
// chanrecv receives on channel c and writes the received data to ep.
// ep may be nil, in which case received data is ignored.
// If block == false and no elements are available, returns (false, false).
// Otherwise, if c is closed, zeros *ep and returns (true, false).
// Otherwise, fills in *ep with an element and returns (true, true).
// A non-nil ep must point to the heap or the caller's stack.
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
	// raceenabled: don't need to check ep, as it is always on the stack
	// or is new memory allocated by reflect.
	// c: 对应的hchan指针
	// ep: 被发送到channel的变量的地址
	// block: 发送操作是否阻塞(代表如果send不能立即完成的话,是否阻塞)

	// selected和received
	// 如果received为true,则说明数据是从channel接收到的
	// 如果received为false, selected为true, 说明channel是通道关闭,并且得到零值
	// 如果received为false, selected为false, 则是因为非阻塞操作返回

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

	// 判断channel是否为nil
	if c == nil {
		// 如果是非阻塞类型,直接返回
		if !block {
			// 两个false, 通道不想阻塞而返回
			return
		}
		// 是阻塞类型(永久性挂起)
		gopark(nil, nil, waitReasonChanReceiveNilChan, traceBlockForever, 2)
		throw("unreachable")
	}

	if c.bubble != nil && getg().bubble != c.bubble {
		fatal("receive on synctest channel from outside bubble")
	}

	if c.timer != nil {
		c.timer.maybeRunChan(c)
	}

	// Fast path: check for failed non-blocking operation without acquiring the lock.
	// 非阻塞类型,并且channel是空的(无缓冲,且sendq为空)
	if !block && empty(c) {
		// After observing that the channel is not ready for receiving, we observe whether the
		// channel is closed.
		//
		// Reordering of these checks could lead to incorrect behavior when racing with a close.
		// For example, if the channel was open and not empty, was closed, and then drained,
		// reordered reads could incorrectly indicate "open and empty". To prevent reordering,
		// we use atomic loads for both checks, and rely on emptying and closing to happen in
		// separate critical sections under the same lock.  This assumption fails when closing
		// an unbuffered channel with a blocked send, but that is an error condition anyway.
		if atomic.Load(&c.closed) == 0 {
			// Because a channel cannot be reopened, the later observation of the channel
			// being not closed implies that it was also not closed at the moment of the
			// first observation. We behave as if we observed the channel at that moment
			// and report that the receive cannot proceed.
			// 通道没有关闭,返回两个false, 通道不想阻塞而返回
			return
		}
		// The channel is irreversibly closed. Re-check whether the channel has any pending data
		// to receive, which could have arrived between the empty and closed checks above.
		// Sequential consistency is also required here, when racing with such a send.
		if empty(c) {
			// 将ep清空
			// The channel is irreversibly closed and empty.
			if raceenabled {
				raceacquire(c.raceaddr())
			}
			if ep != nil {
				typedmemclr(c.elemtype, ep)
			}
			// 通道关闭了,但是channel是空的(无缓冲,且sendq为空),返回true,false,表示channel因为通道为空,收到零值
			return true, false
		}
	}

	var t0 int64
	if blockprofilerate > 0 {
		t0 = cputicks()
	}

	// 加锁
	lock(&c.lock)

	// 如果channel已经关闭
	if c.closed != 0 {
		if c.qcount == 0 {
			if raceenabled {
				raceacquire(c.raceaddr())
			}
			unlock(&c.lock)
			// 清空ep
			if ep != nil {
				typedmemclr(c.elemtype, ep)
			}
			// 返回true, false, 说明channel关闭,并且得到零值
			return true, false
		}
		// The channel has been closed, but the channel's buffer have data.
	} else {
		// 如果通道没有关闭,并且写队列里面有 等待的goroutine
		// Just found waiting sender with not closed.
		if sg := c.sendq.dequeue(); sg != nil {
			// Found a waiting sender. If buffer is size 0, receive value
			// directly from sender. Otherwise, receive from head of queue
			// and add sender's value to the tail of the queue (both map to
			// the same buffer slot because the queue is full).
			recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
			return true, true
		}
	}

	// channel中有元素
	if c.qcount > 0 {
		// Receive directly from queue
		qp := chanbuf(c, c.recvx)
		if raceenabled {
			racenotify(c, c.recvx, nil)
		}
		// 将recvx上的 数据 写入到ep中
		if ep != nil {
			typedmemmove(c.elemtype, ep, qp)
		}
		// 清空recvx上的数据
		typedmemclr(c.elemtype, qp)

		// index++
		c.recvx++

		// 环形数组操作
		if c.recvx == c.dataqsiz {
			c.recvx = 0
		}

		// 元素数量--
		c.qcount--
		unlock(&c.lock)
		// 返回true, true, 说明channel中有元素,并且channel没有关闭
		return true, true
	}

	if !block {
		unlock(&c.lock)
		// 通道不想阻塞而返回
		return false, false
	}

	// no sender available: block on this channel.
	gp := getg()
	mysg := acquireSudog()
	mysg.releasetime = 0
	if t0 != 0 {
		mysg.releasetime = -1
	}
	// No stack splits between assigning elem and enqueuing mysg
	// on gp.waiting where copystack can find it.
	mysg.elem = ep
	mysg.waitlink = nil
	gp.waiting = mysg

	mysg.g = gp
	mysg.isSelect = false
	mysg.c = c
	gp.param = nil
	// 包装sudog进入读队列 (recvq)
	c.recvq.enqueue(mysg)
	if c.timer != nil {
		blockTimerChan(c)
	}

	// Signal to anyone trying to shrink our stack that we're about
	// to park on a channel. The window between when this G's status
	// changes and when we set gp.activeStackChans is not safe for
	// stack shrinking.
	gp.parkingOnChan.Store(true)
	reason := waitReasonChanReceive
	if c.bubble != nil {
		reason = waitReasonSynctestChanReceive
	}

	// 挂起当前goroutine,等待goready唤醒
	gopark(chanparkcommit, unsafe.Pointer(&c.lock), reason, traceBlockChanRecv, 2)

	// someone woke us up
	if mysg != gp.waiting {
		throw("G waiting list is corrupted")
	}
	if c.timer != nil {
		unblockTimerChan(c)
	}

	// 清空一些状态
	gp.waiting = nil
	gp.activeStackChans = false
	if mysg.releasetime > 0 {
		blockevent(mysg.releasetime-t0, 2)
	}
	success := mysg.success
	gp.param = nil
	mysg.c = nil

	// 回收sudog
	releaseSudog(mysg)
	return true, success
}


selected 主要表示是否成功选择了一个通道(是否发生了接收操作)。
received 主要表示是否从通道实际接收到数据。

往channel读取数据会出现三种情况

case1:channel中有写等待goroutine

go 复制代码
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
    // c: 对应的hchan指针
    // ep: 被发送到channel的变量的地址
    // ...

    // 加锁
    lock(&c.lock)

    // 如果通道没有关闭,并且写队列里面有 等待的goroutine
    if sg := c.sendq.dequeue(); sg != nil {
        // Found a waiting sender. If buffer is size 0, receive value
        // directly from sender. otherwise, receive from head of queue
        // and add sender's value to the tail of the queue(both map to
        // the same buffer slot because the queue is full).
        recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
        return true, true
    }

    // .......
}

func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
	// A. 无缓冲 channel:直接从发送方拷贝到接收方
    if c.dataqsiz == 0 {
        if ep != nil {
            // channel无容量, 将sudog 对应的数据写给ep
            recvDirect(c.elemtype, sg, ep)
        }
    } else {
    	// B. 有缓冲且队列里有等待发送者:做"队头出队 + 队尾入队"的等价交换
        // 到这一步, channel 环形数组一定是满的 (因为sendq里面有等待者)
        // recvx对应的位置的地址
        // buffer 的队头槽位
        qp := chanbuf(c, c.recvx)
        // 将qp上的数据(环形缓冲#recvx 指向的数据) 写入ep
        // 先把队头给接收方
        if ep != nil {
            typedmemmove(c.elemtype, ep, qp)
        }
        // 并且sudog上的数据写入 qp
        // 再把发送方的数据塞回这个槽位
        // 接收方取走队头元素(给 ep),然后把等待发送者的元素立刻填进刚释放出来的队头槽位。
        typedmemmove(c.elemtype, qp, sg.elem)
        // 读index++
        c.recvx++

        // 环形数组处理
        if c.recvx == c.dataqsiz {
            c.recvx = 0
        }

        // 同步sendx 和 recvx
        c.sendx = c.recvx // c.sendx = (c.sendx+1) % c.dataqsiz
    }
    // 重置这个sudog状态
    sg.elem = nil
    // 找到当初阻塞的发送方 goroutine
    gp := sg.g

    // 释放锁
    unlockf()
    gp.param = unsafe.Pointer(sg)
    // 告诉它"发送成功了"
    sg.success = true
    if sg.releasetime != 0 {
        sg.releasetime = cputicks()
    }
    // 唤醒对应的写等待goroutine
    // 把它从等待态唤醒,重新进入可运行队列
    goready(gp, skip+1)
}
  • 先拿锁

  • 从sendq(写等待队列)里面弹出队列头部的sudog,进入recv流程

  • 如果channel无缓冲区,直接读取sudog里面的数据,并唤醒sudog对应goroutine

  • 如果channel有缓冲区,读取环形缓冲区recvx下标对应的元素,并将sudog中的元素写入到缓冲区,唤醒sudog对应goroutine

  • 释放锁


case2:channel中没有写等待goroutine,并且环形缓冲数组里面有剩余元素

go 复制代码
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
    // c: 对应的hchan指针
    // ep: 被发送到channel的变量的地址
    // ......

    // 加锁
    lock(&c.lock)

    // channel中有元素
    if c.qcount > 0 {
        // 取recvx对应地址上的元素
        qp := chanbuf(c, c.recvx)
        // 将这个元素 写入到ep中
        if ep != nil {
            typedmemmove(c.elemtype, ep, qp)
        }
        // 清空recvx上的数据
        typedmemclr(c.elemtype, qp)
        // index++
        c.recvx++
        // 环形数组操作
        if c.recvx == c.dataqsiz {
            c.recvx = 0
        }
        // 元素数量--
        c.qcount--
        unlock(&c.lock)
        // 返回true, true, 说明channel中有元素, 并且channel没有关闭
        return true, true
    }

    // ......
}


memmove(to, from, n) 做的事情只有一个:把 from 指向的那 n 个字节复制到 to 指向的内存里(按字节拷贝)。所以它本质上就是"拷贝内存内容"。

  • 先拿锁

  • 读取recvx指向的数据

  • recvx++, qcount--

  • 释放锁

case3:channel中没有写等待goroutine,并且环形缓冲数组里面无剩余元素

go 复制代码
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
    // c: 对应的hchan指针
    // ep: 被发送到channel的变量的地址
    // ......

    // 加锁
    lock(&c.lock)
    // 获取当前goroutine
    gp := getg()
    // 获取一个sudog
    mysg := acquireSudog()
    // 绑定接收指针
    mysg.elem = ep
    gp.waiting = mysg
    // 绑定goroutine
    mysg.g = gp
    // 绑定channel
    mysg.c = c
    // 包装sudog进入读队列 (recvq)
    c.recvq.enqueue(mysg)
    // 挂起当前goroutine, 等待goready唤醒
    gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)

    // 清空channel状态
    gp.waiting = nil
    gp.activeStackChans = false
    success := mysg.success
    gp.param = nil
    mysg.c = nil
    // 回收sudog
    releaseSudog(mysg)
    return true, success
}
  • 锁保护步骤同样有的

  • 获取一个sudog结构,绑定对应的channel,goroutine,还有ep指针

  • 将sudog放入channel的读等待队列(recvq)

  • runtime.gopark(挂起当前goroutine,可以看作是解绑当前g和m,然后开启下一轮调度)

特殊case

第一种特殊情况:读取的channel为nil

go 复制代码
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
    // 判断channel是否为nil
    if c == nil {
        // 如果是非阻塞类型, 直接返回
        if !block {
            // 两个false, 通道不想阻塞而返回
            return false, false
        }
        // 是阻塞类型(永久性挂起)
        gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
        throw("unreachable")
    }

    // ......
}

当channel为nil的时候,对channel进行读操作,会导致当前goroutine永久性挂起,如果当前goroutine是main goroutine的话,还会导致整个程序退出

第二种特殊情况:channel已经关闭,并且buf里面没有元素

go 复制代码
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
    // ......

    // 如果channel已经关闭
    if c.closed != 0 {
        if c.qcount == 0 {
            unlock(&c.lock)
            // 清空ep
            if ep != nil {
                typedmemclr(c.elemtype, ep)
            }
            // 返回true, false, 说明channel关闭, 并且得到零值
            return true, false
        }
    }
	if c.qcount > 0 {
		// Receive directly from queue
		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--
		unlock(&c.lock)
		return true, true
	}

    // ......
}

channel已经关闭,并且没有剩余元素,还想读取channel会得到对应类型的零值

channel关闭

管道的关闭很简单,操作如下

go 复制代码
ch := make(chan int)
close(ch)

底层其实就是调用了 runtime.closechan 函数,源码如下:

go 复制代码
func closechan(c *hchan) {
	// chan为nil,想要执行关闭操作,直接panic
	if c == nil {
		panic(plainError("close of nil channel"))
	}
	if c.bubble != nil && getg().bubble != c.bubble {
		fatal("close of synctest channel from outside bubble")
	}

	lock(&c.lock)
	if c.closed != 0 {
		unlock(&c.lock)
		// 通道已经关闭,再次执行关闭的话,直接panic
		panic(plainError("close of closed channel"))
	}

	if raceenabled {
		callerpc := sys.GetCallerPC()
		racewritepc(c.raceaddr(), callerpc, abi.FuncPCABIInternal(closechan))
		racerelease(c.raceaddr())
	}

	// 通道置为1,表示关闭
	c.closed = 1

	// 通过一个glist来记录channel中所有goroutine等待者
	var glist gList

	// release all readers
	// 将所有recvq的等待者加入到glist中
	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)
	}

	// release all writers (they will panic)
	// 将所有sendq的等待者加入到glist中
	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)

	// Ready all Gs now that we've dropped the channel lock.
	// 依次唤醒glist中所有等待者
	for !glist.empty() {
		gp := glist.pop()
		gp.schedlink = 0
		goready(gp, 3)
	}
}

这里我们直接按照源码来分析流程

  • 如果对一个nil的channel执行close操作,会发生panic
  • 加锁
  • 如果重复关闭channel,也会panic
  • 关闭channel(c.closed置为1)
  • 将sendq和recvq里面所有等待者加入到glist中
  • 唤醒glist中所有等待者(唤醒sudog对应的goroutine)

Select

select也被称为多路select,指的是一个goroutine可以服务多个 channel的读或写操作

源码较长,这里只总结下select的原理:

select分为两种,包含非阻塞型select(包含default分支的) 和 阻塞型select(不包含default分支的)

阻塞型例子:

go 复制代码
package main

func main() {
    ch := make(chan int)

    select {
    case <-ch:

    case ch <- 1:
    }
}

非阻塞型例子:

go 复制代码
package main

func main() {
    ch := make(chan int)

    select {
    case <-ch1:

    case ch2 <- 1:

    default:
    }
}

select的核心原理是,按照随机的顺序执行case,直到某个case完成操作 ,如果所有case的都没有完成操作,则看有没有default分支,如果有default分支,则直接走default,防止阻塞

如果没有的话,需要将当前goroutine 加入到所有case对应channel的等待队列中,并挂起当前goroutine,等待唤醒。

如果当前goroutine被某一个case上的channel操作唤醒后,还需要将当前goroutine从所有case对应channel的等待队列中剔除

检验

1、channel 是线程安全的吗?

是的,一般来说,我们对channel就只有读,写,关闭三种操作,这三种操作,channel底层数据结构都用同一把runtime.Mutex来进行保护

2、channel 的底层实现原理

channel的底层结构体叫做runtime.hchan

  • 拥有一把runtime.mutex来保证channel进行读,写,关闭操作逻辑的并发安全

  • 通过读写索引(sendx,recvx)和一个buf数组 实现一个环形缓冲队列,可以让channel拥有存储数据的能力

  • 拥有读写等待队列,当一个goroutine对channel进行读或写操作,操作无法及时完成的时候,可以进入到等待队列等待,当前goroutine也被runtime.gopark进行挂起

  • 而读写操作也能取出 等待队列里面的goroutine,通过runtime.goready将等待中goroutine唤醒(放入gmp模型中),等待GMP的调度

3、对channel 进行读,写,关闭操作会怎么样?

1. 对nil的channel进行读、写、关闭

都会造成当前goroutine永久阻塞(如果当前goroutine是main goroutine,则会让整个程序直接报fatal error 退出)关闭则会发生panic (但这里我们不要忘记,还有一种叫做非阻塞的方式操作channel ,这种模式下,就算对为nil的channel读写,也不会阻塞的

go 复制代码
package main

import (
    "time"
)

// 为nil的channel
var ch chan int

func main() {
    // 对nil channel进行读操作
    // receiveExample1()
    // receiveExample2()

    // 对nil channel进行写操作
    // sendExample1()
    // sendExample2()

    // 对nil channel进行close操作
    // close(ch)

    // 非阻塞模式
	// select {
    // case <-ch:
    //     fmt.Println("1")
    // default:
    //     fmt.Println("default")
    // }

    time.Sleep(1 * time.Second)
}

// 在主goroutine对nil channel进行读
func receiveExample1() {
    <-ch
}

// 在普通goroutine对nil channel进行读
func receiveExample2() {
    go func() {
        <-ch
    }()
}

// 在主goroutine对nil channel进行写
func sendExample1() {
    ch <- 1
}

// 在普通goroutine对nil channel进行写
func sendExample2() {
    go func() {
        ch <- 1
    }()
}

2. 对不为nil,并且未关闭的channel操作,读和写都有两种情况

a. 读操作:

  • i. 成功读取: 如果channel中有数据,直接从channel里面读取,如果此时写等待队列里面有goroutine,还需要将队列头部goroutine数据写入到channel中,并唤醒这个goroutine;如果channel没有数据,就尝试从写等待队列中读取数据,并做对应的唤醒操作【指channel没有数据,但有写等待者,就是无缓冲的channel】

  • ii. 阻塞挂起(读操作无法及时完成): channel里面没有数据 并且 写等待队列为空,则当前goroutine 加入读等待队列中,并挂起,等待唤醒

b. 写操作

  • i. 成功写入: 如果channel 读等待队列不为空,则取 头部goroutine,将数据直接复制给这个头部goroutine,并将其唤醒,流程结束;否则就尝试将数据写入到channel 环形缓冲中

  • ii. 阻塞挂起(写操作无法及时完成): 通道里面buf满了 并且 读等待队列为空,则当前goroutine 加入写等待队列中,并挂起,等待唤醒

3. 对已经关闭的channel进行写、关闭、读

对已经关闭的channel进行写和关闭 都会导致panic,而读取是直到读完channel中剩余数据,还想读的话,就会获得零值

4、运行下面代码,会得到什么结果?

go 复制代码
package main

import "fmt"

func main() {
	case1 := make(chan int)
	case2 := make(chan int)
	close(case1)
	close(case2)

	select {
	case <-case1:
		fmt.Println("case1")
	case case2 <- 1:
		fmt.Println("case2")
	default:
		fmt.Println("default")
	}
}

A: 进入default分支,打印"default"

B: 进入case1分支,打印"case1"

C: 进入case2分支,打印"case2"

D: 程序panic

E: 程序可能panic,也可能打印"case1"

答案:E

go 复制代码
root@GoLang:~/proj/goforjob# go run main.go 
panic: send on closed channel

goroutine 1 [running]:
main.main()
        /root/proj/goforjob/main.go:11 +0xb5
exit status 2
root@GoLang:~/proj/goforjob# 
go 复制代码
root@GoLang:~/proj/goforjob# go run main.go 
case1
root@GoLang:~/proj/goforjob# 

5、运行下面代码,会得到什么结果?

go 复制代码
package main

func main() {
	c := make(chan int, 1)

	done := false
	for !done {
		select {
		case <-c:
			println(1)
			c = nil
		case c <- 1:
			println(2)
		default:
			println(3)
			done = true
		}
	}
}

答案是

bash 复制代码
root@GoLang:~/proj/goforjob# go run main.go 
2
1
3

之后我会持续更新,如果喜欢我的文章,请记得一键三连哦,点赞关注收藏,你的每一个赞每一份关注每一次收藏都将是我前进路上的无限动力 !!!↖(▔▽▔)↗感谢支持!

相关推荐
liuyunshengsir1 小时前
golang Gin 框架下的大数据量 CSV 流式下载
开发语言·golang·gin
BlockChain8882 小时前
MPC 钱包实战(三):Rust MPC Node + Java 调度层 + ETH 实际转账(可运行)
java·开发语言·rust
Charlie_lll2 小时前
RAG+ReAct 智能体深度重构|从「固定三步执行」到「动态思考-行动循环」
人工智能·spring boot·redis·后端·ai·重构
吉吉612 小时前
在 Windows 和 Linux 的 VSCode 中配置 PHP Debug
开发语言·php
蜜汁小强2 小时前
macOS 上升级到 python 3.12
开发语言·python·macos
Remember_9932 小时前
【数据结构】Java集合核心:线性表、List接口、ArrayList与LinkedList深度解析
java·开发语言·数据结构·算法·leetcode·list
小旭95272 小时前
【Java 面试高频考点】finally 与 return 执行顺序 解析
java·开发语言·jvm·面试·intellij-idea
hixiong1232 小时前
C# OpenVinoSharp部署Yolo26模型进行推理
开发语言·c#·openvino·yolo26
+VX:Fegn08952 小时前
计算机毕业设计|基于springboot + vue校园实验室管理系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计