news 2026/10/16 1:10:43

2024 Go面试高频题精讲:Goroutine与Channel实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
2024 Go面试高频题精讲:Goroutine与Channel实战指南

简介:2024年最新Go语言面试30题及答案讲解,聚焦Go核心考点,适合求职面试者、在校学生以及希望系统巩固Go基础的开发者。题目覆盖Go语言特点、Goroutine轻量级线程、Channel通道通信、select多路复用、接口与空接口、错误处理、defer执行顺序及panic/recover机制等高频知识点,并配合代码示例帮助理解。资源包为单个docx文档,共1个文件,大小约16KB,结构紧凑、便于阅读和复习。已有607人学习下载。文档不仅给出参考答案,还包含关键机制解析与易错点提示,能够帮助读者快速构建Go并发模型与异常处理的思维框架,在面试中更从容地应对技术追问。

1. 2024 Go 面试题文档:三十道高频题的正确打开方式

面试前翻到一份 2024 年整理的 Go 语言面试题文档,30 道题全部带详细答案讲解,从 Goroutine 调度模型到 Channel、接口、错误处理、GC 和测试,覆盖的全是日常写 Go 代码时真正高频出现的考点。它不像常见的刷题仓库那样只有问题和一句话答案,而是每一题都给了「是什么、为什么、怎么写」三层解释,读起来像有人在面前给你讲题。对准备 Go 后端岗位面试的人,这是一份现成的知识体检表;对负责带新人的开发者,也可以直接拿来当摸底问卷。这篇笔记按我的理解把它拆成六个模块,补上了文档里没展开的代码演示、参数边界和实际踩过的坑,方便你照着过一遍,再决定哪些需要展开复习。

2. Goroutine 不是银弹:go 关键字背后的调度模型与资源边界

2.1 Goroutine 与传统线程:轻量化到底轻在哪

这道题基本是 golang 面试的第一道送分题,但送得不好也会把印象分送走。Goroutine 是 Go 运行时管理的并发单元,初始栈只有几 KB,会随使用动态伸缩;传统操作系统线程的栈空间动辄 1MB 以上,创建和切换都要经过内核态,代价高得多。所以一个 Go 进程里开几万个 goroutine 是常态,换成线程早就把调度器压垮了。

回答时我会补一句:goroutine 共享进程地址空间,没有进程级隔离,所以数据安全要靠自己管。还有一个关键点——「并发不是并行」,一个 goroutine 阻塞在 IO 上时,调度器会切去执行别的 goroutine,这才是它能扛住高并发的根本原因。文档里的答案强调了创建销毁开销小、通过 Channel 通信,这些点面试官通常希望你能展开讲,而不是复述关键词。

一点个人体会:背「Goroutine 是轻量级线程」这句话没用,要能说出轻在栈、轻在切换、轻在创建成本三处,才算真正理解。下面这段代码可以直观感受 goroutine 的成本:启动十万个 goroutine,内存占用和启动时间都远小于同量级系统线程,虽然这里没法直接对比线程,但数字本身已经很有参考性。

package main import ( "fmt" "runtime" "sync" ) func main() { var wg sync.WaitGroup for i := 0; i < 100000; i++ { wg.Add(1) go func(n int) { defer wg.Done() _ = n }(i) } wg.Wait() fmt.Println("goroutines after wait:", runtime.NumGoroutine()) }

参数说明:十万这个数量级只是演示用,生产环境按业务量调整,别照抄。runtime.NumGoroutine()返回当前存活的 goroutine 数,循环全部结束后应该只剩 main 自身那一个。这里传给匿名函数的i是参数拷贝,不是闭包捕获,这个细节后面专门讲。

2.2 MPG 模型:M、P、G 谁管谁、GOMAXPROCS 控制什么

调度模型是更高一档的并发题。M(Machine)是操作系统线程,P(Processor)是调度处理器,G(Goroutine)是协程本身。Go 运行时会维护一个全局就绪队列,每个 P 还有自己的本地队列。当一个 G 因为系统调用阻塞时,调度器会把对应的 P 重新分配给其他 M,让这个 P 本地队列里剩下的 G 继续跑,而不是跟着 M 一起卡住。

面试常问的是 GOMAXPROCS:它限制的是同时运行的 P 数量,不是 CPU 核数。默认值等于可用逻辑核数,改小可以压低并发度,改大不一定更快。CPU 密集型任务把 GOMAXPROCS 设成核数的几倍,只会引入无谓的上下文切换;IO 密集型任务可以稍微调大,因为阻塞期间 P 会被复用,有更多 P 意味着有更多本地队列可以并行调度。

用一句话答题:「MPG 让阻塞的 goroutine 不占着 M 不放,P 可以换个 M 继续跑。」下面是查询和修改 GOMAXPROCS 的最小演示:

package main import ( "fmt" "runtime" ) func main() { fmt.Println("default GOMAXPROCS:", runtime.GOMAXPROCS(0)) runtime.GOMAXPROCS(2) fmt.Println("after set:", runtime.GOMAXPROCS(0)) }

参数说明:runtime.GOMAXPROCS(0)传入 0 表示只查询不修改,返回值是当前生效值。改成 2 后同一进程内再次查询会返回 2。要注意的是这个设置是全局的,不是某个 goroutine 私有的,线上改它要谨慎,最好在 init 阶段就确定。

2.3 创建与启动:三种写法与闭包陷阱

创建 goroutine 有三种常见姿势:go f()启动一个已命名函数,go func(){}()启动匿名函数,go obj.Method()启动方法。文档里给的匿名函数例子是后两种的混合,能跑,但没提参数传递的问题。

真正的坑在闭包捕获。下面这段代码,很多人第一反应是输出0 1 2,实际大概率是2 2 2或者3 3 3:

package main import ( "fmt" "time" ) func main() { for i := 0; i < 3; i++ { go func() { fmt.Print(i, " ") }() } time.Sleep(100 * time.Millisecond) }

原因在于匿名函数捕获的是循环变量i的引用,循环结束后i的值停在 3,而 goroutine 可能在循环结束后才被调度执行,读到的就是最终值。解决方式是把i作为参数传进去,每次迭代复制一份值;Go 1.22 之后循环变量每次迭代有了新语义,但为了兼容旧环境,面试现场最好主动给出显式传参的写法。

for i := 0; i < 3; i++ { go func(n int) { fmt.Print(n, " ") }(i) }

逻辑说明:go func(n int)的n是每次循环求值后的拷贝,goroutine 内部读的是这份拷贝,和循环变量不再共享地址。参数说明:如果你需要同时捕获多个循环变量,全部走参数列表,不要用闭包,这是写并发代码时最值得养成的习惯之一。

3. Channel、select 与超时:并发通信的三种典型姿态

3.1 通道声明与初始化:无缓冲、有缓冲与单向通道

Channel 是 Go 并发模型的核心,面试题从声明语法问到阻塞语义是常规操作。声明用var ch chan int只是声明,没有初始化时值是 nil,向 nil 通道发送或接收会永久阻塞,这个细节文档没有强调,但实际排查时经常撞上。

初始化统一用make。make(chan int)创建无缓冲通道,发送方必须等到接收方就绪才能完成发送;make(chan int, 10)创建容量为 10 的有缓冲通道,只要缓冲区没满,发送就不阻塞。选哪个取决于你对「生产者会不会跑得比消费者快」的判断,有缓冲解决的是短暂的不匹配,不是设计缺陷的遮羞布。

通道类型创建方式收发行为
无缓冲make(chan T)发送必须等接收就绪,严格同步
有缓冲make(chan T, n)容量未满发送不阻塞,未空接收不阻塞
单向发送make(chan<- T)只能发送,不能接收
单向接收make(<-chan T)只能接收,不能发送

单向通道主要用于函数签名,比如func Worker(ch chan<- int)告诉调用方这个函数只会往里写。文档里提到通过第二个参数创建带缓冲通道,实际写代码时我更建议把容量当成一个需要 review 的参数,随手写10的人很多,但真正问一句「为什么是 10」的人很少。

package main import "fmt" func main() { ch := make(chan int, 2) ch <- 1 ch <- 2 fmt.Println("buffered channel len:", len(ch), "cap:", cap(ch)) }

逻辑说明:容量 2 的通道连续发送两条数据不会阻塞。len返回当前队列长度,cap返回总容量,这两个函数在排查阻塞问题时很有用。注意第三发送会阻塞,因为缓冲区满了。

3.2 发送、接收、关闭:三个容易答错的细节

通道的关闭是面试官最喜欢追问的点。文档说关闭后不能再发送,但仍可以接收已发送的数据,这个说法没错,但不完整。从已关闭的通道接收时,如果缓冲区里还有数据,可以正常读完;缓冲区空了之后再接收,返回的是元素类型的零值,而且不会阻塞。问题在于你无法区分「这个零值是真实数据」还是「通道已关闭」。

标准解法是用value, ok := <-ch,ok为 false 表示通道已关闭且数据读尽。这一手在面试和实际开发里都高频出现。

package main import "fmt" func main() { ch := make(chan int, 2) ch <- 1 close(ch) for { v, ok := <-ch if !ok { fmt.Println("channel closed") break } fmt.Println("received:", v) } }

逻辑说明:第一次循环读到 1,ok为 true;第二次通道已空且已关闭,ok为 false,循环退出。参数说明:对已关闭通道执行send会 panic,对已关闭通道执行close也会 panic,这两条是实际开发里最容易翻车的操作,代码审查时看到close都应该留意是否有并发关闭的可能。

3.3 select 与 time.After:超时控制的正确姿势

select 语句用于在多个通道操作中选择一个可执行的,多个都就绪时随机选一个。文档里的超时例子是标准答案:用time.After生成一个计时器通道,放在 select 里和业务通道竞争。但这里有个容易被忽略的坑——time.After每次调用都会创建一个新的计时器,如果这个 select 运行在循环里,并且每次都走了正常分支,计时器要等到时间到了才被 GC 回收,极端情况下会造成短暂的内存尖峰。

更稳的写法是提前创建一个计时器并复用,每次循环调用Reset。不过面试阶段先把文档里的标准答法说出来,再补一句「高频循环里我一般会复用 time.Timer」就能体现工程经验。

package main import ( "fmt" "time" ) func main() { ch := make(chan int) go func() { time.Sleep(2 * time.Second) ch <- 42 }() select { case v := <-ch: fmt.Println("received:", v) case <-time.After(1 * time.Second): fmt.Println("timeout after 1s") } }

逻辑说明:业务 goroutine 睡 2 秒才发数据,select 里的time.After(1s)先到期,走超时分支。参数说明:1 * time.Second是超时阈值,实际项目里建议做成常量或配置项,不要散落在业务代码里。另外一个技巧是给 select 加default分支做非阻塞检查,像select { case v := <-ch: ...; default: ... }这样,没有数据时立即走默认分支,适合轮询场景。

4. 语言特性题的分区:切片、指针、接口与错误处理

4.1 数组、切片与指针:基础题别答岔

数组和切片的区别是基础题里的送分题,但答岔的人真不少。数组长度是类型的一部分,[3]int和[4]int是两种类型,作为函数参数时整个数组会被值拷贝。切片是数组的抽象层,结构上包含指针、长度和容量,作为参数传递时传的是这个描述结构的副本,底层数组仍然共享。

对比项数组切片
长度编译期固定,是类型一部分可以动态增长
作为参数值拷贝,修改不生效共享底层数组,修改可见
底层结构连续存储的定长内存指针 + len + cap
扩容不支持超出容量时自动扩容

指针这道题,文档里只给了声明语法var ptr *int。面试官的追问通常集中在「什么时候必须用指针」:修改接收者的状态时、不想拷贝大结构体时、需要表示可选值时。反驳题则常问 slice 本身就是引用传递为何还要传指针——如果函数里要修改 slice 的长度或容量,就得传*[]int,否则append的结果不会带出去。

package main import "fmt" func tryAppend(s []int) { s = append(s, 99) } func realAppend(s *[]int) { *s = append(*s, 99) } func main() { s := []int{1, 2} tryAppend(s) fmt.Println("after tryAppend:", s) realAppend(&s) fmt.Println("after realAppend:", s) }

逻辑说明:tryAppend里的append只在函数作用域内修改了切片描述结构,外层看不到;realAppend直接改底层指针,外层能感知。参数说明:这个例子把「值传递」和「引用语义」的区别展示得很清楚,切片本身按值传递,但底层数组共享,修改元素双方都可见,修改长度则必须用指针。

4.2 接口与空接口:隐式实现的边界

Go 的接口是隐式实现——类型只要实现了接口中声明的所有方法,就自动被视为该接口的实现,不需要显式的implements关键字。这在面试中通常被当作优点来谈:解耦、灵活、易于 mock。但也可以从另一面回答:接口定义不显式,代码阅读时需要靠编译器自己确认,大型项目里接口被谁实现需要通过工具或 IDE 辅助才能看清。

接口嵌套是文档提过的点,type ReadWriter interface { Reader; Writer }这种组合语法很常用。空接口interface{}在文档里被描述为「可以表示任意类型的值」,这个说法正确但容易误导新人。空接口不是没有约束,而是把所有类型安全检查都推迟到了运行时,一旦断言不当就会 panic。建议的回答是:空接口适合作为「不知道类型」的边界,比如fmt.Println的参数,而不是作为设计模式到处用。

package main import "fmt" type Person struct { Name string Age int } func describe(v interface{}) { switch p := v.(type) { case Person: fmt.Println("person:", p.Name, p.Age) case string: fmt.Println("string:", p) default: fmt.Println("unknown type") } } func main() { describe(Person{Name: "Alice", Age: 30}) describe("hello") describe(42) }

逻辑说明:switch v.(type)是类型断言的常用形式,能同时做类型判断并拿到类型化后的值。参数说明:interface{}做函数参数时,任何类型都能传进来,但函数内部必须自己处理类型分支,否则运行时不匹配会 panic,这也是「灵活」的代价。

4.3 错误处理:error 值、自定义错误与包装

Go 的错误处理就是函数返回error,调用方负责检查。文档里给出的自定义错误写法是实现了Error() string方法,这在基础题层面够用了。但工程上更推荐在新代码里用errors.Is和errors.As配合%w包装,这样错误在传递过程中可以带上下文,同时保留原始的相等性判断。

package main import ( "errors" "fmt" ) type MyError struct { Msg string } func (e *MyError) Error() string { return e.Msg } func doSomething() error { return fmt.Errorf("wrapped: %w", &MyError{Msg: "disk full"}) } func main() { err := doSomething() var myErr *MyError if errors.As(err, &myErr) { fmt.Println("found MyError:", myErr.Msg) } fmt.Println("is MyError:", errors.Is(err, &MyError{Msg: "disk full"})) }

逻辑说明:fmt.Errorf配合%w生成包装错误,errors.As能穿透包装层找到具体类型,errors.Is判断错误链里是否存在目标错误。参数说明:errors.As第二个参数必须是目标错误类型的指针的指针,写成&myErr没问题,但如果直接传myErr会直接 panic,这是个高频翻车点。

4.4 defer 与 panic/recover:顺序与使用边界

defer 的两个考点:一是延迟执行,函数返回前执行;二是执行顺序倒序,后声明的先执行。文档里两句都说了,实际使用时需要补充的是 defer 和命名返回值的关系——defer 可以修改命名返回值,因为命名返回值在 defer 执行时仍然可见。

package main import "fmt" func calc() (result int) { result = 1 defer func() { result *= 2 }() return result } func main() { fmt.Println("calc result:", calc()) }

逻辑说明:calc先给result赋值为 1,return result会把 result 设为返回值,但 defer 在 return 之后执行,把 result 乘 2,最终返回值是 2。参数说明:如果不用命名返回值,defer 里无法修改未被捕获的局部返回值,这是面试最常见的变形题。

panic 和 recover 的正确使用边界是三句话:recover 只能在 defer 函数里直接调用才有效;panic 应该留给「程序状态已不可恢复」的场景,比如 switch 的 default 分支不应该用 panic 兜底;业务错误一律走 error。文档给的示例代码能跑,但没有说清楚「什么时候别用 panic」,面试时主动提一句「panic 不是 try-catch 的替代品」会很加分。

5. 避坑排查:答案之外,五个最容易翻车的现场

5.1 现象:goroutine 数量只增不减,内存跟着涨

程序跑了一段时间后,runtime.NumGoroutine()的数字像计数器一样往上涨,内存也持续走高。原因通常是 goroutine 启动后没有任何退出条件,或退出信号根本没传进去——比如只往 channel 发数据,但接收方已经提前 return 了。解决方式是用 context 控制生命周期,goroutine 内部通过 select 监听ctx.Done()。

ctx, cancel := context.WithCancel(context.Background()) go func() { for { select { case <-ctx.Done(): return default: // 业务逻辑 } } }() // 不需要时 cancel()

逻辑说明:context.WithCancel生成一个可取消的 ctx,cancel调用后ctx.Done()通道关闭,select 立即走return分支。参数说明:cancel只是发信号,不强制终止,goroutine 内部必须主动检查Done(),所以这个模式不能省掉 select 里的监听分支。

5.2 现象:程序直接报 fatal error: all goroutines are asleep - deadlock!

死锁是 channel 题最常见的运行时错误。无缓冲通道发送必须等到接收方就绪,如果 main 里直接ch <- 1而没有任何 goroutine 在另一头等,整个程序就卡死。文档答案没说透的是:无缓冲通道是同步点,收发必须配对;有缓冲通道在满的时候同样会阻塞等待。

解决方向是检查三件事:发送方和接收方是否成对出现、缓冲容量是否合理、select 里是否漏了 default。下面这段代码能跑,是因为接收 goroutine 先睡 1 秒模拟延迟,但最终会消费数据:

ch := make(chan int) go func() { time.Sleep(time.Second) <-ch }() ch <- 1

逻辑说明:main 发送时进入阻塞,接收 goroutine 醒来后读走数据,发送完成。如果把 sleep 去掉或直接删掉 goroutine,程序立刻 deadlock。参数说明:make(chan int)没有第二个参数就是无缓冲,这是面试官最爱设置的陷阱之一。

5.3 现象:循环里启动的 goroutine 全部输出同一个值

这个在前面闭包陷阱里提过,现场翻车率极高。现象是日志里每个 goroutine 打出的循环变量都一样,原因就是闭包捕获了变量引用而不是值。解法是显式传参,或者在新版本环境里依赖循环变量新语义,但别赌线上环境的版本。

for _, item := range items { go func(v string) { fmt.Println(v) }(item) }

逻辑说明:v是每次迭代对item的拷贝,goroutine 内部使用的是独立值。参数说明:如果循环变量是切片元素,拷贝的代价通常可忽略;如果元素是大结构体,考虑传索引再在 goroutine 内取值,避免大拷贝。

5.4 现象:写了 recover 但程序还是崩了

recover 没生效的原因十有八九是它不在 defer 函数里直接调用,而是被包了一层。比如defer func() { safeRecover() }()写法里,recover 发生在safeRecover函数栈帧内,管不到当前函数的 panic。解决方式是让 recover 在 defer 函数的函数体里直接出现:

defer func() { if r := recover(); r != nil { fmt.Println("recovered:", r) } }()

逻辑说明:recover 只能在其所在函数与 panic 触发点同一调用链时生效,隔着包装函数就失效。参数说明:recover()返回 panic 传入的值,这里打印出来,生产环境一般写入日志后决定是否重新 panic 或降级返回。

5.5 现象:多个 goroutine 写同一个 map 触发 concurrent map writes

Go 的 map 不是并发安全的数据结构,多 goroutine 同时写入会直接 panic。文档第 26 题的互斥锁答案是正确方向,但只说 Mutex 还不够。更细的回答是:读多写少用sync.RWMutex,写多且 key 分布均匀可以直接用sync.Map,或者自己分片锁。下面是个最小的加锁计数器:

type Counter struct { mu sync.Mutex n int } func (c *Counter) Inc() { c.mu.Lock() c.n++ c.mu.Unlock() }

逻辑说明:Inc加锁后修改n,解锁后其他 goroutine 才能进入临界区。参数说明:锁要覆盖「读改写」完整过程,只给n++加锁而读取不加锁,still 存在数据竞争,配合go test -race能直接检出来。

6. 验证你的并发结论:go test -race 加一个超时用例

6.1 用测试用例把每个答案变成可运行的代码

面试题的答案背下来只是第一步,真正的验证方式是把它写成测试。文档第 19 题讲了 testing 包的用法,这里补一个并发场景的完整用例:并发安全计数器加超时控制,一次把所有组件串起来。

package counter import ( "encoding/json" "errors" "sync" "testing" "time" ) type Counter struct { mu sync.Mutex n int } func (c *Counter) Inc() { c.mu.Lock() c.n++ c.mu.Unlock() } func WithTimeout(ch <-chan int, d time.Duration) (int, error) { select { case v := <-ch: return v, nil case <-time.After(d): return 0, errors.New("timeout") } } func TestCounter(t *testing.T) { c := &Counter{} var wg sync.WaitGroup for i := 0; i < 1000; i++ { wg.Add(1) go func() { defer wg.Done() c.Inc() }() } wg.Wait() out, _ := json.Marshal(map[string]int{"count": c.n}) t.Logf("counter state: %s", out) if c.n != 1000 { t.Fatalf("expected 1000, got %d", c.n) } }

逻辑说明:Inc用sync.Mutex保护n,1000 个 goroutine 并发自增后结果仍为 1000。WithTimeout演示 select 超时,channel 无数据时在d时间后返回错误。这里顺手用了encoding/json把状态序列化到测试日志,对应文档第 30 题的 JSON 操作。参数说明:t.Logf只有在测试失败或开启-v时才输出;-race是 Go 自带的竞态检测器,跑一遍能抓住大多数数据竞争。

6.2 面试现场怎么讲这套验证过程

光会写测试不够,面试官会更喜欢你主动说「我复习这部分时把答案写成了测试用例」。比如这道题你可以在讲完 select 超时后补一句:我用time.After(100ms)的用例验证过阻塞场景,配合go test -race跑过,没检测到竞态。这句话展示的东西比答案本身多得多——说明你有验证手段,且知道怎么用工具。

执行命令是go test -race ./...,在项目根目录跑会递归测试所有包。-race开启竞态检测后运行速度会慢一些,但代价换来的是并发正确性的保障。从那以后,我每次复习 Go 并发相关的题目,都强制把对应场景写成测试用例,至少跑一遍go test -race,背不熟的答案就这么变成了能跑的代码。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 1:10:29

金融数据竞赛小白实战复盘:从逻辑回归到LightGBM的提分之路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:09:52

Qoder Skill功能实战:从配置到进阶,打造AI编程上下文管理利器

1. 为什么我会花一整周去啃Qoder的Skill功能先说结论&#xff1a;Qoder的Skill功能是我最近半年里遇到的、少数让我觉得"这工具设计者是真的自己写代码"的功能模块。这东西表面上是一个"给AI预处理指令"的配置面板&#xff0c;但实际用下来&#xff0c;它解…

作者头像 李华
网站建设 2026/10/16 1:09:52

HarmonyOS 6.0企业安全办公App实战:窗口增强与数据防泄露指南

企业安全办公App这东西&#xff0c;真不是做个登录页面、存个密码就算“安全”了。我见过太多团队&#xff0c;移动端跑得挺好&#xff0c;一上PC就翻车&#xff1a;没有多窗口思维&#xff0c;窗口一最大化就把整个屏幕霸占&#xff1b;没有防截屏意识&#xff0c;核心数据被人…

作者头像 李华
网站建设 2026/10/16 1:09:47

Android自绘滚轮实现省市区三级联动城市选择器

简介&#xff1a;面向Android应用开发者的城市选择器组件&#xff0c;适合在电商、旅游、生活服务等场景中快速接入城市选择功能。该组件界面模仿美团&#xff0c;并借助高德地图API完成定位与城市数据展示&#xff0c;兼具美观度和实用性。压缩包内共285个文件&#xff0c;大小…

作者头像 李华
网站建设 2026/10/16 1:09:43

2S电池组被动均衡方案:从电压失配到MP2642与PIC24F实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:09:10

无监督单目深度估计:左右一致性如何用双目几何约束训练模型

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华