Как справиться с паникой и восстановиться в голанге

Go

Надпись

С момента своего выпуска язык Go известен своей высокой производительностью и высоким уровнем параллелизма. Поскольку стандартная библиотека предоставляет пакет http, даже начинающие программисты могут легко писать служебные программы http.

Однако у всего есть две стороны. Язык с его гордыми преимуществами также должен скрывать много ям. Если новичок не знает этих ям, легко в них упасть. Серия сообщений в блоге «Путь в го» будет написана на языке го.panicиrecoverВ начале я познакомлю вас с различными ямами, на которые наступил автор, и методом их заполнения.

Введение в панику и восстановление

  • panic

panicСлово имеет на английском языке恐慌、恐慌的и так далее. В буквальном смысле в языке Go это представляет собой чрезвычайно серьезную проблему, проблему, которой больше всего боятся программисты. Оказавшись там, это означает конец программы и выход. на языке гоpanicКлючевые слова в основном используются для активного создания исключений, подобныхjavaна других языкахthrowключевые слова.

  • recover

recoverЭто слово на английском языке恢复、复原и так далее.从字面意思理解的话,在 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и другие механизмы изменят текущее состояние программы (в основном это регистр указателя стека процессора sp и счетчик программы pc , Go'srecoverзависитdeferподдерживать sp и pc ) вthrow,panic,longjumpв общей памяти. Когда есть исключение, извлеките из памяти ранее сохраненные значения регистров sp и pc, напрямую перенесите стек функций обратно в позицию, на которую указывает sp, и выполните следующую инструкцию, на которую указывает регистр ip, чтобы восстановить программу. из ненормального состояния в нормальное состояние.

Глубоко погрузиться в панику и восстановиться

исходный код

panicиrecoverИсходный код находится в исходном коде Gosrc/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 _panic
	p.arg = e
	p.link = gp._panic
	gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

	atomic.Xadd(&runningPanicDefers, 1)

	for {
		d := gp._defer
		if d == nil {
			break
		}

        // 如果触发 defer 的 panic 是在前一个 panic 或者 Goexit 的 defer 中触发的,那么将前一个 defer 从列表中去除。前一个 panic 或者 Goexit 将不再继续执行。
		if d.started {
			if d._panic != nil {
				d._panic.aborted = true
			}
			d._panic = nil
			d.fn = nil
			gp._defer = d.link
			freedefer(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 = nil
		d.fn = nil
		gp._defer = d.link

		// 这里用 GC() 来触发堆栈收缩以测试堆栈拷贝。由于是测试代码,所以注释掉了。参考 stack_test.go:TestStackPanic
		//GC()

		pc := d.pc
		sp := 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 signal
				gp.sig = 0
			}
			// 将正在恢复的栈帧传给 recovery。
			gp.sigcode0 = uintptr(sp)
			gp.sigcode1 = pc
			mcall(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._panic
	if p != nil && !p.recovered && argp == uintptr(p.argp) {
		p.recovered = true
		return p.arg
	}
	return nil
}

Из кода функции мы видимpanicОсновной внутренний процесс выглядит следующим образом:

  • Получить текущее местоположение вызывающего абонентаg, это,goroutine
  • пройти и выполнитьgсерединаdeferфункция
  • еслиdeferВ функции есть вызовrecover, и обнаружил, что произошлоpanic, тоpanicотметить какrecovered
  • пересекаяdeferпроцесс, если открытие было помечено какrecovered, затем извлекитеdefersp и pc, хранится вgв двух полях кода состояния.
  • перечислитьruntime.mcallсократить доm->g0и прыгать вrecoveryфункция, которая преобразует ранее полученныеgпередается как параметр вrecoveryфункция.runtime.mcallКод находится в исходном коде gosrc/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-8
    MOVQ	fn+0(FP), DI

    get_tls(CX)
    MOVQ	g(CX), AX	// save state in g->sched
    MOVQ	0(SP), BX	// caller's PC
    MOVQ	BX, (g_sched+gobuf_pc)(AX)
    LEAQ	fn+0(FP), BX	// caller's SP
    MOVQ	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 fn
    MOVQ	g(CX), BX
    MOVQ	g_m(BX), BX
    MOVQ	m_g0(BX), SI
    CMPQ	SI, AX	// if g == m->g0 call badmcall
    JNE	3(PC)
    MOVQ	$runtime·badmcall(SB), AX
    JMP	AX
    MOVQ	SI, g(CX)	// g = m->g0
    MOVQ	(g_sched+gobuf_sp)(SI), SP	// sp = m->g0->sched.sp
    PUSHQ	AX
    MOVQ	DI, DX
    MOVQ	0(DI), DI
    CALL	DI
    POPQ	AX
    MOVQ	$runtime·badmcall2(SB), AX
    JMP	AX
    RET

Причина, по которой мы хотим вырезать здесьm->g0, в основном из-за Goruntimeокружение должно иметь собственный стек иgoroutinerecoveryв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.sigcode0
      pc := 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 = sp
      gp.sched.pc = pc
      gp.sched.lr = 0
      gp.sched.ret = 1
      gogo(&gp.sched)
  }
// src/runtime/asm_amd64.s 第 274 行

// func gogo(buf *gobuf)
// restore state from Gobuf; longjmp
TEXT runtime·gogo(SB), NOSPLIT, $16-8
    MOVQ	buf+0(FP), BX		// gobuf
    MOVQ	gobuf_g(BX), DX
    MOVQ	0(DX), CX		// make sure g != nil
    get_tls(CX)
    MOVQ	DX, g(CX)
    MOVQ	gobuf_sp(BX), SP	// 从 gobuf 中恢复 SP ,以便后面做跳转
    MOVQ	gobuf_ret(BX), AX
    MOVQ	gobuf_ctxt(BX), DX
    MOVQ	gobuf_bp(BX), BP
    MOVQ	$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

Перейдите в основе обработки поток вышеперечисленного ненормальны, оптимизируется на три этапа:

  • deferЗвонить в функциюrecover
  • курокpanicи сократить доruntimeОкружающая среда получается вdeferпозвонил вrecoverизgсп и ПК
  • возобновленоdeferсерединаrecoverЛогика обработки позади

Что такое ямы

Как упоминалось ранее,panicФункции в основном используются для активного запуска исключений. Когда мы внедряем бизнес-код, на этапе запуска программы, если есть ошибка в инициализации ресурса, мы можем активно вызыватьpanicНемедленно завершите программу. Для новичков это легко и просто.

Однако реальность часто бывает жестокой - Go'sruntimeВо многих частях кода называютсяpanicФункция, для новичков, которые не понимают лежащей в основе реализации Go, это, несомненно, копание глубоких ям. Если вы не знакомы с этими ямами, невозможно написать надежный код Go.

Далее автор даст вам подробный счет ям.

  • Нижний индекс массива (среза) выходит за пределы
    Это легко понять: для статически типизированных языков выход индекса массива за границы является фатальной ошибкой. Можно проверить следующий код:
package main

import (
    "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 range

goroutine 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
  • доступ к неинициализированному или нулевому указателю
    Для тех, у кого есть опыт разработки на C/C++, это легко понять. Но это самый распространенный тип ошибок для новичков, которые никогда не использовали указатели. Можно проверить следующий код:
package main

import (
    "fmt"
)

func foo(){
    defer func(){
        if err := recover(); err != nil {
            fmt.Println(err)
        }
    }()
    var bar *int
    fmt.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
  • Попытка закрытьchanотправить данные
    это просто обучениеchanтипичные ошибки новичков. Можно проверить следующий код:
package main

import (
    "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

Если вы закомментируете recovery , будет выведено:

panic: send on closed channel

goroutine 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 int64
    if 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 = 0
    if t0 != 0 {
        mysg.releasetime = -1
    }
    // No stack splits between assigning elem and enqueuing mysg
    // on gp.waiting where copystack can find it.
    mysg.elem = ep
    mysg.waitlink = nil
    mysg.g = gp
    mysg.isSelect = false
    mysg.c = c
    gp.waiting = mysg
    gp.param = nil
    c.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 = nil
    if gp.param == nil {
        if c.closed == 0 {
            throw("chansend: spurious wakeup")
        }
        panic(plainError("send on closed channel"))
    }
    gp.param = nil
    if mysg.releasetime > 0 {
        blockevent(mysg.releasetime-t0, 2)
    }
    mysg.c = nil
    releaseSudog(mysg)
    return true
}
  • Чтение и запись одной и той же карты одновременно

Для студентов, которые только изучают параллельное программирование, одновременное чтение и запись карты также является проблемой, с которой легко столкнуться. Можно проверить следующий код:

  package main

  import (
      "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 write

  goroutine 5 [running]:
  runtime.throw(0x4bd8b0, 0x21)
      /home/letian/.gvm/gos/go1.12/src/runtime/panic.go:617 +0x72 fp=0xc00004c780 sp=0xc00004c750 pc=0x427f22
  runtime.mapaccess1_fast64(0x49eaa0, 0xc000088180, 0x1, 0xc0000260d8)
      /home/letian/.gvm/gos/go1.12/src/runtime/map_fast64.go:21 +0x1a8 fp=0xc00004c7a8 sp=0xc00004c780 pc=0x40eb58
  main.foo.func2(0xc000088180)
      /home/letian/work/go/src/test/test.go:21 +0x5c fp=0xc00004c7d8 sp=0xc00004c7a8 pc=0x48708c
  runtime.goexit()
      /home/letian/.gvm/gos/go1.12/src/runtime/asm_amd64.s:1337 +0x1 fp=0xc00004c7e0 sp=0xc00004c7d8 pc=0x450e51
  created by main.foo
      /home/letian/work/go/src/test/test.go:14 +0x68

  goroutine 1 [runnable]:
  main.foo()
      /home/letian/work/go/src/test/test.go:25 +0x8b
  main.main()
      /home/letian/work/go/src/test/test.go:30 +0x22
  exit status 2

Аккуратных друзей найти не сложно, в выводе лога на печать в конце программы нет явки.exit, но напрямую печатает стек вызовов. Проверятьsrc/runtime/map.goНетрудно найти эти строки в коде:

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

В отличие от нескольких упомянутых выше случаев,runtimeвызыватьthrowИсключение, генерируемое функцией, не может быть передано в бизнес-код.recoverЗахваченный, это самый смертоносный. Поэтому для одновременного чтения и записи карты карта должна быть заблокирована.

  • Утверждение типа
    При использовании утверждений типа противinterfaceТакже легко случайно наступить на яму при преобразовании типов, и эта яма готова к использованию.interfaceЭто также проблема, которую люди склонны игнорировать какое-то время. Можно проверить следующий код:
package main

import (
    "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 *_type
    if have != nil {
        t = have._type
    }
    panicdottypeE(t, want, iface)
}

больше паники

Упомянутые выше — это лишь некоторые из тех, которые часто встречаются в базовой грамматике.panicсценарии, в стандартной библиотеке Go есть больше примененийpanicгде можно искать в исходном кодеpanic(Найдите место для вызова, чтобы потом не наступить на яму при использовании функции стандартной библиотеки.

  限于篇幅,本文暂不介绍填坑技巧,后面再开其他篇幅逐个介绍。 Спасибо за чтение!

Следующее замечание

Канал и горутина на языке Go

рекомендуемая статья

Как использовать Go для создания системы всплесков трафика на уровне миллионов

Отсканируйте код, чтобы подписаться на официальный аккаунт

在这里插入图片描述