Golang-GMP 万字洗髓经

基础概念

从线程到协程

线程(Thread)与协程(Coroutine)是并发编程中的经典概念:

  • 线程是操作系统内核视角下的最小调度单元,其创建、销毁、切换、调度都需要由内核参与
  • 协程又称为用户态线程,是用户程序对线程概念的二次封装,和线程为多对一的关系,在逻辑意义上属于更细粒度的调度单元,其调度过程由用户态闭环完成,无需内核的介入

总的来说,线程更加简单直观,天然契合操作系统系统调度模型,而协程是用户态下二次加工的产物,需要引入额外的复杂度,但是相对于线程而言有着更轻粒度和更小的开销

从协程到Goroutine

Golang是一门天然支持协程的语言,Goroutine是其对协程的本土化实现,并且在原生协程的基础上做了很大的优化改进

当我们聊到Goroutine,需要明白这不是一个能被单独拆解的概念,其本身是强依附于GMP体系而生的

通过GMP架构的建设,使得Goroutine相比于原生协程具备着如下核心优势:

  • G 与 P、M之间可以动态结合,整个调度过程有着很高的灵活性
  • G 栈空间大小可以做动态扩缩,既能做到使用方便,也尽可能的节约资源

此外,Golang中完全屏蔽了线程的概念,围绕着GMP打造的一系列并发工具都以 G 为并发粒度

GMP架构

GMP = Goroutine + Machine + Processor 下面我们对这三个核心组件展开介绍:

Groutine

  • G 即Goroutine,是Golang中对协程的抽象
  • G 有自己的运行栈、生命周期状态、以及执行的任务函数(用户通过go func指定)
  • G 需要绑定在 M 上执行,在 G 视角中,可以将 M 理解为它的CPU

我们可以把GMP理解为一个任务调度系统,那么 G 就是这个系统中所谓的 "任务" , 是一种需要被分配和执行的资源

Machine

  • M 即 Machine,是Golang中对线程的抽象
  • M 需要和 P 进行结合,从而进入到GMP的调度体系之中
  • M 的运行目标始终在 G0 和 G 之间进行切换,当运行 G0 时执行的是 M 的调度流程,负责寻找合适的 "任务",也就是G,当运行G的时候,执行的是 M 获取到的 "任务",也就是用户通过 go func 启动的Goroutine

当我们把GMP理解为一个任务调度系统的时候,那么 M 就是这个系统中的 "引擎",当 M 和 P 结合后,就限定了 "引擎" 的运行是围绕着GMP这条轨道进行的,使得 "引擎" 运行着两个周而复始、不断交替的步骤 --- 寻找任务(执行 G0) 和 执行任务(执行 G)

Processor

  • P 即 Processor,是Golang中的调度器
  • P 可以理解为 M 的执行代理,M 需要与 P 绑定后,才会进入到 GMP 的调度模式中,因此 P 的数量决定了 G 最大并行数量
  • P 是 G 的存储容器,其自带一个本地 G 的队列(local run queue),承载着一系列等待被调度的 G

当我们把 GMP 理解为一个任务调度系统,那么 P 就是这个系统的 "中枢",当其和作为 "引擎"的 M 结合后,才会引导 M 进入GMP的运行模式,同时 P 也是这个系统中存储 "任务" 的 "容器",为 M 提供用于执行的任务资源

结合上图可以看到,承载 G 的容器分为两个部分:

  • P 的本地队列 lrq(local run queue) :这是每个 P 私有的 G 队列,通常由 P 自行访问,并发竞争情况较少,因此设计为无锁化结构,通过CAS操作访问

当 M 与 P 结合后,不论是创建 G 还是获取 G,都优先从私有的 lrq 中获取,从而尽可能的减少并发竞争的行为,这里聊到并发情况较少,但并非完全没有,是因为还可能存在来自其他 P 的窃取行为(Steal Work)

  • 全局队列 grq(global run queue) :是全局调度模块中的全局共享 G 队列,作为当某个 lrq 不满足条件时的备用容器,因为不同的 M 都有可能访问grq,因此并发竞争比较激烈,访问前需要加全局锁

介绍完 G 的存储容器设计后,接下来聊聊将 G 放入容器和取出容器的流程设计:

  • put G:当某个 G 中通过 go func(...){...}操作创建子Goroutine的时候,会现场将子G添加到当前所在的 P 的 lrq 中(无锁化),如果lrq满了,则会将 G 追加到 grq 中(全局锁),此处采取的思路是 "就近原则"

  • get G:GMP 调度流程中,M 和 P 结合后,运行的G0会不断的寻找合适的G用于执行,此时会采取 "负载均衡" 的思路:

    • 优先从当前的 P 的 lrq 中获取 G(无锁化 - CAS)
    • 从全局的 grq 中获取 G(全局锁)
    • 取 IO 就绪的 G (netpoll机制)
    • 从其他 P 的 lrq 中窃取 G (无锁化 - CAS)

在 get 流程中,还有一个细节需要注意,就是在 G0 每经过61次调度循环后,下一次会在处理 lrq 之前优先处理一次 grq,避免因为 lrq 过于忙碌而致使 grq 陷入饥荒的状态

GMP生态

在Golang中已经完全屏蔽了线程的概念,将Goroutine统一为整个语言层面的并发粒度,并遵循着GMP的秩序进行运作

如果把GoLang程序比做一个人的话,那么GMP就是这个人的骨架,支持着他的直立与行走

而在此基础上,紧密围绕着GMP理念打造设计的一系列工具、模块则像是在骨架之上填充的血肉,依附于这套框架存在

我们看一下其中的几个经典案例:

  • 内存管理

GoLang的内存管理模块主要继承自 TCMalloc(Thread - Caching - Malloc)的设计思路,其中由契合GMP模型做了因地制宜的适配改造,为每个 P 准备一份私有的高速缓存 -- mcache,能够无锁化的完成一部分 P 本地的内存分配

  • 并发工具

在GoLang中的并发工具(例如Mutex、通道Channel等)均契合GMP进行适配改造,保证在执行阻塞操作时,会将阻塞粒度限制在Groutine粒度,而非 M Thread粒度,使得阻塞与唤醒操作都属于用户态行为,无需内核的介入,同时一个 G 的阻塞也完全不会影响 M下的其他 G 的运行

  • IO多路复用

在设计IO模型的时候,GoLang采用了Linux系统提供的epoll多路复用技术,然而因为epoll_wait操作引起的 M (Thread)粒度的阻塞

GoLang专门设计了一套netpoll机制,使用用户态的gopark指令实现阻塞操作,使用epoll_wait集合goready指令实现唤醒操作

将IO行为全部控制在了Goroutine粒度,很好的契合了GMP调度体系

GMP详细设计

文字性的理论描述难免过于空洞,G、M、P 并不是抽象的概念,事实上三者在源码中都有着具体的实现,定义代码均位于 runtime/runtime2.go. 下面就从具体的源码中寻求原理内容的支撑和佐证.

G 详设

g (goroutine)的类型声明如下,其中包含如下核心成员字段:

  • stack:g 的栈空间
  • stackguard0:栈空间保护区边界. 同时也承担了传递抢占标识的作用(5.3 小节中会进行呼应)
  • panic:g 运行函数中发生的 panic
  • defer:g 运行函数中创建的 defer 操作(以 LIFO 次序组织)
  • m:正在执行 g 的 m(若 g 不为 running 状态,则此字段为空)
  • atomicstatus:g 的生命周期状态(具体流转规则参见上图)
Go 复制代码
// 一个 goroutine 的具象类
type g struct{
    // g 的执行栈空间
    stack       stack   
    /*
        栈空间保护区边界,用于探测是否执行栈扩容
        在 g 超时抢占过程中,用于传递抢占标识
    */
    stackguard0 uintptr
    // ...

    // 记录 g 执行过程中遇到的异常    
    _panic    *_panic 
    // g 中挂载的 defer 函数,是一个 LIFO 的链表结构
    _defer    *_defer 

    // g 从属的 m
    m         *m      
    // ...  
    /*
        g 的状态
        // g 实例刚被分配还未完成初始化
        _Gidle = iota // 0

        // g 处于就绪态.  可以被调度 
        _Grunnable // 1

        // g 正在被调度运行过程中
        _Grunning // 2

        // g 正在执行系统调用
        _Gsyscall // 3

        // g 处于阻塞态,需要等待其他外部条件达成后,才能重新恢复成就绪态
        _Gwaiting // 4

        // 生死本是一个轮回. 当 g 调度结束生命终结,或者刚被初始化准备迎接新生前,都会处于此状态
        _Gdead // 6
    */
    atomicstatus uint32
    // ...
    // 进入全局队列 grq 时指向相邻 g 的 next 指针
    schedlink    guintptr
    // ...
}

M 详设

m(machine)是 go 对 thread 的抽象,其类定义代码中包含如下核心成员:

  • g0:执行调度流程的特殊 g(不由用户创建,是与 m 一对一伴生的特殊 g,为 m 寻找合适的普通 g 用于执行)
  • gsignal:执行信号处理的特殊 g(不由用户创建,是与 m 一对一伴生的特殊 g,处理分配给 m 的 signal)
  • curg:m 上正在执行的普通 g(由用户通过 go func(){...} 操作创建)
  • p:当前与 m 结合的 p
go 复制代码
type m struct{
    // 用于调度普通 g 的特殊 g,与每个 m 一一对应
    g0      *g     
    // ...
    // m 的唯一 id
    procid        uint64
    // 用于处理信号的特殊 g,与每个 m 一一对应
    gsignal       *g              
    // ...
    // m 上正在运行的 g
    curg          *g       
    // m 关联的 p
    p             puintptr 
    // ...
    // 进入 schedt midle 链表时指向相邻 m 的 next 指针 
    schedlink     muintptr
    // ...
}

此处暂时将 gsignal按下不表,我们可以将 m 的运行目标划分为 g0 和 g ,两者是始终交替进行的:g0 就类似于引擎中的调度逻辑,检索任务列表寻找需要执行的任务;g 就是由 g0 找到并分配给 m 执行的一个具体任务.

P 详设

p (processor)是 gmp 中的调度器,其类定义代码中包含如下核心成员字段:

  • status:p 生命周期状态
  • m:当前与 p 结合的 m
  • runq:p 私有的 g 队列------local run queue,简称 lrq
  • runqhead:lrq 中队首节点的索引
  • runqtail:lrq 中队尾节点的索引
  • runnext:lrq 中的特定席,指向下一个即将执行的 g
go 复制代码
type p struct{
    id          int32
    /*
        p 的状态
        // p 因缺少 g 而进入空闲模式,此时会被添加到全局的 idle p 队列中
        _Pidle = iota // 0

        // p 正在运行中,被 m 所持有,可能在运行普通 g,也可能在运行 g0
        _Prunning // 1

        // p 所关联的 m 正在执行系统调用. 此时 p 可能被窃取并与其他 m 关联
        _Psyscall // 2

        // p 已被终止
        _Pdead // 4
    */
    status      uint32// one of pidle/prunning/...
    // 进入 schedt pidle 链表时指向相邻 p 的 next 指针
    link        puintptr        
    // ...
    // p 所关联的 m. 若 p 为 idle 状态,可能为 nil
    m           muintptr   // back-link to associated m (nil if idle)


    // lrq 的队首
    runqhead uint32
    // lrq 的队尾
    runqtail uint32
    // q 的本地 g 队列------lrq
    runq     [256]guintptr
    // 下一个调度的 g. 可以理解为 lrq 中的特等席
    runnext guintptr
    // ...
}

schedt详设

schedt 是全局共享的资源模块,在访问前需要加全局锁:

  • lock:全局维度的互斥锁
  • midle:空闲 m 队列
  • pidle:空闲 p 队列
  • runq:全局 g 队列------global run queue,简称 grq
  • runqsize:grq 中存在的 g 个数
go 复制代码
// 全局调度模块
type schedt struct{
    // ...
    // 互斥锁
    lock mutex

    // 空闲 m 队列
    midle        muintptr // idle m's waiting for work
    // ...
    // 空闲 p 队列
    pidle      puintptr // idle p's
    // ...

    // 全局 g 队列------grq
    runq     gQueue
    // grq 中存量 g 的个数
    runqsize int32
    // ...
}

之所以存在 midle 和 pidle 的设计,就是为了避免 p 和 m 因缺少 g 而导致 cpu 空转. 对于空闲的 p 和 m,会被集成到空闲队列中,并且会暂停 m 的运行

调度原理

本章要和大家聊的流程是"调度",所谓调度,指的是一个由用户通过 go func(){...} 操作创建的 g 是如何被 m 上的g0获取并执行的

所以简单来说,调度就是由 g0 -> g 的流转过程,因为流转的过程是由 m 上运行的 g0 主动发起的,无需第三方进行干预

main函数与g

main 函数作为整个 go 程序的入口是比较特殊的存在,它是由 go 程序全局唯一的 m0(main thread)执行的,对应源码位于 runtime.proc.go:

go 复制代码
//go:linkname main_main main.main
func main_main()

// The main goroutine.
func main(){
    // ...
    // 获取用户声明的 main 函数
    fn := main_main 
    // 执行用户声明的 main 函数
    fn()
    // ...
}

除了 main 函数这个特例之外,所有用户通过 go func(){...} 操作启动的 goroutine,都会以 g 的形式进入到 gmp 架构当中.

go 复制代码
func handle() {
    // 异步启动 goroutine
    go func(){
        // do something ...
    }()
}

在上述代码中,我们会创建出一个 g 实例的创建,将其置为就绪状态,并添加到就绪队列中:

  • 如果当前 p 对应本地队列 lrq 没有满,则添加到 lrq 中;
  • 如果 lrq 满了,则加锁并添加到全局队列 grq 中.

g0 与 g

在每个 m 中会有一个与之伴生的 g0,其任务就是不断寻找可执行的 g. 所以对一个 m 来说,其运行周期就是处在 g0 与 g 之间轮换交替的过程中.

Go 复制代码
type m struct {
    // 用于寻找并调度普通 g 的特殊 g,与每个 m 一一对应
    g0      *g     
    // ...
    // m 上正在运行的普通 g
    curg          *g       
    // ...
}

在 m 运行中,能够通过几个桩方法实现 g0 与 g 之间执行权的切换:

  • g -> g0:mcall、systemstack
  • g0 -> g:gogo

对应方法声明于 runtime/stubs.go 文件中:

go 复制代码
// 从 g 切换至 g0 执行. 只允许在 g 中调用
func mcall(fn func(*g))

// 在普通 g 中调用时,会切换至 g0 压栈执行 fn,执行完成后切回到 g
func systemstack(fn func())

// 从 g0 切换至 g 执行. gobuf 包含 g 运行上下文信息
func gogo(buf *gobuf)

而从 g0 视角出发来看,其在先后经历了两个核心方法后,完成了 g0 -> g 的切换:

  • schedule:调用 findRunnable 方法,获取到可执行的 g
  • execute:更新 g 的上下文信息,调用 gogo 方法,将 m 的执行权由 g0 切换到 g

上述方法均实现于 runtime/proc.go 文件中:

go 复制代码
// 执行方为 g0
func schedule(){
    // 获取当前 g0 
    _g_ := getg()
    // ...

top:
    // 获取当前 p
    pp := _g_.m.p.ptr()
    // ...
    /*
         核心方法:获取需要调度的 g
              - 按照优先级,依次取本地队列 lrq、取全局队列 grq、执行 netpoll、窃取其他 p lrq
              - 若没有合适 g,则将 p 和 m block 住并添加到空闲队列中
    */
    gp, inheritTime, tryWakeP := findRunnable()// blocks until work is available

    // ...
    // 执行 g,该方法中会将执行权由 g0 -> g
    execute(gp, inheritTime)
}

// 执行给定的 g. 当前执行方还是 g0,但会通过 gogo 方法切换至 gp
func execute(gp *g, inheritTime bool){
    // 获取 g0
    _g_ := getg()
    // ...
        /*
            建立 m 和 gp 的关系
            1)将 m 中的 curg 字段指向 gp
            2)将 gp 的 m 字段指向当前 m
        */
    _g_.m.curg = gp
    gp.m = _g_.m

    // 更新 gp 状态 runnable -> running
    casgstatus(gp,_Grunnable,_Grunning)
    // ...
    // 设置 gp 的栈空间保护区边界
    gp.stackguard0 = gp.stack.lo +_StackGuard
    // ...
    // 执行 gogo 方法,m 执行权会切换至 gp
    gogo(&gp.sched)
}

find g

在调度流程中,最核心的步骤就在于,findRunnable 方法中如何按照指定的策略获取到可执行的 g.

主流程:

findRunnable 方法声明于 runtime/proc.go 中,其核心步骤包括::

  • 每经历 61 次调度后,需要先处理一次全局队列 grq(globrunqget------加锁),避免产生饥饿;
  • 尝试从本地队列 lrq 中获取 g(runqget------CAS 无锁)
  • 尝试从全局队列 grq 获取 g(globrunqget------加锁)
  • 尝试获取 io 就绪的 g(netpoll------非阻塞模式)
  • 尝试从其他 p 的 lrq 窃取 g(stealwork)
  • double check 一次 grq(globrunqget------加锁)
  • 若没找到 g,将 p 置为 idle 状态,添加到 schedt pidle 队列(动态缩容)
  • 确保留守一个 m,监听处理 io 就绪的 g(netpoll------阻塞模式)
  • 若 m 仍无事可做,则将其添加到 schedt midle 队列(动态缩容)
  • 暂停 m(回收资源)

让渡设计

所谓"让渡",指的是当 g 在 m 上运行时,主动让出执行权,使得 m 的运行对象重新回到 g0,即由 g -> g0 的流转过程.

"让渡"和"调度"一样,也属于第一视角下的转换,该流转过程是由 m 上运行的 g 主动发起的,而无需第三方角色的干预.

结束让渡

当 g 执行结束时,会正常退出,并将执行权切换回到 g0.

首先,g 在运行结束时会调用 goexit1 方法中,并通过 mcall 指令切换至 g0,由 g0 调用 goexit0 方法,并由 g0 执行下述步骤:

  • 将 g 状态由 running 更新为 dead
  • 清空 g 中的数据
  • 解除 g 和 m 的关系
  • 将 g 添加到 p 的 gfree 队列以供复用
  • 调用 schedule 方法发起新一轮调度

如何理解新一轮的调度?往上翻到 main函数与g 的地方就是新一轮调度

go 复制代码
// goroutine 运行结束. 此时执行方是普通 g
func goexit1(){
    // 通过 mcall,将执行方转为 g0,调用 goexit0 方法
    mcall(goexit0)
}

// 此时执行方为 g0,入参 gp 为已经运行结束的 g
func goexit0(gp *g){
    // 获取 g0
    _g_ := getg()
    // 获取对应的 p
    _p_ := _g_.m.p.ptr()

    // 将 gp 的状态由 running 更新为 dead
    casgstatus(gp,_Grunning,_Gdead)
    // ...

    // 将 gp 中的内容清空
    gp.m =nil
    // ...
    gp._defer =nil// should be true already but just in case.
    gp._panic =nil// non-nil for Goexit during panic. points at stack-allocated data.
    // ...
    // 将 g 和 p 解除关系
    dropg()
    // ...
    // 将 g 添加到 p 的 gfree 队列中
    gfput(_p_, gp)
    // ...
    // 发起新一轮调度流程
    schedule()
}

主动让渡

主动让渡指的是由用户手动调用 runtime.Gosched 方法让出 g 所持有的执行权. 在 Gosched 方法中,会通过 mcall 指令切换至 g0,并由 g0 执行 gosched_m 方法,其中包含如下步骤:

  • 将 g 由 running 改为 runnable 状态
  • 解除 g 和 m 的关系
  • 将 g 直接添加到全局队列 grq 中
  • 调用 schedule 方法发起新一轮调度
Go 复制代码
// 主动让渡出执行权,此时执行方还是普通 g
func Gosched() {
    // ...
    // 通过 mcall,将执行方转为 g0,调用 gosched_m 方法
    mcall(gosched_m)
}

// 将 gp 切换回就绪态后添加到全局队列 grq,并发起新一轮调度
// 此时执行方为 g0
func gosched_m(gp *g){
    // ...
    goschedImpl(gp)
}

func goschedImpl(gp *g){
    // ...
    // 将 g 状态由 running 改为 runnable 就绪态
    casgstatus(gp,_Grunning,_Grunnable)
    // 解除 g 和 m 的关系
    dropg()
    // 将 g 添加到全局队列 grq
    lock(&sched.lock)
    globrunqput(gp)
    unlock(&sched.lock)
    // 发起新一轮调度
    schedule()
}

阻塞让渡

阻塞让渡指的是 g 在执行过程中所依赖的外部条件没有达成,需要进入阻塞等待的状态(waiting),直到条件达成后才能完成将状态重新更新为就绪态(runnable).

Golang 针对 mutex、channel 等并发工具的设计,在底层都是采用了阻塞让渡的设计模式,具体执行的方法是位于 runtime/proc.go 的 gopark 方法:

  • 通过 mcall 从 g 切换至 g0,并由 g0 执行 park_m 方法
  • g0 将 g 由 running 更新为 waiting 状态,然后发起新一轮调度

此处需要注意,在阻塞让渡后,g 不会进入到 lrq 或 grq 中,因为 lrq/grq 属于就绪队列. 在执行 gopark 时,使用方有义务自行维护 g 的引用,并在外部条件就绪时,通过 goready 操作将其更新为 runnable 状态并重新添加到就绪队列中.

go 复制代码
// 此时执行方为普通 g
func gopark(unlockf func(*g, unsafe.Pointer)bool,lockunsafe.Pointer, reason waitReason, traceEv byte, traceskip int){
    // 获取 m 正在执行的 g,也就是要阻塞让渡的 g
    gp := mp.curg
    // ...
    // 通过 mcall,将执行方由普通 g -> g0
    mcall(park_m)
}

// 此时执行方为 g0. 入参 gp 为需要执行 park 的普通 g
func park_m(gp *g){
    // 获取 g0 
    _g_ := getg()

    // 将 gp 状态由 running 变更为 waiting
    casgstatus(gp,_Grunning,_Gwaiting)
    // 解绑 g 与 m 的关系
    dropg()

    // g0 发起新一轮调度流程
    schedule()
}

与 gopark 相对的,是用于唤醒 g 的 goready 方法,其中会通过 systemstack 压栈切换至 g0 执行 ready 方法------将目标 g 状态由 waiting 改为 runnable,然后添加到就绪队列中.

go 复制代码
// 此时执行方为普通 g. 入参 gp 为需要唤醒的另一个普通 g
func goready(gp *g, traceskip int) {
    // 调用 systemstack 后,会切换至 g0 亚展调用传入的 ready 方法. 调用结束后则会直接切换回到当前普通 g 继续执行. 
    systemstack(func() {
        ready(gp, traceskip, true)
    })

    // 恢复成普通 g 继续执行 ...
}

// 此时执行方为 g0. 入参 gp 为拟唤醒的普通 g
func ready(gp *g, traceskip int, next bool){
    // ...

    // 获取当前 g0
    _g_ := getg()
    // ...
    // 将目标 g 状态由 waiting 更新为 runnable
    casgstatus(gp,_Gwaiting,_Grunnable)
    /*
        1) 优先将目标 g 添加到当前 p 的本地队列 lrq
        2)若 lrq 满了,则将 g 追加到全局队列 grq
    */
    runqput(_g_.m.p.ptr(), gp,next)
    // 如果有 m 或 p 处于 idle 状态,将其唤醒
    wakep()
    // ...
}

抢占设计

最后是关于"抢占"的流程介绍,抢占和让渡有相同之处,都表示由 g->g0 的流转过程,但区别在于,让渡是由 g 主动发起的(第一人称),而抢占则是由外力干预(sysmon thread)发起的(第三人称).

监控线程

在 go 程序运行时,会启动一个全局唯一的监控线程------sysmon thread,其负责定时执行监控工作,主要包括:

  • 执行 netpoll 操作,唤醒 io 就绪的 g
  • 执行 retake 操作,对运行时间过长的 g 执行抢占操作
  • 执行 gcTrigger 操作,探测是否需要发起新的 gc 轮次

执行抢占逻辑的 retake 方法是我们研究的重点,其中根据抢占目标和状态的不同,又可以分为系统调用抢占和运行超时抢占.

  • 系统调用抢占

    系统调用是 m(thread)粒度的,在执行期间会导致整个 m 暂时不可用,所以此时的抢占处理思路是,将发起 syscall 的 g 和 m 绑定,但是解除 p 与 m 的绑定关系,使得此期间 p 存在和其他 m 结合的机会.

    当这个 m 执行完 g 后,检查 syscall 期间,p 是否未和其他 m 结合,如果是的话,直接复用 p,继续执行 g

  • 运行超时抢占

    除了系统调用抢占之外,当 sysmon thread 发现某个 g 执行时间过长时,也会对其发起抢占操作.

    检测到哪些 p 中运行一个 g 的时长超过了 10 ms,然后对其发起抢占操作

相关推荐
jack_yin24 分钟前
Telegram DeepSeek Bot 管理平台 发布啦!
后端
小码编匠31 分钟前
C# 上位机开发怎么学?给自动化工程师的建议
后端·c#·.net
库森学长32 分钟前
面试官:发生OOM后,JVM还能运行吗?
jvm·后端·面试
转转技术团队33 分钟前
二奢仓店的静默打印代理实现
java·后端
蓝易云33 分钟前
CentOS 7上安装X virtual framebuffer (Xvfb) 的步骤以及如何解决无X服务器的问题
前端·后端·centos
wenzhangli739 分钟前
从源码到思想:OneCode框架模块化设计如何解决前端大型应用痛点
架构·前端框架
秋千码途1 小时前
小架构step系列07:查找日志配置文件
spring boot·后端·架构
蓝倾2 小时前
京东批量获取商品SKU操作指南
前端·后端·api
开心就好20252 小时前
WebView远程调试全景指南:实战对比主流工具优劣与适配场景
后端
用户21411832636023 小时前
AI 一键搞定!中医药科普短视频制作全流程
后端