当前位置:

首页 > 编程开发 > Go协程的底层原理及分析

Go协程的底层原理及分析

Go协程底层由g结构体表示,包含栈和程序计数器。线程通过m结构体执行调度循环,依次运行协程。多线程引入p结构体,形成G-M-P模型,p维护本地队列,减少全局锁竞争,提升并发性能。

在并发编程的演进中,“协程”这个概念越来越被关注,尤其是Go语言中的Goroutine,已经成为很多开发者讨论高性能架构时绕不开的话题。不过,协程到底是怎么工作的?底层调度逻辑长什么样?今天这篇文章,我们就从头开始,一步步把Go协程的底层原理拆清楚。这不算一篇新手入门帖,更像一份带着源码分析路径的深度解析。

为什么要有协程

什么是进程

Go协程的底层原理及分析

  • 操作系统里“程序”的最小单位
  • 进程用于占用内存空间
  • 打个比方,进程像是工厂的一个厂房,占用了工厂的空间

什么是线程

如果把进程比作厂房,线程就是厂房里的生产线:

Go协程的底层原理及分析

  • 每个进程可以拥有多个线程
  • 线程使用系统分配给进程的内存,且线程之间共享内存

CPU会在线程之间来回切换:

Go协程的底层原理及分析

  • 线程用于占用CPU时间
  • 线程的调度依赖操作系统,开销比较大
  • 线程像厂房里的生产线,占用的是工人(CPU)工时
  • 线程里跑的代码就是生产流程

线程的问题

  • 线程本身占用的资源不小
  • 线程的创建和销毁开销大
  • 线程切换需要陷入内核,开销也很大

什么是协程

协程的思路很有意思:把一段程序的运行状态打包,让它可以在线程间灵活调度:

Go协程的底层原理及分析

  • 协程本质上是将一段程序的运行状态打包,可以在线程之间调度
  • 就好比把生产流程打包,让流程不固定在某一条生产线上
  • 注意,协程并不是要取代线程,它仍然需要在线程上运行
  • 线程是协程的资源,协程利用线程来执行

协程的优势

  • 资源利用率更高
  • 调度速度更快
  • 能够支撑超高并发

总结

  • 进程负责分配内存空间
  • 线程负责分配CPU时间
  • 协程用于更精细地利用线程
  • 协程的本质是一段包含了运行状态的程序

协程的本质

func do1() {  
   func2()  
}  
  
func do2() {  
   func3()  // 在这里打个断点
}  
  
func do3() {  
   fmt.Print("dododo")  
}  
  
func main() {  
   go func1()  
   time.Sleep(time.Minute)  
}

运行这段代码并调试,打开debugger观察栈信息。协程从do1调用到do2,下一步会调用do3,不过因为打了断点,do3还没有被调用进来:

Go协程的底层原理及分析

在Go语言内部,协程是用g结构体表示的。路径是runtime/runtime2.go:

type g struct {  
   stack       stack   // offset known to runtime/cgo
}

这里面的stack就是协程栈,也就是上面调试时看到的栈信息。我们再看看stack结构体内部的细节:

type stack struct {  
   lo uintptr  // 栈的上限
   hi uintptr  // 栈的下限
}

可以简单理解,这个栈有两个指针——高指针和低指针,指向栈在内存中的占用区域,用来存储栈帧信息。接着看g结构体里的另一个关键参数:

type g struct {  
   stack     stack   // offset known to runtime/cgo
   sched     gobuf
}

sched的类型是gobuf,它里面有两个重要的成员:sp(栈指针)指向当前运行到的方法栈帧,pc(程序计数器)记录当前运行到了哪行代码:

type gobuf struct {  
   sp   uintptr
   pc   uintptr
   g    guintptr  
   ctxt unsafe.Pointer  
   ret  uintptr  
   lr   uintptr  
   bp   uintptr // for framepointer-enabled architectures  
}

继续看g结构体,还有其他重要的参数:atomicstatus表示协程状态,goid是协程ID:

type g struct {  
   stack     stack   // offset known to runtime/cgo
   sched     gobuf
   atomicstatus uint32 
   goid         int64
}

综合来看,协程的底层结构可以这样总结:一个g结构体包含多个字段,其中stack表示协程栈,指向stack结构体,内部有lo和hi两个指针分别指向栈的低地址和高地址,用来记录协程的执行位置;shed是gobuf结构体,包含sp(栈指针,指向当前运行到的栈帧)和pc(程序计数器,记录当前执行到哪一行)。

Go协程的底层原理及分析

  • runtime中,协程的本质是一个g结构体
  • stack:记录堆栈地址
  • gobuf:记录当前程序运行现场
  • atomicstatus:记录协程状态

协程的结构清楚了,那它在底层是如何与线程关联的?在runtime/runtime2.go中,还有一个m结构体用来表示操作系统线程:

type m struct {  
   g0      *g     // 调度协程而产生的协程
   curg    *g     // 当前运行的协程
   id      int64  // 线程的id
   mOS            // 记录不同操作系统底层对线程的额外描述信息
}
  • runtime中将操作系统线程抽象为m结构体
  • g0:用于调度的特殊协程
  • curg:当前线程正在运行的协程
  • mOS:记录操作系统线程的信息

协程是如何执行的

单线程循环(Go 0.x)

Go里的每个线程会从schedule()方法开始运行。这个方法是在g0栈上执行的。g0是专门为调度而生的协程,它有自己的栈空间,用来记录方法调用跳转信息。为什么不直接用普通协程的栈呢?原因有两个:第一,普通协程的栈只能记录业务方法的调用关系;第二,在线程还没拿到协程去运行时,根本没有普通协程栈。所以线程一开始用的就是g0栈——可以理解为在内存的栈区里单独留了一块区域,专门记录线程执行的方法。

Go协程的底层原理及分析

schedule()方法位于runtime/proc.go,我们来简单拆一下:

func schedule() {
    var gp *g  // 新建了一个变量叫gp,就是即将要运行的协程
    ...        // 在全局的各种队列或本地的各种队列尝试去拿一个可以运行的协程
    execute(gp, inheritTime)  // 调用了execute方法
}

execute方法的逻辑如下:

func execute(gp *g, inheritTime bool) {
    ... // 给即将要执行的gp协程里面的一些字段赋了值
    gogo(&gp.sched)  // 调用了gogo方法
}

gogo方法只有函数声明:

func gogo(buf *gobuf)

它是汇编实现的。每个平台都有对应的gogo实现,说明这个方法的逻辑是平台相关的。我们看看runtime/asm_amd64.s中gogo的实现:

// func gogo(buf *gobuf) 
// restore state from Gobuf; longjmp  
TEXT runtime·gogo(SB), NOSPLIT, $0-8  
   MOVQ   buf+0(FP), BX     // gobuf  
   MOVQ   gobuf_g(BX), DX  
   MOVQ   0(DX), CX     // make sure g != nil  
   JMP    gogo<>(SB)

传进来的是一个gobuf指针。它最后又跳转到另一个gogo:

TEXT gogo<>(SB), NOSPLIT, $0  
   get_tls(CX)  
   MOVQ   DX, g(CX)  
   MOVQ   DX, R14       // set the g register  
   MOVQ   gobuf_sp(BX), SP   // 人为插入了一个goexit方法的栈帧
   MOVQ   gobuf_ret(BX), AX  
   MOVQ   gobuf_ctxt(BX), DX  
   MOVQ   gobuf_bp(BX), BP  
   MOVQ   $0, gobuf_sp(BX)   // clear to help garbage collector  
   MOVQ   $0, gobuf_ret(BX)  
   MOVQ   $0, gobuf_ctxt(BX)  
   MOVQ   $0, gobuf_bp(BX)  
   MOVQ   gobuf_pc(BX), BX  // 将我们协程运行到了哪行代码的位置取出来 
   JMP    BX // 跳转到那个位置进行执行

这里的两个关键操作:MOVQ gobuf_sp(BX), SP是在协程栈中插入了一个goexit方法的栈帧;MOVQ gobuf_pc(BX), BX和JMP BX则是跳转到程序计数器指示的位置继续执行。

换句话说,流程是这样的:首先在协程栈里插入goexit的栈帧,然后跳转到g结构体里gobuf指示的程序计数器位置执行。业务方法从do1开始执行,do1调用do2,do2调用do3,依次执行。这个过程中使用的是协程自己的栈(g stack),目的是让每个g结构体都能记录自己的执行现场。业务代码执行到do3后打印文本,然后逐步返回,最终退到goexit方法。

goexit也只有声明:

func goexit(neverCallThisFunction)

看看runtime/asm_amd64.s中的实现:

// The top-most function running on a goroutine  
// returns to goexit+PCQuantum.  
TEXT runtime·goexit(SB),NOSPLIT|TOPFRAME,$0-0  
   BYTE   $0x90  // NOP  
   CALL   runtime·goexit1(SB)    // does not return  
   // traceback from goexit1 must hit code range of goexit   BYTE   $0x90  // NOP

核心是CALL runtime·goexit1(SB)。这个goexit1在runtime/proc.go中:

// Finishes execution of the current goroutine.  
func goexit1() {  
   mcall(goexit0) 
}

mcall会切换到g0栈执行函数。后面的调用关系由g0栈记录:

// mcall switches from the g to the g0 stack and invokes fn(g)
func mcall(fn func(*g))

goexit0的逻辑会重置协程的各种参数,最终重新调用schedule():

// goexit continuation on g0.  
func goexit0(gp *g) {
    ... // 设置协程中的各项参数
    schedule() 
}

这就形成了一个循环:Go的业务逻辑在线程上执行,线程内部有一个循环。在业务方法之外(协程逻辑之外),用g0栈记录函数调用和跳转关系;进入业务方法后,使用协程自己的栈记录调用和跳转信息,同时还记录本地变量。业务方法执行完成后,退回到强行插入的goexit栈帧,进入goexit0切换到schedule,然后继续循环。

Go协程的底层原理及分析

用一个更抽象的图来表示:M表示系统线程,G表示协程,有一个协程队列或池子。线程会一个个将协程取出来执行,执行完后寻找下一个,如此循环。这个单线程循环的逻辑在Go 0.x版本中就已经实现了,那时候Go甚至还没有正式对外发布。

多线程循环(Go 1.0)

现在的CPU是多核多线程的,如果还用单线程版本就太浪费资源了。所以从Go 1.0开始引入多线程循环。以下面两个线程为例,它们执行的逻辑完全一样:从协程队列里获取可执行的协程,送入gogo执行其业务方法,然后退出,再取下一个,循环往复。如果有8个线程,那就跑8个这样的循环。

Go协程的底层原理及分析

但问题也来了:所有线程都从同一个全局协程队列获取,会引发并发冲突。需要保证一个协程不会同时被多个线程取走,所以全局队列必须加锁:

Go协程的底层原理及分析

用抽象图来表示:M是系统线程,G是协程,下方是一个全局队列。每个线程都要从这个全局队列里抓取协程执行,执行完后丢弃,再抓取新的。抢锁成为性能瓶颈:

Go协程的底层原理及分析

总结

线程循环:

  • 操作系统并不知道Goroutine的存在
  • 操作系统线程执行一个调度循环,顺序执行Goroutine
  • 调度循环非常像线程池

问题:

  • 协程顺序执行,无法并发
  • 多线程并发时,会抢夺协程队列的全局锁

关键结论:

  • 协程的本质是一个g结构体
  • g结构体记录了协程栈和PC信息
  • 最简场景下,线程执行标准调度循环,依次执行协程

G-M-P调度模型

本地队列

Go协程的底层原理及分析

为了解决全局锁的竞争问题,Go引入了本地队列:线程不再一次只拿一个协程,而是先拿一批放到本地队列里执行,执行完后再拿下一批。这样能大大降低全局锁的冲突频率。

这个本地队列在Go底层由p结构体表示,路径同样是runtime/runtime2.go:

type p struct {
    m        muintptr   // 指向它服务的那个线程
    // 它是可执行的协程的队列,可以无锁进行访问
    runqhead uint32  // 队列的头
    runqtail uint32  // 队列的尾
    runq     [256]guintptr  // 256长度的指针,每个指针会指向一个g结构体
    runnext guintptr  // 指向下一个可用的协程的指针
}

用图来表示p结构体:m指向它服务的线程,runq是一个可容纳256个可执行协程的队列,runqhead和runqtail分别指向队列的头和尾,runnext指向下一个要执行的协程:

Go协程的底层原理及分析

G-M-P模型

有了G、M和P,就构成了经典的G-M-P调度模型。每个P服务于一个M,职责是维护一个本地协程队列。M每次获取协程时,优先从本地队列取。只有当本地队列都执行完了,M才会尝试从全局队列拿一批放到本地,从而再次获得无锁执行的能力。这就是最朴素的G-M-P模型。

Go协程的底层原理及分析

进一步分析P的作用及底层逻辑

窃取式工作分配机制

  • P是M和G之间的中间层(类似送料器)
  • P持有G的队列,M每次获取G时不用每次都去全局找
  • 大大减少了并发冲突

这部分逻辑在schedule方法中体现,我们之前省略了获取协程的具体过程:

func schedule() {
    var gp *g  // 新建了一个变量叫gp,就是即将要运行的协程
      
    if gp == nil {  
        gp, inheritTime = runqget(_g_.m.p.ptr())  
   }
}

如果本地拿不到协程,就调用runqget方法,从可执行队列获取一个协程。入参_g_是当前正在运行的协程,.m到当前线程结构体,.p到本地队列指针。runqget的逻辑很简单:先看runnext,如果有就返回:

func runqget(_p_ *p) (gp *g, inheritTime bool) {
    next := _p_.runnext
    if next != 0 && _p_.runnext.cas(next, 0) {  
        return next.ptr(), true  
    }
}

如果本地队列也没有了,就尝试从全局队列获取。对应schedule里的findrunnable:

func schedule() {
    var gp *g  // 新建了一个变量叫gp,就是即将要运行的协程
      
    if gp == nil {  
        gp, inheritTime = runqget(_g_.m.p.ptr())  
    }
    if gp == nil {  
        gp, inheritTime = findrunnable()
    }
}

findrunnable尝试从全局队列获取:

func findrunnable() (gp *g, inheritTime bool) {
    if sched.runqsize != 0 {  
        lock(&sched.lock)  
        gp := globrunqget(_p_, 0)  
        unlock(&sched.lock)  
        if gp != nil {  
            return gp, false  
        }  
    }
}

全局队列里也找不到呢?这时候线程不会闲着——它还能去“偷”。findrunnable内部调用了stealWork:

gp, inheritTime, tnow, w, newWork := stealWork(now)  

stealWork的作用是从其他P的队列里偷一些协程过来:

// stealWork attempts to steal a runnable goroutine or timer from any P
func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
    
}

本地没有,全局也没有,就从别的P上偷。这就是窃取式工作分配:

Go协程的底层原理及分析

左侧线程的本地和全局队列都空了,但右侧线程对应的P队列上还有任务,左侧线程就会偷一些过来执行:

Go协程的底层原理及分析

窃取式工作分配:

  • 本地和全局队列都找不到G时
  • 去其他P里“偷”
  • 有效提升线程利用率

新建协程的放置

  • 随机寻找一个P
  • 将新协程放入P的runnext(插队)
  • 若P本地队列满了,再放入全局队列

Go会认为新创建的协程优先级最高,尽量往前排,优先执行。对应的代码在runtime/proc.go的newproc方法中:

func newproc(fn *funcval) {
    newg := newproc1(fn, gp, pc)  // 新建一个协程
    _p_ := getg().m.p.ptr()  
    runqput(_p_, newg, true) // 寻找p,放到本地队列或全局队列
}

如何实现协程并发

G-M-P模型解决了全局锁竞争问题,但还有一个问题没解决:协程顺序执行,无法并发怎么办?

协程饥饿问题

如果有两个线程,各自运行的协程执行时间特别长,就会导致后面时间敏感的协程迟迟无法得到调度:

Go协程的底层原理及分析

解决办法是什么?回到单线程循环模型,如果遇到超长协程,可以引入某种轮换机制:执行一段时间后将这个协程暂停,切换执行后面的协程,防止时间敏感的协程饿死。

Go协程的底层原理及分析

所以,当协程执行到业务轮换点时,保存现场(业务数据和代码执行行数),放到自己的协程结构体中,然后放回队列或进入休眠,让出线程去处理优先级更高的协程:

Go协程的底层原理及分析

用抽象图表示,这就是通过本地队列的小循环来解决本地队列中协程的饥饿问题:

Go协程的底层原理及分析

但问题还没完:如果本地队列的协程循环多次还没执行完,全局队列的协程也会被饿死。所以本地小循环在适当的时候,会从全局队列中拿一些协程进来一起执行:

Go协程的底层原理及分析

回到schedule方法,可以看到这个逻辑的具体实现——每执行61次线程循环,就从全局队列中拿一个协程到本地队列:

func schedule() {
    if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {  
        lock(&sched.lock)  
        gp = globrunqget(_g_.m.p.ptr(), 1)  
        unlock(&sched.lock)  
    }
}

切换的时机

那超大协程的切换点到底是怎么判断的呢?主要有两种情况:

  • 主动挂起(runtime.gopark)
  • 系统调用完成时

主动挂起(runtime.gopark)

Go协程的底层原理及分析

业务方法中调用gopark(),会直接从线程循环跳到schedule(),重新从队列取协程执行:

// 让我们现在正在运行的这个协程进入等待状态
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {  
   ......
   mcall(park_m)  
}

park_m中进行状态维护后调用schedule():

// park continuation on g0.  
func park_m(gp *g) {  
   ......
   schedule()  
}

这里有两个点值得留意:第一,gopark是小写开头的,我们自己不能直接调用,但业务中使用的逻辑如锁、channel、sleep等会主动调用它。第二,调用gopark后协程会进入waiting状态,不能立即被再次调度,比如sleep结束后系统才会把状态改为runnable,调度器才会再次调度它。

系统调用完成时

Go协程的底层原理及分析

业务程序难免会做系统调用,比如检查网络状态、硬件状态。系统调用完成后,协程会在exitsyscall()方法中进行切换:

//go:nosplit  
//go:nowritebarrierrec  
//go:linkname exitsyscall  
func exitsyscall() {
    ......
    Gosched()
    ......
}

Gosched用mcall调用gosched_m:

func Gosched() {  
   checkTimeouts()  
   mcall(gosched_m)  
}

gosched_m调用goschedImpl,最终再次调用schedule():

func goschedImpl(gp *g) {  
   ......
   schedule()  
}

系统调用的细节不用太在意,只需要知道:一旦涉及系统调用,会进入exitsyscall,线程会停止当前协程,返回线程循环的起始位置执行其他协程。这样做的主要目的就是让协程在执行完系统调用后找一个时机切换,防止它一直霸占线程。

总结

  • 协程顺序执行会导致饥饿问题
  • 在协程执行中间将其挂起,切换执行其他协程
  • 可以在完成系统调用时挂起,也可以主动挂起
  • 为防止全局队列饥饿,本地队列会随机从全局队列中抽取协程

抢占式调度

如果有一个超大业务的协程,既不主动挂起也不做系统调用,那该怎么办?

基于协作的抢占式调度

思路

有没有一个方法经常会被调用?可以在那里做点文章?答案是runtime.morestack_noctxt。

回到之前的代码例子,用汇编看看:

func do1() {  
   func2()  
}  
  
func do2() {  
   func3()  // 在这里打个断点
}  
  
func do3() {  
   fmt.Print("dododo")  
}  
  
func main() {  
   go func1()  
   time.Sleep(time.Minute)  
}

执行go build -gcflags -S main.go,会发现do1在调用do2前,do2在调用do3前,都调用了runtime.morestack_noctxt。只要方法中调用了其他方法,编译器都会插入这个调用:

Go协程的底层原理及分析

runtime.morestack_noctxt

runtime.morestack_noctxt的本意是检查协程栈是否有足够空间,不够就扩容,本身和协程调度没有直接关系。但因为每次函数调用时都会被编译器插入,我们可以在这个方法里下个钩子。

标记抢占

怎么钩?系统监控到某个Goroutine运行超过10ms时,认为它是一个大协程,会引发其他协程饥饿。于是系统会把g结构体里的stackguard0字段值设为0xfffffade——这个值就是抢占标志。

抢占

当runtime.morestack_noctxt被调用时,会检查是否被抢占。如果是,就直接回到schedule方法。在runtime/stubs.go中只有声明,实现是汇编的:

func morestack_noctxt()

查看runtime/asm_amd64.s中的实现:

// morestack but not preserving ctxt.  
TEXT runtime·morestack_noctxt(SB),NOSPLIT,$0  
   MOVL   $0, DX  
   JMP    runtime·morestack(SB)

跳转到runtime.morestack,它也由汇编实现,最终调用runtime.newstack:

TEXT runtime·morestack(SB),NOSPLIT,$0-0  
   // Cannot grow scheduler stack (m->g0).  
   ......
   CALL   runtime·newstack(SB)  
   ......

在runtime/stack.go的newstack方法中:

//go:nowritebarrierrec  
func newstack() {
    stackguard0 := atomic.Loaduintptr(&gp.stackguard0)
    preempt := stackguard0 == stackPreempt
    if preempt {  
        // Act like goroutine called runtime.Gosched
       gopreempt_m(gp) // never return  
    }
}

preempt就是抢占的意思。如果stackguard0被标记为0xfffffade,就会调用gopreempt_m,里面最终调用goschedImpl,再调用schedule,回到线程循环起点。

总结一下:每次函数调用都会经过morestack,它会判断协程是否被标记了抢占。如果是,就回到schedule获取新协程执行,从而防止饥饿:

Go协程的底层原理及分析

但这种方法并不完美。

基于信号的抢占式调度

基于协作抢占的缺陷

看下面这个例子:

func do1() {  
   i := 0  
   for true {  
      i++  
   }  
}  
  
func main() {  
   go do1()  
}

这段代码既没有系统调用,也不会主动挂起,甚至不会被标记抢占——因为它没有任何函数调用,编译器不会插入morestack_noctxt。用汇编一看,确实没有。这个协程会永远占据这个线程。如果多开几个这种协程,其他协程全都要饿死。

为了解决这个问题,大神们想出了基于信号的抢占式调度。这里的信号,指的是线程信号。

线程信号

  • 操作系统中,有很多基于信号的底层通信方式
  • 比如Linux系统的SIGPIPE、SIGURG、SIGHUP等
  • 线程可以注册对应信号的处理函数

实现

  • 注册不常用的SIGURG信号的处理函数
  • GC工作时,向目标线程发送信号(GC期间很多线程业务都停了,适合做抢占)
  • 线程收到信号,触发重新调度

这个信号处理函数叫doSigPreempt。当GC发送SIGURG抢占信号后,陷入业务方法执行的线程会立即跳转到doSigPreempt,执行重新调度的循环:

Go协程的底层原理及分析

doSigPreempt在runtime/signal_unix.go中:

// doSigPreempt handles a preemption signal on gp.  
func doSigPreempt(gp *g, ctxt *sigctxt) {
    ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), newpc)
}

核心是调用asyncPreempt,由汇编实现:

// asyncPreempt is implemented in assembly.
func asyncPreempt()

asyncPreempt再调到asyncPreempt2,里面通过mcall调用preemptPark:

func asyncPreempt2() {   
    ......
    mcall(preemptPark) 
    ...... 
}

preemptPark最终调用schedule(),回到线程循环的起点:

func preemptPark(gp *g) {
    schedule()
}

总结

  • 基于系统调用和主动挂起,协程可能无法正常调度
  • 基于协作的抢占式调度:业务主动调用morestack()
  • 基于信号的抢占式调度:强制线程调用doSigPreempt()
本站声明:本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系bd@zhengruan.com
作者最新文章
编程开发
相关文章 更多
codex安装windows教程桌面版完整安装步骤指南
codex安装windows教程桌面版完整安装步骤指南

详解如何在 Windows 电脑上通过 Microsoft Store 安装 Codex 桌面应用,包括启动登录、切换 Codex 模式、关联本地代码项目及终端环境配置的界面操作指引。

github配置ssh key windows
github配置ssh key windows

本文详解如何在Windows环境中使用PowerShell生成SSH密钥对,配置ssh-agent自动加载私钥,并将公钥安全添加至GitHub账户,最后通过git remote set-url切换为SSH地址,解决每次操作需输入账号密码的问题。

backdrop和background区别
backdrop和background区别

深入解析backdrop与background在网页开发及视觉设计中的区别。通过CSS代码示例,展示如何正确使用background设置元素背景,以及利用backdrop-filter创建弹窗遮罩模糊效果,避免概念混用。

windsurf ide download Windows版安装教程
windsurf ide download Windows版安装教程

详解 Windsurf IDE 在 Windows 系统的下载来源、安装步骤及首次启动界面。指导用户如何导入编辑器设置、打开项目文件夹及使用终端与 AI 功能,适合初次接触该工具的开发者阅读。

ServBay安装配置详细教程与操作指南
ServBay安装配置详细教程与操作指南

新手入门 ServBay 本地开发环境,详解安装包下载、Dashboard 状态监控、Packages 组件安装、Services 服务控制及 Websites 项目配置。掌握 .servbay.config 版本管理与日志排查技巧,快速搭建稳定的 PHP、Node.js 等多语言开发环境。

codekit环境配置指南从安装到环境搭建完整教程
codekit环境配置指南从安装到环境搭建完整教程

详解 CodeKit 在 macOS 下的安装步骤、项目导入方法、Sass与JavaScript编译设置及浏览器自动刷新功能,助您快速搭建高效的前端开发环境。

codex安装windows 命令行完整操作教程
codex安装windows 命令行完整操作教程

详解Windows环境下安装OpenAI Codex CLI的步骤,包括WSL环境检查、Node.js/npm配置、npm全局安装命令及首次启动验证,适合开发者快速上手。

NativeRest环境配置要求与完整操作教程
NativeRest环境配置要求与完整操作教程

学习如何配置 NativeRest REST API 客户端。涵盖 Windows/macOS/Linux 安装后的工作区创建、环境变量管理、请求编辑及响应查看步骤,帮助开发者快速完成基础环境搭建与连通性测试。

CSS设置透明度的注意事项有哪些?opacity属性详解
CSS设置透明度的注意事项有哪些?opacity属性详解

深入解析CSS中设置透明度的核心属性opacity,剖析子元素继承、事件穿透、层叠上下文等关键注意事项,并提供与rgba、hsla的实用选型对比。

flutter页面传值到后台的方法及示例代码
flutter页面传值到后台的方法及示例代码

flutter页面传值到后台的完整实现方法及示例代码,帮助读者快速掌握相关技术要点。

查看更多
精品专题 更多
装机必备
装机必备

正软商城装机必备专区,精选办公、浏览器、安全防护、影音播放、压缩解压、设计创作和系统工具等电脑常用正版软件,帮助用户快速完成新电脑软件配置。

Windows
Windows

正软商城Windows软件专区,汇集适用于Windows电脑的办公、设计、安全防护、影音播放、开发工具和系统优化软件,提供软件介绍、系统要求、正版授权及购买下载服务。

PDF教程
PDF教程

正软商城PDF教程频道提供PDF编辑、转换、合并、拆分、压缩及格式处理方法,同时介绍常用PDF软件和工具的使用技巧。

Mac软件 更多
WPS Office macOS版
WPS Office macOS版
macOS

WPS Office是一款面向macOS用户的综合办公套件,将文字处理、电子表格、演示文稿、PDF工具、模板与云协作整合在同一应用中。它兼容常见Microsoft Office格式,支持Intel及Apple芯片Mac,基础功能可免费使用,

Shapr3D macOS版
Shapr3D macOS版
Mac

Shapr3D是一款面向工业设计、机械工程、建筑概念和三维打印工作流的CAD软件。Mac版采用Parasolid建模内核,支持草图约束、实体建模、工程图、可视化渲染及常见CAD格式交换,并可通过账户在多台设备之间同步项目。

REAPER macOS版
REAPER macOS版
Mac

REAPER是Cockos开发的数字音频工作站,提供多轨音频与MIDI录制、剪辑、处理、混音和母带制作工具。Mac版兼容Intel与Apple芯片,支持AU、VST、VST3、CLAP等插件格式,并提供高度可定制的工作流程。

WINDOWS 更多
Adobe Photoshop Windows版
Adobe Photoshop Windows版
Windows

Adobe Photoshop 是面向摄影师、设计师和内容创作者的专业图像处理工具,可在 Windows 上完成照片修饰、选区抠图、图层合成、文字排版、绘画及生成式编辑。软件采用订阅授权,由 Creative Cloud 管理安装、更新与许

Adobe InDesign Windows版
Adobe InDesign Windows版
Windows

Adobe InDesign 是面向印刷与数字出版的专业页面排版工具,可制作书籍、杂志、宣传册、海报、交互式 PDF 和电子书。它提供精细的文字样式、多页面管理、印前检查及多格式输出能力,并能与 Photoshop、Illustrator

Adobe Illustrator Windows版
Adobe Illustrator Windows版
Windows

Adobe Illustrator 是用于绘制矢量图形、插画、图标、徽标和版式的专业设计工具。它以路径和锚点为核心,作品放大后仍能保持清晰,并提供画板、文字、渐变、图像描摹及精确变换等功能,适合品牌视觉、印刷物料、数字界面和商业插画创作。