前两天有个同学在群里抛了一个结论:“Java线程池在2万并发时CPU直接打满,换成Go的goroutine之后,10万并发都轻轻松松,所以线程这个模型是不是该淘汰了?”我当时回了句:你先把两个测试的服务代码贴出来,八成有一个在忙等,有一个真的在sleep。后来他把代码发过来,果然Java那边用了同步阻塞IO,线程全部卡在等待上,内核不断做上下文切换;Go那边则走的是网络轮询和goroutine调度,两个模型根本不是同一个维度的东西。
这个场景其实很典型。Go协程和线程性能对比这个话题,几乎每个做后端的人都会遇到,尤其是从Java、C++转过来的同学,最先被安利的就是“goroutine成本低到可以随便开”。但问题在于,“低到可以随便开”不代表线程可以被降维打击,也不代表你的业务里只剩goroutine这一种选择。这篇文章我想用自己的实测数据和底层原理,把这两者的性能差异拆开讲透。你会看到它们分别强在哪、弱在哪,以及在真实业务里到底应该怎么取舍。适合正在做技术选型、准备高并发面试,或者单纯想把并发模型搞清楚的同学阅读。
1. 为什么我从一个“反常识结论”开始聊
1.1 线上JVM线程池被打穿,第一反应不是加线程
我之前维护过一个老项目,里面用Java线程池处理外部系统的回执消息,高峰期流量一上来,线程池队列就开始堆积,紧接着监控报警线程活跃数接近上限。当时团队里最本能的反应是把核心线程数往上调,从200调到500,再从500调到800。表面上看问题确实缓解了一小段时间,但代价是系统整体响应变慢,CPU的sys占用率明显升高,最后连垃圾回收都开始不规律。
这个案例后来成了我判断线程模型的一个反面教材。不是线程池本身垃圾,而是我们拿它硬扛了一个本质上是“海量短连接+IO等待”的场景。每个任务拿到外部连接后大部分时间都在等响应,等的时候线程被内核挂起,等响应回来后又要被重新唤醒。线程数量越大,内核在这上面的调度成本就越高,可真正在计算的CPU时间反而没多少。换句话说,线程的并行能力没问题,但它的资源占用和调度开销在当前场景里成了一种负担。
后来我把这部分逻辑迁移到Go的服务里,每个连接开一个goroutine,代码看着更简洁,压力也确实小了很多。当时直观的感觉是:同样的并发量,goroutine版本的内存占用和延迟都要好不少。但要问好在哪个环节,当时的我说不清楚,只知道“goroutine很轻”。
1.2 泛泛地吹“协程比线程更轻”很容易翻车
网上很多文章标题写得很猛,像“线程已经过时”“协程吊打线程”,但如果只停留在结论上,不解释底层调度机制,读者很容易产生两个误解。
第一个误解是把协程当成一种“更小的线程”,以为两者的差别只是数量级不同,比如一个goroutine占2KB,一个线程占8MB,那goroutine当然厉害。实际上线程是操作系统内核管理的执行体,goroutine是Go运行时自己管理的并发单元,创建、切换、销毁都由用户态调度器完成,只有最终执行时才被绑定到某个内核线程上。这两者走的不是同一条调度链路,光是拿栈大小去解释性能差异远远不够。
第二个误解是认为“用goroutine就一定比用线程快”。如果你把一个纯CPU密集的计算任务拆成一百万个goroutine,而机器只有8个核,最终同一时刻能干活的只有8个,大量的goroutine只是在等待被调度,性能不但不会更好,还可能因为调度器本身的压力变差。并发模型解决的核心问题是“如何高效表达和调度大量任务”,不是单纯地给代码提速。
1.3 这篇对比到底在比什么
为了避免又写一篇情绪化水文,我先约束一下讨论边界。这里说的线程,指的是操作系统原生线程,或者工程里依赖原生线程实现的传统线程池,比如Java的ThreadPoolExecutor、C++的std::thread这类体系;这里说的协程,特指Go语言的goroutine。JDK 21里也有虚拟线程,思路类似但实现细节不同,不在这次的对比范围内。
同时我会把性能拆成几个维度来聊:创建成本、内存占用、调度切换成本、可承载并发规模、CPU密集场景下的表现。每个维度背后的原理不同,结论也可能相反。只有把维度拆开,你才能真正理解为什么在IO密集型高并发场景下goroutine占优,也才能反过来理解线程在哪些场景依然不会被淘汰。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 线程与goroutine的底层机制到底差在哪
2.1 线程:由内核调度的执行体,生命周期并不便宜
先从一个基础问题说起:进程和线程有什么区别。教科书上的标准答案是,进程是资源分配的最小单位,线程是CPU调度的最小单位。这句话放到现代操作系统里依然成立。进程拥有独立的地址空间、文件描述符表等资源,线程则共享进程的地址空间和大多数资源,但每个线程有自己的程序计数器、寄存器上下文和栈。
线程的创建和切换,核心动作都在内核态完成。当你调用pthread_create或者Java里new Thread时,底层会通过clone等系统调用创建一份内核线程结构,同时分配栈空间。这个操作不是免费的,需要陷入内核,可能要经过一系列权限检查和资源分配。调度时,内核需要把当前线程的寄存器状态保存起来,再从运行队列里选下一个线程恢复执行,这个过程涉及用户态和内核态的切换,专业一点叫上下文切换。
还有个很容易被忽略的细节是线程栈大小。Linux上glibc的线程默认栈一般是8MB,虽然这个8MB绝大部分是虚拟内存,不一定会立刻对应到物理内存,但大量线程创建之后,光是虚拟地址空间和内核为每个线程维护的管理结构就会造成很大压力。实际生产中线程数量上了千就要开始谨慎评估,上万往往就要调系统参数,十万基本是噩梦。
所以线程模型的核心问题是:它不是不好用,而是贵。每一条线程都是一个完整的内核调度实体,需要内核“认真对待”。
2.2 goroutine:运行在用户态的并发单元,为什么能谈“轻”
goroutine是Go运行时创建的用户态执行单元。当你写下go func() {},Go运行时会在当前进程内申请一小块内存,初始化协程栈和相关状态,然后把它扔进调度器的队列。整个过程不涉及系统调用,不进入内核,不需要内核去维护这个“任务”的存在。
Go的goroutine有一个很重要的特性:初始栈非常小,大概只有几KB,而且是动态增长的。如果goroutine里只是做一次简单计算,栈不会扩大多少;如果发生了较深递归或者局部变量很大,运行时会在检测到栈不够用时自动扩容。这种设计让创建goroutine的成本极低,也让一个Go进程同时存活数十万个goroutine变成可能。这是线程在默认配置下很难做到的。
但要注意,goroutine轻不代表没有成本。Go运行时要维护每个goroutine的状态对象、调度队列、栈内存,还需要时不时做栈扫描和垃圾回收。goroutine的数量一旦到百万级,内存依然会相当可观,调度也会出现瓶颈。它只是把“单个并发单元的固定成本”降到了很低的水平,不是降到了零。
2.3 Go的GMP模型与关键设计原因
想要真正理解goroutine的性能表现,绕不开GMP模型。G是goroutine本身,保存了函数入口、参数、栈信息和调度状态;M是machine,代表操作系统线程,是真正能跑到CPU上干活的执行者;P是processor,不是CPU,而是Go调度器维护的一个“本地工作队列”和运行环境,默认数量等于可使用的CPU核数。
整个调度流程大致是这样:创建好的G会被放到某个P的本地队列,或者全局队列;P需要找活干,本地队列没有就去全局队列拿,再没有就去其他P“偷”。当一个G需要阻塞,比如Channel等待、加锁等待、系统调用,当前M可能会被让出,调度器会把这个P和M解绑,再找其他M来接替这个P继续执行别的G;系统调用返回后,这个G再重新进入调度队列。
这个设计带来的直接好处是,goroutine的调度大多发生在用户态,切换的是G自己的上下文,而不是M的内核上下文。Go在1.14版本之后还加入了异步抢占,以前一个正在死循环的goroutine可能会卡住整个P,现在每隔一段时间就会收到抢占信号,把执行权还给调度器。所以goroutine并不是不依赖操作系统线程,而是把大量调度工作从内核搬到了运行时内部。
用生活化的比喻理解:你和同事在一个车间干活,线程模式有点像每个工人都要拿着一台专门的设备,设备出厂自带一整套系统,工人换工位就要整机切换;goroutine模式则是车间里只有几个固定工人和设备,但你们把工作拆成一叠一叠的小任务单,谁手里的活干完了,转身再取一张继续,没人需要每次重新启动设备。
2.4 两者差异为什么直接映射到性能
从底层机制可以推导出几个关键结论。
第一,创建成本上goroutine远小于线程,因为它不进入内核,只是用户态内存分配和入队。第二,栈空间上goroutine初始栈只有几KB且可以伸缩,线程默认固定大栈,无法支撑海量并发。第三,上下文切换成本上,goroutine切换是用户态完成的寄存器保存与恢复,线程切换则要经过内核调度器,代价高一个数量级以上。第四,阻塞处理上,goroutine被Go的运行时接管,网络IO这类场景会注册到epoll上,不干等线程;而传统线程一旦执行阻塞IO,内核只能把这个线程挂起。
因此,当你的应用里存在大量IO等待、大量短连接、大量低频但数量庞大的任务时,goroutine能明显压低资源占用,减少无效调度。而线程模型更贴近操作系统本身,对CPU密集任务、强实时要求的场景反而更可控。理解这一点,已经可以解释绝大多数“协程为什么这么能扛”的案例。
3. 可复现对比实验:我是怎么测的
3.1 明确环境,避免“拿八倍核数比单核”这种无效测试
做性能对比最忌讳的就是不公平。比如一边开满多核,另一边代码里只用了单核,最后得出结论说某语言更强,这没有任何参考价值。我这次对比的机器配置在8核16G的Linux云主机上,Go版本1.22,Java版本17,测试时都使用默认的内核调度参数,没有刻意调大文件句柄或用其他技巧压榨系统。
我选择Java作为“线程”侧的代表,是因为Java的传统Thread在当代实现里基本就是操作系统线程的包装,语言层面可读性也高,很多后端同学都有Java基础。测试过程不在同一个进程里执行,只对比同一台机器上的宏观表现。每次测试前先空跑一轮,让JVM完成预热,避免JIT编译干扰。
需要强调一句:这次对比不是要比“Go比Java快”这种无意义结论,而是比较在同样规模的任务面前,不同并发原语的承载能力。
3.2 用例A:海量IO等待任务,谁能在高并发下撑住
第一个用例模拟的是一类非常常见的业务场景:有大量任务,每个任务要发起一次短请求或短暂等待,比如查询一次Redis、调一次外部接口。抽象成代码就是让每个任务sleep 1毫秒,然后统计总耗时。
Go侧代码很直白:
go复制package main
import (
"fmt"
"sync"
"time"
)
func main() {
const total = 100000
var wg sync.WaitGroup
wg.Add(total)
start := time.Now()
for i := 0; i < total; i++ {
go func() {
defer wg.Done()
time.Sleep(time.Millisecond)
}()
}
wg.Wait()
fmt.Println("elapsed:", time.Since(start))
}
Java侧,最直观的对照是一次性创建10万个Thread:
java复制public class ThreadSleepTest {
public static void main(String[] args) throws InterruptedException {
int total = 100_000;
CountDownLatch latch = new CountDownLatch(total);
long start = System.currentTimeMillis();
for (int i = 0; i < total; i++) {
Thread t = new Thread(() -> {
try {
Thread.sleep(1);
} catch (InterruptedException ignored) {
} finally {
latch.countDown();
}
});
t.start();
}
latch.await();
System.out.println("elapsed: " + (System.currentTimeMillis() - start));
}
}
这里我建议你不要真的把total设成10万再跑Java版本,我实测在默认参数下,进程通常会直接报OutOfMemoryError或者Unable to create native thread。你可以从1万开始试,大概率已经开始告警。Go那边跑10万个goroutine基本无感,整个程序在几十毫秒到一百多毫秒内结束。
这个结果不能理解成“Go单线程能力比Java强”,而是Java每个任务对应一条原生线程,10万条原生线程对操作系统来说已经是巨大负担。Go侧这10万个goroutine最终只占用了少量M,通过调度器在几个线程上轮流执行,相当于一个线程池结构在替你并发。
3.3 用例B:CPU密集型计算,线程和协程看谁更快
第二个用例场景反过来:所有任务都在执行计算,没有任何等待。简单一点,让每个任务反复做浮点数运算,总任务数远大于核数,测两种模型完成全部任务的时间。
Go侧最常见的写法是用固定数量的worker channel,或者直接创建N个goroutine。直接把任务平分给N个goroutine也可以:
go复制package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func compute(done func()) {
defer done()
sum := 0.0
for i := 0; i < 50000000; i++ {
sum += sqrt(float64(i))
}
_ = sum
}
func sqrt(x float64) float64 {
z := 1.0
for j := 0; j < 20; j++ {
z -= (z*z - x) / (2 * z)
}
return z
}
func main() {
runtime.GOMAXPROCS(8)
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < 8000; i++ {
wg.Add(1)
go func() {
compute(wg.Done)
}()
}
wg.Wait()
fmt.Println("elapsed:", time.Since(start))
}
Java侧使用核心线程数等于CPU核数的FixedThreadPool,把同样数量的任务提交进去,确保它的并发规模不超过8,最终的完成时间理论上不会和Go版本出现数量级差异。JVM预热后,两者差距可能落在10%-30%的范围内,有时Go快,有时Java快,完全在正常波动范围里。
这说明一个非常关键的结论:当任务不再等待,而是一直占用CPU时,你真正比拼的不是线程还是协程,而是算法、编译器优化和CPU指令执行效率。goroutine并不会有魔法加成,因为真正在物理核上跑的依然是数量有限的系统线程。
3.4 测量时需要避开哪些坑
有三次测量结果很容易误导外人,我踩过的坑应该写出来。
第一次,我没有控制GOMAXPROCS。Go默认会按宿主机核心数设置P的数量,但如果你在容器里跑,宿主机核数可能和容器limit不一致,结果会偏高或偏低。测试前先把GOMAXPROCS显式固定。
第二次,Java侧忘了预热。JVM第一次执行某个方法时会触发类加载和JIT编译,耗时可能比稳定运行高一个数量级。你不预热就跑,测出来的全是JVM启动成本。
第三次,把sleep当成真实IO。sleep本身虽然模拟了阻塞,但真实网络IO还有内核协议栈处理、连接建立等开销,sleep省去了这些环节的复杂度。若想更接近真实,应该用实际的Redis或HTTP调用去测,而sleep只能帮你观察调度器结构上的差异。
4. 结果解读:性能差距到底从哪里来
4.1 内存与创建成本:栈的数量级决定并发上限
第一个看得见的差距是内存模型。一条线程在创建时就被分配了固定大小的栈,Linux默认8MB,虽然虚拟内存不是立刻占物理内存,但内核要维护一堆线程相关的元数据,线程数量上去后,内存和句柄压力很明显。举个例子,如果你创建1万个线程,默认栈的虚拟内存总量就是接近80GB,这已经超过了绝大多数单机物理内存,哪怕大部分页面没有实际touch到,系统的地址空间和mmap限制都会开始告警。
goroutine的初始栈很小,在64位机器上通常只有2KB到几KB,而且会根据需要扩容,空闲时又可以被GC回收。这个数量级差距带来的直接效果是,线程能支撑到几千已经要小心翼翼,而goroutine到十万、几十万仍然在可控范围内。
但这里也需要强调,goroutine的内存开销不只是栈。每个G对象本身还有运行时元数据,goroutine内部的channel、临时变量、逃逸到堆上的对象都会产生额外分配。如果你开了一百万个goroutine,每个goroutine再往channel里塞大量数据,内存依然会爆。只不过相比线程,它把“并发单元本身的固定成本”压到了非常低。
4.2 调度成本:从阻塞到唤醒这段路,两条路线完全不同
在线程模型里,大量线程同时等待IO,内核调度器就要频繁做上下文切换。一个线程从睡眠状态被唤醒,要走完“事件发生 -> 中断处理 -> 内核把线程加入运行队列 -> 切换线程上下文”这条完整链路,期间还可能涉及CPU缓存失效。这种切换成本被称为“昂贵的幸福”,尤其在核数有限的情况下,大量线程只在等待,系统的大部分时间都花在“评估谁可以运行”上。
goroutine这边,当它执行到网络IO、sleep或者channel阻塞时,Go运行时会把当前G从P的运行队列摘下,标记为等待状态,然后立刻从队列里拿出另一个G来执行。这个切换全程在用户态完成,不需要内核感知到“我换了一个执行体”。只有当P下面没有可运行的G时,对应M才可能真正去睡眠。这让Go在高并发IO等待场景下看起来很从容:并发数量大,但内核层次的上下文切换数量很少。
你可以观察到Go服务即使有十万个goroutine阻塞在channel上,操作系统的线程数量通常也只有几十个,CPU的sys占用很低。这不是巧合,是GMP模型把“并发任务调度”和“操作系统线程调度”之间做了隔离。
4.3 CPU密集场景:差距没有很多文章说的那么大
如果只看IO等待用例,很容易得出“goroutine全面超越线程”的错误结论。把场景换成CPU密集任务后,趋势会立刻变平。原因很简单:无论goroutine还是线程,最终能同时执行的实体数量受CPU核数限制。
CPU密集任务的性能瓶颈在于“物理核能不能持续被有效使用”。线程是内核直接调度的完整实体,调度策略成熟,内核可以按时间片分发;goroutine则需要Go调度器先把G分配给M,再交给内核去调度M,多了一层用户态分发。这一层带来的额外开销在任务粒度很小时可能显现,但在任务粒度较大的场景中被计算时间稀释,几乎感觉不到差别。
从工程角度来看,如果你有一个纯计算任务,长期运行、没有IO等待,那么线程模型依然是一个稳定成熟的选择。甚至在某些强实时或者需要精确控制线程优先级的场景,原生线程可能更适合,因为内核能给你更直接的调度保障。Go虽然能抢占,但它是协作式和异步抢占的结合,调度时机不如内核可控。
4.4 实际性能结论,不能只停留在一张表上
把两种模型的特点汇总成一张对照表,方便你以后直接查阅:
| 对比维度 | 操作系统原生线程 | Go goroutine |
|---|---|---|
| 创建与销毁成本 | 高,涉及系统调用和内核资源分配 | 低,用户态内存分配和入队 |
| 初始栈大小 | Linux默认约8MB | 几KB起,按需扩容 |
| 切换成本 | 内核上下文切换,代价高 | 用户态调度,代价低 |
| 调度主体 | 操作系统内核 | Go运行时 |
| 最大可承载并发量 | 受内存、句柄等限制,通常数千到数万 | 可支撑十万到百万级别 |
| 阻塞IO处理 | 线程被内核阻塞,占用线程资源 | 网络IO挂到事件轮询,不阻塞M |
| CPU密集场景 | 内核调度成熟,适合长计算 | 最终仍由M承载,无明显优势 |
| 调试工具链 | gdb、jstack等成熟 | pprof、runtime包方便 |
这张表看下来,真正的结论只有一句话:goroutine的优势不在“比线程算得快”,而在“用更少的系统资源承载更多的并发任务”。工程选型时要区分的,不是语言谁更好,而是场景更像哪一列。
5. 工程上到底怎么选:线程池、goroutine还是混合
5.1 线程池的核心价值并没有被协程替代
Java、C++里线程池之所以重要,本质原因是原生线程的创建和销毁成本太高,不能来一个任务就new一个线程,必须通过复用降低开销,同时通过队列缓存任务、控制最大并发数。线程池的核心线程数、最大线程数、阻塞队列策略这些配置,本质上是在“响应时间”和“资源上限”之间做权衡。
有人认为Go里可以直接开goroutine,所以不需要线程池。但“不需要池化线程”不等于“不需要限制并发”。如果你在Go程序里收到请求就直接go func,并且逻辑里还有耗时的下游调用,流量一大goroutine数量就会失控,最后内存暴涨、GC频繁、服务瘫痪。这跟Java里无脑new Thread没有本质区别,只不过goroutine的体型更小,所以崩得更慢而已。
Go工程里对应的做法是用信号量或者固定数量的worker goroutine来控制最大并发数。最简单的信号量实现就是带缓冲的channel:
go复制limit := make(chan struct{}, 1000)
for _, task := range tasks {
task := task
go func() {
limit <- struct{}{}
defer func() { <-limit }()
process(task)
}()
}
缓冲容量就是同时能执行的goroutine数量上限。想要复用goroutine、减小创建调度开销,也可以封装一个worker池,从jobs channel里不断取任务执行。但多数业务场景下,goroutine创建开销已经很低,你真正需要限制的是同时运行的数量,而不是为了池化而池化。
5.2 传统线程池的参数经验,对Go也有启发
顺带聊一个很常见的面试题:Java线程池核心线程数怎么设置。网上流传一种经验公式:CPU密集任务设为CPU核数或核数加1,IO密集任务设为CPU核数乘以某个系数。这个公式看起来简单,但实际并不精确,因为它忽略了IO等待占比到底是多少。
更完整的思路是,IO线程的理想并行度约等于“CPU核数乘以每个任务在CPU上的耗时和等待时间的比值”。一个任务如果计算10毫秒,IO等待90毫秒,你可以让一个CPU在等待期间穿插执行更多任务,所以线程数可以超出核数很多。但超过一定程度,线程切换和内存开销反而会成为瓶颈。这背后的原理和Go里需要用信号量限流是一致的:高并行度只适用于高等待占比任务,纯计算任务的最佳并行度接近核数。
线程池的阻塞队列选择也是一个同类问题。在Java里,SynchronousQueue适合需要“任务必须立即被调度到线程执行”的场景,因为队列本身不缓存;LinkedBlockingQueue是无界队列,任务积压不拒绝但可能耗尽内存;ArrayBlockingQueue有界,能提供背压。Go里没有直接等价的原生阻塞队列概念,但channel天然承担了队列角色,无缓冲channel类似SynchronousQueue,有缓冲channel则类似有界队列。你在选型时想清楚“队列满了怎么办”这个问题,两种语言里的答案其实是互通的。
5.3 防goroutine泄漏是Go高并发落地的第一课
Go写高并发最大的坑并不是性能,而是泄漏。一个goroutine如果阻塞在channel上,而这个channel再也等不到数据,它就会一直存活,表现为goroutine数量只增不减。
排查方式也比较固定:先用runtime.NumGoroutine看数量异常,再用pprof的goroutine profile抓出当前所有goroutine的调用栈,重点看有没有积压在某个channel的send或receive操作上。常见原因有调用外部服务时没有设置超时时间、select里没有default分支、任务被取消时没有通过context把信号传给子goroutine。
写代码时有一个我很推荐的习惯:goroutine启动后,第一件事想清楚它“如何退出”。即使你判断它只会运行几毫秒,也要有一个明确的退出条件。对于等待多个事件的情况,用select把context.Done()和业务channel一起监听,这能极大减少泄漏概率。
5.4 死锁和并发安全:这些问题不会因为用Go就消失
从线程切换到goroutine后,死锁、数据竞争、锁竞争这些经典问题依然存在。Go提供的sync.Mutex和RWMutex对应传统线程互斥锁;channel本身可以做通信,但跨多个goroutine并发读写同一个map时,依然会panic。
死锁的高频原因有两类:一类是多个goroutine按相反顺序申请多把锁,另一类是channel互相等待对方先发送。第一类是教科书式的循环等待,解决思路是锁的获取顺序全局一致,或者尽量用单把锁缩小临界区;第二类在Go里更常见,比如A goroutine等B往channel发数据,B又等A读channel,如果它们都在无缓冲channel上执行send或recv,就会互相卡死。
排查死锁时,内存和CPU一般都不高,程序处于挂起状态。Go的pprof可以从goroutine profile里看到每个goroutine阻塞的锁对象和channel位置,比盲查日志高效很多。race检测器go run -race在有数据竞争时能直接打印出冲突读写点,强烈建议测试环境常开。
6. 常见问题速查与经验清单
6.1 高频疑问汇总表
| 问题 | 一句话结论 | 更详细的思路 |
|---|---|---|
| goroutine和线程是什么关系 | goroutine是Go运行时的并发单元,最终要跑在M对应的线程上 | GMP模型里,P提供本地队列,M是实际执行者 |
| 为什么高并发IO场景优先考虑Go | goroutine在等待IO时不占用系统线程,能支撑更多并发连接 | Go把网络IO挂到事件轮询,让出M给其他G运行 |
| 进程、线程、协程怎么区分 | 进程是资源容器,线程是内核调度单元,协程是用户态调度单元 | 一个进程可以有很多线程,一个线程可以轮流执行很多协程 |
| 线程池里的核心线程数怎么设 | CPU密集接近核数,IO密集考虑吞吐和队列能力 | 更完整公式要考虑任务计算和等待的时间比 |
| 创建线程报Unable to create native thread | 线程数超过系统限制 | 检查ulimit、cgroup pids限制、线程栈大小和内存 |
| goroutine泄漏怎么排查 | 看runtime.NumGoroutine是否只增不减 | 用pprof抓goroutine stack,重点找阻塞在channel的调用点 |
| 线程死锁了为什么看不出异常 | 程序CPU不高但请求不返回 | 用jstack或Go pprof查看阻塞点,检查锁顺序和channel等待关系 |
| Linux怎么看进程下的线程数 | ps -Lf pid,或ls /proc/pid/task/ | 与线程调度问题配合观察CPU和内存 |
| Go容器里只吃满一个核怎么办 | 检查GOMAXPROCS是否等于容器真实limit | 可以用自动探测CPU配额的工具,或自行设置GOMAXPROCS |
6.2 一点我自己的体会
从第一次被线程池打穿,到后来在项目里大量使用goroutine,我最大的体会是:不要给某个并发原语封神,也不要急于全面替换。线程是操作系统给你的能力底座,协程则是在这个底座上提出的更高效封装。它们服务的场景不同,适合的团队基础也不同。
如果团队里全是Java背景、对JVM调优很熟,那么你强行把所有模块改成Go协程,可能只是把问题从线程管理转移到了goroutine生命周期管理上,结果未必更好。如果业务确实属于“高并发IO接入、长连接、消息密集”这类形态,Go的goroutine模型确实能让代码结构更简单,也让系统资源利用率上一个台阶。
最后再分享一个小技巧:无论你用的是线程池还是goroutine,上线前都应该给“最大并发数”设置一个显式的兜底值。Java里是ArrayBlockingQueue配合RejectedExecutionHandler,Go里是带缓冲channel,或者golang.org/x/sync/errgroup与semaphore包。把并发边界钉死,是高性能系统活下来的第一步。
