go 高并发的工作原理 Goroutine

为什么要有协程,线程不好吗

go 语言的协程和其他编程语言的协程有区别

  • 进程
    • 占据内存空间
    • 进程是操作系统的最小单位(一个进程就是一个应用或者一个程序)
  • 线程
    • 占用 cpu 的处理能力(cpu 的时间)
    • 一个进程可以有多个线程
    • 线程使用进程的内存,线程之间共享内存
    • 线程的调度需要由系统进行,开销比较大
    • 缺点:
      • 线程本身占用资源大
      • 线程操作开销大(状态转换)
      • 线程切换开销大(在不同线程之间切换)
  • 协程
    • 将一段程序的运行状态打包,在线程上调度(单线程)
    • 协程也要在线程上运行
    • 线程时协程的资源,协程使用线程这个资源
    • 优势
      • 快速调度(线程调度需要操作系统,协程调度不需要)
      • 超高并发

他们之间的关系大概可以理解为:

  • 内存 -> 园区
  • 进程 -> 工厂的厂房
  • 线程 -> 工厂的生产线

本来生产多种产品需要多条生产线,但是协程的作用是在一条生成线上生产多种产品

协程的本质是什么

协程在 go 中本质是一个 g 结构体,在 runtime/runtime2.go 中定义

go 复制代码
type g struct {
  stack       stack
  sched     gobuf
  atomicstatus atomic.Uint32
  goid         uint64
  // ...
}
  • stack 是协程的协程栈

    • 类型是一个 stack 的结构体

    • lo 是低指针

    • hi 是高指针

      go 复制代码
      type stack struct {
        lo uintptr
        hi uintptr
      }
  • sched

    • 类型是 gobuf
      • spStackPointer,栈指针,它记录的是当前栈执行到地址(指向方法或者变量的栈帧)

      • pcProgramCounter,程序计数器,它记录是程序运行到了哪一行代码

        go 复制代码
        type gobuf struct {
          sp   uintptr
          pc   uintptr
          // ...
        }
  • atomicstatus 表示协程的状态

  • goid 协程的 id

协程底层原理如图所示:

由上面可以知道:

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

go 中线程 m 结构体,是用来描述操作系统中的线程,go 操作不了,在 runtime/runtime2.go 中定义

go 复制代码
type m struct {
  g0   *g // goroutine with scheduling stack
  curg *g // current running goroutine
  id   int64

  mOS
}
  • g0go 启动时的第一个协程,操作调度器
  • curg 是现在正在运行的协程
  • id 是线程的 id
  • mOS 是用来记录每种操作系统对于线程描述的额外信息

协程是如何执行的

go 中每个线程都在执行 schedule() -> execute -> gogo -> 业务方法 -> goexit() -> schedule() 这样一个循环

通过这几个方法调用业务方法,业务方法是代码中协程需要跑的方法,如图所示:

go 中每个线程是从 schedule 开始调度,schedule 开始是在 g0 stack 中执行的

g0 stack 是给 g0 协程在栈空间中分配的内存,用来记录函数跳转的信息

为什么不用普通协程的栈记录呢?

  1. 普通协程的栈只能记录业务方法的函数调用
  2. 当线程还没有拿到协程时,是不知道普通协程栈的

schedule

schedule 方法在 runtime/proc.go 中定义

schedule 方法是在全局的协程的队列中拿到一个可以执行的协程

找到后调用 execute 方法

go 复制代码
func schedule() {
  // ...
top:
  // 即将要运行的协程
  gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available

  if sched.disable.user && !schedEnabled(gp) {
    // ...
  }

  if gp.lockedm != 0 {
    // ..
  }

  execute(gp, inheritTime)
}

execute

execute 方法在 runtime/proc.go 中定义

exectue 方法给要执行的协程赋了一些值

最后调用 gogo 方法

go 复制代码
func execute(gp *g, inheritTime bool) {
  mp := getg().m

  mp.curg = gp
  gp.m = mp
  casgstatus(gp, _Grunnable, _Grunning)
  gp.waitsince = 0
  gp.preempt = false
  gp.stackguard0 = gp.stack.lo + stackGuard
  // ...

  gogo(&gp.sched)
}

gogo

gogo 只有一个函数声明,在 runtime/stubs.go 文件中,说明这是一个由汇编实现的方法

我们找到 asm_amd64.s 文件中的 gogo 方法

asm_amd64.s 文件是 windowslinux 下的 go 代码的汇编文件,m3 芯片的 mac 电脑是 asm_arm64.s 文件

我们可以看到它的入参是 gobuf 结构体的指针

下面代码最核心的两句是:

  1. MOVQ gobuf_sp(BX), SP
    • gobuf 中取出 sp 赋值给 SP 寄存器(作用是往协程栈中插入了一个栈帧,这个栈帧是 goexit 方法)
    • goexit 方法的栈帧不是调用的,而是人为的插入进来,为的是协程退出时,能够正常的退到 goexit 方法
  2. MOVQ gobuf_pc(BX), BXJMP BX
    1. 跳转线程正在执行的计数器
    2. gobuf_pc 记录的是协程执行到的位置,JMP 指令是跳转到这个位置
    3. 执行的时候用的是协程自己的协程栈(作用是记录自己协程的相关信息)
s 复制代码
// 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)

TEXT gogo<>(SB), NOSPLIT, $0
  get_tls(CX)
  MOVQ	DX, g(CX)
  MOVQ	DX, R14		// set the g register
  // 往协程栈中插入了一个栈帧,这个栈帧是 goexit 方法
  MOVQ	gobuf_sp(BX), SP	// restore SP
  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

goexit

goexit 只有一个函数声明,在 runtime/stubs.go 文件中,说明这是一个由汇编实现的方法

我们找到 asm_amd64.s 文件中的 goexit 方法

这个函数最核心的一段是 CALL runtime·goexit1(SB),调用 go runtime 中的 goexit1 方法

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

goexit1 方法在 runtime/proc.go 中定义

goexit1 方法调用了 mcall 方法

go 复制代码
func goexit1() {
  // ...
  mcall(goexit0)
}

mcall 方法也只有一个函数声明,它的作用是用 mcall 调用一个方法时,会切换栈,就会从 g stack 切换到 g0 stack

go 复制代码
// mcall switches from the g to the g0 stack and invokes fn(g),
// where g is the goroutine that made the call.
// mcall saves g's current PC/SP in g->sched so that it can be restored later.
// It is up to fn to arrange for that later execution, typically by recording
// g in a data structure, causing something to call ready(g) later.
// mcall returns to the original goroutine g later, when g has been rescheduled.
// fn must not return at all; typically it ends by calling schedule, to let the m
// run other goroutines.
//
// mcall can only be called from g stacks (not g0, not gsignal).
//
// This must NOT be go:noescape: if fn is a stack-allocated closure,
// fn puts g on a run queue, and g executes before fn returns, the
// closure will be invalidated while it is still executing.
func mcall(fn func(*g))

goexit0 函数的作用是将刚刚退出的协程相关状态修改下,最后调用 schedule 方法,循环继续

go 复制代码
func goexit0(gp *g) {
  mp := getg().m
  pp := mp.p.ptr()

  casgstatus(gp, _Grunning, _Gdead)
  gcController.addScannableStack(pp, -int64(gp.stack.hi-gp.stack.lo))
  if isSystemGoroutine(gp, false) {
    sched.ngsys.Add(-1)
  }
  gp.m = nil
  locked := gp.lockedm != 0
  gp.lockedm = 0
  mp.lockedg = 0
  gp.preemptStop = false
  gp.paniconfault = false
  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.
  gp.writebuf = nil
  gp.waitreason = waitReasonZero
  gp.param = nil
  gp.labels = nil
  gp.timer = nil
  schedule()
}

线程循环

单线程循环在 go 0.x 时就已经有了

M 表示系统线程,用三角形,G 表示协程,用圆形

多线程循环是多个线程执行一模一样的线程循环逻辑,他们不断的在协程队列中找可以执行的协程,然后执行

如果有多个线程在做这件事时,协程队列就要保证一个协程只能被一个线程执行,所以全局协程队列需要加锁

  • 操作系统不知道 goroutine 的存在
  • 操作系统线程执行一个调度循环,顺序执行 goroutine
    • 调度循环是由 go 或者汇编代码组成的循环
    • 执行 goroutine 时,强行跳转到 goroutine
  • 调度循环非常向线程池

GMP 调度模型

多线程并发时,会抢夺协程队列的协程锁,造成锁的冲突和锁的等待,影像了性能

解决这个问题的方法是引入本地队列,每个线程都有一个本地队列,每次从全局的协程队列中拿多个协程过来,放到本地队列中

如图所示

p 是一个线程的本地队列,它是一个结构体,定义在 runtime/runtime2.go

go 复制代码
type p struct {
  m           muintptr   // back-link to associated m (nil if idle)
  // Queue of runnable goroutines. Accessed without lock.
  runqhead uint32
  runqtail uint32
  runq     [256]guintptr
  // runnext, if non-nil, is a runnable G that was ready'd by
  // the current G and should be run next instead of what's in
  // runq if there's time remaining in the running G's time
  // slice. It will inherit the time left in the current time
  // slice. If a set of goroutines is locked in a
  // communicate-and-wait pattern, this schedules that set as a
  // unit and eliminates the (potentially large) scheduling
  // latency that otherwise arises from adding the ready'd
  // goroutines to the end of the run queue.
  //
  // Note that while other P's may atomically CAS this to zero,
  // only the owner P can CAS it to a valid G.
  runnext guintptr
}
  • m:原始指针,内存中的物理地址
  • 队列:
    • runqhead:队列的头序号
    • runqtail:队列的尾序号
    • runq:队列,256 长度的指针
  • runnext:下一个可用的协程的指针

每一个 p 服务于一个 mp 的职责是构建一个本地队列

p 的作用是:

  • mg 之间的中介
  • p 持有一些 g,使得每次获取 g 时,不需要从全局获取
  • 减少了并发冲突的问题

从本地队列 p 中获取 g 的方法是 runqget,定义在 runtime/proc.go

schedule 方法中调用 findRunnable 方法

findRunable 方法中调用分别调用了 runqgetglobrunqgetsteakWork 方法:

  • runqget 方法找到一个只可以执行的 g
  • globrunqget 方法从全局协程队列中找到一批协程放到本地队列 p
  • stealWork 方法的作用是从其他的协程队列中投一些协程过来,因为本地队列和全局队列都没有可以执行的协程了,但是其他协程队列中有可以执行的协程,就去偷一些过来执行
go 复制代码
func schedule() {
  gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
}

runqget 方法的作用是从本地队列中获取一个 g

go 复制代码
// Get g from local runnable queue.
// If inheritTime is true, gp should inherit the remaining time in the
// current time slice. Otherwise, it should start a new time slice.
// Executed only by the owner P.
func runqget(pp *p) (gp *g, inheritTime bool) {
  // If there's a runnext, it's the next G to run.
  next := pp.runnext
  // If the runnext is non-0 and the CAS fails, it could only have been stolen by another P,
  // because other Ps can race to set runnext to 0, but only the current P can set it to non-0.
  // Hence, there's no need to retry this CAS if it fails.
  if next != 0 && pp.runnext.cas(next, 0) {
    return next.ptr(), true
  }
  // ...
}

globrunqget 方法的作用是从全局协程队列中拿一批协程放到本地队列 p

go 复制代码
// Try get a batch of G's from the global runnable queue.
// sched.lock must be held.
func globrunqget(pp *p, max int32) *g {
  // ...
}

stealWork 方法的作用是从其他的协程队列中投一些协程过来,因为本地队列和全局队列都没有可以执行的协程了,但是其他协程队列中有可以执行的协程,就去偷一些过来执行,增加线程的利用率

go 复制代码
// stealWork attempts to steal a runnable goroutine or timer from any P.
//
// If newWork is true, new work may have been readied.
//
// If now is not 0 it is the current time. stealWork returns the passed time or
// the current time if now was passed as 0.
func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {}

新建协程时,会随机找一个本地队列,然后将新的协程放入 prunnext (插队),如果本地队列都满了,才会放入全局的协程队列中

go 复制代码
// Create a new g running fn.
// Put it on the queue of g's waiting to run.
// The compiler turns a go statement into a call to this.
func newproc(fn *funcval) {
  gp := getg()
  pc := getcallerpc()
  systemstack(func() {
    // 新建一个协程
    newg := newproc1(fn, gp, pc)

    pp := getg().m.p.ptr()
    // 随机找一个本地队列
    runqput(pp, newg, true)

    if mainStarted {
      wakep()
    }
  })
}

如何实现协程并发

如果一个协程执行时间过长,就需要一种轮换机制,让其他协程也有机会执行

在执行到一半时,需要暂停当前协程的执行,暂停执行当前协程的执行需要做一些保存现场的事情,比如执行栈中执行到哪个程序计数器,栈指针指向哪里,这些都需要保存下来

将这些信息保存在协程中,放到协程队列中(本地或者全局),然后跳出当前协程,重新执行 schedule 方法

本地队列的小循环,加上一定几率的全局队列大循环

这个操作在函数 findRunnable 中运行,每执行 61 次线程循环,会去全局队列中找一次协程放入本地队列中

go 复制代码
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
  // Check the global runnable queue once in a while to ensure fairness.
  // Otherwise two goroutines can completely occupy the local runqueue
  // by constantly respawning each other.
  // 每执行 61 次线程循环,会去全局队列中找一次协程放入本地队列中
  if pp.schedtick%61 == 0 && sched.runqsize > 0 {
    lock(&sched.lock)
    gp := globrunqget(pp, 1)
    unlock(&sched.lock)
    if gp != nil {
      return gp, false, false
    }
  }
}

切换时机

任务在执行时,很难被打断,所以有两种切换时机:

  1. 主动挂起(runtime.gopark)
    • 业务方法中主动调用 runtime.gopark 方法,将当前协程挂起(因为 gopark 方法会调用 mcall 方法,切换栈)

      go 复制代码
      // 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.
      func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceReason traceBlockReason, traceskip int) {
        // ...
        mcall(park_m)
      }
    • gopark 函数开发者无法调用,但是我们开发中使用的 time.Sleep() 函数内部会调用 gopark

  2. 系统调用完成时
    • go 在运行时,会有一些系统调用,一旦涉及到系统的底层调用时,在调用结束后,会调用 exitsyscall()

抢占式调度

一个协程如果不主动挂起,或者不走系统调用,那么它就会一直执行,不会被打断

go 在函数调用函数时,会先调用 runtime.morestack 方法,这个方法会检查当前协程的栈是否足够,如果不够就会扩容

将下面的代码运行 go build -gcflags -S main.go,就可以看到编译后的汇编代码

go 复制代码
func do1() {
  do2()
}
func do2() {
  do3()
  go do1()
}
func do3() {
  fmt.Println("do3")
}

通过编译出来的汇编,我们可以看到 do1do2 方法中都调用了 runtime.morestack_noctxt

s 复制代码
# command-line-arguments
main.do1 STEXT size=24 args=0x0 locals=0x8 funcid=0x0 align=0x0
  0x0011 00017 (/Astak/project/tools/limit-go/main.go:16)    CALL    runtime.morestack_noctxt(SB)
  0x0016 00022 (/Astak/project/tools/limit-go/main.go:16)    PCDATA  $0, $-1
  0x0016 00022 (/Astak/project/tools/limit-go/main.go:16)    JMP     0
main.do2 STEXT size=103 args=0x0 locals=0x40 funcid=0x0 align=0x0
  0x0060 00096 (/Astak/project/tools/limit-go/main.go:20)    CALL    runtime.morestack_noctxt(SB)
  0x0065 00101 (/Astak/project/tools/limit-go/main.go:20)    PCDATA  $0, $-1
  0x0065 00101 (/Astak/project/tools/limit-go/main.go:20)    JMP     0

在编译时 go 会将 runtime.morestack() 方法插入到函数调用的前面

go 复制代码
func do1() {
  runtime.morestack() // 在编译时 go 会将 runtime.morestack() 方法插入到函数调用的前面
  do2()
}

morestack 方法本意是检查协程栈是否有足够的空间,既然在每个方法调用前都会调用 morestack,那么就给他设置一个标记抢占的钩子

标记抢占钩子:当系统监控到 goroutine 运行超过 10ms,会将 g.stackguard0 设置为 stackPreempt,对应的十六进制数是 0xfffffade

抢占:执行 morestack 时判断协程是否被抢占了,如果被抢占了,就会调用 runtime.preemptone 方法,这个方法会将协程的状态设置为 Gpreempted,然后调用 schedule 方法,重新调度

runtime.morestack 方法在 runtime/stubs.go 文件中定义,但只是一个函数声明,实际的实现在 runtime/asm_amd64.s 文件中

汇编语言实现了 runtime.morestack 方法,它的作用是检查协程栈是否有足够的空间,如果没有就扩容,然后在调用 go 中的 newstack 方法

s 复制代码
// Called during function prolog when more stack is needed.
//
// The traceback routines see morestack on a g0 as being
// the top of a stack (for example, morestack calling newstack
// calling the scheduler calling newm calling gc), so we must
// record an argument size. For that purpose, it has no arguments.
TEXT runtime·morestack(SB),NOSPLIT|NOFRAME,$0-0
  // Cannot grow scheduler stack (m->g0).
  // ...
  // 调用了 runtime·newstack(SB) 方法
  CALL	runtime·newstack(SB)
  CALL	runtime·abort(SB)	// crash if newstack returns
  RET

runtime.newstack 方法在 runtime/stack.go 文件中定义

go 复制代码
// Called from runtime·morestack when more stack is needed.
// Allocate larger stack and relocate to new stack.
// Stack growth is multiplicative, for constant amortized cost.
//
// g->atomicstatus will be Grunning or Gscanrunning upon entry.
// If the scheduler is trying to stop this g, then it will set preemptStop.
//
// This must be nowritebarrierrec because it can be called as part of
// stack growth from other nowritebarrierrec functions, but the
// compiler doesn't check this.
//
//go:nowritebarrierrec
func newstack() {
  // Be conservative about where we preempt.
  // We are interested in preempting user Go code, not runtime code.
  // If we're holding locks, mallocing, or preemption is disabled, don't
  // preempt.
  // This check is very early in newstack so that even the status change
  // from Grunning to Gwaiting and back doesn't happen in this case.
  // That status change by itself can be viewed as a small preemption,
  // because the GC might change Gwaiting to Gscanwaiting, and then
  // this goroutine has to wait for the GC to finish before continuing.
  // If the GC is in some way dependent on this goroutine (for example,
  // it needs a lock held by the goroutine), that small preemption turns
  // into a real deadlock.
  // 判断是否被抢占
  preempt := stackguard0 == stackPreempt

  // 如果被抢占了调用 gopreempt_m -> goschedImpl -> schedule
  if preempt {
    // Act like goroutine called runtime.Gosched.
    gopreempt_m(gp) // never return
  }
}

这种还有个问题就是如果在一个函数中运行了类似死循环的代码,go 是不会调用 morestack 方法的,所以这种情况下,go 会一直执行下去,不会被抢占

这就有了基于信号的抢占式调度

基于信号的抢占式调度

这个信号是线程信号,因为在操作系统,有很多是基于信号的底层通信方式

比如 SIGPIPESIGURGSIGHUP,线程可以注册对应的信号处理函数,如果线程收到了信号,就可以自动的跳转到信号处理函数中执行

这个信号就是 SIGURG 紧急信号

GC 工作时,向目标线程发送信号,因为在 GC 时,很多线程都已经停止了,适合做抢占,在 GC 会向线程发送抢占信号,然后线程收到信号后触发调度

我们注册的信号处理函数是 doSigPreempt,当 GC 向线程发送 SIGURG 抢占信号时,在执行业务方法的协程会立即跳到 doSigPreempt 方法中,然后重新调度循环

doSigPreempt 方法在 runtime/signal_unix.go 文件中定义

go 复制代码
// doSigPreempt handles a preemption signal on gp.
func doSigPreempt(gp *g, ctxt *sigctxt) {
  // Check if this G wants to be preempted and is safe to
  // preempt.
  if wantAsyncPreempt(gp) {
    if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {
      // Adjust the PC and inject a call to asyncPreempt.
      ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), newpc)
    }
  }

  // Acknowledge the preemption.
  gp.m.preemptGen.Add(1)
  gp.m.signalPending.Store(0)

  if GOOS == "darwin" || GOOS == "ios" {
    pendingPreemptSignals.Add(-1)
  }
}

这个方法会调用 asyncPreemptasyncPreempt 方法在 runtime/preempt.go 文件中定义,但这个方法只有一个函数声明,再由汇编调用 asyncPreempt2 方法

go 复制代码
//go:nosplit
func asyncPreempt2() {
  gp := getg()
  gp.asyncSafePoint = true
  if gp.preemptStop {
    mcall(preemptPark)
  } else {
    mcall(gopreempt_m)
  }
  gp.asyncSafePoint = false
}

总结

  • 基于系统调用和主动挂起,协程可能无法调度
  • 基于协作的抢占式调度:业务主动调用 morestack 方法
  • 基于信号的抢占式调度:强制线程调用 doSigPreempt 方法

协程太多有什么问题

如果协程太多 go 会报错:too many concurrent operations on a single file or socket

这个报错的原因大概是系统资源耗尽,系统资源耗尽有好几种情况:

  • 文件打开数限制
  • 内存限制
  • 调度开销过大
    • 调度开销过大的意思是在调度协程上花费的时间比执行协程的时间还要长

解决方法是:

  • 优化业务逻辑
  • 利用 channel 的缓存区,可以看:channel 限流
    • 启动协程时,向 channel 中发送一个空结构体
    • 协程结束,取出空结构体
  • 协程池(慎用)
    • go 语言的线程,已经相当于池化了
    • 二级池化会增加系统复杂度
    • go 语言的初衷是希望协程即用即毁,不要池化
  • 调整系统资源
相关推荐
qq_17448285751 小时前
springboot基于微信小程序的旧衣回收系统的设计与实现
spring boot·后端·微信小程序
锅包肉的九珍1 小时前
Scala的Array数组
开发语言·后端·scala
心仪悦悦1 小时前
Scala的Array(2)
开发语言·后端·scala
2401_882727572 小时前
BY组态-低代码web可视化组件
前端·后端·物联网·低代码·数学建模·前端框架
心仪悦悦2 小时前
Scala中的集合复习(1)
开发语言·后端·scala
代码小鑫3 小时前
A043-基于Spring Boot的秒杀系统设计与实现
java·开发语言·数据库·spring boot·后端·spring·毕业设计
真心喜欢你吖3 小时前
SpringBoot与MongoDB深度整合及应用案例
java·spring boot·后端·mongodb·spring
激流丶3 小时前
【Kafka 实战】Kafka 如何保证消息的顺序性?
java·后端·kafka
uzong4 小时前
一个 IDEA 老鸟的 DEBUG 私货之多线程调试
java·后端
飞升不如收破烂~4 小时前
Spring boot常用注解和作用
java·spring boot·后端