Go 语言踩坑记——panic 与 recover
题记
Go 语言自发布以来,一直以高性能、高并发著称。因为标准库提供了 http 包,即使刚学不久的程序员,也能轻松写出 http 服务程序。
不过,任何事情都有两面性。一门语言,有它值得骄傲的优点,也必定隐藏了不少坑。新手若不知道这些坑,很容易就会掉进坑里。《 Go 语言踩坑记》系列博文将以 Go 语言中的 panic
与 recover
开头,给大家介绍笔者踩过的各种坑,以及填坑方法。
初识 panic 和 recover
panic
panic
这个词,在英语中具有恐慌、恐慌的
等意思。从字面意思理解的话,在 Go 语言中,代表极其严重的问题,程序员最害怕出现的问题。一旦出现,就意味着程序的结束并退出。Go 语言中 panic
关键字主要用于主动抛出异常,类似 java
等语言中的 throw
关键字。
recover
recover
这个词,在英语中具有恢复、复原
等意思。从字面意思理解的话,在 Go 语言中,代表将程序状态从严重的错误中恢复到正常状态。Go语言中 recover
关键字主要用于捕获异常,让程序回到正常状态,类似 java
等语言中的 try ... catch
。
笔者有过 6 年 linux 系统 C 语言开发经历。C 语言中没有异常捕获的概念,没有 try ... catch
,也没有 panic
和 recover
。不过,万变不离其宗,异常与 if error then return
方式的差别,主要体现在函数调用栈的深度上。如下图:
正常逻辑下的函数调用栈,是逐个回溯的,而异常捕获可以理解为:程序调用栈的长距离跳转。这点在 C 语言里,是通过 setjump
和 longjump
这两个函数来实现的。例如以下代码:
#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 catch
、 recover
、setjump
等机制会将程序当前状态(主要是 cpu 的栈指针寄存器 sp 和程序计数器 pc , Go 的 recover
是依赖 defer
来维护 sp 和 pc )保存到一个与 throw
、panic
、longjump
共享的内存里。当有异常的时候,从该内存中提取之前保存的sp和pc寄存器值,直接将函数栈调回到sp指向的位置,并执行ip寄存器指向的下一条指令,将程序从异常状态中恢复到正常状态。
深入 panic 和 recover
源码
panic
和 recover
的源码在 Go 源码的 src/runtime/panic.go
里,名为 gopanic
和 gorecover
的函数。
// 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
中调用了recover
的g
的 sp 和 pc - 恢复到
defer
中recover
后面的处理逻辑
都有哪些坑
前面提到,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.go
的 chansend
函数中,如下图:
// 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相关推荐
- go nil json.marshal 完是null_字节跳动踩坑记#3:Go服务灵异panic
这个坑比较新鲜,刚填完,还冒着冷气. - 1 - 在字节跳动,我们服务的所有 log 都通过统一的日志库采集到流式日志服务.落地 ES 集群,配上字节云超(sang)级(xin)强(bing)大(ku ...
- python从入门到实践django看不懂_Python编程:从入门到实践踩坑记 Django
<>踩坑记 Django Django Python 19.1.1.5 模板new_topic 做完书上的步骤后,对主题添加页面经行测试,但是浏览器显示 服务器异常. 个人采用的开发环境是 ...
- 微信公众号服务器配置但没有回调,微信公众号 “服务器配置” 踩坑记
前言 今天工作的时候,碰到一个极其无语的关于微信公众号的坑,为此,我语言攻击了腾讯的机器人客服.然而并没有卵用...万万没想到,我还是解决了这个问题,并记录下踩坑经历,希望能帮到一些朋友吧. 背景 今 ...
- GoDB开发踩坑记(代码实现)
前言 之前写了一篇GoDB开发踩坑记但是内容有些不全,所以来补充一下.所以没看过GoDB开发踩坑记的可以先看一下那篇文章. 正文 golang encode_josn--把map[string]int ...
- 口罩、安全帽识别比赛踩坑记(一) 经验漫谈及随想
前言 因为疫情迎来的史无前例大假期,从开始理直气壮的天天划手机,到中间百无聊赖的躺尸,再到之后实在憋得慌,就想找点什么事搞一搞.恰好这时,一直关注的极视角联合 Intel 公司举办了一个对口罩和安全帽 ...
- 东八区转为0时区_踩坑记 | Flink 天级别窗口中存在的时区问题
❝ 本系列每篇文章都是从一些实际的 case 出发,分析一些生产环境中经常会遇到的问题,抛砖引玉,以帮助小伙伴们解决一些实际问题.本文介绍 Flink 时间以及时区问题,分析了在天级别的窗口时会遇到的 ...
- Spring @Transactional踩坑记
@Transactional踩坑记 总述 Spring在1.2引入@Transactional注解, 该注解的引入使得我们可以简单地通过在方法或者类上添加@Transactional注解,实现事务 ...
- 服务器重新部署踩坑记
服务器重新部署踩坑记 Intro 之前的服务器是 Ubuntu 18.04 ,上周周末想升级一下服务器系统,从 18.04 升级到 20.04,结果升级升挂了... 后来 SSH 始终连不上,索性删除 ...
- IdentityServer 部署踩坑记
IdentityServer 部署踩坑记 Intro 周末终于部署了 IdentityServer 以及 IdentityServerAdmin 项目,踩了几个坑,在此记录分享一下. 部署架构 项目是 ...
最新文章
- Centos7 下安装VIM编辑器
- python进行探索性数据分析EDA(Exploratory Data Analysis)分析
- eclipse安装lombok后无法启动解决办法
- 网上一个仿TP挂钩内核的源码
- sqlserver2008 创建支持文件流的数据库
- 第一节:复习委托,并且通过委托的异步调用开启一个新线程和异步回调、异步等待
- 生长区域算法的php实现
- centos 6.3+mysql+5.6+nginx 1.5.8
- 对短链接服务暴露的URL进行网络侦察
- 卡巴斯基7.0如何设置授权文件
- 技术对接场景,打破创新窘境
- 流水灯c语言代码switch,单片机C语言入门之六switch case语句流水灯
- Emmagee——开源Android性能测试工具
- node scripts/install.js 安装失败解决办法
- python量化交易策略实例_实践《Python与量化投资从基础到实战》PDF代码+《量化交易之路用Python做股票量化分析》PDF代码解释...
- 三页搞定GB2818/SIP/RTP、PS封装
- Final Cut Pro资源库占用内存太大如何释放磁盘空间?
- EOS智能合约开发系列(13): 多签合约代码分析(二)
- 对猫眼T100进行简单数据分析
- 领英精灵和领英助理哪个好?为什么领英精灵是LinkedIn最好的配套工具?