看完本文你将速通以下内容:
这几把啥:进程线程协程,GMP模型->抢占式调度,工作窃取,内存模型
快速上手:Goroutine,Channel->Buffered/Unbuffered, Nil/Closed 读写panic行为,Select->Timeout (超时控制),For Select (循环监听)
由于篇幅问题下面内容将放在下一篇:
同步与安全:WaitGroup,互斥锁,读写锁,Sync Map,Sync Once,Sync.Pool (GC优化),Sync.Cond
上下文:Context Base,Cancellation (取消),Timeout/Deadline (截止时间),Values (传值),Context 的父子传递与内存泄漏风险
模式与工具:原子操作(CAS),工作池,扇入扇出,协程泄漏,ErrGroup,优雅退出 (Graceful Shutdown),流水线 (Pipeline),限流 (Rate Limit)
调试与诊断工具:Pprof(CPU/Mem/Block/Mutex profile),Trace(可视化调度流程),死锁检查,Race Detector (go build -race),逃逸分析
先跑起来
看下面这些东西前,先看这个案例感受到Go并发的强大
同样的任务,创建1千个子任务,每个都是干同一件事就是生成两个1~99的随机数相加然后直接return
如果使用传统win32 api,使用CreateThread,在Go里就是这样写的
1 | package main |
看看它的执行时间,使用了108毫秒。后续的运行可能会比第一次快,因为Windows有个SuperFetch机制。可以多运行几次看看耗时。我这里差不多是80~120毫秒
1 | PS C:\go-start> go build .\hello.go |
而如果切换成Go的并发写法,也就是使用Go的协程
1 | package main |
来看看有多快,只用了14毫秒:
1 | PS C:\go-start> go build .\hello.go |
这几把啥
Do not communicate by sharing memory; instead, share memory by communicating.
不要通过共享内存来通信,而应通过通信来共享内存。
进程?线程?协程?
需要极高的隔离性和稳定性(一个挂了不影响另一个),用进程。
需要利用多核 CPU 进行计算密集型任务,用线程。
面临高并发 IO(如处理 10万个 Socket 连接),且逻辑复杂,用协程(避免了 10万个线程带来的巨大内存消耗和调度开销)。
概念与区别
| 特性 | 进程 (Process) | 线程 (Thread) | 协程 / 纤程 |
|---|---|---|---|
| 定义 | 资源分配的基本单位 | CPU调度的基本单位 | 用户态的轻量级线程 |
| 内存空间 | 独立的虚拟地址空间 | 共享进程的地址空间 | 共享进程的地址空间 |
| 调度者 | 操作系统内核 (OS Kernel) | 操作系统内核 (OS Kernel) | 用户程序 (Runtime/程序员) |
| 切换开销 | 极大 (切页表、切缓存) | 较大 (切寄存器、切内核栈) | 极小 (仅切少量寄存器) |
| 通信机制 | IPC (管道、套接字、共享内存) | 直接读写共享变量 + 锁 | 直接读写共享变量 (通常无需锁) |
| 并行性 | 多进程可并行 (多核) | 多线程可并行 (多核) | 并发不并行 (同一时间通常只在一个线程上跑) |
底层原理
以Windows操作系统为例,Windows 是基于抢占式多线程的操作系统。
看不懂就不要强求了如果你没有Windows相关开发经验的话
1. 进程 (Process)
内核对象:在内核中由 EPROCESS 结构体表示。
进程本质上是一个容器。它不执行代码,它拥有一个私有的虚拟地址空间(Virtual Address Space)、一个句柄表(Handle Table,用于管理文件、窗口等资源)和安全上下文(Token)。
每个进程至少包含一个主线程。
Windows 通过页表(Page Tables)映射,保证进程 A 无法直接访问进程 B 的内存。
2. 线程 (Thread)
内核对象:在内核中由 ETHREAD 结构体表示。
线程是 Windows 调度器真正调度的对象。每个线程拥有自己的栈(包括内核栈和用户栈)、程序计数器(PC/RIP)和寄存器上下文。
在用户态,每个线程有一个 TEB (Thread Environment Block),用于存储线程局部存储(TLS)和异常处理链表。
Windows 使用基于优先级的轮转调度。当线程时间片用完或被更高优先级抢占时,内核会触发上下文切换(Context Switch),保存当前线程寄存器状态到内核栈,加载新线程状态。需要从用户态(Ring 3)陷入内核态(Ring 0)。
3. 协程 (Coroutine) & 纤程 (Fiber)
协程:是编程语言层面实现的。本质上是“用户态线程”。操作系统不知道协程的存在,它只看到承载协程的线程。
纤程 (Fiber):Windows 操作系统层面提供的“协程”机制。
Windows 内核将纤程视为轻量级对象,但调度由用户手动控制(非抢占式)。内核只调度线程,线程内部的代码逻辑决定运行哪个纤程。
纤程有自己的栈和一部分寄存器上下文,保存在用户空间。
使用与开销和性能分析
1. 进程 (Process)
相关API: 创建:CreateProcess,结束:ExitProcess, TerminateProcess,获取信息:OpenProcess, GetProcessId
其中CreateProcess是最复杂的 API 之一。它会创建内核对象、分配 4GB(32位)或更巨大的(64位)虚拟内存空间、加载 EXE 和 DLL、创建主线程。
内存开销: 极高,因为需要独立的页目录、页表,虽然利用写时复制(COW),但初始启动仍需数 MB 到数 GB 不等。
进程切换/调度最慢(> 几微秒),切换页表目录基址(CR3 寄存器)。这导致 CPU 的 TLB(转换后备缓冲器)失效,TLB 失效意味着内存访问速度大幅下降(直到缓存热身完毕),并且切换的操作包含线程切换的所有步骤。
2. 线程 (Thread)
相关API:创建:CreateThread (Win32 API) / _beginthreadex (C Runtime),挂起/恢复:SuspendThread, ResumeThread,同步:WaitForSingleObject (等待线程结束), InitializeCriticalSection (锁),睡眠:Sleep (主动放弃 CPU 时间片)CreateThread会在当前进程空间内分配栈内存,创建内核对象,加入调度队列。
内存开销:中等,虽然ETHREAD 结构体约 1-2KB,但是默认情况下,Windows 为每个线程预留1MB的栈空间
线程切换/调度慢 (约 1-5 微秒)。
- 模式切换:用户态 -> 内核态 -> 用户态(Ring 3 <-> Ring 0)。这涉及 CPU 特权级检查和中断处理。
- 保存上下文:将通用寄存器、浮点寄存器保存到内核栈。
- 调度算法:内核需要扫描就绪队列,计算优先级。
- 缓存污染:新线程的代码和数据可能不在 L1/L2 Cache 中。
3. 协程 (Coroutine) & 纤程 (Fiber)
语言级协程没有统一的 Win32 API,Windows的纤程有但是这里不展开了
内存开销:极低,Go 协程初始 2KB,动态扩容,C++20 无栈协程甚至只占用堆上的一个数十字节的状态帧结构体
协程切换/调度:极快 (约 10-200 纳秒),因为不涉及内核态切换,没有系统调用,只需保存几个被调用者保存寄存器(Callee-saved registers)和栈指针(SP)。切换目标通常由代码直接指定(如 Yield 或 SwitchToFiber),复杂度为 O(1)。因为还在同一个线程内,L1/L2 Cache 大概率还有效。
GMP模型
GMP 是 Go 运行时(Runtime)实现的一套用户态调度系统。它在操作系统内核线程之上,构建了一层更轻量的调度逻辑,目的是让极少的内核线程(M)支撑起极多的 Go 协程(G)。这是M:N 调度模型(M个协程映射到 N个内核线程)。
G M P
G (Goroutine) —— 砖头
含义:Go 协程。
本质:它是 Go 语言层面的线程,非常轻量。
资源:初始栈只有 2KB(OS 线程通常 1-2MB),可动态伸缩。
包含:函数代码、指令指针(PC)、栈指针(SP)、状态(等待、运行等)。
角色:相当于“要搬的砖”或者“要执行的任务”。
M (Machine) —— 工人
含义:工作线程(OS Kernel Thread)。
本质:操作系统内核线程,对应底层的 Windows/Linux 线程。
资源:开销大,创建和切换由操作系统管理(涉及内核态切换)。
角色:相当于“搬砖的工人”。只有 M 才能真正利用 CPU 执行指令。
一个 OS 线程(M)必须绑定一个逻辑处理器(P),才能执行 P 本地队列里的协程(G)。
P (Processor) —— 小推车
含义:逻辑处理器(Context)。
本质:运行 G 所需的资源上下文。
数量:由 GOMAXPROCS 决定,通常等于 CPU 核心数。
包含:本地队列(Local Run Queue)。这是 P 的核心,存储着等待被 M 执行的 G。
角色:相当于“手推车”或“工位”。工人(M)必须拿到手推车(P),才能去装砖头(G)干活。
在同一时刻,一个 P 只能被一个 M 绑定。
调度策略
本地队列 —— 减少锁竞争
早期的 Go 只有 G 和 M,所有 G 都放在一个全局队列,M 获取 G 时需要猛烈地抢全局锁,性能极差。
现在每个 P 都有自己的本地队列。M 绑定 P 后,优先从 P 的本地队列取 G 执行。这几乎不需要加锁,因为一个 P 此时只属于一个 M,极大地提高了效率。
工作窃取 —— 负载均衡
M1 绑定的 P1 里的 G 执行完了,没事干了。M1 不会闲着,它会尝试从其他 P(比如 P2)的本地队列里偷一半 G 过来自己执行。所有 CPU 核心都能一直保持忙碌,不会出现“一核有难,八核围观”的情况。
调度循环
当一个 M 想要执行任务时,它会按照以下顺序寻找 G:
- 检查自己绑定的 P 的本地队列。如果有,直接取出来干。(无锁,最快)。
- 为了防止全局队列里的 G 饿死,M 会以一定的概率(每 61 次调度检查 1 次)去检查全局队列。(需要加锁)。
- 检查网络轮询器,看是否有网络 IO 就绪的 G。(非阻塞)。
- 开偷:如果上面都没找到,M 就开始“偷”了。它会随机选择另一个 P(Victim),试图从它的本地队列里偷走一半的 G。
- 如果连偷都偷不到,M 就会解绑 P,进入休眠状态,放入空闲 M 列表。
细节
为什么偷一半?偷 1 个的话,频率太高,频繁加锁/CAS 操作开销大。全偷走的话,原来的 P 变空了,马上又得去偷别人的,造成震荡。偷一半是权衡后的最佳策略,既分担了负载,又减少了竞争。
在进入休眠前,M 会进行一段时间的“自旋”(空转循环),持续尝试窃取。这是因为创建/唤醒线程的开销很大,如果只是短暂没活干,不如空转一会儿等待新任务,这比让线程睡眠再唤醒要快得多。
系统调用 —— 避免阻塞
这是解决“一个线程阻塞导致整个 CPU 闲置”的关键。
M1 正在执行 G1,G1 调用了阻塞的系统调用(比如读取文件、网络请求),导致 M1 必须在内核态等待,无法继续利用 CPU。
- 分离:P1 发现 M1 被阻塞了,马上把 M1 踢开(解绑)。
- 接管:P1 寻找(或新建)一个新的线程 M2 来绑定自己,继续执行队列里剩下的 G。
- 回归:当 M1 的系统调用结束,它会尝试获取一个空闲的 P。如果拿不到,就把 G1 扔到全局队列,M1 自己进入休眠(或被回收)。
抢占式调度
在 Go 1.14 之前,Go 的调度其实是“协作式”的(尽管有一些补丁),但在 Go 1.14 引入了基于信号的异步抢占后,它变成了真正的抢占式。如果是协作式调度,如果一个 G 写了个死循环 for {},它会一直霸占 M,导致该 P 上的其他 G 饿死。如果 GOMAXPROCS 较小,甚至会导致整个程序停止响应(GC 也无法运行)
Go 1.14 及以后,利用的是操作系统的信号机制。实现真正抢占式
- 监控:
sysmon是一个特殊的独立线程(不绑定 P),它每隔一段时间(10ms - 10ms)醒来检查所有的 P。 - 发现:如果发现某个 G 运行时间超过了 10ms,
sysmon判定它需要被抢占。 - 发信号:
sysmon向运行该 G 的线程(M)发送一个操作系统信号(Linux 下通常是SIGURG)。 - 中断:M 收到信号,操作系统中断 M 当前的执行流,挂起用户代码,转而去执行信号处理函数。
- 插桩:Go 注册的信号处理函数会修改 M 的程序计数器(PC)和栈指针(SP),将其指向一个特定的 Go 运行时函数
asyncPreempt。 - 切换:当信号处理返回,M 不再回到原来的代码位置,而是去执行
asyncPreempt。这个函数会保存当前 G 的上下文(Context),将其放入全局队列(Global Queue),然后 M 会去调度下一个 G。
内存模型
内存分配机制
Go 的内存分配器是基于 Google 的TCMalloc算法改进的。它和 GMP 模型高度结合,解决了多线程内存分配需要频繁加锁的问题。
mcache (P 的本地缓存 - 无锁):
- 每个 P 都有一个
mcache。 - 因为一个 P 在同一时间只能被一个 M 拥有,所以 M 从 P 的 mcache 分配内存是不需要加锁的,这就是 Go 内存分配极快的原因。
- 它维护了不同大小规格(Span Class)的空闲链表,专门用于分配小对象(<= 32KB)。
- 每个 P 都有一个
mcentral (中心缓存 - 需要锁):
- 当 P 的
mcache里的内存用完了,它会向mcentral申请一批新的内存块。 mcentral是所有 P 共享的,所以需要加锁。
- 当 P 的
mheap (全局堆 - 需要锁):
- 当
mcentral也没内存了,就向操作系统申请(虚拟内存),由mheap管理。
- 当
- 微对象/小对象:从 P 的
mcache拿。 - mcache 不够:去
mcentral批发一组。 - 大对象 (>32KB):从
mheap分配。
内存一致性
在一个协程里对变量 A 的修改,什么时候能被另一个协程看到?
Go保证了以下核心的先行发生规则,只要遵循这些就能保证内存安全:
- 初始化:
main函数的启动一定发生在所有init函数执行完成之后。 - Goroutine 创建:
go func()语句的执行,一定发生在协程内部代码执行之前。 - Channel:
- Send -> Receive:对一个 Channel 的发送操作,一定发生在接收操作完成之前。
- Close -> Receive:Channel 的关闭操作,一定发生在接收方收到“零值”之前。
- (注意:无缓冲 Channel 的接收,发生在发送完成之前。这是一种同步点。)
- Lock:
Unlock()一定发生在该锁被下一次Lock()之前。这意味着你必须先释放锁,别人才能拿到,以此保证临界区数据的可见性。
快速上手
你已经精通了基本概念,现在速速开始写代码
Goroutine
Goroutine,也就是这里称作协程的用户态线程。这是 Go 的原子级并发单位。
注意:main 函数本身也是一个协程。如果 main 结束了,所有的 Goroutine 都会被强制杀掉(不管有没有执行完)。通常需要 sync.WaitGroup 来等待。
1 | package main |
运行结果如下:
1 | PS C:\go-start> go run .\hello.go |
("Goroutine-1")何意味
传值给go func(name string)这个匿名函数的。上一篇迁移的文章可能没有提到,匿名函数的语法是go func(参数声明) { 函数体 }(实际传入的值)
func(name string) 定义了匿名函数接收一个字符串参数。末尾的 ("Goroutine-1") 就像普通函数调用一样,把字符串传给了 name。
前面的 go 关键字告诉 Go 运行时:请在一个新的协程里异步执行这个函数调用。
wg.Add(1)何意味
如果你熟悉别的语言多线程开发的肯定这里会有疑惑,看得懂wg.Wait()是等协程全干完活的意思,但是wg.Add(1)何意味?不用指定是哪个协程?
如果是Windows创建线程要用WaitForSingleObject然后指定线程句柄等待结束,go里直接wg.Add(1)怎么知道我要等哪个协程?万一只有一个要等别的不等无所谓呢?
这就是 Go 协程和 Windows/C++ 线程模型最大的区别之一:sync.WaitGroup 本质上只是一个“计数器”,它完全不关心是谁在执行。
Windows WaitForSingleObject 逻辑:主线程盯着那个具体的句柄(Handle),看有没有完成。这是一种“强绑定”的关系。wg.Add(1):计数器 +1(“有 1 件事还没办完”)。wg.Done():计数器 -1(“有 1 件事办完了”)。wg.Wait():阻塞,直到计数器归零。
它不知道、也不需要知道是哪个协程。wg 只看加了多少,减了多少。它不记得哪个协程加的,哪个协程减的。 只要有一个协程执行了 Done(),计数器就会减 1。
Go的哲学是“高并发”。假设启动了10000个协程去爬网页,肯定不想维护10,000个句柄。只想说:“我有10,000个任务,全部减到0了告诉我一声就行”。
如果 Add 的次数比 Done 多,Wait 会死锁;如果 Done 多了,程序会 Panic。
Channel
上面还有一个没解决的问题就是,万一只有一个协程要等别的不等无所谓呢?
可以使用多个WaitGroup,定义 wg1 和 wg2。wg1.Wait() 只会等那些调用了 wg1.Done() 的协程。
Channel 是协程之间通信的管道。如果习惯了Windows那种“拿到一个句柄/标志”来判断,Go 里的Channel(通道) 才是对应的工具。
1 | package main |
运行结果如下:
1 | PS C:\go-start> go run .\hello.go |
在继续前,先了解<-操作符
<-操作符
在Go中,<- 符号是 通道操作符。它的含义非常直观:数据流向哪里,箭头就指向哪里。
在上面的示例代码里有两个作用,分别是发送数据和接收数据。
done <- true:箭头在通道变量 done 的右边,把右边的值(true)“塞进”左边的通道(done)里。就是通道 <- 数据 ,即数据流入通道。
<-done : 箭头在通道变量 done 的左边。从通道 done 中“取出来”一个值。就是<- 通道 ,即数据从通道流出。
为什么用它来等指定协程?
阻塞是Go协程同步的核心机制。默认情况下,通道的发送和接收都是阻塞的。
当主线程运行到 <-done 时,它会像看门大爷一样在那里等着。如果 done 里面没有东西,主线程就停在这一行,不再往下执行。
此时,子协程正在 time.Sleep。等子协程睡醒了,执行 done <- true,把一个值放进了通道。
主线程发现通道里有东西了,立刻取出这个值,然后解除阻塞,继续执行后面的 fmt.Println。
接收并赋值
数据从左边流出,所以其实也可以把管道里的数据取出来并且赋值
1 | func main() { |
运行结果就是
1 | 正在干活... |
通道缓冲
- 无缓冲 (Unbuffered):
make(chan int)- 同步通信。发送方(Sender)会阻塞,直到有一个接收方(Receiver)准备好拿数据。就像“快递员必须亲手把货交给你,否则他不走”。
- 有缓冲 (Buffered):
make(chan int, 3)- 异步通信。只要缓冲区没满,发送方发完就走,不阻塞。如果满了,发送方阻塞。就像“快递员把货放快递柜”。
1 | func main() { |
运行的结果是:
1 | 发送方: 开始发送 |
上面的无缓冲就不多说了,刚刚就是使用无缓冲通道的机制实现等待指定协程结束
打个比方
缓冲和栈的顺序不同,栈是后进先出,而缓冲是先进先出。所以可以把缓冲理解成队列
既然如此,可以把ch2想象成一个传送带
make(chan string, 2):买了两个格子的传送带。ch2 <- "A":把 A 放到了第一个格子里。因为还有空位,不需要等,放完就走(非阻塞)。ch2 <- "B":把 B 放到了第二个格子里。此时格子满了,但放完了,还是可以继续走(非阻塞)。ch2 <- "C":如果再放 C,因为传送带满了,必须在那等着,直到有人从另一头拿走一个。
让我看看
可以使用两个内置函数查看缓冲的状态:
cap(ch2):查看缓冲通道总容量。len(ch2):查看现在缓冲通道已经使用的量。
没人等我啊
如果尝试把上面那个ch2 <- "C"取消注释后运行就会发现,程序没有卡死而是直接坠机了
1 | 发送方: 开始发送 |
因为Go有一个内置的“死锁检测机制
为什么不等
到ch2 <- "C" 时,主线程其实尝试在那里等。
但是,Go 运行时在后台巡视了一圈,发现:主线程在等别人从 ch2 取走数据。其他的协程:一个都没有!全都在睡觉或者退出了
主线程在等一个永远不会发生的信号,因为没人能从通道取数据了,如果不报错,这个程序就会永远卡死在内存里浪费电。所以干脆报错退出
fatal error: all goroutines are asleep - deadlock!
致命错误:所有的协程都睡着了 ——死锁了!
给我等
留一个活着的协程晚点再去从通道里取数据,这样主线程就会等这个协程了
1 | func main() { |
运行结果是
1 | 主线程:准备放 C... |
我给那个匿名函数加了个sync.WaitGroup来等,不加的话可能子协程还没来得及输出子协程:拿走了 [A]这句话,主线程就取出B和C然后结束了
- 阻塞(Wait)是功能:当通道满了或空了,协程会进入等待状态。
- 死锁(Deadlock)是错误:当 Go 发现所有存活的协程都在互相等待,或者都在等待一个不可能到达的信号时,它会主动Panic,防止程序变成僵尸进程。
遍历Channel
可以直接使用for v := range ch2,由于通道的特性,Channel里每新增一个数据,for循环里的代码就会立即执行。
1 | func main() { |
运行的结果是
1 | 【接收方】开始巡逻(进入 range)... |
关闭Channel
如果业务逻辑不需要通过“关闭信号”来通知接收者停止工作,只是简单地把 Channel 当作同步工具或数据队列,且不涉及 range 循环,那么完全可以不用手动关闭Channel。当程序运行结束或不再使用时,GC会处理它。
但是如果要用for range 遍历Channel或者其他需要明确告诉对方“我的任务彻底结束了”时。就需要手动调用 close(ch)关闭Channel
Channel 什么时候会关闭?
Channel不会自动关闭,必须手动调用 close(ch)。
通常在以下两种场景下需要关闭:
1.循环读取 Channel(比如使用 for range)时,如果不关闭,接收方会一直阻塞在那里等。只有关闭了,range 循环才会正常结束。
2.广播下班信号:关闭 Channel 会瞬间让所有阻塞在“接收”状态的协程收到信号。
广播下班信号
这个特性是 Go 语言实现“广播信号”的技巧。因为从一个已经关闭的 Channel 读取数据,不会阻塞,而是会立刻返回该类型的“零值”以及一个“失败”标志。
如果往通道发一个数据( ch <- 1),只有一个正在等待的协程能抢到这个数据,抢完后通道又空了,其他协程继续等。
如果关闭通道(close(ch)),所有正在等待的协程都会瞬间“被唤醒”,并收到通道已关闭的消息。
举个例子,百米赛跑三个运动员在等下班
1 | func runner(name string, startChan chan struct{}, wg *sync.WaitGroup) { |
运行结果就是
1 | 运动员-B 已就位,等待起跑口令... |
通过关闭通道实现了广播下班信号
未初始化的 Channel
简而言之就是nil Channel。
1 | var ch chan int // 只是声明了,没有 make,此时 ch 是 nil |
它的行为非常怪异且危险:
往里发数据:永久阻塞(Deadlock)。
从里取数据:永久阻塞(Deadlock)。
关闭它:直接 Panic。
但不是一无是处,在高级 select 切换中很有用。比如暂时想禁用某个 Case,可以把那个 Channel 设为 nil。后文会说
Channel的行为表
| 操作 | nil channel (未初始化) | closed channel (已关闭) | open channel (正常) |
|---|---|---|---|
| Close | Panic | Panic | 成功关闭 |
| Send (写) | 永久阻塞 | Panic | 阻塞直到满或被读 |
| Receive (读) | 永久阻塞 | 没有数据的时候返回零值 | 阻塞直到有数据 |
向已关闭的 channel 发送数据会 Panic。读已关闭的 channel 不会阻塞,它会一直返回该类型的零值(0, “”, nil, false)。
Select
select 类似于 Switch,但它用于 Channel。它会随机(防止某个通道总是被忽略) 选择一个可以执行的 case 执行。
举个例子:
1 | func main() { |
这样的运行结果是
1 | 消息来自 ch1 |
因为ch1先往通道里输入数据,如果把下面default注释去掉,运行结果一定是:没有任何消息准备好。因为 select 的特性是:它只会“看一眼”所有的 case。如果有准备好的就执行;如果没有,且写了 default,它就会立刻执行 default 然后头也不回地往下走。
Timeout(超时控制)
这是后端开发最常用的模式,防止某个请求卡死整个服务。利用 select 和 time.After。
1 | func main() { |
运行结果肯定就是错误: 操作超时了!
For Select (循环监听 / 守护进程)
这是 Go 中实现Worker或Daemon的例子。需要一直监听任务,同时还需要能优雅退出
1 | func main() { |
运行结果就是
1 | 处理任务: 1 |
最后先挖个坑吧,因为使用Gin/GORM开发后端的时候是不用手写go func()的,甚至大部分时间不用写sql语句。所以高级并发操作就等以后有空再深入研究