写点什么

理解 Golang 中函数调用的原理

  • 2019-12-03
  • 本文字数:0 字

    阅读完需:约 1 分钟

理解 Golang 中函数调用的原理

2.1 函数调用

函数是 Go 语言中的一等公民,理解和掌握函数的调用过程是深入学习 Golang 时无法跳过的步骤,这里会介绍 Go 语言中函数调用的过程和实现原理并与 C 语言中函数执行的过程进行对比,同时对参数传递的原理进行剖析,让读者能够清楚地知道 Go 在函数的执行过程中究竟都做了哪些工作。


本文将从函数的调用惯例和参数的传递方法两个方面分别介绍函数的执行过程,同时在这里会默认阅读这篇文章的读者已经掌握了栈、寄存器 等概念,文章并不会就这两部分内容展开进行介绍。


__1. 调用惯例

在计算机科学中,调用惯例其实就是指在实现层面上,一个函数(子程序)如何接受主程序传递的参数并如何将返回值传递回主程序。不同语言对于传递参数和返回值的实现上会有一些差异,不过无论是在 C、Go 语言这种比较接近系统的编程语言,还是 Ruby、Python 这类语言,它们在_函数调用_上往往都具有相同的形式,也就是一般包含函数名、参数列表两个部分:


somefunction(arg0, arg1)
复制代码


虽然它们的调用形式看起来差不多,但是在这里我们需要考虑 C 和 Go 这两门语言究竟是如何实现调用惯例的,这对于我们理解的 Go 语言的函数调用原理会有非常大的帮助。


__1.1. C 语言

如果想要了解 C 语言中的函数调用的原理,我们可以通过 gcc 或者 clang 将 C 语言的代码编译成汇编语言,从汇编语言中可以一窥函数调用的具体过程,作者使用的是编译器和内核的版本如下:


$ gcc --versiongcc (Ubuntu 4.8.2-19ubuntu1) 4.8.2Copyright (C) 2013 Free Software Foundation, Inc.This is free software; see the source for copying conditions.  There is NOwarranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
$ uname -aLinux iZ255w13cy6Z 3.13.0-32-generic #57-Ubuntu SMP Tue Jul 15 03:51:08 UTC 2014 x86_64 x86_64 x86_64 GNU/Linux
复制代码


gcc 和 clang 虽然在编译 C 语言代码时生成的汇编语言可能有比较大的差别,但是生成代码的结构不会有太大的区别,需要注意的是不同内核版本的操作系统生成的汇编指令可能有比较大的不同,不过对于我们这些只是想要了解实现原理的开发者来说没有太多的影响。


假设我们有以下的 C 语言代码,代码中只包含两个函数,其中一个是主函数 main,另一个就是我们定义的函数 my_function,代码非常简单:


int my_function(int arg1, int arg2) {    return arg1 + arg2;}
int main() { int i = my_function(1, 2);}
复制代码


接下来我们可以使用 gcc -S main.c 将当前的文件编译成包含汇编语言的 main.s 文件,为了减少一些与函数调用无关的噪音,我们将生成的汇编代码经过一些简单地删减展示出来:


my_function:.LFB0:    .cfi_startproc    pushq    %rbp    .cfi_def_cfa_offset 16    .cfi_offset 6, -16    movq    %rsp, %rbp    .cfi_def_cfa_register 6    movl    %edi, -4(%rbp)    movl    %esi, -8(%rbp)    movl    -8(%rbp), %eax    movl    -4(%rbp), %edx    addl    %edx, %eax // arg1 + arg2    popq    %rbp    .cfi_def_cfa 7, 8    ret    .cfi_endprocmain:.LFB1:    .cfi_startproc    pushq    %rbp    .cfi_def_cfa_offset 16    .cfi_offset 6, -16    movq    %rsp, %rbp    .cfi_def_cfa_register 6    subq    $16, %rsp    movl    $2, %esi // 处理第二个参数    movl    $1, %edi // 处理第一个参数    call    my_function    movl    %eax, -4(%rbp)    leave    .cfi_def_cfa 7, 8    ret    .cfi_endproc
复制代码


我们可以看到在调用 my_function 函数之前,上述代码将该函数需要的两个参数分别存到了 edi 和 esi 寄存器中;在 my_function 执行时,它会先从寄存器中取出数据并放置到堆栈上,随后通过汇编指令在 eax 寄存器上进行计算,最后的结果其实是通过另一个寄存器 eax 返回的,main 函数在 my_function 返回之后将返回值存储到堆栈上的 i 变量中。


当我们将 my_function 函数的参数增加至 8 个之后,我们再重新编译当前的 C 语言代码,这时可以得到更长的汇编语言代码:


// main.cint my_function(int arg1, int arg2, int arg3, int arg4, int arg5, int arg6, int arg7, int arg8) {    return arg1 + arg2 + arg3 + arg4 + arg5 + arg6 + arg7 + arg8;}
int main() { my_function(1, 2, 3, 4, 5, 6, 7, 8);}
// main.smain:.LFB1: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 subq $16, %rsp movl $8, 8(%rsp) movl $7, (%rsp) movl $6, %r9d movl $5, %r8d movl $4, %ecx movl $3, %edx movl $2, %esi movl $1, %edi call my_function leave .cfi_def_cfa 7, 8 ret .cfi_endproc
复制代码


从这段代码我们可以看出如下的几个问题,首先,前六个参数的传递依然是通过寄存器进行的,分别存储在 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器中,这里的寄存器的使用顺序也是有讲究的,作为调用惯例,传递参数时第一个参数一定会放在 edi 寄存器中,第二个参数放在 esi 寄存器,以此类推;最后的第七和第八两个参数都通过栈进行传递,我们可以通过这张图片来简单理解 main 函数在调用 my_function 时的堆栈:


需要说的是,rbp 是一个存储函数调用堆栈基址指针的寄存器,也就是说当前函数栈是从哪里开始的;另一个寄存器 rsp 存储的就是当前函数调用栈栈顶的位置,也就是当前栈的内存分配到了哪里,这两个寄存器表示一个函数栈的开始和结束。



在调用函数之前通过 subq $16, %rsp 指令分配了 16 个字节的栈地址,随后按照从右向左的顺序对参数进行压栈,将第八和第七这两个参数放置到栈中,在这之后将余下的 6 个参数存储到寄存器后才运行 call my_function 指令调用 my_function 函数:


my_function:.LFB0:    .cfi_startproc    pushq    %rbp    .cfi_def_cfa_offset 16    .cfi_offset 6, -16    movq    %rsp, %rbp    .cfi_def_cfa_register 6    movl    %edi, -4(%rbp)    movl    %esi, -8(%rbp)    movl    %edx, -12(%rbp)    movl    %ecx, -16(%rbp)    movl    %r8d, -20(%rbp)    movl    %r9d, -24(%rbp)    movl    -8(%rbp), %eax // eax = 2    movl    -4(%rbp), %edx // edx = 1    addl    %eax, %edx     // edx = eax + edx = 3    movl    -12(%rbp), %eax    addl    %eax, %edx     movl    -16(%rbp), %eax    addl    %eax, %edx     movl    -20(%rbp), %eax     addl    %eax, %edx    movl    -24(%rbp), %eax    addl    %eax, %edx    movl    16(%rbp), %eax  // eax = 7    addl    %eax, %edx      // edx = eax + edx = 28    movl    24(%rbp), %eax  // eax = 8    addl    %edx, %eax      // edx = eax + edx = 36    popq    %rbp    .cfi_def_cfa 7, 8    ret    .cfi_endproc
复制代码


因为 my_function 函数没有调用其他的函数,所以并没有通过 subq 修改栈指针寄存器 rsp 中的内容,它的执行过程就是先将寄存器中的全部数据都转移到栈上,然后利用 eax 寄存器计算并返回结果。


简单总结一下,如果我们在 x86-64 的机器上使用 C 语言中调用一个函数,函数的参数是通过寄存器和栈传递的,在 x86_64 的机器上,6 个以下(含 6 个)的参数会按照顺序分别使用 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器传递,超过 6 个的剩余参数会通过栈进行传递;函数的返回值是通过 eax 寄存器进行传递的,这也就是为什么 C 语言中不支持多个返回值。


__1.2. Golang

介绍了 C 语言中函数调用的流程之后,接下来我们再来剖析一下 Golang 中函数调用时参数和返回值如何传递的。在这里我们以下面这个非常简单的代码片段为例简单分析一下:


package main
func myFunction(a, b int) (int, int) { return a + b, a - b}
func main() { myFunction(66, 77)}
复制代码


上述代码片段中的 myFunction 函数接受两个类型为 int 的函数并返回两个类型为 int 的参数,main 函数在调用该函数时会将 66 和 77 两个参数传递到当前函数中,经过编译我们可以得到如下所示的伪汇编代码:


"".main STEXT size=68 args=0x0 locals=0x28    0x0000 00000 (main.go:7)    TEXT    "".main(SB), $40-0    0x0000 00000 (main.go:7)    MOVQ    (TLS), CX    0x0009 00009 (main.go:7)    CMPQ    SP, 16(CX)    0x000d 00013 (main.go:7)    JLS    61    0x000f 00015 (main.go:7)    SUBQ    $40, SP // 分配 40 字节栈空间    0x0013 00019 (main.go:7)    MOVQ    BP, 32(SP) // 将基址指针存储到栈上    0x0018 00024 (main.go:7)    LEAQ    32(SP), BP    0x001d 00029 (main.go:8)    MOVQ    $66, (SP)  // 第一个参数    0x0025 00037 (main.go:8)    MOVQ    $77, 8(SP) // 第二个参数    0x002e 00046 (main.go:8)    PCDATA    $0, $0    0x002e 00046 (main.go:8)    CALL    "".myFunction(SB)    0x0033 00051 (main.go:9)    MOVQ    32(SP), BP    0x0038 00056 (main.go:9)    ADDQ    $40, SP    0x003c 00060 (main.go:9)    RET
复制代码


需要注意的是编译这段 Golang 代码时,需要使用如下的命令进行编译 GOOS=linux GOARCH=amd64 go tool compile -S -N -l main.go,如果不加上 -N -l 的参数,编译器会对汇编代码进行优化,编译结果会跟这里的差别非常大。


根据 main 函数生成的汇编指令,我们可以分析出在 main 函数中调用 myFunction 之前的堆栈情况:



main 函数中,通过 SUBQ $40, SP 指令一共在栈上分配了 40 字节的内存空间,最开始的 8 个字节存储了 main 函数的栈基址指针,之后的 16 个字节是为函数 myFunction 的两个返回值预留的空间,最后的 16 字节存储了该函数调用时需要的两个参数,压栈的顺序和 C 语言中一样,也是从右到左。


接下来就调用了汇编指令 CALL "".myFunction(SB),这个指令首先会将当前函数 main 的返回值压栈,然后改变当前的栈指针 SP 并开始执行 myFunction 的汇编指令:


"".myFunction STEXT nosplit size=49 args=0x20 locals=0x0    0x0000 00000 (main.go:3)    TEXT    "".myFunction(SB), NOSPLIT, $0-32    0x0000 00000 (main.go:3)    MOVQ    $0, "".~r2+24(SP) // 初始化第一个返回值    0x0009 00009 (main.go:3)    MOVQ    $0, "".~r3+32(SP) // 初始化第二个返回值    0x0012 00018 (main.go:4)    MOVQ    "".a+8(SP), AX    // AX = 66    0x0017 00023 (main.go:4)    ADDQ    "".b+16(SP), AX   // AX = AX + 77 = 143    0x001c 00028 (main.go:4)    MOVQ    AX, "".~r2+24(SP) // (24)SP = AX = 143    0x0021 00033 (main.go:4)    MOVQ    "".a+8(SP), AX    // AX = 66    0x0026 00038 (main.go:4)    SUBQ    "".b+16(SP), AX   // AX = AX - 77 = -11    0x002b 00043 (main.go:4)    MOVQ    AX, "".~r3+32(SP) // (32)SP = AX = -11    0x0030 00048 (main.go:4)    RET
复制代码


从上述的汇编代码中我们可以看出,当前函数在执行时首先会将 main 函数中的预留的两个返回值地址置成 int 类型的默认值 0,然后根据栈的相对位置获取参数并进行加减操作,最终将值存储回栈中,所以经过分析 myFunction 执行之后的堆栈信息如下:



myFunction 函数返回之后,main 函数就会通过以下的指令来恢复栈基址指针并销毁已经失去作用的 40 字节的栈空间:


0x0033 00051 (main.go:9)    MOVQ    32(SP), BP    0x0038 00056 (main.go:9)    ADDQ    $40, SP    0x003c 00060 (main.go:9)    RET
复制代码


通过对 Golang 伪汇编语言语言的分析,我们发现 Go 语言传递和接受参数使用的都是栈,它没有像 C 语言一样在函数参数较少时使用寄存器传递参数,同时使用栈代替 eax 寄存器传递返回值也能够同时返回多个结果,但是需要注意的是,函数入参和出参的内存空间都需要调用方在栈上进行分配,这种使用栈进行参数传递的方式虽然跟使用寄存器相比在性能上会有一些损失,但是也能够带来其他的好处:


  1. 能够降低实现的复杂度;

  2. 不需要考虑超过寄存器个数的参数应该如何传递;

  3. 更方便的兼容不同的硬件;

  4. 不同 CPU 的寄存器差别比较大;

  5. 函数可以具有多个返回值;

  6. 栈上的内存地址与相比寄存器的个数是无限的;

  7. 使用寄存器支持多个返回值也会非常困难,超出寄存器个数的返回值也需要使用栈来传递;


__2. 参数传递

除了函数的调用惯例之外,函数的参数在调用时究竟是传值还是传引用也是一个非常有趣的问题,很多人都会说无论是传值还是传引用本质上都是对值的传递,这种论调其实并没有什么意义,我们其实需要知道对函数中参数的修改会不会影响调用方栈上的内容。


不同语言对于参数传递的方式可能设计有所不同,Java 在传递基本类型时会对字面量进行拷贝,不过传递对象参数时就是对象在堆中的地址,相比于 Java 这种稍显复杂的设计,Golang 的设计就简单了很多,无论是传递基本类型、结构体还是指针,都会对传递的参数进行拷贝,这一节剩下的内容就会帮助验证这个结论的正确性。


__2.1. 整型和数组

我们构建一个如下的函数 myFunction,它接受两个参数,一个整型变量 i 和另一个是数组 arr,这个函数会将传入的两个参数的地址打印出来,在最外层的主函数也会在方法调用前后分别打印两个参数的地址:


func myFunction(i int, arr [2]int) {    fmt.Printf("in my_funciton - i=%p arr=%p\n", &i, &arr)}
func main() { i := 30 arr := [2]int{66, 77} fmt.Printf("before calling - i=%p arr=%p\n", &i, &arr) myFunction(i, arr) fmt.Printf("after calling - i=%p arr=%p\n", &i, &arr)}
$ go run main.gobefore calling - i=0xc00009a000 arr=0xc00009a010in my_funciton - i=0xc00009a008 arr=0xc00009a020after calling - i=0xc00009a000 arr=0xc00009a010
复制代码


通过命令运行这段代码我们会发现,main 函数和被调用者 myFunction 中参数的地址是完全不同的。


但是无论是调用 myFunction 前还是调用 myFunction 后,两个参数的地址都没有变化,如果我们尝试在函数内部对参数进行修改又会怎么样呢?


func myFunction(i int, arr [2]int) {    i = 29    arr[1] = 88    fmt.Printf("in my_funciton - i=(%d, %p) arr=(%v, %p)\n", i, &i, arr, &arr)}
func main() { i := 30 arr := [2]int{66, 77} fmt.Printf("before calling - i=(%d, %p) arr=(%v, %p)\n", i, &i, arr, &arr) myFunction(i, arr) fmt.Printf("after calling - i=(%d, %p) arr=(%v, %p)\n", i, &i, arr, &arr)}
$ go run main.gobefore calling - i=(30, 0xc000072008) arr=([66 77], 0xc000072010)in my_funciton - i=(29, 0xc000072028) arr=([66 88], 0xc000072040)after calling - i=(30, 0xc000072008) arr=([66 77], 0xc000072010)
复制代码


可以看到在 myFunction 中对参数的修改也仅仅影响了当前的函数栈,同时并没有对调用方的函数栈有任何的影响,我们也能给出如下的结论 - Go 语言中对于整型和数组类型的参数都是值传递的,也就是在调用函数时会对内容进行拷贝,需要注意的是如果当前数组的大小非常的大,这种直接复制传值的方式就会对性能造成比较大的影响。


__2.2. 结构体和指针

接下来我们需要再来看一下在 Golang 中的另外两种常见的类型 - 结构体和指针在参数的传递过程中是传值还是传引用的。在这里定义一个只包含一个成员变量的简单结构体 MyStruct 以及接受两个参数的 myFunction 方法:


type MyStruct struct {    i int}
func myFunction(a MyStruct, b *MyStruct) { a.i = 31 b.i = 41 fmt.Printf("in my_function - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b)}
func main() { a := MyStruct{i: 30} b := &MyStruct{i: 40} fmt.Printf("before calling - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b) myFunction(a, b) fmt.Printf("after calling - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b)}
$ go run main.gobefore calling - a=({30}, 0xc000018178) b=(&{40}, 0xc00000c028)in my_function - a=({31}, 0xc000018198) b=(&{41}, 0xc00000c038)after calling - a=({30}, 0xc000018178) b=(&{41}, 0xc00000c028)
复制代码


从这段代码中我们其实可以看出,如果传递的是结构体,那么在传递参数时依然会对结构体中的全部内容进行拷贝,而传递指针时复制的其实也是指针的内容 - 地址,所以对指针的修改其实就是修改指针背后的值,b.i 可以被理解成 (*b).i,也就是我们先获取指针 b 背后的结构体,再修改结构体中的成员变量,传递的指针其实也就是地址。我们简单对上述代码进行修改,看看当前结构体在内存中是如何布局的:


package main
import "unsafe"import "fmt"
type MyStruct struct { i int j int}
func myFunction(ms *MyStruct) { ptr := unsafe.Pointer(ms) for i := 0; i < 2; i++ { c := (*int)(unsafe.Pointer((uintptr(ptr) + uintptr(8*i)))) *c += i + 1 fmt.Printf("[%p] %d\n", c, *c) }}
func main() { a := &MyStruct{i: 40, j: 50} myFunction(a) fmt.Printf("[%p] %v\n", a, a)}
$ go run main.go[0xc000018180] 41[0xc000018188] 52[0xc000018180] &{41 52}
复制代码


在这段代码中,我们直接通过指针的方式修改结构体中的成员变量,结构体在内存中其实就是一篇连续的空间,其中的多个成员变量连续布局,指向结构体的指针其实也是指向这个结构体的首地址,如果我们将 MyStruct 指针修改成 int 类型的,那么对新指针的去引用(dereference)就会返回一个整型变量 i,由于一个 int 类型的变量占 8 个字节,所以将指针移动 8 位之后就能获取下一个结构的成员变量 j


如果我们将上述代码中大部分无关的信息省略并只对其中的 myFuncionMyStruct 这两段代码进行编译:


type MyStruct struct {    i int    j int}
func myFunction(ms *MyStruct) *MyStruct { return ms}
// assembly"".myFunction STEXT nosplit size=20 args=0x10 locals=0x0 0x0000 00000 (main.go:8) TEXT "".myFunction(SB), NOSPLIT, $0-16 0x0000 00000 (main.go:8) FUNCDATA $0, gclocals·aef1f7ba6e2630c93a51843d99f5a28a(SB) 0x0000 00000 (main.go:8) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x0000 00000 (main.go:8) MOVQ $0, "".~r1+16(SP) // 初始化返回值 0x0009 00009 (main.go:9) MOVQ "".ms+8(SP), AX // 复制引用 0x000e 00014 (main.go:9) MOVQ AX, "".~r1+16(SP) // 返回引用 0x0013 00019 (main.go:9) RET
复制代码


在这段汇编语言中我们发现当参数是指针时,其实也会使用 MOVQ "".ms+8(SP), AX 指令先对引用进行复制,然后再将复制后的指针作为返回值传递回调用方。



所以其实将指针作为参数传入某一个函数时,其实在函数内部会对指针进行复制,也就是会同时出现两个指针指向原有的内存空间,所以 Go 语言中『传指针』其实也是传值。


__2.3. 小结

当我们对 Go 语言中大多数常见的数据结构进行验证之后,其实就能够推测出 Go 语言在传递参数时其实使用的就是传值的方式,接收方收到参数时会对这些参数进行复制;了解到这一点之后,在传递数组或者内存占用非常大的结构体时,我们在一些函数中应该尽量使用指针作为参数类型来避免发生大量数据的拷贝而影响性能。


__3. 总结

这一节我们详细介绍了 Go 语言中方法调用时的调用惯例,包括参数传递的过程和原理,简单梳理一下方法调用的过程:Go 通过堆栈的方式对函数的参数和返回值进行传递和接受,在调用函数之前会在栈上为返回值分配合适的内存空间,随后按照入参从右到左按顺序压栈,被调用方接受参数时会对参数进行拷贝后再进行计算,返回值最终会被放置到调用者预留好的栈空间上,Go 语言函数调用的原理可以总结成以下的几条规则:


  1. 通过堆栈传递参数,入栈的顺序是从右到左;

  2. 函数返回值通过堆栈传递并由调用者预先分配内存空间;

  3. 调用函数时都是传值,接收方会对入参进行复制再计算;


__4. Reference


**本文转载自 Draveness 技术博客。


原文链接:https://draveness.me/golang/basic/golang-function-call.html


2019-12-03 15:161453

评论 1 条评论

发布
用户头像
老哥 fmt.Printf("after calling - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b) 这里打印的&b是指向指针的指针
2020-01-03 10:54
回复
没有更多了
发现更多内容

有奖征文 | “用 TDengine,写 TDengine”,万元大奖等你来瓜分!

TDengine

tdengine TDengine征文

多线程安全问题原理和4种解决办法

华为云开发者联盟

开发 华为云 12 月 PK 榜

ROMA集成关键技术:增量数据集成

华为云开发者联盟

云计算 后端 华为云 12 月 PK 榜

云计算技术是基于互联网和网络的新技术

Finovy Cloud

云服务器 云技术 云渲染

我人傻了!新入职的同事三下五除二就搭建了一个简易版秒杀系统

程序员小毕

程序员 程序人生 后端 架构师 秒杀系统

数据治理:聊聊数据血缘!

用友BIP

MatrixDB v4.5.0 重磅发布,全新推出 MARS2 存储引擎!

YMatrix 超融合数据库

数据库 存储引擎 超融合数据库 版本迭代 YMatrix

Zebec正在以流支付的方式,推动加密资产支付走上正轨

EOSdreamer111

Java: 在Excel中插入和提取图片

Geek_249eec

Java Excel 图片

Zebec正在以流支付的方式,推动加密支付走向主流

股市老人

行业方案 | 新规落地,企业集团财务公司如何构建数智财务体系?

袋鼠云数栈DTinsight

柏睿数据完成阿里云PolarDB数据库产品生态集成认证

阿里云数据库开源

阿里云 polarDB PolarDB-X PolarDB-PG PolarDB for PostgreSQL

阿里面试败北:5种微服务注册中心如何选型?这几个维度告诉你!

架构师之道

微服务 java面试

PCB工程师最实用的拼版案例合集请查收!

攻城狮华哥

PCB PCB设计 拼版

AI作画技术实践第二期|用腾讯云智能图片融合优化AI绘画的效果

牵着蜗牛去散步

腾讯云 腾讯 AI作画 腾讯云智能 智能内容创作

能让Java开发者提高效率的10个工具

千锋IT教育

任务管理轻松实现大规模设备管理控制——设备管理类

阿里云AIoT

运维 监控 云安全 消息中间件 储存

可以保存ins照片的软件~距离实现ins图片下载自由只差这一个步骤!

frank

ins图片下载

ubuntu18.04编译webrtc_android库

alps2006

android WebRTC

软件测试丨只需学会Docker,环境问题再也不是测开路上的「坑」

测试人

Docker 容器 软件测试 自动化测试 测试开发

面对复杂中国式报表,哪款BI商业智能工具适合你?

小偏执o

StoneDB荣获"2022年度优秀开源技术团队" : 决心做好下一代MySQL高性能分析加速器

StoneDB

MySQL 开源 HTAP 数据库· StoneDB

QSDK/ipq5018/2T2R/Bluetooth BT5.1 supporting QCN9074/industrial wifi6 module

wallyspipi

QCN9074 ipq5018

LeaRun.net代码生成器 一键生成前后端代码

力软低代码开发平台

产品负责人 VS 产品经理

ShineScrum捷行

产品经理 产品负责人

2022年牛客网最新版大厂Java八股文面试题总结(覆盖所有面试题考点)

架构师之道

编程 程序员 java面试

研发 Leader 怎样写出非研发也看得懂的年终总结?

思码逸研发效能

研发效能 年终总结

体验百度Java后端一面凉经,让我有了新的感悟

小小怪下士

Java 百度 程序员 面试

无代码是否能成为主流

间隔

公司项目终于用上了插入式注解,真香!

Java永远的神

程序员 程序人生 项目 架构师 后端开发

一名曾因线上P0故障导致月工资扣了10%的码农心得:如何在故障10分钟黄金时间快速排障

KINDLING

Java 运维 可观测性 线上故障 ebpf

理解 Golang 中函数调用的原理_文化 & 方法_Draveness_InfoQ精选文章