题记

Go 语言自发布以来,一直以高性能、高并发著称。因为标准库提供了 http 包,即使刚学不久的程序员,也能轻松写出 http 服务程序。

不过,任何事情都有两面性。一门语言,有它值得骄傲的优点,也必定隐藏了不少坑。新手若不知道这些坑,很容易就会掉进坑里。《 Go 语言踩坑记》系列博文将以 Go 语言中的 panicrecover 开头,给大家介绍笔者踩过的各种坑,以及填坑方法。

初识 panic 和 recover

  • panic

panic 这个词,在英语中具有恐慌、恐慌的等意思。从字面意思理解的话,在 Go 语言中,代表极其严重的问题,程序员最害怕出现的问题。一旦出现,就意味着程序的结束并退出。Go 语言中 panic 关键字主要用于主动抛出异常,类似 java 等语言中的 throw 关键字。

  • recover

recover 这个词,在英语中具有恢复、复原等意思。从字面意思理解的话,在 Go 语言中,代表将程序状态从严重的错误中恢复到正常状态。Go语言中 recover 关键字主要用于捕获异常,让程序回到正常状态,类似 java 等语言中的 try ... catch

笔者有过 6 年 linux 系统 C 语言开发经历。C 语言中没有异常捕获的概念,没有 try ... catch ,也没有 panicrecover 。不过,万变不离其宗,异常与 if error then return 方式的差别,主要体现在函数调用栈的深度上。如下图:

正常逻辑下的函数调用栈,是逐个回溯的,而异常捕获可以理解为:程序调用栈的长距离跳转。这点在 C 语言里,是通过 setjumplongjump 这两个函数来实现的。例如以下代码:

#include <setjmp.h>
#include <stdio.h>static jmp_buf env;double divide(double to, double by)
{if(by == 0){longjmp(env, 1);}return to / by;
}void test_divide()
{divide(2, 0);printf("done\n");
}int main()
{if (setjmp(env) == 0){test_divide();}else{printf("Cannot / 0\n");return -1;}return 0;
}

由于发生了长距离跳转,直接从 divide 函数内跳转到 main 函数内,中断了正常的执行流,以上代码编译后将输出 Cannot / 0 而不会输出 done 。是不是很神奇?

try catchrecoversetjump 等机制会将程序当前状态(主要是 cpu 的栈指针寄存器 sp 和程序计数器 pc , Go 的 recover 是依赖 defer 来维护 sp 和 pc )保存到一个与 throwpaniclongjump共享的内存里。当有异常的时候,从该内存中提取之前保存的sp和pc寄存器值,直接将函数栈调回到sp指向的位置,并执行ip寄存器指向的下一条指令,将程序从异常状态中恢复到正常状态。

深入 panic 和 recover

源码

panicrecover 的源码在 Go 源码的 src/runtime/panic.go 里,名为 gopanicgorecover 的函数。

// gopanic 的代码,在 src/runtime/panic.go 第 454 行// 预定义函数 panic 的实现
func gopanic(e interface{}) {gp := getg()if gp.m.curg != gp {print("panic: ")printany(e)print("\n")throw("panic on system stack")}if gp.m.mallocing != 0 {print("panic: ")printany(e)print("\n")throw("panic during malloc")}if gp.m.preemptoff != "" {print("panic: ")printany(e)print("\n")print("preempt off reason: ")print(gp.m.preemptoff)print("\n")throw("panic during preemptoff")}if gp.m.locks != 0 {print("panic: ")printany(e)print("\n")throw("panic holding locks")}var p _panicp.arg = ep.link = gp._panicgp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))atomic.Xadd(&runningPanicDefers, 1)for {d := gp._deferif d == nil {break}// 如果触发 defer 的 panic 是在前一个 panic 或者 Goexit 的 defer 中触发的,那么将前一个 defer 从列表中去除。前一个 panic 或者 Goexit 将不再继续执行。if d.started {if d._panic != nil {d._panic.aborted = true}d._panic = nild.fn = nilgp._defer = d.linkfreedefer(d)continue}// 将 defer 标记为 started,但是保留在列表上,这样,如果在 reflectcall 开始执行 d.fn 之前发生了堆栈增长或垃圾回收,则 traceback 可以找到并更新 defer 的参数帧。d.started = true// 将正在执行 defer 的 panic 保存下来。如果在该 panic 的 defer 函数中触发了新的 panic ,则新 panic 在列表中将会找到 d 并将 d._panic 标记为 aborted 。d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))p.argp = unsafe.Pointer(getargp(0))reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz))p.argp = nil// reflectcall 不会 panic,移除 d 。if gp._defer != d {throw("bad defer entry in panic")}d._panic = nild.fn = nilgp._defer = d.link// 这里用 GC() 来触发堆栈收缩以测试堆栈拷贝。由于是测试代码,所以注释掉了。参考 stack_test.go:TestStackPanic//GC()pc := d.pcsp := unsafe.Pointer(d.sp) // 必须是指针,以便在堆栈复制期间进行调整// defer 处理函数的内存是动态分配的,在执行完后需要释放内存。所以,如果 defer 一直得不到执行(比如在死循环中一直创建 defer),将会导致内存泄露freedefer(d)if p.recovered {atomic.Xadd(&runningPanicDefers, -1)gp._panic = p.link// 已退出的 panic 已经被标记,但还遗留在 g.panic 列表里,从列表里移除他们。for gp._panic != nil && gp._panic.aborted {gp._panic = gp._panic.link}if gp._panic == nil { // must be done with signalgp.sig = 0}// 将正在恢复的栈帧传给 recovery。gp.sigcode0 = uintptr(sp)gp.sigcode1 = pcmcall(recovery)throw("recovery failed") // mcall 不应该返回}}// 如果所有的 defer 都遍历完毕,意味着没有 recover(前面提到,mcall 执行 recovery 是不返回的),继续执行 panic 后续流程,如:输出调用栈信息和错误信息// 由于在冻结世界之后调用任意用户代码是不安全的,因此我们调用preprintpanics来调用所有必要的Error和String方法以在startpanic之前准备 panic 输出的字符串。preprintpanics(gp._panic)fatalpanic(gp._panic) // 不应该返回*(*int)(nil) = 0      // 因为 fatalpanic 不应该返回,正常情况下这里不会执行。如果执行到了,这行代码将触发 panic
}
// gorecover 的代码,在 src/runtime/panic.go 第 585 行// 预定义函数 recover 的实现。
// 无法拆分堆栈,因为它需要可靠地找到其调用方的堆栈段。
//
// TODO(rsc): Once we commit to CopyStackAlways,
// this doesn't need to be nosplit.
//go:nosplit
func gorecover(argp uintptr) interface{} {// 在处理 panic 的时候,recover 函数的调用必须放在 defer 的顶层处理函数中。// p.argp 是最顶层的延迟函数调用的参数指针,与调用方传递的argp进行比较,如果一致,则该调用方是可以恢复的。gp := getg()p := gp._panicif p != nil && !p.recovered && argp == uintptr(p.argp) {p.recovered = truereturn p.arg}return nil
}

从函数代码中我们可以看到 panic 内部主要流程是这样:

  • 获取当前调用者所在的 g ,也就是 goroutine
  • 遍历并执行 g 中的 defer 函数
  • 如果 defer 函数中有调用 recover ,并发现已经发生了 panic ,则将 panic 标记为 recovered
  • 在遍历 defer 的过程中,如果发现已经被标记为 recovered ,则提取出该 defer 的 sp 与 pc,保存在 g 的两个状态码字段中。
  • 调用 runtime.mcall 切到 m->g0 并跳转到 recovery 函数,将前面获取的 g 作为参数传给 recovery 函数。
    runtime.mcall 的代码在 go 源码的 src/runtime/asm_xxx.s 中,xxx 是平台类型,如 amd64 。代码如下:
// src/runtime/asm_amd64.s 第 274 行// func mcall(fn func(*g))
// Switch to m->g0's stack, call fn(g).
// Fn must never return. It should gogo(&g->sched)
// to keep running g.
TEXT runtime·mcall(SB), NOSPLIT, $0-8MOVQ   fn+0(FP), DIget_tls(CX)MOVQ    g(CX), AX   // save state in g->schedMOVQ    0(SP), BX   // caller's PCMOVQ BX, (g_sched+gobuf_pc)(AX)LEAQ fn+0(FP), BX   // caller's SPMOVQ BX, (g_sched+gobuf_sp)(AX)MOVQ AX, (g_sched+gobuf_g)(AX)MOVQ  BP, (g_sched+gobuf_bp)(AX)// switch to m->g0 & its stack, call fnMOVQ   g(CX), BXMOVQ   g_m(BX), BXMOVQ m_g0(BX), SICMPQ    SI, AX  // if g == m->g0 call badmcallJNE  3(PC)MOVQ   $runtime·badmcall(SB), AXJMP    AXMOVQ  SI, g(CX)   // g = m->g0MOVQ    (g_sched+gobuf_sp)(SI), SP // sp = m->g0->sched.spPUSHQ AXMOVQ  DI, DXMOVQ  0(DI), DICALL   DIPOPQ  AXMOVQ  $runtime·badmcall2(SB), AXJMP   AXRET

这里之所以要切到 m->g0 ,主要是因为 Go 的 runtime 环境是有自己的堆栈和 goroutine,而 recovery 是在 runtime 环境下执行的,所以要先调度到 m->g0 来执行 recovery 函数。

  • recovery 函数中,利用 g 中的两个状态码回溯栈指针 sp 并恢复程序计数器 pc 到调度器中,并调用 gogo 重新调度 g ,将 g 恢复到调用 recover 函数的位置, goroutine 继续执行。
    代码如下:
  // gorecover 的代码,在 src/runtime/panic.go 第 637 行// 在 panic 后,在延迟函数中调用 recover 的时候,将回溯堆栈,并且继续执行,就像延迟函数的调用者正常返回一样。func recovery(gp *g) {// Info about defer passed in G struct.sp := gp.sigcode0pc := gp.sigcode1// 延迟函数的参数必须已经保存在堆栈中了(这里通过判断 sp 是否处于栈内存地址的范围内来保障参数的正确处理)if sp != 0 && (sp < gp.stack.lo || gp.stack.hi < sp) {print("recover: ", hex(sp), " not in [", hex(gp.stack.lo), ", ", hex(gp.stack.hi), "]\n")throw("bad recovery")}// 让延迟函数的 deferproc 再次返回,这次返回 1 。调用函数将跳转到标准返回结尾。gp.sched.sp = spgp.sched.pc = pcgp.sched.lr = 0gp.sched.ret = 1gogo(&gp.sched)}
// src/runtime/asm_amd64.s 第 274 行// func gogo(buf *gobuf)
// restore state from Gobuf; longjmp
TEXT runtime·gogo(SB), NOSPLIT, $16-8MOVQ   buf+0(FP), BX      // gobufMOVQ    gobuf_g(BX), DXMOVQ 0(DX), CX       // make sure g != nilget_tls(CX)MOVQ   DX, g(CX)MOVQ   gobuf_sp(BX), SP    // 从 gobuf 中恢复 SP ,以便后面做跳转MOVQ   gobuf_ret(BX), AXMOVQ   gobuf_ctxt(BX), DXMOVQ  gobuf_bp(BX), BPMOVQ    $0, gobuf_sp(BX)    // 这里开始清理 gobuf ,以便垃圾回收。MOVQ $0, gobuf_ret(BX)MOVQ   $0, gobuf_ctxt(BX)MOVQ  $0, gobuf_bp(BX)MOVQ    gobuf_pc(BX), BX    // 从 gobuf 中恢复 pc ,以便跳转JMP   BX

以上便是 Go 底层处理异常的流程,精简为三步便是:

  • defer 函数中调用 recover
  • 触发 panic 并切到 runtime 环境获取在 defer 中调用了 recoverg 的 sp 和 pc
  • 恢复到 deferrecover 后面的处理逻辑

都有哪些坑

前面提到,panic 函数主要用于主动触发异常。我们在实现业务代码的时候,在程序启动阶段,如果资源初始化出错,可以主动调用 panic 立即结束程序。对于新手来说,这没什么问题,很容易做到。

但是,现实往往是残酷的—— Go 的 runtime 代码中很多地方都调用了 panic 函数,对于不了解 Go 底层实现的新人来说,这无疑是挖了一堆深坑。如果不熟悉这些坑,是不可能写出健壮的 Go 代码。

接下来,笔者给大家细数下都有哪些坑。

  • 数组( slice )下标越界

    这个比较好理解,对于静态类型语言,数组下标越界是致命错误。如下代码可以验证:

package mainimport ("fmt"
)func foo(){defer func(){if err := recover(); err != nil {fmt.Println(err)}}()var bar = []int{1}fmt.Println(bar[1])
}func main(){foo()fmt.Println("exit")
}

输出:

runtime error: index out of range
exit

因为代码中用了 recover ,程序得以恢复,输出 exit

如果将 recover 那几行注释掉,将会输出如下日志:

panic: runtime error: index out of rangegoroutine 1 [running]:
main.foo()/home/letian/work/go/src/test/test.go:14 +0x3e
main.main()/home/letian/work/go/src/test/test.go:18 +0x22
exit status 2
  • 访问未初始化的指针或 nil 指针

    对于有 c/c++ 开发经验的人来说,这个很好理解。但对于没用过指针的新手来说,这是最常见的一类错误。
    如下代码可以验证:

package mainimport ("fmt"
)func foo(){defer func(){if err := recover(); err != nil {fmt.Println(err)}}()var bar *intfmt.Println(*bar)
}func main(){foo()fmt.Println("exit")
}

输出:

runtime error: invalid memory address or nil pointer dereference
exit

如果将 recover 那几行代码注释掉,则会输出:

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x4869ff]goroutine 1 [running]:
main.foo()/home/letian/work/go/src/test/test.go:14 +0x3f
main.main()/home/letian/work/go/src/test/test.go:18 +0x22
exit status 2
  • 试图往已经 close 的 chan 里发送数据

    这也是刚学用 chan 的新手容易犯的错误。如下代码可以验证:

package mainimport ("fmt"
)func foo(){defer func(){if err := recover(); err != nil {fmt.Println(err)}}()var bar = make(chan int, 1)close(bar)bar<-1
}func main(){foo()fmt.Println("exit")
}

输出:

send on closed channel
exit

如果注释掉 recover ,将输出:

panic: send on closed channelgoroutine 1 [running]:
main.foo()/home/letian/work/go/src/test/test.go:15 +0x83
main.main()/home/letian/work/go/src/test/test.go:19 +0x22
exit status 2

源码处理逻辑在 src/runtime/chan.gochansend 函数中,如下图:

// src/runtime/chan.go 第 269 行// 如果 block 不为 nil ,则协议将不会休眠,但如果无法完成则返回。
// 当关闭休眠中的通道时,可以使用 g.param == nil 唤醒睡眠。
// 我们可以非常容易循环并重新运行该操作,并且将会看到它处于已关闭状态。
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {if c == nil {if !block {return false}gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)throw("unreachable")}if debugChan {print("chansend: chan=", c, "\n")}if raceenabled {racereadpc(c.raceaddr(), callerpc, funcPC(chansend))}// Fast path: check for failed non-blocking operation without acquiring the lock.//// After observing that the channel is not closed, we observe that the channel is// not ready for sending. Each of these observations is a single word-sized read// (first c.closed and second c.recvq.first or c.qcount depending on kind of channel).// Because a closed channel cannot transition from 'ready for sending' to// 'not ready for sending', even if the channel is closed between the two observations,// they imply a moment between the two when the channel was both not yet closed// and not ready for sending. We behave as if we observed the channel at that moment,// and report that the send cannot proceed.//// It is okay if the reads are reordered here: if we observe that the channel is not// ready for sending and then observe that it is not closed, that implies that the// channel wasn't closed during the first observation.if !block && c.closed == 0 && ((c.dataqsiz == 0 && c.recvq.first == nil) ||(c.dataqsiz > 0 && c.qcount == c.dataqsiz)) {return false}var t0 int64if blockprofilerate > 0 {t0 = cputicks()}lock(&c.lock)if c.closed != 0 {unlock(&c.lock)panic(plainError("send on closed channel"))}if sg := c.recvq.dequeue(); sg != nil {// Found a waiting receiver. We pass the value we want to send// directly to the receiver, bypassing the channel buffer (if any).send(c, sg, ep, func() { unlock(&c.lock) }, 3)return true}if c.qcount < c.dataqsiz {// Space is available in the channel buffer. Enqueue the element to send.qp := chanbuf(c, c.sendx)if raceenabled {raceacquire(qp)racerelease(qp)}typedmemmove(c.elemtype, qp, ep)c.sendx++if c.sendx == c.dataqsiz {c.sendx = 0}c.qcount++unlock(&c.lock)return true}if !block {unlock(&c.lock)return false}// Block on the channel. Some receiver will complete our operation for us.gp := getg()mysg := acquireSudog()mysg.releasetime = 0if t0 != 0 {mysg.releasetime = -1}// No stack splits between assigning elem and enqueuing mysg// on gp.waiting where copystack can find it.mysg.elem = epmysg.waitlink = nilmysg.g = gpmysg.isSelect = falsemysg.c = cgp.waiting = mysggp.param = nilc.sendq.enqueue(mysg)goparkunlock(&c.lock, waitReasonChanSend, traceEvGoBlockSend, 3)// Ensure the value being sent is kept alive until the// receiver copies it out. The sudog has a pointer to the// stack object, but sudogs aren't considered as roots of the// stack tracer.KeepAlive(ep)// someone woke us up.if mysg != gp.waiting {throw("G waiting list is corrupted")}gp.waiting = nilif gp.param == nil {if c.closed == 0 {throw("chansend: spurious wakeup")}panic(plainError("send on closed channel"))}gp.param = nilif mysg.releasetime > 0 {blockevent(mysg.releasetime-t0, 2)}mysg.c = nilreleaseSudog(mysg)return true
}
  • 并发读写相同 map

对于刚学并发编程的同学来说,并发读写 map 也是很容易遇到的问题。如下代码可以验证:

  package mainimport ("fmt")func foo(){defer func(){if err := recover(); err != nil {fmt.Println(err)}}()var bar = make(map[int]int)go func(){defer func(){if err := recover(); err != nil {fmt.Println(err)}}()for{_ = bar[1]}}()for{bar[1]=1}}func main(){foo()fmt.Println("exit")}

输出:

fatal error: concurrent map read and map writegoroutine 5 [running]:runtime.throw(0x4bd8b0, 0x21)/home/letian/.gvm/gos/go1.12/src/runtime/panic.go:617 +0x72 fp=0xc00004c780 sp=0xc00004c750 pc=0x427f22runtime.mapaccess1_fast64(0x49eaa0, 0xc000088180, 0x1, 0xc0000260d8)/home/letian/.gvm/gos/go1.12/src/runtime/map_fast64.go:21 +0x1a8 fp=0xc00004c7a8 sp=0xc00004c780 pc=0x40eb58main.foo.func2(0xc000088180)/home/letian/work/go/src/test/test.go:21 +0x5c fp=0xc00004c7d8 sp=0xc00004c7a8 pc=0x48708cruntime.goexit()/home/letian/.gvm/gos/go1.12/src/runtime/asm_amd64.s:1337 +0x1 fp=0xc00004c7e0 sp=0xc00004c7d8 pc=0x450e51created by main.foo/home/letian/work/go/src/test/test.go:14 +0x68goroutine 1 [runnable]:main.foo()/home/letian/work/go/src/test/test.go:25 +0x8bmain.main()/home/letian/work/go/src/test/test.go:30 +0x22exit status 2

细心的朋友不难发现,输出日志里没有出现我们在程序末尾打印的 exit,而是直接将调用栈打印出来了。查看 src/runtime/map.go 中的代码不难发现这几行:

  if h.flags&hashWriting != 0 {throw("concurrent map read and map write")}

与前面提到的几种情况不同,runtime 中调用 throw 函数抛出的异常是无法在业务代码中通过 recover 捕获的,这点最为致命。所以,对于并发读写 map 的地方,应该对 map 加锁。

  • 类型断言

    在使用类型断言对 interface 进行类型转换的时候也容易一不小心踩坑,而且这个坑是即使用 interface 有一段时间的人也容易忽略的问题。如下代码可以验证:

package mainimport ("fmt"
)func foo(){defer func(){if err := recover(); err != nil {fmt.Println(err)}}()var i interface{} = "abc"_ = i.([]string)
}func main(){foo()fmt.Println("exit")
}

输出:

interface conversion: interface {} is string, not []string
exit

源码在 src/runtime/iface.go 中,如下两个函数:

// panicdottypeE is called when doing an e.(T) conversion and the conversion fails.
// have = the dynamic type we have.
// want = the static type we're trying to convert to.
// iface = the static type we're converting from.
func panicdottypeE(have, want, iface *_type) {panic(&TypeAssertionError{iface, have, want, ""})
}// panicdottypeI is called when doing an i.(T) conversion and the conversion fails.
// Same args as panicdottypeE, but "have" is the dynamic itab we have.
func panicdottypeI(have *itab, want, iface *_type) {var t *_typeif have != nil {t = have._type}panicdottypeE(t, want, iface)
}

更多的 panic

前面提到的只是基本语法中常遇到的几种 panic 场景,Go 标准库中有更多使用 panic 的地方,大家可以在源码中搜索 panic( 找到调用的地方,以免后续用标准库函数的时候踩坑。

限于篇幅,本文暂不介绍填坑技巧,后面再开其他篇幅逐个介绍。
  感谢阅读!

下回预告

Go语言踩坑记之channel与goroutine

推荐文章

如何用Go打造千万级流量秒杀系统

扫码关注公众号

Go 语言踩坑记——panic 与 recover相关推荐

  1. go nil json.marshal 完是null_字节跳动踩坑记#3:Go服务灵异panic

    这个坑比较新鲜,刚填完,还冒着冷气. - 1 - 在字节跳动,我们服务的所有 log 都通过统一的日志库采集到流式日志服务.落地 ES 集群,配上字节云超(sang)级(xin)强(bing)大(ku ...

  2. python从入门到实践django看不懂_Python编程:从入门到实践踩坑记 Django

    <>踩坑记 Django Django Python 19.1.1.5 模板new_topic 做完书上的步骤后,对主题添加页面经行测试,但是浏览器显示 服务器异常. 个人采用的开发环境是 ...

  3. 微信公众号服务器配置但没有回调,微信公众号 “服务器配置” 踩坑记

    前言 今天工作的时候,碰到一个极其无语的关于微信公众号的坑,为此,我语言攻击了腾讯的机器人客服.然而并没有卵用...万万没想到,我还是解决了这个问题,并记录下踩坑经历,希望能帮到一些朋友吧. 背景 今 ...

  4. GoDB开发踩坑记(代码实现)

    前言 之前写了一篇GoDB开发踩坑记但是内容有些不全,所以来补充一下.所以没看过GoDB开发踩坑记的可以先看一下那篇文章. 正文 golang encode_josn--把map[string]int ...

  5. 口罩、安全帽识别比赛踩坑记(一) 经验漫谈及随想

    前言 因为疫情迎来的史无前例大假期,从开始理直气壮的天天划手机,到中间百无聊赖的躺尸,再到之后实在憋得慌,就想找点什么事搞一搞.恰好这时,一直关注的极视角联合 Intel 公司举办了一个对口罩和安全帽 ...

  6. 东八区转为0时区_踩坑记 | Flink 天级别窗口中存在的时区问题

    ❝ 本系列每篇文章都是从一些实际的 case 出发,分析一些生产环境中经常会遇到的问题,抛砖引玉,以帮助小伙伴们解决一些实际问题.本文介绍 Flink 时间以及时区问题,分析了在天级别的窗口时会遇到的 ...

  7. Spring @Transactional踩坑记

    @Transactional踩坑记 总述 ​ Spring在1.2引入@Transactional注解, 该注解的引入使得我们可以简单地通过在方法或者类上添加@Transactional注解,实现事务 ...

  8. 服务器重新部署踩坑记

    服务器重新部署踩坑记 Intro 之前的服务器是 Ubuntu 18.04 ,上周周末想升级一下服务器系统,从 18.04 升级到 20.04,结果升级升挂了... 后来 SSH 始终连不上,索性删除 ...

  9. IdentityServer 部署踩坑记

    IdentityServer 部署踩坑记 Intro 周末终于部署了 IdentityServer 以及 IdentityServerAdmin 项目,踩了几个坑,在此记录分享一下. 部署架构 项目是 ...

最新文章

  1. Centos7 下安装VIM编辑器
  2. python进行探索性数据分析EDA(Exploratory Data Analysis)分析
  3. eclipse安装lombok后无法启动解决办法
  4. 网上一个仿TP挂钩内核的源码
  5. sqlserver2008 创建支持文件流的数据库
  6. 第一节:复习委托,并且通过委托的异步调用开启一个新线程和异步回调、异步等待
  7. 生长区域算法的php实现
  8. centos 6.3+mysql+5.6+nginx 1.5.8
  9. 对短链接服务暴露的URL进行网络侦察
  10. 卡巴斯基7.0如何设置授权文件
  11. 技术对接场景,打破创新窘境
  12. 流水灯c语言代码switch,单片机C语言入门之六switch case语句流水灯
  13. Emmagee——开源Android性能测试工具
  14. node scripts/install.js 安装失败解决办法
  15. python量化交易策略实例_实践《Python与量化投资从基础到实战》PDF代码+《量化交易之路用Python做股票量化分析》PDF代码解释...
  16. 三页搞定GB2818/SIP/RTP、PS封装
  17. Final Cut Pro资源库占用内存太大如何释放磁盘空间?
  18. EOS智能合约开发系列(13): 多签合约代码分析(二)
  19. 对猫眼T100进行简单数据分析
  20. 领英精灵和领英助理哪个好?为什么领英精灵是LinkedIn最好的配套工具?

热门文章

  1. 16个VS Code快捷方式,可加快编码速度
  2. 百度地图 api php,百度地图API使用方法详解
  3. 史上最强的绕口令,吐血也读不出!~(转)
  4. matlab 打包封装,MATLAB程序封装成EXE文件
  5. Node.JS 根据时间戳获取年月日时分秒
  6. 华为云会议,云上办公更轻松高效
  7. 【EF框架】DbContext的使用
  8. java设计狗叫和狗咬_Java类谜题47:啊呀!我的猫变成狗了
  9. wx.getLocation接口申请失败 失败原因总结 解决思路 解决方案 案例
  10. 闫燕飞:Kafka的高性能揭秘及优化